Fix item closing overly triggering save dialogues (#21374)

Kirill Bulatov created

Closes https://github.com/zed-industries/zed/issues/12029

Allows to introspect project items inside items more deeply, checking
them for being dirty.
For that:
* renames `project::Item` into `project::ProjectItem`
* adds an `is_dirty(&self) -> bool` method to the renamed trait
* changes the closing logic to only care about dirty project items when
checking for save prompts conditions
* save prompts are raised only if the item is singleton without a
project path; or if the item has dirty project items that are not open
elsewhere

Release Notes:

- Fixed item closing overly triggering save dialogues

Change summary

crates/diagnostics/src/diagnostics.rs     |   2 
crates/editor/src/editor.rs               |   4 
crates/editor/src/git/blame.rs            |   2 
crates/editor/src/items.rs                |   6 
crates/image_viewer/src/image_viewer.rs   |   2 
crates/outline_panel/src/outline_panel.rs |   2 
crates/project/src/buffer_store.rs        |   2 
crates/project/src/image_store.rs         |   8 
crates/project/src/lsp_store.rs           |   2 
crates/project/src/project.rs             |   9 
crates/project_panel/src/project_panel.rs |   6 
crates/repl/src/notebook/notebook_ui.rs   |  27 
crates/repl/src/repl_editor.rs            |   2 
crates/repl/src/repl_sessions_ui.rs       |   2 
crates/search/src/project_search.rs       |   2 
crates/workspace/src/item.rs              |  20 
crates/workspace/src/pane.rs              | 110 ++++-
crates/workspace/src/workspace.rs         | 473 ++++++++++++++++++++++++
crates/zed/src/zed.rs                     |   2 
19 files changed, 599 insertions(+), 84 deletions(-)

Detailed changes

crates/diagnostics/src/diagnostics.rs 🔗

@@ -716,7 +716,7 @@ impl Item for ProjectDiagnosticsEditor {
     fn for_each_project_item(
         &self,
         cx: &AppContext,
-        f: &mut dyn FnMut(gpui::EntityId, &dyn project::Item),
+        f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
     ) {
         self.editor.for_each_project_item(cx, f)
     }

crates/editor/src/editor.rs 🔗

@@ -125,8 +125,8 @@ use parking_lot::{Mutex, RwLock};
 use project::{
     lsp_store::{FormatTarget, FormatTrigger},
     project_settings::{GitGutterSetting, ProjectSettings},
-    CodeAction, Completion, CompletionIntent, DocumentHighlight, InlayHint, Item, Location,
-    LocationLink, Project, ProjectTransaction, TaskSourceKind,
+    CodeAction, Completion, CompletionIntent, DocumentHighlight, InlayHint, Location, LocationLink,
+    Project, ProjectItem, ProjectTransaction, TaskSourceKind,
 };
 use rand::prelude::*;
 use rpc::{proto::*, ErrorExt};

crates/editor/src/git/blame.rs 🔗

@@ -10,7 +10,7 @@ use gpui::{Model, ModelContext, Subscription, Task};
 use http_client::HttpClient;
 use language::{markdown, Bias, Buffer, BufferSnapshot, Edit, LanguageRegistry, ParsedMarkdown};
 use multi_buffer::MultiBufferRow;
-use project::{Item, Project};
+use project::{Project, ProjectItem};
 use smallvec::SmallVec;
 use sum_tree::SumTree;
 use url::Url;

crates/editor/src/items.rs 🔗

@@ -22,8 +22,8 @@ use language::{
 use lsp::DiagnosticSeverity;
 use multi_buffer::AnchorRangeExt;
 use project::{
-    lsp_store::FormatTrigger, project_settings::ProjectSettings, search::SearchQuery, Item as _,
-    Project, ProjectPath,
+    lsp_store::FormatTrigger, project_settings::ProjectSettings, search::SearchQuery, Project,
+    ProjectItem as _, ProjectPath,
 };
 use rpc::proto::{self, update_view, PeerId};
 use settings::Settings;
@@ -665,7 +665,7 @@ impl Item for Editor {
     fn for_each_project_item(
         &self,
         cx: &AppContext,
-        f: &mut dyn FnMut(EntityId, &dyn project::Item),
+        f: &mut dyn FnMut(EntityId, &dyn project::ProjectItem),
     ) {
         self.buffer
             .read(cx)

crates/image_viewer/src/image_viewer.rs 🔗

@@ -78,7 +78,7 @@ impl Item for ImageView {
     fn for_each_project_item(
         &self,
         cx: &AppContext,
-        f: &mut dyn FnMut(gpui::EntityId, &dyn project::Item),
+        f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
     ) {
         f(self.image_item.entity_id(), self.image_item.read(cx))
     }

crates/outline_panel/src/outline_panel.rs 🔗

@@ -36,7 +36,7 @@ use language::{BufferId, BufferSnapshot, OffsetRangeExt, OutlineItem};
 use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrev};
 
 use outline_panel_settings::{OutlinePanelDockPosition, OutlinePanelSettings, ShowIndentGuides};
-use project::{File, Fs, Item, Project};
+use project::{File, Fs, Project, ProjectItem};
 use search::{BufferSearchBar, ProjectSearchView};
 use serde::{Deserialize, Serialize};
 use settings::{Settings, SettingsStore};

crates/project/src/buffer_store.rs 🔗

@@ -1,7 +1,7 @@
 use crate::{
     search::SearchQuery,
     worktree_store::{WorktreeStore, WorktreeStoreEvent},
-    Item, ProjectPath,
+    ProjectItem as _, ProjectPath,
 };
 use ::git::{parse_git_remote_url, BuildPermalinkParams, GitHostingProviderRegistry};
 use anyhow::{anyhow, Context as _, Result};

crates/project/src/image_store.rs 🔗

@@ -1,6 +1,6 @@
 use crate::{
     worktree_store::{WorktreeStore, WorktreeStoreEvent},
-    Project, ProjectEntryId, ProjectPath,
+    Project, ProjectEntryId, ProjectItem, ProjectPath,
 };
 use anyhow::{Context as _, Result};
 use collections::{hash_map, HashMap, HashSet};
@@ -114,7 +114,7 @@ impl ImageItem {
     }
 }
 
-impl crate::Item for ImageItem {
+impl ProjectItem for ImageItem {
     fn try_open(
         project: &Model<Project>,
         path: &ProjectPath,
@@ -151,6 +151,10 @@ impl crate::Item for ImageItem {
     fn project_path(&self, cx: &AppContext) -> Option<ProjectPath> {
         Some(self.project_path(cx).clone())
     }
+
+    fn is_dirty(&self) -> bool {
+        false
+    }
 }
 
 trait ImageStoreImpl {

crates/project/src/lsp_store.rs 🔗

@@ -10,7 +10,7 @@ use crate::{
     toolchain_store::{EmptyToolchainStore, ToolchainStoreEvent},
     worktree_store::{WorktreeStore, WorktreeStoreEvent},
     yarn::YarnPathStore,
-    CodeAction, Completion, CoreCompletion, Hover, InlayHint, Item as _, ProjectPath,
+    CodeAction, Completion, CoreCompletion, Hover, InlayHint, ProjectItem as _, ProjectPath,
     ProjectTransaction, ResolveState, Symbol, ToolchainStore,
 };
 use anyhow::{anyhow, Context as _, Result};

crates/project/src/project.rs 🔗

@@ -111,7 +111,7 @@ const MAX_PROJECT_SEARCH_HISTORY_SIZE: usize = 500;
 const MAX_SEARCH_RESULT_FILES: usize = 5_000;
 const MAX_SEARCH_RESULT_RANGES: usize = 10_000;
 
-pub trait Item {
+pub trait ProjectItem {
     fn try_open(
         project: &Model<Project>,
         path: &ProjectPath,
@@ -121,6 +121,7 @@ pub trait Item {
         Self: Sized;
     fn entry_id(&self, cx: &AppContext) -> Option<ProjectEntryId>;
     fn project_path(&self, cx: &AppContext) -> Option<ProjectPath>;
+    fn is_dirty(&self) -> bool;
 }
 
 #[derive(Clone)]
@@ -4354,7 +4355,7 @@ impl ResolvedPath {
     }
 }
 
-impl Item for Buffer {
+impl ProjectItem for Buffer {
     fn try_open(
         project: &Model<Project>,
         path: &ProjectPath,
@@ -4373,6 +4374,10 @@ impl Item for Buffer {
             path: file.path().clone(),
         })
     }
+
+    fn is_dirty(&self) -> bool {
+        self.is_dirty()
+    }
 }
 
 impl Completion {

crates/project_panel/src/project_panel.rs 🔗

@@ -7511,7 +7511,7 @@ mod tests {
         path: ProjectPath,
     }
 
-    impl project::Item for TestProjectItem {
+    impl project::ProjectItem for TestProjectItem {
         fn try_open(
             _project: &Model<Project>,
             path: &ProjectPath,
@@ -7528,6 +7528,10 @@ mod tests {
         fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
             Some(self.path.clone())
         }
+
+        fn is_dirty(&self) -> bool {
+            false
+        }
     }
 
     impl ProjectItem for TestProjectItemView {

crates/repl/src/notebook/notebook_ui.rs 🔗

@@ -158,16 +158,6 @@ impl NotebookEditor {
         })
     }
 
-    fn is_dirty(&self, cx: &AppContext) -> bool {
-        self.cell_map.values().any(|cell| {
-            if let Cell::Code(code_cell) = cell {
-                code_cell.read(cx).is_dirty(cx)
-            } else {
-                false
-            }
-        })
-    }
-
     fn clear_outputs(&mut self, cx: &mut ViewContext<Self>) {
         for cell in self.cell_map.values() {
             if let Cell::Code(code_cell) = cell {
@@ -500,7 +490,7 @@ pub struct NotebookItem {
     id: ProjectEntryId,
 }
 
-impl project::Item for NotebookItem {
+impl project::ProjectItem for NotebookItem {
     fn try_open(
         project: &Model<Project>,
         path: &ProjectPath,
@@ -561,6 +551,10 @@ impl project::Item for NotebookItem {
     fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
         Some(self.project_path.clone())
     }
+
+    fn is_dirty(&self) -> bool {
+        false
+    }
 }
 
 impl NotebookItem {
@@ -656,7 +650,7 @@ impl Item for NotebookEditor {
     fn for_each_project_item(
         &self,
         cx: &AppContext,
-        f: &mut dyn FnMut(gpui::EntityId, &dyn project::Item),
+        f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
     ) {
         f(self.notebook_item.entity_id(), self.notebook_item.read(cx))
     }
@@ -734,8 +728,13 @@ impl Item for NotebookEditor {
     }
 
     fn is_dirty(&self, cx: &AppContext) -> bool {
-        // self.is_dirty(cx) TODO
-        false
+        self.cell_map.values().any(|cell| {
+            if let Cell::Code(code_cell) = cell {
+                code_cell.read(cx).is_dirty(cx)
+            } else {
+                false
+            }
+        })
     }
 }
 

crates/repl/src/repl_editor.rs 🔗

@@ -7,7 +7,7 @@ use anyhow::{Context, Result};
 use editor::Editor;
 use gpui::{prelude::*, Entity, View, WeakView, WindowContext};
 use language::{BufferSnapshot, Language, LanguageName, Point};
-use project::{Item as _, WorktreeId};
+use project::{ProjectItem as _, WorktreeId};
 
 use crate::repl_store::ReplStore;
 use crate::session::SessionEvent;

crates/repl/src/repl_sessions_ui.rs 🔗

@@ -3,7 +3,7 @@ use gpui::{
     actions, prelude::*, AnyElement, AppContext, EventEmitter, FocusHandle, FocusableView,
     Subscription, View,
 };
-use project::Item as _;
+use project::ProjectItem as _;
 use ui::{prelude::*, ButtonLike, ElevationIndex, KeyBinding};
 use util::ResultExt as _;
 use workspace::item::ItemEvent;

crates/search/src/project_search.rs 🔗

@@ -449,7 +449,7 @@ impl Item for ProjectSearchView {
     fn for_each_project_item(
         &self,
         cx: &AppContext,
-        f: &mut dyn FnMut(EntityId, &dyn project::Item),
+        f: &mut dyn FnMut(EntityId, &dyn project::ProjectItem),
     ) {
         self.results_editor.for_each_project_item(cx, f)
     }

crates/workspace/src/item.rs 🔗

@@ -208,7 +208,7 @@ pub trait Item: FocusableView + EventEmitter<Self::Event> {
     fn for_each_project_item(
         &self,
         _: &AppContext,
-        _: &mut dyn FnMut(EntityId, &dyn project::Item),
+        _: &mut dyn FnMut(EntityId, &dyn project::ProjectItem),
     ) {
     }
     fn is_singleton(&self, _cx: &AppContext) -> bool {
@@ -386,7 +386,7 @@ pub trait ItemHandle: 'static + Send {
     fn for_each_project_item(
         &self,
         _: &AppContext,
-        _: &mut dyn FnMut(EntityId, &dyn project::Item),
+        _: &mut dyn FnMut(EntityId, &dyn project::ProjectItem),
     );
     fn is_singleton(&self, cx: &AppContext) -> bool;
     fn boxed_clone(&self) -> Box<dyn ItemHandle>;
@@ -563,7 +563,7 @@ impl<T: Item> ItemHandle for View<T> {
     fn for_each_project_item(
         &self,
         cx: &AppContext,
-        f: &mut dyn FnMut(EntityId, &dyn project::Item),
+        f: &mut dyn FnMut(EntityId, &dyn project::ProjectItem),
     ) {
         self.read(cx).for_each_project_item(cx, f)
     }
@@ -891,7 +891,7 @@ impl<T: Item> WeakItemHandle for WeakView<T> {
 }
 
 pub trait ProjectItem: Item {
-    type Item: project::Item;
+    type Item: project::ProjectItem;
 
     fn for_project_item(
         project: Model<Project>,
@@ -1045,6 +1045,7 @@ pub mod test {
     pub struct TestProjectItem {
         pub entry_id: Option<ProjectEntryId>,
         pub project_path: Option<ProjectPath>,
+        pub is_dirty: bool,
     }
 
     pub struct TestItem {
@@ -1065,7 +1066,7 @@ pub mod test {
         focus_handle: gpui::FocusHandle,
     }
 
-    impl project::Item for TestProjectItem {
+    impl project::ProjectItem for TestProjectItem {
         fn try_open(
             _project: &Model<Project>,
             _path: &ProjectPath,
@@ -1073,7 +1074,6 @@ pub mod test {
         ) -> Option<Task<gpui::Result<Model<Self>>>> {
             None
         }
-
         fn entry_id(&self, _: &AppContext) -> Option<ProjectEntryId> {
             self.entry_id
         }
@@ -1081,6 +1081,10 @@ pub mod test {
         fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
             self.project_path.clone()
         }
+
+        fn is_dirty(&self) -> bool {
+            self.is_dirty
+        }
     }
 
     pub enum TestItemEvent {
@@ -1097,6 +1101,7 @@ pub mod test {
             cx.new_model(|_| Self {
                 entry_id,
                 project_path,
+                is_dirty: false,
             })
         }
 
@@ -1104,6 +1109,7 @@ pub mod test {
             cx.new_model(|_| Self {
                 project_path: None,
                 entry_id: None,
+                is_dirty: false,
             })
         }
     }
@@ -1225,7 +1231,7 @@ pub mod test {
         fn for_each_project_item(
             &self,
             cx: &AppContext,
-            f: &mut dyn FnMut(EntityId, &dyn project::Item),
+            f: &mut dyn FnMut(EntityId, &dyn project::ProjectItem),
         ) {
             self.project_items
                 .iter()

crates/workspace/src/pane.rs 🔗

@@ -1295,10 +1295,12 @@ impl Pane {
     ) -> Task<Result<()>> {
         // Find the items to close.
         let mut items_to_close = Vec::new();
+        let mut item_ids_to_close = HashSet::default();
         let mut dirty_items = Vec::new();
         for item in &self.items {
             if should_close(item.item_id()) {
                 items_to_close.push(item.boxed_clone());
+                item_ids_to_close.insert(item.item_id());
                 if item.is_dirty(cx) {
                     dirty_items.push(item.boxed_clone());
                 }
@@ -1339,16 +1341,23 @@ impl Pane {
                 }
             }
             let mut saved_project_items_ids = HashSet::default();
-            for item in items_to_close.clone() {
-                // Find the item's current index and its set of project item models. Avoid
+            for item_to_close in items_to_close {
+                // Find the item's current index and its set of dirty project item models. Avoid
                 // storing these in advance, in case they have changed since this task
                 // was started.
-                let (item_ix, mut project_item_ids) = pane.update(&mut cx, |pane, cx| {
-                    (pane.index_for_item(&*item), item.project_item_model_ids(cx))
-                })?;
-                let item_ix = if let Some(ix) = item_ix {
-                    ix
-                } else {
+                let mut dirty_project_item_ids = Vec::new();
+                let Some(item_ix) = pane.update(&mut cx, |pane, cx| {
+                    item_to_close.for_each_project_item(
+                        cx,
+                        &mut |project_item_id, project_item| {
+                            if project_item.is_dirty() {
+                                dirty_project_item_ids.push(project_item_id);
+                            }
+                        },
+                    );
+                    pane.index_for_item(&*item_to_close)
+                })?
+                else {
                     continue;
                 };
 
@@ -1356,27 +1365,34 @@ impl Pane {
                 // in the workspace, AND that the user has not already been prompted to save.
                 // If there are any such project entries, prompt the user to save this item.
                 let project = workspace.update(&mut cx, |workspace, cx| {
-                    for item in workspace.items(cx) {
-                        if !items_to_close
-                            .iter()
-                            .any(|item_to_close| item_to_close.item_id() == item.item_id())
-                        {
-                            let other_project_item_ids = item.project_item_model_ids(cx);
-                            project_item_ids.retain(|id| !other_project_item_ids.contains(id));
+                    for open_item in workspace.items(cx) {
+                        let open_item_id = open_item.item_id();
+                        if !item_ids_to_close.contains(&open_item_id) {
+                            let other_project_item_ids = open_item.project_item_model_ids(cx);
+                            dirty_project_item_ids
+                                .retain(|id| !other_project_item_ids.contains(id));
                         }
                     }
                     workspace.project().clone()
                 })?;
-                let should_save = project_item_ids
+                let should_save = dirty_project_item_ids
                     .iter()
-                    .any(|id| saved_project_items_ids.insert(*id));
+                    .any(|id| saved_project_items_ids.insert(*id))
+                    // Always propose to save singleton files without any project paths: those cannot be saved via multibuffer, as require a file path selection modal.
+                    || cx
+                        .update(|cx| {
+                            item_to_close.is_dirty(cx)
+                                && item_to_close.is_singleton(cx)
+                                && item_to_close.project_path(cx).is_none()
+                        })
+                        .unwrap_or(false);
 
                 if should_save
                     && !Self::save_item(
                         project.clone(),
                         &pane,
                         item_ix,
-                        &*item,
+                        &*item_to_close,
                         save_intent,
                         &mut cx,
                     )
@@ -1390,7 +1406,7 @@ impl Pane {
                     if let Some(item_ix) = pane
                         .items
                         .iter()
-                        .position(|i| i.item_id() == item.item_id())
+                        .position(|i| i.item_id() == item_to_close.item_id())
                     {
                         pane.remove_item(item_ix, false, true, cx);
                     }
@@ -3725,11 +3741,41 @@ mod tests {
 
         assert_item_labels(&pane, [], cx);
 
+        add_labeled_item(&pane, "A", true, cx).update(cx, |item, cx| {
+            item.project_items
+                .push(TestProjectItem::new(1, "A.txt", cx))
+        });
+        add_labeled_item(&pane, "B", true, cx).update(cx, |item, cx| {
+            item.project_items
+                .push(TestProjectItem::new(2, "B.txt", cx))
+        });
+        add_labeled_item(&pane, "C", true, cx).update(cx, |item, cx| {
+            item.project_items
+                .push(TestProjectItem::new(3, "C.txt", cx))
+        });
+        assert_item_labels(&pane, ["A^", "B^", "C*^"], cx);
+
+        let save = pane
+            .update(cx, |pane, cx| {
+                pane.close_all_items(
+                    &CloseAllItems {
+                        save_intent: None,
+                        close_pinned: false,
+                    },
+                    cx,
+                )
+            })
+            .unwrap();
+
+        cx.executor().run_until_parked();
+        cx.simulate_prompt_answer(2);
+        save.await.unwrap();
+        assert_item_labels(&pane, [], cx);
+
         add_labeled_item(&pane, "A", true, cx);
         add_labeled_item(&pane, "B", true, cx);
         add_labeled_item(&pane, "C", true, cx);
         assert_item_labels(&pane, ["A^", "B^", "C*^"], cx);
-
         let save = pane
             .update(cx, |pane, cx| {
                 pane.close_all_items(
@@ -3744,8 +3790,11 @@ mod tests {
 
         cx.executor().run_until_parked();
         cx.simulate_prompt_answer(2);
+        cx.executor().run_until_parked();
+        cx.simulate_prompt_answer(2);
+        cx.executor().run_until_parked();
         save.await.unwrap();
-        assert_item_labels(&pane, [], cx);
+        assert_item_labels(&pane, ["A*^", "B^", "C^"], cx);
     }
 
     #[gpui::test]
@@ -3833,14 +3882,14 @@ mod tests {
     }
 
     // Assert the item label, with the active item label suffixed with a '*'
+    #[track_caller]
     fn assert_item_labels<const COUNT: usize>(
         pane: &View<Pane>,
         expected_states: [&str; COUNT],
         cx: &mut VisualTestContext,
     ) {
-        pane.update(cx, |pane, cx| {
-            let actual_states = pane
-                .items
+        let actual_states = pane.update(cx, |pane, cx| {
+            pane.items
                 .iter()
                 .enumerate()
                 .map(|(ix, item)| {
@@ -3859,12 +3908,11 @@ mod tests {
                     }
                     state
                 })
-                .collect::<Vec<_>>();
-
-            assert_eq!(
-                actual_states, expected_states,
-                "pane items do not match expectation"
-            );
-        })
+                .collect::<Vec<_>>()
+        });
+        assert_eq!(
+            actual_states, expected_states,
+            "pane items do not match expectation"
+        );
     }
 }

crates/workspace/src/workspace.rs 🔗

@@ -391,12 +391,12 @@ impl Global for ProjectItemOpeners {}
 pub fn register_project_item<I: ProjectItem>(cx: &mut AppContext) {
     let builders = cx.default_global::<ProjectItemOpeners>();
     builders.push(|project, project_path, cx| {
-        let project_item = <I::Item as project::Item>::try_open(project, project_path, cx)?;
+        let project_item = <I::Item as project::ProjectItem>::try_open(project, project_path, cx)?;
         let project = project.clone();
         Some(cx.spawn(|cx| async move {
             let project_item = project_item.await?;
             let project_entry_id: Option<ProjectEntryId> =
-                project_item.read_with(&cx, project::Item::entry_id)?;
+                project_item.read_with(&cx, project::ProjectItem::entry_id)?;
             let build_workspace_item = Box::new(|cx: &mut ViewContext<Pane>| {
                 Box::new(cx.new_view(|cx| I::for_project_item(project, project_item, cx)))
                     as Box<dyn ItemHandle>
@@ -2721,7 +2721,7 @@ impl Workspace {
     where
         T: ProjectItem,
     {
-        use project::Item as _;
+        use project::ProjectItem as _;
         let project_item = project_item.read(cx);
         let entry_id = project_item.entry_id(cx);
         let project_path = project_item.project_path(cx);
@@ -6422,24 +6422,26 @@ mod tests {
         let item1 = cx.new_view(|cx| {
             TestItem::new(cx)
                 .with_dirty(true)
-                .with_project_items(&[TestProjectItem::new(1, "1.txt", cx)])
+                .with_project_items(&[dirty_project_item(1, "1.txt", cx)])
         });
         let item2 = cx.new_view(|cx| {
             TestItem::new(cx)
                 .with_dirty(true)
                 .with_conflict(true)
-                .with_project_items(&[TestProjectItem::new(2, "2.txt", cx)])
+                .with_project_items(&[dirty_project_item(2, "2.txt", cx)])
         });
         let item3 = cx.new_view(|cx| {
             TestItem::new(cx)
                 .with_dirty(true)
                 .with_conflict(true)
-                .with_project_items(&[TestProjectItem::new(3, "3.txt", cx)])
+                .with_project_items(&[dirty_project_item(3, "3.txt", cx)])
         });
         let item4 = cx.new_view(|cx| {
-            TestItem::new(cx)
-                .with_dirty(true)
-                .with_project_items(&[TestProjectItem::new_untitled(cx)])
+            TestItem::new(cx).with_dirty(true).with_project_items(&[{
+                let project_item = TestProjectItem::new_untitled(cx);
+                project_item.update(cx, |project_item, _| project_item.is_dirty = true);
+                project_item
+            }])
         });
         let pane = workspace.update(cx, |workspace, cx| {
             workspace.add_item_to_active_pane(Box::new(item1.clone()), None, true, cx);
@@ -6531,7 +6533,7 @@ mod tests {
                 cx.new_view(|cx| {
                     TestItem::new(cx)
                         .with_dirty(true)
-                        .with_project_items(&[TestProjectItem::new(
+                        .with_project_items(&[dirty_project_item(
                             project_entry_id,
                             &format!("{project_entry_id}.txt"),
                             cx,
@@ -6713,6 +6715,9 @@ mod tests {
                 })
             });
             item.is_dirty = true;
+            for project_item in &mut item.project_items {
+                project_item.update(cx, |project_item, _| project_item.is_dirty = true);
+            }
         });
 
         pane.update(cx, |pane, cx| {
@@ -7411,6 +7416,434 @@ mod tests {
         });
     }
 
+    #[gpui::test]
+    async fn test_no_save_prompt_when_multi_buffer_dirty_items_closed(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.background_executor.clone());
+        let project = Project::test(fs, [], cx).await;
+        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
+        let pane = workspace.update(cx, |workspace, _| workspace.active_pane().clone());
+
+        let dirty_regular_buffer = cx.new_view(|cx| {
+            TestItem::new(cx)
+                .with_dirty(true)
+                .with_label("1.txt")
+                .with_project_items(&[dirty_project_item(1, "1.txt", cx)])
+        });
+        let dirty_regular_buffer_2 = cx.new_view(|cx| {
+            TestItem::new(cx)
+                .with_dirty(true)
+                .with_label("2.txt")
+                .with_project_items(&[dirty_project_item(2, "2.txt", cx)])
+        });
+        let dirty_multi_buffer_with_both = cx.new_view(|cx| {
+            TestItem::new(cx)
+                .with_dirty(true)
+                .with_singleton(false)
+                .with_label("Fake Project Search")
+                .with_project_items(&[
+                    dirty_regular_buffer.read(cx).project_items[0].clone(),
+                    dirty_regular_buffer_2.read(cx).project_items[0].clone(),
+                ])
+        });
+        let multi_buffer_with_both_files_id = dirty_multi_buffer_with_both.item_id();
+        workspace.update(cx, |workspace, cx| {
+            workspace.add_item(
+                pane.clone(),
+                Box::new(dirty_regular_buffer.clone()),
+                None,
+                false,
+                false,
+                cx,
+            );
+            workspace.add_item(
+                pane.clone(),
+                Box::new(dirty_regular_buffer_2.clone()),
+                None,
+                false,
+                false,
+                cx,
+            );
+            workspace.add_item(
+                pane.clone(),
+                Box::new(dirty_multi_buffer_with_both.clone()),
+                None,
+                false,
+                false,
+                cx,
+            );
+        });
+
+        pane.update(cx, |pane, cx| {
+            pane.activate_item(2, true, true, cx);
+            assert_eq!(
+                pane.active_item().unwrap().item_id(),
+                multi_buffer_with_both_files_id,
+                "Should select the multi buffer in the pane"
+            );
+        });
+        let close_all_but_multi_buffer_task = pane
+            .update(cx, |pane, cx| {
+                pane.close_inactive_items(
+                    &CloseInactiveItems {
+                        save_intent: Some(SaveIntent::Save),
+                        close_pinned: true,
+                    },
+                    cx,
+                )
+            })
+            .expect("should have inactive files to close");
+        cx.background_executor.run_until_parked();
+        assert!(
+            !cx.has_pending_prompt(),
+            "Multi buffer still has the unsaved buffer inside, so no save prompt should be shown"
+        );
+        close_all_but_multi_buffer_task
+            .await
+            .expect("Closing all buffers but the multi buffer failed");
+        pane.update(cx, |pane, cx| {
+            assert_eq!(dirty_regular_buffer.read(cx).save_count, 0);
+            assert_eq!(dirty_multi_buffer_with_both.read(cx).save_count, 0);
+            assert_eq!(dirty_regular_buffer_2.read(cx).save_count, 0);
+            assert_eq!(pane.items_len(), 1);
+            assert_eq!(
+                pane.active_item().unwrap().item_id(),
+                multi_buffer_with_both_files_id,
+                "Should have only the multi buffer left in the pane"
+            );
+            assert!(
+                dirty_multi_buffer_with_both.read(cx).is_dirty,
+                "The multi buffer containing the unsaved buffer should still be dirty"
+            );
+        });
+
+        let close_multi_buffer_task = pane
+            .update(cx, |pane, cx| {
+                pane.close_active_item(
+                    &CloseActiveItem {
+                        save_intent: Some(SaveIntent::Close),
+                    },
+                    cx,
+                )
+            })
+            .expect("should have the multi buffer to close");
+        cx.background_executor.run_until_parked();
+        assert!(
+            cx.has_pending_prompt(),
+            "Dirty multi buffer should prompt a save dialog"
+        );
+        cx.simulate_prompt_answer(0);
+        cx.background_executor.run_until_parked();
+        close_multi_buffer_task
+            .await
+            .expect("Closing the multi buffer failed");
+        pane.update(cx, |pane, cx| {
+            assert_eq!(
+                dirty_multi_buffer_with_both.read(cx).save_count,
+                1,
+                "Multi buffer item should get be saved"
+            );
+            // Test impl does not save inner items, so we do not assert them
+            assert_eq!(
+                pane.items_len(),
+                0,
+                "No more items should be left in the pane"
+            );
+            assert!(pane.active_item().is_none());
+        });
+    }
+
+    #[gpui::test]
+    async fn test_no_save_prompt_when_dirty_singleton_buffer_closed_with_a_multi_buffer_containing_it_present_in_the_pane(
+        cx: &mut TestAppContext,
+    ) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.background_executor.clone());
+        let project = Project::test(fs, [], cx).await;
+        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
+        let pane = workspace.update(cx, |workspace, _| workspace.active_pane().clone());
+
+        let dirty_regular_buffer = cx.new_view(|cx| {
+            TestItem::new(cx)
+                .with_dirty(true)
+                .with_label("1.txt")
+                .with_project_items(&[dirty_project_item(1, "1.txt", cx)])
+        });
+        let dirty_regular_buffer_2 = cx.new_view(|cx| {
+            TestItem::new(cx)
+                .with_dirty(true)
+                .with_label("2.txt")
+                .with_project_items(&[dirty_project_item(2, "2.txt", cx)])
+        });
+        let clear_regular_buffer = cx.new_view(|cx| {
+            TestItem::new(cx)
+                .with_label("3.txt")
+                .with_project_items(&[TestProjectItem::new(3, "3.txt", cx)])
+        });
+
+        let dirty_multi_buffer_with_both = cx.new_view(|cx| {
+            TestItem::new(cx)
+                .with_dirty(true)
+                .with_singleton(false)
+                .with_label("Fake Project Search")
+                .with_project_items(&[
+                    dirty_regular_buffer.read(cx).project_items[0].clone(),
+                    dirty_regular_buffer_2.read(cx).project_items[0].clone(),
+                    clear_regular_buffer.read(cx).project_items[0].clone(),
+                ])
+        });
+        workspace.update(cx, |workspace, cx| {
+            workspace.add_item(
+                pane.clone(),
+                Box::new(dirty_regular_buffer.clone()),
+                None,
+                false,
+                false,
+                cx,
+            );
+            workspace.add_item(
+                pane.clone(),
+                Box::new(dirty_multi_buffer_with_both.clone()),
+                None,
+                false,
+                false,
+                cx,
+            );
+        });
+
+        pane.update(cx, |pane, cx| {
+            pane.activate_item(0, true, true, cx);
+            assert_eq!(
+                pane.active_item().unwrap().item_id(),
+                dirty_regular_buffer.item_id(),
+                "Should select the dirty singleton buffer in the pane"
+            );
+        });
+        let close_singleton_buffer_task = pane
+            .update(cx, |pane, cx| {
+                pane.close_active_item(&CloseActiveItem { save_intent: None }, cx)
+            })
+            .expect("should have active singleton buffer to close");
+        cx.background_executor.run_until_parked();
+        assert!(
+            !cx.has_pending_prompt(),
+            "Multi buffer is still in the pane and has the unsaved buffer inside, so no save prompt should be shown"
+        );
+
+        close_singleton_buffer_task
+            .await
+            .expect("Should not fail closing the singleton buffer");
+        pane.update(cx, |pane, cx| {
+            assert_eq!(dirty_regular_buffer.read(cx).save_count, 0);
+            assert_eq!(
+                dirty_multi_buffer_with_both.read(cx).save_count,
+                0,
+                "Multi buffer itself should not be saved"
+            );
+            assert_eq!(dirty_regular_buffer_2.read(cx).save_count, 0);
+            assert_eq!(
+                pane.items_len(),
+                1,
+                "A dirty multi buffer should be present in the pane"
+            );
+            assert_eq!(
+                pane.active_item().unwrap().item_id(),
+                dirty_multi_buffer_with_both.item_id(),
+                "Should activate the only remaining item in the pane"
+            );
+        });
+    }
+
+    #[gpui::test]
+    async fn test_save_prompt_when_dirty_multi_buffer_closed_with_some_of_its_dirty_items_not_present_in_the_pane(
+        cx: &mut TestAppContext,
+    ) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.background_executor.clone());
+        let project = Project::test(fs, [], cx).await;
+        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
+        let pane = workspace.update(cx, |workspace, _| workspace.active_pane().clone());
+
+        let dirty_regular_buffer = cx.new_view(|cx| {
+            TestItem::new(cx)
+                .with_dirty(true)
+                .with_label("1.txt")
+                .with_project_items(&[dirty_project_item(1, "1.txt", cx)])
+        });
+        let dirty_regular_buffer_2 = cx.new_view(|cx| {
+            TestItem::new(cx)
+                .with_dirty(true)
+                .with_label("2.txt")
+                .with_project_items(&[dirty_project_item(2, "2.txt", cx)])
+        });
+        let clear_regular_buffer = cx.new_view(|cx| {
+            TestItem::new(cx)
+                .with_label("3.txt")
+                .with_project_items(&[TestProjectItem::new(3, "3.txt", cx)])
+        });
+
+        let dirty_multi_buffer_with_both = cx.new_view(|cx| {
+            TestItem::new(cx)
+                .with_dirty(true)
+                .with_singleton(false)
+                .with_label("Fake Project Search")
+                .with_project_items(&[
+                    dirty_regular_buffer.read(cx).project_items[0].clone(),
+                    dirty_regular_buffer_2.read(cx).project_items[0].clone(),
+                    clear_regular_buffer.read(cx).project_items[0].clone(),
+                ])
+        });
+        let multi_buffer_with_both_files_id = dirty_multi_buffer_with_both.item_id();
+        workspace.update(cx, |workspace, cx| {
+            workspace.add_item(
+                pane.clone(),
+                Box::new(dirty_regular_buffer.clone()),
+                None,
+                false,
+                false,
+                cx,
+            );
+            workspace.add_item(
+                pane.clone(),
+                Box::new(dirty_multi_buffer_with_both.clone()),
+                None,
+                false,
+                false,
+                cx,
+            );
+        });
+
+        pane.update(cx, |pane, cx| {
+            pane.activate_item(1, true, true, cx);
+            assert_eq!(
+                pane.active_item().unwrap().item_id(),
+                multi_buffer_with_both_files_id,
+                "Should select the multi buffer in the pane"
+            );
+        });
+        let _close_multi_buffer_task = pane
+            .update(cx, |pane, cx| {
+                pane.close_active_item(&CloseActiveItem { save_intent: None }, cx)
+            })
+            .expect("should have active multi buffer to close");
+        cx.background_executor.run_until_parked();
+        assert!(
+            cx.has_pending_prompt(),
+            "With one dirty item from the multi buffer not being in the pane, a save prompt should be shown"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_no_save_prompt_when_dirty_multi_buffer_closed_with_all_of_its_dirty_items_present_in_the_pane(
+        cx: &mut TestAppContext,
+    ) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.background_executor.clone());
+        let project = Project::test(fs, [], cx).await;
+        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
+        let pane = workspace.update(cx, |workspace, _| workspace.active_pane().clone());
+
+        let dirty_regular_buffer = cx.new_view(|cx| {
+            TestItem::new(cx)
+                .with_dirty(true)
+                .with_label("1.txt")
+                .with_project_items(&[dirty_project_item(1, "1.txt", cx)])
+        });
+        let dirty_regular_buffer_2 = cx.new_view(|cx| {
+            TestItem::new(cx)
+                .with_dirty(true)
+                .with_label("2.txt")
+                .with_project_items(&[dirty_project_item(2, "2.txt", cx)])
+        });
+        let clear_regular_buffer = cx.new_view(|cx| {
+            TestItem::new(cx)
+                .with_label("3.txt")
+                .with_project_items(&[TestProjectItem::new(3, "3.txt", cx)])
+        });
+
+        let dirty_multi_buffer = cx.new_view(|cx| {
+            TestItem::new(cx)
+                .with_dirty(true)
+                .with_singleton(false)
+                .with_label("Fake Project Search")
+                .with_project_items(&[
+                    dirty_regular_buffer.read(cx).project_items[0].clone(),
+                    dirty_regular_buffer_2.read(cx).project_items[0].clone(),
+                    clear_regular_buffer.read(cx).project_items[0].clone(),
+                ])
+        });
+        workspace.update(cx, |workspace, cx| {
+            workspace.add_item(
+                pane.clone(),
+                Box::new(dirty_regular_buffer.clone()),
+                None,
+                false,
+                false,
+                cx,
+            );
+            workspace.add_item(
+                pane.clone(),
+                Box::new(dirty_regular_buffer_2.clone()),
+                None,
+                false,
+                false,
+                cx,
+            );
+            workspace.add_item(
+                pane.clone(),
+                Box::new(dirty_multi_buffer.clone()),
+                None,
+                false,
+                false,
+                cx,
+            );
+        });
+
+        pane.update(cx, |pane, cx| {
+            pane.activate_item(2, true, true, cx);
+            assert_eq!(
+                pane.active_item().unwrap().item_id(),
+                dirty_multi_buffer.item_id(),
+                "Should select the multi buffer in the pane"
+            );
+        });
+        let close_multi_buffer_task = pane
+            .update(cx, |pane, cx| {
+                pane.close_active_item(&CloseActiveItem { save_intent: None }, cx)
+            })
+            .expect("should have active multi buffer to close");
+        cx.background_executor.run_until_parked();
+        assert!(
+            !cx.has_pending_prompt(),
+            "All dirty items from the multi buffer are in the pane still, no save prompts should be shown"
+        );
+        close_multi_buffer_task
+            .await
+            .expect("Closing multi buffer failed");
+        pane.update(cx, |pane, cx| {
+            assert_eq!(dirty_regular_buffer.read(cx).save_count, 0);
+            assert_eq!(dirty_multi_buffer.read(cx).save_count, 0);
+            assert_eq!(dirty_regular_buffer_2.read(cx).save_count, 0);
+            assert_eq!(
+                pane.items()
+                    .map(|item| item.item_id())
+                    .sorted()
+                    .collect::<Vec<_>>(),
+                vec![
+                    dirty_regular_buffer.item_id(),
+                    dirty_regular_buffer_2.item_id(),
+                ],
+                "Should have no multi buffer left in the pane"
+            );
+            assert!(dirty_regular_buffer.read(cx).is_dirty);
+            assert!(dirty_regular_buffer_2.read(cx).is_dirty);
+        });
+    }
+
     mod register_project_item_tests {
         use ui::Context as _;
 
@@ -7423,7 +7856,7 @@ mod tests {
         // Model
         struct TestPngItem {}
 
-        impl project::Item for TestPngItem {
+        impl project::ProjectItem for TestPngItem {
             fn try_open(
                 _project: &Model<Project>,
                 path: &ProjectPath,
@@ -7443,6 +7876,10 @@ mod tests {
             fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
                 None
             }
+
+            fn is_dirty(&self) -> bool {
+                false
+            }
         }
 
         impl Item for TestPngItemView {
@@ -7485,7 +7922,7 @@ mod tests {
         // Model
         struct TestIpynbItem {}
 
-        impl project::Item for TestIpynbItem {
+        impl project::ProjectItem for TestIpynbItem {
             fn try_open(
                 _project: &Model<Project>,
                 path: &ProjectPath,
@@ -7505,6 +7942,10 @@ mod tests {
             fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
                 None
             }
+
+            fn is_dirty(&self) -> bool {
+                false
+            }
         }
 
         impl Item for TestIpynbItemView {
@@ -7702,4 +8143,12 @@ mod tests {
             Project::init_settings(cx);
         });
     }
+
+    fn dirty_project_item(id: u64, path: &str, cx: &mut AppContext) -> Model<TestProjectItem> {
+        let item = TestProjectItem::new(id, path, cx);
+        item.update(cx, |item, _| {
+            item.is_dirty = true;
+        });
+        item
+    }
 }

crates/zed/src/zed.rs 🔗

@@ -29,7 +29,7 @@ use gpui::{
 pub use open_listener::*;
 use outline_panel::OutlinePanel;
 use paths::{local_settings_file_relative_path, local_tasks_file_relative_path};
-use project::{DirectoryLister, Item};
+use project::{DirectoryLister, ProjectItem};
 use project_panel::ProjectPanel;
 use quick_action_bar::QuickActionBar;
 use recent_projects::open_ssh_project;