Merge pull request #2045 from zed-industries/fewer-unsaved-prompts

Max Brunsfeld created

Avoid prompting to save when closing an untitled buffer that is still open elsewhere

Change summary

crates/collab/src/tests/integration_tests.rs  |   6 
crates/diagnostics/src/diagnostics.rs         |   9 
crates/editor/src/items.rs                    |  28 +--
crates/editor/src/multi_buffer.rs             |  12 
crates/project/src/project.rs                 |  10 +
crates/search/src/project_search.rs           |   9 
crates/terminal_view/src/terminal_view.rs     |  11 -
crates/theme_testbench/src/theme_testbench.rs |  21 -
crates/workspace/src/item.rs                  | 141 ++++++++++++++----
crates/workspace/src/pane.rs                  |  51 +++---
crates/workspace/src/shared_screen.rs         |  19 -
crates/workspace/src/workspace.rs             | 157 ++++++++++----------
12 files changed, 258 insertions(+), 216 deletions(-)

Detailed changes

crates/collab/src/tests/integration_tests.rs 🔗

@@ -32,7 +32,9 @@ use std::{
     sync::Arc,
 };
 use unindent::Unindent as _;
-use workspace::{item::Item, shared_screen::SharedScreen, SplitDirection, ToggleFollow, Workspace};
+use workspace::{
+    item::ItemHandle as _, shared_screen::SharedScreen, SplitDirection, ToggleFollow, Workspace,
+};
 
 #[ctor::ctor]
 fn init_logger() {
@@ -5602,7 +5604,7 @@ async fn test_following(
     });
     assert!(cx_b.read(|cx| editor_b2.is_focused(cx)));
     assert_eq!(
-        editor_b2.read_with(cx_b, |editor, cx| editor.project_path(cx)),
+        cx_b.read(|cx| editor_b2.project_path(cx)),
         Some((worktree_id, "2.txt").into())
     );
     assert_eq!(

crates/diagnostics/src/diagnostics.rs 🔗

@@ -21,7 +21,6 @@ use language::{
 use project::{DiagnosticSummary, Project, ProjectPath};
 use serde_json::json;
 use settings::Settings;
-use smallvec::SmallVec;
 use std::{
     any::{Any, TypeId},
     cmp::Ordering,
@@ -521,12 +520,8 @@ impl Item for ProjectDiagnosticsEditor {
         )
     }
 
-    fn project_path(&self, _: &AppContext) -> Option<project::ProjectPath> {
-        None
-    }
-
-    fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[project::ProjectEntryId; 3]> {
-        self.editor.project_entry_ids(cx)
+    fn for_each_project_item(&self, cx: &AppContext, f: &mut dyn FnMut(usize, &dyn project::Item)) {
+        self.editor.for_each_project_item(cx, f)
     }
 
     fn is_singleton(&self, _: &AppContext) -> bool {

crates/editor/src/items.rs 🔗

@@ -14,11 +14,10 @@ use gpui::{
     RenderContext, Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle,
 };
 use language::proto::serialize_anchor as serialize_text_anchor;
-use language::{Bias, Buffer, File as _, OffsetRangeExt, Point, SelectionGoal};
-use project::{File, FormatTrigger, Project, ProjectEntryId, ProjectPath};
+use language::{Bias, Buffer, OffsetRangeExt, Point, SelectionGoal};
+use project::{FormatTrigger, Item as _, Project, ProjectPath};
 use rpc::proto::{self, update_view};
 use settings::Settings;
-use smallvec::SmallVec;
 use std::{
     borrow::Cow,
     cmp::{self, Ordering},
@@ -555,22 +554,10 @@ impl Item for Editor {
             .boxed()
     }
 
-    fn project_path(&self, cx: &AppContext) -> Option<ProjectPath> {
-        let buffer = self.buffer.read(cx).as_singleton()?;
-        let file = buffer.read(cx).file();
-        File::from_dyn(file).map(|file| ProjectPath {
-            worktree_id: file.worktree_id(cx),
-            path: file.path().clone(),
-        })
-    }
-
-    fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[ProjectEntryId; 3]> {
+    fn for_each_project_item(&self, cx: &AppContext, f: &mut dyn FnMut(usize, &dyn project::Item)) {
         self.buffer
             .read(cx)
-            .files(cx)
-            .into_iter()
-            .filter_map(|file| File::from_dyn(Some(file))?.project_entry_id(cx))
-            .collect()
+            .for_each_buffer(|buffer| f(buffer.id(), buffer.read(cx)));
     }
 
     fn is_singleton(&self, cx: &AppContext) -> bool {
@@ -607,7 +594,12 @@ impl Item for Editor {
     }
 
     fn can_save(&self, cx: &AppContext) -> bool {
-        !self.buffer().read(cx).is_singleton() || self.project_path(cx).is_some()
+        let buffer = &self.buffer().read(cx);
+        if let Some(buffer) = buffer.as_singleton() {
+            buffer.read(cx).project_path(cx).is_some()
+        } else {
+            true
+        }
     }
 
     fn save(

crates/editor/src/multi_buffer.rs 🔗

@@ -9,11 +9,10 @@ use gpui::{AppContext, Entity, ModelContext, ModelHandle, Task};
 pub use language::Completion;
 use language::{
     char_kind, AutoindentMode, Buffer, BufferChunks, BufferSnapshot, CharKind, Chunk, CursorShape,
-    DiagnosticEntry, File, IndentSize, Language, OffsetRangeExt, OffsetUtf16, Outline, OutlineItem,
+    DiagnosticEntry, IndentSize, Language, OffsetRangeExt, OffsetUtf16, Outline, OutlineItem,
     Point, PointUtf16, Selection, TextDimension, ToOffset as _, ToOffsetUtf16 as _, ToPoint as _,
     ToPointUtf16 as _, TransactionId, Unclipped,
 };
-use smallvec::SmallVec;
 use std::{
     borrow::Cow,
     cell::{Ref, RefCell},
@@ -1311,12 +1310,11 @@ impl MultiBuffer {
             .and_then(|(buffer, offset)| buffer.read(cx).language_at(offset))
     }
 
-    pub fn files<'a>(&'a self, cx: &'a AppContext) -> SmallVec<[&'a Arc<dyn File>; 2]> {
-        let buffers = self.buffers.borrow();
-        buffers
+    pub fn for_each_buffer(&self, mut f: impl FnMut(&ModelHandle<Buffer>)) {
+        self.buffers
+            .borrow()
             .values()
-            .filter_map(|buffer| buffer.buffer.read(cx).file())
-            .collect()
+            .for_each(|state| f(&state.buffer))
     }
 
     pub fn title<'a>(&'a self, cx: &'a AppContext) -> Cow<'a, str> {

crates/project/src/project.rs 🔗

@@ -67,8 +67,9 @@ use util::{debug_panic, defer, post_inc, ResultExt, TryFutureExt as _};
 pub use fs::*;
 pub use worktree::*;
 
-pub trait Item: Entity {
+pub trait Item {
     fn entry_id(&self, cx: &AppContext) -> Option<ProjectEntryId>;
+    fn project_path(&self, cx: &AppContext) -> Option<ProjectPath>;
 }
 
 // Language server state is stored across 3 collections:
@@ -6401,4 +6402,11 @@ impl Item for Buffer {
     fn entry_id(&self, cx: &AppContext) -> Option<ProjectEntryId> {
         File::from_dyn(self.file()).and_then(|file| file.project_entry_id(cx))
     }
+
+    fn project_path(&self, cx: &AppContext) -> Option<ProjectPath> {
+        File::from_dyn(self.file()).map(|file| ProjectPath {
+            worktree_id: file.worktree_id(cx),
+            path: file.path().clone(),
+        })
+    }
 }

crates/search/src/project_search.rs 🔗

@@ -15,7 +15,6 @@ use gpui::{
 use menu::Confirm;
 use project::{search::SearchQuery, Project};
 use settings::Settings;
-use smallvec::SmallVec;
 use std::{
     any::{Any, TypeId},
     ops::Range,
@@ -264,12 +263,8 @@ impl Item for ProjectSearchView {
             .boxed()
     }
 
-    fn project_path(&self, _: &gpui::AppContext) -> Option<project::ProjectPath> {
-        None
-    }
-
-    fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[project::ProjectEntryId; 3]> {
-        self.results_editor.project_entry_ids(cx)
+    fn for_each_project_item(&self, cx: &AppContext, f: &mut dyn FnMut(usize, &dyn project::Item)) {
+        self.results_editor.for_each_project_item(cx, f)
     }
 
     fn is_singleton(&self, _: &AppContext) -> bool {

crates/terminal_view/src/terminal_view.rs 🔗

@@ -18,10 +18,9 @@ use gpui::{
     AnyViewHandle, AppContext, Element, ElementBox, Entity, ModelHandle, MutableAppContext, Task,
     View, ViewContext, ViewHandle, WeakViewHandle,
 };
-use project::{LocalWorktree, Project, ProjectPath};
+use project::{LocalWorktree, Project};
 use serde::Deserialize;
 use settings::{Settings, TerminalBlink, WorkingDirectory};
-use smallvec::SmallVec;
 use smol::Timer;
 use terminal::{
     alacritty_terminal::{
@@ -616,13 +615,7 @@ impl Item for TerminalView {
         None
     }
 
-    fn project_path(&self, _cx: &gpui::AppContext) -> Option<ProjectPath> {
-        None
-    }
-
-    fn project_entry_ids(&self, _cx: &gpui::AppContext) -> SmallVec<[project::ProjectEntryId; 3]> {
-        SmallVec::new()
-    }
+    fn for_each_project_item(&self, _: &AppContext, _: &mut dyn FnMut(usize, &dyn project::Item)) {}
 
     fn is_singleton(&self, _cx: &gpui::AppContext) -> bool {
         false

crates/theme_testbench/src/theme_testbench.rs 🔗

@@ -6,12 +6,11 @@ use gpui::{
         Padding, ParentElement,
     },
     fonts::TextStyle,
-    Border, Element, Entity, ModelHandle, MutableAppContext, Quad, RenderContext, Task, View,
-    ViewContext, ViewHandle, WeakViewHandle,
+    AppContext, Border, Element, Entity, ModelHandle, MutableAppContext, Quad, RenderContext, Task,
+    View, ViewContext, ViewHandle, WeakViewHandle,
 };
-use project::{Project, ProjectEntryId, ProjectPath};
+use project::Project;
 use settings::Settings;
-use smallvec::SmallVec;
 use theme::{ColorScheme, Layer, Style, StyleSet};
 use workspace::{
     item::{Item, ItemEvent},
@@ -306,7 +305,7 @@ impl Item for ThemeTestbench {
         &self,
         _: Option<usize>,
         style: &theme::Tab,
-        _: &gpui::AppContext,
+        _: &AppContext,
     ) -> gpui::ElementBox {
         Label::new("Theme Testbench".into(), style.label.clone())
             .aligned()
@@ -314,21 +313,15 @@ impl Item for ThemeTestbench {
             .boxed()
     }
 
-    fn project_path(&self, _: &gpui::AppContext) -> Option<ProjectPath> {
-        None
-    }
-
-    fn project_entry_ids(&self, _: &gpui::AppContext) -> SmallVec<[ProjectEntryId; 3]> {
-        SmallVec::new()
-    }
+    fn for_each_project_item(&self, _: &AppContext, _: &mut dyn FnMut(usize, &dyn project::Item)) {}
 
-    fn is_singleton(&self, _: &gpui::AppContext) -> bool {
+    fn is_singleton(&self, _: &AppContext) -> bool {
         false
     }
 
     fn set_nav_history(&mut self, _: workspace::ItemNavHistory, _: &mut ViewContext<Self>) {}
 
-    fn can_save(&self, _: &gpui::AppContext) -> bool {
+    fn can_save(&self, _: &AppContext) -> bool {
         false
     }
 

crates/workspace/src/item.rs 🔗

@@ -49,8 +49,7 @@ pub trait Item: View {
     }
     fn tab_content(&self, detail: Option<usize>, style: &theme::Tab, cx: &AppContext)
         -> ElementBox;
-    fn project_path(&self, cx: &AppContext) -> Option<ProjectPath>;
-    fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[ProjectEntryId; 3]>;
+    fn for_each_project_item(&self, _: &AppContext, _: &mut dyn FnMut(usize, &dyn project::Item));
     fn is_singleton(&self, cx: &AppContext) -> bool;
     fn set_nav_history(&mut self, _: ItemNavHistory, _: &mut ViewContext<Self>);
     fn clone_on_split(&self, _workspace_id: WorkspaceId, _: &mut ViewContext<Self>) -> Option<Self>
@@ -147,6 +146,8 @@ pub trait ItemHandle: 'static + fmt::Debug {
         -> ElementBox;
     fn project_path(&self, cx: &AppContext) -> Option<ProjectPath>;
     fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[ProjectEntryId; 3]>;
+    fn project_item_model_ids(&self, cx: &AppContext) -> SmallVec<[usize; 3]>;
+    fn for_each_project_item(&self, _: &AppContext, _: &mut dyn FnMut(usize, &dyn project::Item));
     fn is_singleton(&self, cx: &AppContext) -> bool;
     fn boxed_clone(&self) -> Box<dyn ItemHandle>;
     fn clone_on_split(
@@ -240,11 +241,36 @@ impl<T: Item> ItemHandle for ViewHandle<T> {
     }
 
     fn project_path(&self, cx: &AppContext) -> Option<ProjectPath> {
-        self.read(cx).project_path(cx)
+        let this = self.read(cx);
+        let mut result = None;
+        if this.is_singleton(cx) {
+            this.for_each_project_item(cx, &mut |_, item| {
+                result = item.project_path(cx);
+            });
+        }
+        result
     }
 
     fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[ProjectEntryId; 3]> {
-        self.read(cx).project_entry_ids(cx)
+        let mut result = SmallVec::new();
+        self.read(cx).for_each_project_item(cx, &mut |_, item| {
+            if let Some(id) = item.entry_id(cx) {
+                result.push(id);
+            }
+        });
+        result
+    }
+
+    fn project_item_model_ids(&self, cx: &AppContext) -> SmallVec<[usize; 3]> {
+        let mut result = SmallVec::new();
+        self.read(cx).for_each_project_item(cx, &mut |id, _| {
+            result.push(id);
+        });
+        result
+    }
+
+    fn for_each_project_item(&self, cx: &AppContext, f: &mut dyn FnMut(usize, &dyn project::Item)) {
+        self.read(cx).for_each_project_item(cx, f)
     }
 
     fn is_singleton(&self, cx: &AppContext) -> bool {
@@ -582,7 +608,7 @@ impl<T: Item> WeakItemHandle for WeakViewHandle<T> {
 }
 
 pub trait ProjectItem: Item {
-    type Item: project::Item;
+    type Item: project::Item + gpui::Entity;
 
     fn for_project_item(
         project: ModelHandle<Project>,
@@ -690,18 +716,19 @@ impl<T: FollowableItem> FollowableItemHandle for ViewHandle<T> {
 
 #[cfg(test)]
 pub(crate) mod test {
-    use std::{any::Any, borrow::Cow, cell::Cell};
-
+    use super::{Item, ItemEvent};
+    use crate::{sidebar::SidebarItem, ItemId, ItemNavHistory, Pane, Workspace, WorkspaceId};
     use gpui::{
-        elements::Empty, AppContext, Element, ElementBox, Entity, ModelHandle, RenderContext, Task,
-        View, ViewContext, ViewHandle, WeakViewHandle,
+        elements::Empty, AppContext, Element, ElementBox, Entity, ModelHandle, MutableAppContext,
+        RenderContext, Task, View, ViewContext, ViewHandle, WeakViewHandle,
     };
-    use project::{Project, ProjectEntryId, ProjectPath};
-    use smallvec::SmallVec;
+    use project::{Project, ProjectEntryId, ProjectPath, WorktreeId};
+    use std::{any::Any, borrow::Cow, cell::Cell, path::Path};
 
-    use crate::{sidebar::SidebarItem, ItemId, ItemNavHistory, Pane, Workspace, WorkspaceId};
-
-    use super::{Item, ItemEvent};
+    pub struct TestProjectItem {
+        pub entry_id: Option<ProjectEntryId>,
+        pub project_path: Option<ProjectPath>,
+    }
 
     pub struct TestItem {
         pub workspace_id: WorkspaceId,
@@ -713,13 +740,26 @@ pub(crate) mod test {
         pub is_dirty: bool,
         pub is_singleton: bool,
         pub has_conflict: bool,
-        pub project_entry_ids: Vec<ProjectEntryId>,
-        pub project_path: Option<ProjectPath>,
+        pub project_items: Vec<ModelHandle<TestProjectItem>>,
         pub nav_history: Option<ItemNavHistory>,
         pub tab_descriptions: Option<Vec<&'static str>>,
         pub tab_detail: Cell<Option<usize>>,
     }
 
+    impl Entity for TestProjectItem {
+        type Event = ();
+    }
+
+    impl project::Item for TestProjectItem {
+        fn entry_id(&self, _: &AppContext) -> Option<ProjectEntryId> {
+            self.entry_id
+        }
+
+        fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
+            self.project_path.clone()
+        }
+    }
+
     pub enum TestItemEvent {
         Edit,
     }
@@ -735,8 +775,7 @@ pub(crate) mod test {
                 is_dirty: self.is_dirty,
                 is_singleton: self.is_singleton,
                 has_conflict: self.has_conflict,
-                project_entry_ids: self.project_entry_ids.clone(),
-                project_path: self.project_path.clone(),
+                project_items: self.project_items.clone(),
                 nav_history: None,
                 tab_descriptions: None,
                 tab_detail: Default::default(),
@@ -745,6 +784,27 @@ pub(crate) mod test {
         }
     }
 
+    impl TestProjectItem {
+        pub fn new(id: u64, path: &str, cx: &mut MutableAppContext) -> ModelHandle<Self> {
+            let entry_id = Some(ProjectEntryId::from_proto(id));
+            let project_path = Some(ProjectPath {
+                worktree_id: WorktreeId::from_usize(0),
+                path: Path::new(path).into(),
+            });
+            cx.add_model(|_| Self {
+                entry_id,
+                project_path,
+            })
+        }
+
+        pub fn new_untitled(cx: &mut MutableAppContext) -> ModelHandle<Self> {
+            cx.add_model(|_| Self {
+                project_path: None,
+                entry_id: None,
+            })
+        }
+    }
+
     impl TestItem {
         pub fn new() -> Self {
             Self {
@@ -755,8 +815,7 @@ pub(crate) mod test {
                 reload_count: 0,
                 is_dirty: false,
                 has_conflict: false,
-                project_entry_ids: Vec::new(),
-                project_path: None,
+                project_items: Vec::new(),
                 is_singleton: true,
                 nav_history: None,
                 tab_descriptions: None,
@@ -781,13 +840,19 @@ pub(crate) mod test {
             self
         }
 
-        pub fn with_project_entry_ids(mut self, project_entry_ids: &[u64]) -> Self {
-            self.project_entry_ids.extend(
-                project_entry_ids
-                    .iter()
-                    .copied()
-                    .map(ProjectEntryId::from_proto),
-            );
+        pub fn with_dirty(mut self, dirty: bool) -> Self {
+            self.is_dirty = dirty;
+            self
+        }
+
+        pub fn with_conflict(mut self, has_conflict: bool) -> Self {
+            self.has_conflict = has_conflict;
+            self
+        }
+
+        pub fn with_project_items(mut self, items: &[ModelHandle<TestProjectItem>]) -> Self {
+            self.project_items.clear();
+            self.project_items.extend(items.iter().cloned());
             self
         }
 
@@ -830,12 +895,14 @@ pub(crate) mod test {
             Empty::new().boxed()
         }
 
-        fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
-            self.project_path.clone()
-        }
-
-        fn project_entry_ids(&self, _: &AppContext) -> SmallVec<[ProjectEntryId; 3]> {
-            self.project_entry_ids.iter().copied().collect()
+        fn for_each_project_item(
+            &self,
+            cx: &AppContext,
+            f: &mut dyn FnMut(usize, &dyn project::Item),
+        ) {
+            self.project_items
+                .iter()
+                .for_each(|item| f(item.id(), item.read(cx)))
         }
 
         fn is_singleton(&self, _: &AppContext) -> bool {
@@ -879,8 +946,12 @@ pub(crate) mod test {
             self.has_conflict
         }
 
-        fn can_save(&self, _: &AppContext) -> bool {
-            !self.project_entry_ids.is_empty()
+        fn can_save(&self, cx: &AppContext) -> bool {
+            !self.project_items.is_empty()
+                && self
+                    .project_items
+                    .iter()
+                    .all(|item| item.read(cx).entry_id.is_some())
         }
 
         fn save(

crates/workspace/src/pane.rs 🔗

@@ -482,7 +482,7 @@ impl Pane {
     ) -> Box<dyn ItemHandle> {
         let existing_item = pane.update(cx, |pane, cx| {
             for (index, item) in pane.items.iter().enumerate() {
-                if item.project_path(cx).is_some()
+                if item.is_singleton(cx)
                     && item.project_entry_ids(cx).as_slice() == [project_entry_id]
                 {
                     let item = item.boxed_clone();
@@ -804,13 +804,13 @@ impl Pane {
         items_to_close.sort_by_key(|item| !item.is_singleton(cx));
 
         cx.spawn(|workspace, mut cx| async move {
-            let mut saved_project_entry_ids = HashSet::default();
+            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 entries. Avoid
                 // storing these in advance, in case they have changed since this task
                 // was started.
-                let (item_ix, mut project_entry_ids) = pane.read_with(&cx, |pane, cx| {
-                    (pane.index_for_item(&*item), item.project_entry_ids(cx))
+                let (item_ix, mut project_item_ids) = pane.read_with(&cx, |pane, cx| {
+                    (pane.index_for_item(&*item), item.project_item_model_ids(cx))
                 });
                 let item_ix = if let Some(ix) = item_ix {
                     ix
@@ -823,25 +823,20 @@ impl Pane {
                 // any project entries that are not open anywhere else 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 should_save = if project_entry_ids.is_empty() {
-                    true
-                } else {
-                    workspace.read_with(&cx, |workspace, cx| {
-                        for item in workspace.items(cx) {
-                            if !items_to_close
-                                .iter()
-                                .any(|item_to_close| item_to_close.id() == item.id())
-                            {
-                                let other_project_entry_ids = item.project_entry_ids(cx);
-                                project_entry_ids
-                                    .retain(|id| !other_project_entry_ids.contains(id));
-                            }
+                workspace.read_with(&cx, |workspace, cx| {
+                    for item in workspace.items(cx) {
+                        if !items_to_close
+                            .iter()
+                            .any(|item_to_close| item_to_close.id() == item.id())
+                        {
+                            let other_project_item_ids = item.project_item_model_ids(cx);
+                            project_item_ids.retain(|id| !other_project_item_ids.contains(id));
                         }
-                    });
-                    project_entry_ids
-                        .iter()
-                        .any(|id| saved_project_entry_ids.insert(*id))
-                };
+                    }
+                });
+                let should_save = project_item_ids
+                    .iter()
+                    .any(|id| saved_project_items_ids.insert(*id));
 
                 if should_save
                     && !Self::save_item(project.clone(), &pane, item_ix, &*item, true, &mut cx)
@@ -1677,7 +1672,7 @@ mod tests {
     use std::sync::Arc;
 
     use super::*;
-    use crate::item::test::TestItem;
+    use crate::item::test::{TestItem, TestProjectItem};
     use gpui::{executor::Deterministic, TestAppContext};
     use project::FakeFs;
 
@@ -1866,7 +1861,7 @@ mod tests {
             let item = TestItem::new()
                 .with_singleton(true)
                 .with_label("buffer 1")
-                .with_project_entry_ids(&[1]);
+                .with_project_items(&[TestProjectItem::new(1, "one.txt", cx)]);
 
             Pane::add_item(
                 workspace,
@@ -1885,7 +1880,7 @@ mod tests {
             let item = TestItem::new()
                 .with_singleton(true)
                 .with_label("buffer 1")
-                .with_project_entry_ids(&[1]);
+                .with_project_items(&[TestProjectItem::new(1, "1.txt", cx)]);
 
             Pane::add_item(
                 workspace,
@@ -1904,7 +1899,7 @@ mod tests {
             let item = TestItem::new()
                 .with_singleton(true)
                 .with_label("buffer 2")
-                .with_project_entry_ids(&[2]);
+                .with_project_items(&[TestProjectItem::new(2, "2.txt", cx)]);
 
             Pane::add_item(
                 workspace,
@@ -1923,7 +1918,7 @@ mod tests {
             let item = TestItem::new()
                 .with_singleton(false)
                 .with_label("multibuffer 1")
-                .with_project_entry_ids(&[1]);
+                .with_project_items(&[TestProjectItem::new(1, "1.txt", cx)]);
 
             Pane::add_item(
                 workspace,
@@ -1942,7 +1937,7 @@ mod tests {
             let item = TestItem::new()
                 .with_singleton(false)
                 .with_label("multibuffer 1b")
-                .with_project_entry_ids(&[1]);
+                .with_project_items(&[TestProjectItem::new(1, "1.txt", cx)]);
 
             Pane::add_item(
                 workspace,

crates/workspace/src/shared_screen.rs 🔗

@@ -8,12 +8,11 @@ use futures::StreamExt;
 use gpui::{
     elements::*,
     geometry::{rect::RectF, vector::vec2f},
-    Entity, ModelHandle, MouseButton, RenderContext, Task, View, ViewContext, ViewHandle,
-    WeakViewHandle,
+    AppContext, Entity, ModelHandle, MouseButton, RenderContext, Task, View, ViewContext,
+    ViewHandle, WeakViewHandle,
 };
 use project::Project;
 use settings::Settings;
-use smallvec::SmallVec;
 use std::{
     path::PathBuf,
     sync::{Arc, Weak},
@@ -106,7 +105,7 @@ impl Item for SharedScreen {
         &self,
         _: Option<usize>,
         style: &theme::Tab,
-        _: &gpui::AppContext,
+        _: &AppContext,
     ) -> gpui::ElementBox {
         Flex::row()
             .with_child(
@@ -130,15 +129,9 @@ impl Item for SharedScreen {
             .boxed()
     }
 
-    fn project_path(&self, _: &gpui::AppContext) -> Option<project::ProjectPath> {
-        Default::default()
-    }
-
-    fn project_entry_ids(&self, _: &gpui::AppContext) -> SmallVec<[project::ProjectEntryId; 3]> {
-        Default::default()
-    }
+    fn for_each_project_item(&self, _: &AppContext, _: &mut dyn FnMut(usize, &dyn project::Item)) {}
 
-    fn is_singleton(&self, _: &gpui::AppContext) -> bool {
+    fn is_singleton(&self, _: &AppContext) -> bool {
         false
     }
 
@@ -155,7 +148,7 @@ impl Item for SharedScreen {
         Some(Self::new(&track, self.peer_id, self.user.clone(), cx))
     }
 
-    fn can_save(&self, _: &gpui::AppContext) -> bool {
+    fn can_save(&self, _: &AppContext) -> bool {
         false
     }
 

crates/workspace/src/workspace.rs 🔗

@@ -2746,7 +2746,7 @@ pub fn open_new(app_state: &Arc<AppState>, cx: &mut MutableAppContext) -> Task<(
 mod tests {
     use std::{cell::RefCell, rc::Rc};
 
-    use crate::item::test::{TestItem, TestItemEvent};
+    use crate::item::test::{TestItem, TestItemEvent, TestProjectItem};
 
     use super::*;
     use fs::FakeFs;
@@ -2853,15 +2853,11 @@ mod tests {
             project.worktrees(cx).next().unwrap().read(cx).id()
         });
 
-        let item1 = cx.add_view(&workspace, |_| {
-            let mut item = TestItem::new();
-            item.project_path = Some((worktree_id, "one.txt").into());
-            item
+        let item1 = cx.add_view(&workspace, |cx| {
+            TestItem::new().with_project_items(&[TestProjectItem::new(1, "one.txt", cx)])
         });
-        let item2 = cx.add_view(&workspace, |_| {
-            let mut item = TestItem::new();
-            item.project_path = Some((worktree_id, "two.txt").into());
-            item
+        let item2 = cx.add_view(&workspace, |cx| {
+            TestItem::new().with_project_items(&[TestProjectItem::new(2, "two.txt", cx)])
         });
 
         // Add an item to an empty pane
@@ -2962,16 +2958,11 @@ mod tests {
 
         // When there are dirty untitled items, prompt to save each one. If the user
         // cancels any prompt, then abort.
-        let item2 = cx.add_view(&workspace, |_| {
-            let mut item = TestItem::new();
-            item.is_dirty = true;
-            item
-        });
-        let item3 = cx.add_view(&workspace, |_| {
-            let mut item = TestItem::new();
-            item.is_dirty = true;
-            item.project_entry_ids = vec![ProjectEntryId::from_proto(1)];
-            item
+        let item2 = cx.add_view(&workspace, |_| TestItem::new().with_dirty(true));
+        let item3 = cx.add_view(&workspace, |cx| {
+            TestItem::new()
+                .with_dirty(true)
+                .with_project_items(&[TestProjectItem::new(1, "1.txt", cx)])
         });
         workspace.update(cx, |w, cx| {
             w.add_item(Box::new(item2.clone()), cx);
@@ -2996,30 +2987,27 @@ mod tests {
             Workspace::new(Default::default(), 0, project, default_item_factory, cx)
         });
 
-        let item1 = cx.add_view(&workspace, |_| {
-            let mut item = TestItem::new();
-            item.is_dirty = true;
-            item.project_entry_ids = vec![ProjectEntryId::from_proto(1)];
-            item
+        let item1 = cx.add_view(&workspace, |cx| {
+            TestItem::new()
+                .with_dirty(true)
+                .with_project_items(&[TestProjectItem::new(1, "1.txt", cx)])
         });
-        let item2 = cx.add_view(&workspace, |_| {
-            let mut item = TestItem::new();
-            item.is_dirty = true;
-            item.has_conflict = true;
-            item.project_entry_ids = vec![ProjectEntryId::from_proto(2)];
-            item
+        let item2 = cx.add_view(&workspace, |cx| {
+            TestItem::new()
+                .with_dirty(true)
+                .with_conflict(true)
+                .with_project_items(&[TestProjectItem::new(2, "2.txt", cx)])
         });
-        let item3 = cx.add_view(&workspace, |_| {
-            let mut item = TestItem::new();
-            item.is_dirty = true;
-            item.has_conflict = true;
-            item.project_entry_ids = vec![ProjectEntryId::from_proto(3)];
-            item
+        let item3 = cx.add_view(&workspace, |cx| {
+            TestItem::new()
+                .with_dirty(true)
+                .with_conflict(true)
+                .with_project_items(&[TestProjectItem::new(3, "3.txt", cx)])
         });
-        let item4 = cx.add_view(&workspace, |_| {
-            let mut item = TestItem::new();
-            item.is_dirty = true;
-            item
+        let item4 = cx.add_view(&workspace, |cx| {
+            TestItem::new()
+                .with_dirty(true)
+                .with_project_items(&[TestProjectItem::new_untitled(cx)])
         });
         let pane = workspace.update(cx, |workspace, cx| {
             workspace.add_item(Box::new(item1.clone()), cx);
@@ -3042,15 +3030,20 @@ mod tests {
                 [item1_id, item3_id, item4_id].contains(&id)
             })
         });
-
         cx.foreground().run_until_parked();
+
+        // There's a prompt to save item 1.
         pane.read_with(cx, |pane, _| {
             assert_eq!(pane.items_len(), 4);
             assert_eq!(pane.active_item().unwrap().id(), item1.id());
         });
+        assert!(cx.has_pending_prompt(window_id));
 
+        // Confirm saving item 1.
         cx.simulate_prompt_answer(window_id, 0);
         cx.foreground().run_until_parked();
+
+        // Item 1 is saved. There's a prompt to save item 3.
         pane.read_with(cx, |pane, cx| {
             assert_eq!(item1.read(cx).save_count, 1);
             assert_eq!(item1.read(cx).save_as_count, 0);
@@ -3058,9 +3051,13 @@ mod tests {
             assert_eq!(pane.items_len(), 3);
             assert_eq!(pane.active_item().unwrap().id(), item3.id());
         });
+        assert!(cx.has_pending_prompt(window_id));
 
+        // Cancel saving item 3.
         cx.simulate_prompt_answer(window_id, 1);
         cx.foreground().run_until_parked();
+
+        // Item 3 is reloaded. There's a prompt to save item 4.
         pane.read_with(cx, |pane, cx| {
             assert_eq!(item3.read(cx).save_count, 0);
             assert_eq!(item3.read(cx).save_as_count, 0);
@@ -3068,11 +3065,17 @@ mod tests {
             assert_eq!(pane.items_len(), 2);
             assert_eq!(pane.active_item().unwrap().id(), item4.id());
         });
+        assert!(cx.has_pending_prompt(window_id));
 
+        // Confirm saving item 4.
         cx.simulate_prompt_answer(window_id, 0);
         cx.foreground().run_until_parked();
+
+        // There's a prompt for a path for item 4.
         cx.simulate_new_path_selection(|_| Some(Default::default()));
         close_items.await.unwrap();
+
+        // The requested items are closed.
         pane.read_with(cx, |pane, cx| {
             assert_eq!(item4.read(cx).save_count, 0);
             assert_eq!(item4.read(cx).save_as_count, 1);
@@ -3097,29 +3100,35 @@ mod tests {
         // workspace items with multiple project entries.
         let single_entry_items = (0..=4)
             .map(|project_entry_id| {
-                let mut item = TestItem::new();
-                item.is_dirty = true;
-                item.project_entry_ids = vec![ProjectEntryId::from_proto(project_entry_id)];
-                item.is_singleton = true;
-                item
+                cx.add_view(&workspace, |cx| {
+                    TestItem::new()
+                        .with_dirty(true)
+                        .with_project_items(&[TestProjectItem::new(
+                            project_entry_id,
+                            &format!("{project_entry_id}.txt"),
+                            cx,
+                        )])
+                })
             })
             .collect::<Vec<_>>();
-        let item_2_3 = {
-            let mut item = TestItem::new();
-            item.is_dirty = true;
-            item.is_singleton = false;
-            item.project_entry_ids =
-                vec![ProjectEntryId::from_proto(2), ProjectEntryId::from_proto(3)];
-            item
-        };
-        let item_3_4 = {
-            let mut item = TestItem::new();
-            item.is_dirty = true;
-            item.is_singleton = false;
-            item.project_entry_ids =
-                vec![ProjectEntryId::from_proto(3), ProjectEntryId::from_proto(4)];
-            item
-        };
+        let item_2_3 = cx.add_view(&workspace, |cx| {
+            TestItem::new()
+                .with_dirty(true)
+                .with_singleton(false)
+                .with_project_items(&[
+                    single_entry_items[2].read(cx).project_items[0].clone(),
+                    single_entry_items[3].read(cx).project_items[0].clone(),
+                ])
+        });
+        let item_3_4 = cx.add_view(&workspace, |cx| {
+            TestItem::new()
+                .with_dirty(true)
+                .with_singleton(false)
+                .with_project_items(&[
+                    single_entry_items[3].read(cx).project_items[0].clone(),
+                    single_entry_items[4].read(cx).project_items[0].clone(),
+                ])
+        });
 
         // Create two panes that contain the following project entries:
         //   left pane:
@@ -3130,9 +3139,9 @@ mod tests {
         //     multi-entry items:   (3, 4)
         let left_pane = workspace.update(cx, |workspace, cx| {
             let left_pane = workspace.active_pane().clone();
-            workspace.add_item(Box::new(cx.add_view(|_| item_2_3.clone())), cx);
-            for item in &single_entry_items {
-                workspace.add_item(Box::new(cx.add_view(|_| item.clone())), cx);
+            workspace.add_item(Box::new(item_2_3.clone()), cx);
+            for item in single_entry_items {
+                workspace.add_item(Box::new(item), cx);
             }
             left_pane.update(cx, |pane, cx| {
                 pane.activate_item(2, true, true, cx);
@@ -3147,7 +3156,7 @@ mod tests {
 
         //Need to cause an effect flush in order to respect new focus
         workspace.update(cx, |workspace, cx| {
-            workspace.add_item(Box::new(cx.add_view(|_| item_3_4.clone())), cx);
+            workspace.add_item(Box::new(item_3_4.clone()), cx);
             cx.focus(left_pane.clone());
         });
 
@@ -3196,10 +3205,8 @@ mod tests {
             Workspace::new(Default::default(), 0, project, default_item_factory, cx)
         });
 
-        let item = cx.add_view(&workspace, |_| {
-            let mut item = TestItem::new();
-            item.project_entry_ids = vec![ProjectEntryId::from_proto(1)];
-            item
+        let item = cx.add_view(&workspace, |cx| {
+            TestItem::new().with_project_items(&[TestProjectItem::new(1, "1.txt", cx)])
         });
         let item_id = item.id();
         workspace.update(cx, |workspace, cx| {
@@ -3284,7 +3291,9 @@ mod tests {
             workspace.add_item(Box::new(item.clone()), cx);
         });
         item.update(cx, |item, cx| {
-            item.project_entry_ids = Default::default();
+            item.project_items[0].update(cx, |item, _| {
+                item.entry_id = None;
+            });
             item.is_dirty = true;
             cx.blur();
         });
@@ -3315,10 +3324,8 @@ mod tests {
             Workspace::new(Default::default(), 0, project, default_item_factory, cx)
         });
 
-        let item = cx.add_view(&workspace, |_| {
-            let mut item = TestItem::new();
-            item.project_entry_ids = vec![ProjectEntryId::from_proto(1)];
-            item
+        let item = cx.add_view(&workspace, |cx| {
+            TestItem::new().with_project_items(&[TestProjectItem::new(1, "1.txt", cx)])
         });
         let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
         let toolbar = pane.read_with(cx, |pane, _| pane.toolbar().clone());