Start work on handling multibuffers properly when closing unsaved buffers

Max Brunsfeld created

Change summary

Cargo.lock                            |   2 
crates/diagnostics/Cargo.toml         |   1 
crates/diagnostics/src/diagnostics.rs |   5 
crates/editor/src/items.rs            |  14 
crates/editor/src/multi_buffer.rs     |  23 
crates/gpui/src/app.rs                |  19 
crates/gpui/src/platform/test.rs      |   9 
crates/search/Cargo.toml              |   1 
crates/search/src/project_search.rs   |   8 
crates/workspace/src/pane.rs          | 528 ++++++++--------------------
crates/workspace/src/workspace.rs     | 387 ++++++++++++++++++++-
crates/zed/src/menus.rs               |   7 
12 files changed, 582 insertions(+), 422 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -1242,6 +1242,7 @@ dependencies = [
  "project",
  "serde_json",
  "settings",
+ "smallvec",
  "theme",
  "unindent",
  "util",
@@ -4137,6 +4138,7 @@ dependencies = [
  "serde",
  "serde_json",
  "settings",
+ "smallvec",
  "theme",
  "unindent",
  "util",

crates/diagnostics/Cargo.toml 🔗

@@ -9,6 +9,7 @@ doctest = false
 
 [dependencies]
 anyhow = "1.0"
+smallvec = { version = "1.6", features = ["union"] }
 collections = { path = "../collections" }
 editor = { path = "../editor" }
 language = { path = "../language" }

crates/diagnostics/src/diagnostics.rs 🔗

@@ -18,6 +18,7 @@ use language::{
 use project::{DiagnosticSummary, Project, ProjectPath};
 use serde_json::json;
 use settings::Settings;
+use smallvec::SmallVec;
 use std::{
     any::{Any, TypeId},
     cmp::Ordering,
@@ -479,8 +480,8 @@ impl workspace::Item for ProjectDiagnosticsEditor {
         None
     }
 
-    fn project_entry_id(&self, _: &AppContext) -> Option<project::ProjectEntryId> {
-        None
+    fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[project::ProjectEntryId; 3]> {
+        self.editor.project_entry_ids(cx)
     }
 
     fn navigate(&mut self, data: Box<dyn Any>, cx: &mut ViewContext<Self>) -> bool {

crates/editor/src/items.rs 🔗

@@ -9,6 +9,7 @@ use language::{Bias, Buffer, File as _, SelectionGoal};
 use project::{File, Project, ProjectEntryId, ProjectPath};
 use rpc::proto::{self, update_view};
 use settings::Settings;
+use smallvec::SmallVec;
 use std::{fmt::Write, path::PathBuf, time::Duration};
 use text::{Point, Selection};
 use util::TryFutureExt;
@@ -293,14 +294,21 @@ impl Item for Editor {
     }
 
     fn project_path(&self, cx: &AppContext) -> Option<ProjectPath> {
-        File::from_dyn(self.buffer().read(cx).file(cx)).map(|file| 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_id(&self, cx: &AppContext) -> Option<ProjectEntryId> {
-        File::from_dyn(self.buffer().read(cx).file(cx)).and_then(|file| file.project_entry_id(cx))
+    fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[ProjectEntryId; 3]> {
+        self.buffer
+            .read(cx)
+            .files(cx)
+            .into_iter()
+            .filter_map(|file| File::from_dyn(Some(file))?.project_entry_id(cx))
+            .collect()
     }
 
     fn clone_on_split(&self, cx: &mut ViewContext<Self>) -> Option<Self>

crates/editor/src/multi_buffer.rs 🔗

@@ -12,6 +12,7 @@ use language::{
     ToPointUtf16 as _, TransactionId,
 };
 use settings::Settings;
+use smallvec::SmallVec;
 use std::{
     cell::{Ref, RefCell},
     cmp, fmt, io,
@@ -1126,18 +1127,26 @@ impl MultiBuffer {
             .and_then(|(buffer, _)| buffer.read(cx).language())
     }
 
-    pub fn file<'a>(&self, cx: &'a AppContext) -> Option<&'a dyn File> {
-        self.as_singleton()?.read(cx).file()
+    pub fn files<'a>(&'a self, cx: &'a AppContext) -> SmallVec<[&'a dyn File; 2]> {
+        let buffers = self.buffers.borrow();
+        buffers
+            .values()
+            .filter_map(|buffer| buffer.buffer.read(cx).file())
+            .collect()
     }
 
     pub fn title(&self, cx: &AppContext) -> String {
         if let Some(title) = self.title.clone() {
-            title
-        } else if let Some(file) = self.file(cx) {
-            file.file_name(cx).to_string_lossy().into()
-        } else {
-            "untitled".into()
+            return title;
         }
+
+        if let Some(buffer) = self.as_singleton() {
+            if let Some(file) = buffer.read(cx).file() {
+                return file.file_name(cx).to_string_lossy().into();
+            }
+        }
+
+        "untitled".into()
     }
 
     #[cfg(test)]

crates/gpui/src/app.rs 🔗

@@ -521,12 +521,27 @@ impl TestAppContext {
             .downcast_mut::<platform::test::Window>()
             .unwrap();
         let mut done_tx = test_window
-            .last_prompt
-            .take()
+            .pending_prompts
+            .borrow_mut()
+            .pop_front()
             .expect("prompt was not called");
         let _ = done_tx.try_send(answer);
     }
 
+    pub fn has_pending_prompt(&self, window_id: usize) -> bool {
+        let mut state = self.cx.borrow_mut();
+        let (_, window) = state
+            .presenters_and_platform_windows
+            .get_mut(&window_id)
+            .unwrap();
+        let test_window = window
+            .as_any_mut()
+            .downcast_mut::<platform::test::Window>()
+            .unwrap();
+        let prompts = test_window.pending_prompts.borrow_mut();
+        !prompts.is_empty()
+    }
+
     #[cfg(any(test, feature = "test-support"))]
     pub fn leak_detector(&self) -> Arc<Mutex<LeakDetector>> {
         self.cx.borrow().leak_detector()

crates/gpui/src/platform/test.rs 🔗

@@ -4,11 +4,12 @@ use crate::{
     keymap, Action, ClipboardItem,
 };
 use anyhow::{anyhow, Result};
+use collections::VecDeque;
 use parking_lot::Mutex;
 use postage::oneshot;
 use std::{
     any::Any,
-    cell::{Cell, RefCell},
+    cell::RefCell,
     path::{Path, PathBuf},
     rc::Rc,
     sync::Arc,
@@ -36,7 +37,7 @@ pub struct Window {
     event_handlers: Vec<Box<dyn FnMut(super::Event)>>,
     resize_handlers: Vec<Box<dyn FnMut()>>,
     close_handlers: Vec<Box<dyn FnOnce()>>,
-    pub(crate) last_prompt: Cell<Option<oneshot::Sender<usize>>>,
+    pub(crate) pending_prompts: RefCell<VecDeque<oneshot::Sender<usize>>>,
 }
 
 #[cfg(any(test, feature = "test-support"))]
@@ -188,7 +189,7 @@ impl Window {
             close_handlers: Vec::new(),
             scale_factor: 1.0,
             current_scene: None,
-            last_prompt: Default::default(),
+            pending_prompts: Default::default(),
         }
     }
 }
@@ -242,7 +243,7 @@ impl super::Window for Window {
 
     fn prompt(&self, _: crate::PromptLevel, _: &str, _: &[&str]) -> oneshot::Receiver<usize> {
         let (done_tx, done_rx) = oneshot::channel();
-        self.last_prompt.replace(Some(done_tx));
+        self.pending_prompts.borrow_mut().push_back(done_tx);
         done_rx
     }
 

crates/search/Cargo.toml 🔗

@@ -21,6 +21,7 @@ anyhow = "1.0"
 log = { version = "0.4.16", features = ["kv_unstable_serde"] }
 postage = { version = "0.4.1", features = ["futures-traits"] }
 serde = { version = "1", features = ["derive"] }
+smallvec = { version = "1.6", features = ["union"] }
 
 [dev-dependencies]
 editor = { path = "../editor", features = ["test-support"] }

crates/search/src/project_search.rs 🔗

@@ -11,6 +11,7 @@ use gpui::{
 };
 use project::{search::SearchQuery, Project};
 use settings::Settings;
+use smallvec::SmallVec;
 use std::{
     any::{Any, TypeId},
     ops::Range,
@@ -18,7 +19,8 @@ use std::{
 };
 use util::ResultExt as _;
 use workspace::{
-    menu::Confirm, Item, ItemNavHistory, Pane, ToolbarItemLocation, ToolbarItemView, Workspace,
+    menu::Confirm, Item, ItemHandle, ItemNavHistory, Pane, ToolbarItemLocation, ToolbarItemView,
+    Workspace,
 };
 
 actions!(project_search, [Deploy, SearchInNew, ToggleFocus]);
@@ -234,8 +236,8 @@ impl Item for ProjectSearchView {
         None
     }
 
-    fn project_entry_id(&self, _: &AppContext) -> Option<project::ProjectEntryId> {
-        None
+    fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[project::ProjectEntryId; 3]> {
+        self.results_editor.project_entry_ids(cx)
     }
 
     fn can_save(&self, _: &gpui::AppContext) -> bool {

crates/workspace/src/pane.rs 🔗

@@ -9,10 +9,10 @@ use gpui::{
     geometry::{rect::RectF, vector::vec2f},
     impl_actions, impl_internal_actions,
     platform::{CursorStyle, NavigationDirection},
-    AppContext, Entity, MutableAppContext, PromptLevel, Quad, RenderContext, Task, View,
-    ViewContext, ViewHandle, WeakViewHandle,
+    AppContext, AsyncAppContext, Entity, ModelHandle, MutableAppContext, PromptLevel, Quad,
+    RenderContext, Task, View, ViewContext, ViewHandle, WeakViewHandle,
 };
-use project::{ProjectEntryId, ProjectPath};
+use project::{Project, ProjectEntryId, ProjectPath};
 use serde::Deserialize;
 use settings::Settings;
 use std::{any::Any, cell::RefCell, cmp, mem, path::Path, rc::Rc};
@@ -71,7 +71,11 @@ pub fn init(cx: &mut MutableAppContext) {
     cx.add_async_action(Pane::close_inactive_items);
     cx.add_async_action(|workspace: &mut Workspace, action: &CloseItem, cx| {
         let pane = action.pane.upgrade(cx)?;
-        Some(Pane::close_item(workspace, pane, action.item_id, cx))
+        let task = Pane::close_item(workspace, pane, action.item_id, cx);
+        Some(cx.foreground().spawn(async move {
+            task.await?;
+            Ok(())
+        }))
     });
     cx.add_action(|pane: &mut Pane, action: &Split, cx| {
         pane.split(action.0, cx);
@@ -294,7 +298,7 @@ impl Pane {
     ) -> Box<dyn ItemHandle> {
         let existing_item = pane.update(cx, |pane, cx| {
             for (ix, item) in pane.items.iter().enumerate() {
-                if item.project_entry_id(cx) == Some(project_entry_id) {
+                if item.project_entry_ids(cx).as_slice() == &[project_entry_id] {
                     let item = item.boxed_clone();
                     pane.activate_item(ix, true, focus_item, cx);
                     return Some(item);
@@ -351,27 +355,13 @@ impl Pane {
         self.items.get(self.active_item_index).cloned()
     }
 
-    pub fn project_entry_id_for_item(
-        &self,
-        item: &dyn ItemHandle,
-        cx: &AppContext,
-    ) -> Option<ProjectEntryId> {
-        self.items.iter().find_map(|existing| {
-            if existing.id() == item.id() {
-                existing.project_entry_id(cx)
-            } else {
-                None
-            }
-        })
-    }
-
     pub fn item_for_entry(
         &self,
         entry_id: ProjectEntryId,
         cx: &AppContext,
     ) -> Option<Box<dyn ItemHandle>> {
         self.items.iter().find_map(|item| {
-            if item.project_entry_id(cx) == Some(entry_id) {
+            if item.project_entry_ids(cx).as_slice() == &[entry_id] {
                 Some(item.boxed_clone())
             } else {
                 None
@@ -445,12 +435,13 @@ impl Pane {
             None
         } else {
             let item_id_to_close = pane.items[pane.active_item_index].id();
-            Some(Self::close_items(
-                workspace,
-                pane_handle,
-                cx,
-                move |item_id| item_id == item_id_to_close,
-            ))
+            let task = Self::close_items(workspace, pane_handle, cx, move |item_id| {
+                item_id == item_id_to_close
+            });
+            Some(cx.foreground().spawn(async move {
+                task.await?;
+                Ok(())
+            }))
         }
     }
 
@@ -465,8 +456,11 @@ impl Pane {
             None
         } else {
             let active_item_id = pane.items[pane.active_item_index].id();
-            Some(Self::close_items(workspace, pane_handle, cx, move |id| {
-                id != active_item_id
+            let task =
+                Self::close_items(workspace, pane_handle, cx, move |id| id != active_item_id);
+            Some(cx.foreground().spawn(async move {
+                task.await?;
+                Ok(())
             }))
         }
     }
@@ -476,125 +470,67 @@ impl Pane {
         pane: ViewHandle<Pane>,
         item_id_to_close: usize,
         cx: &mut ViewContext<Workspace>,
-    ) -> Task<Result<()>> {
+    ) -> Task<Result<bool>> {
         Self::close_items(workspace, pane, cx, move |view_id| {
             view_id == item_id_to_close
         })
     }
 
-    pub fn close_all_items(
-        workspace: &mut Workspace,
-        pane: ViewHandle<Pane>,
-        cx: &mut ViewContext<Workspace>,
-    ) -> Task<Result<()>> {
-        Self::close_items(workspace, pane, cx, |_| true)
-    }
-
     pub fn close_items(
         workspace: &mut Workspace,
         pane: ViewHandle<Pane>,
         cx: &mut ViewContext<Workspace>,
         should_close: impl 'static + Fn(usize) -> bool,
-    ) -> Task<Result<()>> {
-        const CONFLICT_MESSAGE: &'static str = "This file has changed on disk since you started editing it. Do you want to overwrite it?";
-        const DIRTY_MESSAGE: &'static str =
-            "This file contains unsaved edits. Do you want to save it?";
-
+    ) -> Task<Result<bool>> {
         let project = workspace.project().clone();
+
+        // Find which items to close.
+        let mut items_to_close = Vec::new();
+        for item in &pane.read(cx).items {
+            if should_close(item.id()) {
+                items_to_close.push(item.boxed_clone());
+            }
+        }
+
         cx.spawn(|workspace, mut cx| async move {
-            while let Some(item_to_close_ix) = pane.read_with(&cx, |pane, _| {
-                pane.items.iter().position(|item| should_close(item.id()))
-            }) {
-                let item =
-                    pane.read_with(&cx, |pane, _| pane.items[item_to_close_ix].boxed_clone());
-
-                let is_last_item_for_entry = workspace.read_with(&cx, |workspace, cx| {
-                    let project_entry_id = item.project_entry_id(cx);
-                    project_entry_id.is_none()
-                        || workspace
-                            .items(cx)
-                            .filter(|item| item.project_entry_id(cx) == project_entry_id)
-                            .count()
-                            == 1
+            for item in items_to_close.clone() {
+                let (item_ix, project_entry_ids) = pane.read_with(&cx, |pane, cx| {
+                    (
+                        pane.index_for_item(item.as_ref()),
+                        item.project_entry_ids(cx),
+                    )
                 });
 
-                if is_last_item_for_entry {
-                    if cx.read(|cx| item.has_conflict(cx) && item.can_save(cx)) {
-                        let mut answer = pane.update(&mut cx, |pane, cx| {
-                            pane.activate_item(item_to_close_ix, true, true, cx);
-                            cx.prompt(
-                                PromptLevel::Warning,
-                                CONFLICT_MESSAGE,
-                                &["Overwrite", "Discard", "Cancel"],
-                            )
-                        });
-
-                        match answer.next().await {
-                            Some(0) => {
-                                cx.update(|cx| item.save(project.clone(), cx)).await?;
-                            }
-                            Some(1) => {
-                                cx.update(|cx| item.reload(project.clone(), cx)).await?;
-                            }
-                            _ => break,
-                        }
-                    } else if cx.read(|cx| item.is_dirty(cx)) {
-                        if cx.read(|cx| item.can_save(cx)) {
-                            let mut answer = pane.update(&mut cx, |pane, cx| {
-                                pane.activate_item(item_to_close_ix, true, true, cx);
-                                cx.prompt(
-                                    PromptLevel::Warning,
-                                    DIRTY_MESSAGE,
-                                    &["Save", "Don't Save", "Cancel"],
-                                )
-                            });
-
-                            match answer.next().await {
-                                Some(0) => {
-                                    cx.update(|cx| item.save(project.clone(), cx)).await?;
-                                }
-                                Some(1) => {}
-                                _ => break,
-                            }
-                        } else if cx.read(|cx| item.can_save_as(cx)) {
-                            let mut answer = pane.update(&mut cx, |pane, cx| {
-                                pane.activate_item(item_to_close_ix, true, true, cx);
-                                cx.prompt(
-                                    PromptLevel::Warning,
-                                    DIRTY_MESSAGE,
-                                    &["Save", "Don't Save", "Cancel"],
-                                )
-                            });
-
-                            match answer.next().await {
-                                Some(0) => {
-                                    let start_abs_path = project
-                                        .read_with(&cx, |project, cx| {
-                                            let worktree = project.visible_worktrees(cx).next()?;
-                                            Some(
-                                                worktree
-                                                    .read(cx)
-                                                    .as_local()?
-                                                    .abs_path()
-                                                    .to_path_buf(),
-                                            )
-                                        })
-                                        .unwrap_or(Path::new("").into());
-
-                                    let mut abs_path =
-                                        cx.update(|cx| cx.prompt_for_new_path(&start_abs_path));
-                                    if let Some(abs_path) = abs_path.next().await.flatten() {
-                                        cx.update(|cx| item.save_as(project.clone(), abs_path, cx))
-                                            .await?;
-                                    } else {
-                                        break;
-                                    }
-                                }
-                                Some(1) => {}
-                                _ => break,
-                            }
+                let item_ix = if let Some(ix) = item_ix {
+                    ix
+                } else {
+                    continue;
+                };
+
+                // An item should be saved if either it has *no* project entries, or if it
+                // has project entries that don't exist anywhere else in the workspace.
+                let mut should_save = project_entry_ids.is_empty();
+                let mut project_entry_ids_to_save = project_entry_ids;
+                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 project_entry_ids = item.project_entry_ids(cx);
+                            project_entry_ids_to_save.retain(|id| !project_entry_ids.contains(&id));
                         }
                     }
+                });
+                if !project_entry_ids_to_save.is_empty() {
+                    should_save = true;
+                }
+
+                if should_save
+                    && !Self::save_item(project.clone(), &pane, item_ix, &item, true, &mut cx)
+                        .await?
+                {
+                    break;
                 }
 
                 pane.update(&mut cx, |pane, cx| {
@@ -629,10 +565,88 @@ impl Pane {
             }
 
             pane.update(&mut cx, |_, cx| cx.notify());
-            Ok(())
+            Ok(true)
         })
     }
 
+    pub async fn save_item(
+        project: ModelHandle<Project>,
+        pane: &ViewHandle<Pane>,
+        item_ix: usize,
+        item: &Box<dyn ItemHandle>,
+        should_prompt_for_save: bool,
+        cx: &mut AsyncAppContext,
+    ) -> Result<bool> {
+        const CONFLICT_MESSAGE: &'static str =
+            "This file has changed on disk since you started editing it. Do you want to overwrite it?";
+        const DIRTY_MESSAGE: &'static str =
+            "This file contains unsaved edits. Do you want to save it?";
+
+        let (has_conflict, is_dirty, can_save, can_save_as) = cx.read(|cx| {
+            (
+                item.has_conflict(cx),
+                item.is_dirty(cx),
+                item.can_save(cx),
+                item.can_save_as(cx),
+            )
+        });
+
+        if has_conflict && can_save {
+            let mut answer = pane.update(cx, |pane, cx| {
+                pane.activate_item(item_ix, true, true, cx);
+                cx.prompt(
+                    PromptLevel::Warning,
+                    CONFLICT_MESSAGE,
+                    &["Overwrite", "Discard", "Cancel"],
+                )
+            });
+            match answer.next().await {
+                Some(0) => cx.update(|cx| item.save(project, cx)).await?,
+                Some(1) => cx.update(|cx| item.reload(project, cx)).await?,
+                _ => return Ok(false),
+            }
+        } else if is_dirty && (can_save || can_save_as) {
+            let should_save = if should_prompt_for_save {
+                let mut answer = pane.update(cx, |pane, cx| {
+                    pane.activate_item(item_ix, true, true, cx);
+                    cx.prompt(
+                        PromptLevel::Warning,
+                        DIRTY_MESSAGE,
+                        &["Save", "Don't Save", "Cancel"],
+                    )
+                });
+                match answer.next().await {
+                    Some(0) => true,
+                    Some(1) => false,
+                    _ => return Ok(false),
+                }
+            } else {
+                true
+            };
+
+            if should_save {
+                if can_save {
+                    cx.update(|cx| item.save(project, cx)).await?;
+                } else if can_save_as {
+                    let start_abs_path = project
+                        .read_with(cx, |project, cx| {
+                            let worktree = project.visible_worktrees(cx).next()?;
+                            Some(worktree.read(cx).as_local()?.abs_path().to_path_buf())
+                        })
+                        .unwrap_or(Path::new("").into());
+
+                    let mut abs_path = cx.update(|cx| cx.prompt_for_new_path(&start_abs_path));
+                    if let Some(abs_path) = abs_path.next().await.flatten() {
+                        cx.update(|cx| item.save_as(project, abs_path, cx)).await?;
+                    } else {
+                        return Ok(false);
+                    }
+                }
+            }
+        }
+        Ok(true)
+    }
+
     pub fn focus_active_item(&mut self, cx: &mut ViewContext<Self>) {
         if let Some(active_item) = self.active_item() {
             cx.focus(active_item);
@@ -924,253 +938,3 @@ impl NavHistory {
         }
     }
 }
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use crate::AppState;
-    use gpui::{ModelHandle, TestAppContext, ViewContext};
-    use project::Project;
-    use std::sync::atomic::AtomicUsize;
-
-    #[gpui::test]
-    async fn test_close_items(cx: &mut TestAppContext) {
-        cx.foreground().forbid_parking();
-
-        let app_state = cx.update(AppState::test);
-        let project = Project::test(app_state.fs.clone(), None, cx).await;
-        let (window_id, workspace) = cx.add_window(|cx| Workspace::new(project, cx));
-        let item1 = cx.add_view(window_id, |_| {
-            let mut item = TestItem::new();
-            item.is_dirty = true;
-            item
-        });
-        let item2 = cx.add_view(window_id, |_| {
-            let mut item = TestItem::new();
-            item.is_dirty = true;
-            item.has_conflict = true;
-            item
-        });
-        let item3 = cx.add_view(window_id, |_| {
-            let mut item = TestItem::new();
-            item.is_dirty = true;
-            item.has_conflict = true;
-            item
-        });
-        let item4 = cx.add_view(window_id, |_| {
-            let mut item = TestItem::new();
-            item.is_dirty = true;
-            item.can_save = false;
-            item
-        });
-        let pane = workspace.update(cx, |workspace, cx| {
-            workspace.add_item(Box::new(item1.clone()), cx);
-            workspace.add_item(Box::new(item2.clone()), cx);
-            workspace.add_item(Box::new(item3.clone()), cx);
-            workspace.add_item(Box::new(item4.clone()), cx);
-            workspace.active_pane().clone()
-        });
-
-        let close_items = workspace.update(cx, |workspace, cx| {
-            pane.update(cx, |pane, cx| {
-                pane.activate_item(1, true, true, cx);
-                assert_eq!(pane.active_item().unwrap().id(), item2.id());
-            });
-
-            let item1_id = item1.id();
-            let item3_id = item3.id();
-            let item4_id = item4.id();
-            Pane::close_items(workspace, pane.clone(), cx, move |id| {
-                [item1_id, item3_id, item4_id].contains(&id)
-            })
-        });
-
-        cx.foreground().run_until_parked();
-        pane.read_with(cx, |pane, _| {
-            assert_eq!(pane.items.len(), 4);
-            assert_eq!(pane.active_item().unwrap().id(), item1.id());
-        });
-
-        cx.simulate_prompt_answer(window_id, 0);
-        cx.foreground().run_until_parked();
-        pane.read_with(cx, |pane, cx| {
-            assert_eq!(item1.read(cx).save_count, 1);
-            assert_eq!(item1.read(cx).save_as_count, 0);
-            assert_eq!(item1.read(cx).reload_count, 0);
-            assert_eq!(pane.items.len(), 3);
-            assert_eq!(pane.active_item().unwrap().id(), item3.id());
-        });
-
-        cx.simulate_prompt_answer(window_id, 1);
-        cx.foreground().run_until_parked();
-        pane.read_with(cx, |pane, cx| {
-            assert_eq!(item3.read(cx).save_count, 0);
-            assert_eq!(item3.read(cx).save_as_count, 0);
-            assert_eq!(item3.read(cx).reload_count, 1);
-            assert_eq!(pane.items.len(), 2);
-            assert_eq!(pane.active_item().unwrap().id(), item4.id());
-        });
-
-        cx.simulate_prompt_answer(window_id, 0);
-        cx.foreground().run_until_parked();
-        cx.simulate_new_path_selection(|_| Some(Default::default()));
-        close_items.await.unwrap();
-        pane.read_with(cx, |pane, cx| {
-            assert_eq!(item4.read(cx).save_count, 0);
-            assert_eq!(item4.read(cx).save_as_count, 1);
-            assert_eq!(item4.read(cx).reload_count, 0);
-            assert_eq!(pane.items.len(), 1);
-            assert_eq!(pane.active_item().unwrap().id(), item2.id());
-        });
-    }
-
-    #[gpui::test]
-    async fn test_prompting_only_on_last_item_for_entry(cx: &mut TestAppContext) {
-        cx.foreground().forbid_parking();
-
-        let app_state = cx.update(AppState::test);
-        let project = Project::test(app_state.fs.clone(), [], cx).await;
-        let (window_id, workspace) = cx.add_window(|cx| Workspace::new(project, cx));
-        let item = cx.add_view(window_id, |_| {
-            let mut item = TestItem::new();
-            item.is_dirty = true;
-            item.project_entry_id = Some(ProjectEntryId::new(&AtomicUsize::new(1)));
-            item
-        });
-
-        let (left_pane, right_pane) = workspace.update(cx, |workspace, cx| {
-            workspace.add_item(Box::new(item.clone()), cx);
-            let left_pane = workspace.active_pane().clone();
-            let right_pane = workspace.split_pane(left_pane.clone(), SplitDirection::Right, cx);
-            (left_pane, right_pane)
-        });
-
-        workspace
-            .update(cx, |workspace, cx| {
-                let item = right_pane.read(cx).active_item().unwrap();
-                Pane::close_item(workspace, right_pane.clone(), item.id(), cx)
-            })
-            .await
-            .unwrap();
-        workspace.read_with(cx, |workspace, _| {
-            assert_eq!(workspace.panes(), [left_pane.clone()]);
-        });
-
-        let close_item = workspace.update(cx, |workspace, cx| {
-            let item = left_pane.read(cx).active_item().unwrap();
-            Pane::close_item(workspace, left_pane.clone(), item.id(), cx)
-        });
-        cx.foreground().run_until_parked();
-        cx.simulate_prompt_answer(window_id, 0);
-        close_item.await.unwrap();
-        left_pane.read_with(cx, |pane, _| {
-            assert_eq!(pane.items.len(), 0);
-        });
-    }
-
-    #[derive(Clone)]
-    struct TestItem {
-        save_count: usize,
-        save_as_count: usize,
-        reload_count: usize,
-        is_dirty: bool,
-        has_conflict: bool,
-        can_save: bool,
-        project_entry_id: Option<ProjectEntryId>,
-    }
-
-    impl TestItem {
-        fn new() -> Self {
-            Self {
-                save_count: 0,
-                save_as_count: 0,
-                reload_count: 0,
-                is_dirty: false,
-                has_conflict: false,
-                can_save: true,
-                project_entry_id: None,
-            }
-        }
-    }
-
-    impl Entity for TestItem {
-        type Event = ();
-    }
-
-    impl View for TestItem {
-        fn ui_name() -> &'static str {
-            "TestItem"
-        }
-
-        fn render(&mut self, _: &mut RenderContext<Self>) -> ElementBox {
-            Empty::new().boxed()
-        }
-    }
-
-    impl Item for TestItem {
-        fn tab_content(&self, _: &theme::Tab, _: &AppContext) -> ElementBox {
-            Empty::new().boxed()
-        }
-
-        fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
-            None
-        }
-
-        fn project_entry_id(&self, _: &AppContext) -> Option<ProjectEntryId> {
-            self.project_entry_id
-        }
-
-        fn set_nav_history(&mut self, _: ItemNavHistory, _: &mut ViewContext<Self>) {}
-
-        fn clone_on_split(&self, _: &mut ViewContext<Self>) -> Option<Self>
-        where
-            Self: Sized,
-        {
-            Some(self.clone())
-        }
-
-        fn is_dirty(&self, _: &AppContext) -> bool {
-            self.is_dirty
-        }
-
-        fn has_conflict(&self, _: &AppContext) -> bool {
-            self.has_conflict
-        }
-
-        fn can_save(&self, _: &AppContext) -> bool {
-            self.can_save
-        }
-
-        fn save(
-            &mut self,
-            _: ModelHandle<Project>,
-            _: &mut ViewContext<Self>,
-        ) -> Task<anyhow::Result<()>> {
-            self.save_count += 1;
-            Task::ready(Ok(()))
-        }
-
-        fn can_save_as(&self, _: &AppContext) -> bool {
-            true
-        }
-
-        fn save_as(
-            &mut self,
-            _: ModelHandle<Project>,
-            _: std::path::PathBuf,
-            _: &mut ViewContext<Self>,
-        ) -> Task<anyhow::Result<()>> {
-            self.save_as_count += 1;
-            Task::ready(Ok(()))
-        }
-
-        fn reload(
-            &mut self,
-            _: ModelHandle<Project>,
-            _: &mut ViewContext<Self>,
-        ) -> Task<anyhow::Result<()>> {
-            self.reload_count += 1;
-            Task::ready(Ok(()))
-        }
-    }
-}

crates/workspace/src/workspace.rs 🔗

@@ -33,6 +33,7 @@ use postage::prelude::Stream;
 use project::{fs, Fs, Project, ProjectEntryId, ProjectPath, Worktree};
 use settings::Settings;
 use sidebar::{Side, Sidebar, SidebarButtons, ToggleSidebarItem, ToggleSidebarItemFocus};
+use smallvec::SmallVec;
 use status_bar::StatusBar;
 pub use status_bar::StatusItemView;
 use std::{
@@ -82,6 +83,7 @@ actions!(
         Unfollow,
         Save,
         SaveAs,
+        SaveAll,
         ActivatePreviousPane,
         ActivateNextPane,
         FollowNextCollaborator,
@@ -144,6 +146,7 @@ pub fn init(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
     cx.add_async_action(Workspace::toggle_follow);
     cx.add_async_action(Workspace::follow_next_collaborator);
     cx.add_async_action(Workspace::close);
+    cx.add_async_action(Workspace::save_all);
     cx.add_action(Workspace::add_folder_to_project);
     cx.add_action(
         |workspace: &mut Workspace, _: &Unfollow, cx: &mut ViewContext<Workspace>| {
@@ -219,7 +222,7 @@ pub trait Item: View {
     }
     fn tab_content(&self, style: &theme::Tab, cx: &AppContext) -> ElementBox;
     fn project_path(&self, cx: &AppContext) -> Option<ProjectPath>;
-    fn project_entry_id(&self, cx: &AppContext) -> Option<ProjectEntryId>;
+    fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[ProjectEntryId; 3]>;
     fn set_nav_history(&mut self, _: ItemNavHistory, _: &mut ViewContext<Self>);
     fn clone_on_split(&self, _: &mut ViewContext<Self>) -> Option<Self>
     where
@@ -369,7 +372,7 @@ impl<T: FollowableItem> FollowableItemHandle for ViewHandle<T> {
 pub trait ItemHandle: 'static + fmt::Debug {
     fn tab_content(&self, style: &theme::Tab, cx: &AppContext) -> ElementBox;
     fn project_path(&self, cx: &AppContext) -> Option<ProjectPath>;
-    fn project_entry_id(&self, cx: &AppContext) -> Option<ProjectEntryId>;
+    fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[ProjectEntryId; 3]>;
     fn boxed_clone(&self) -> Box<dyn ItemHandle>;
     fn set_nav_history(&self, nav_history: Rc<RefCell<NavHistory>>, cx: &mut MutableAppContext);
     fn clone_on_split(&self, cx: &mut MutableAppContext) -> Option<Box<dyn ItemHandle>>;
@@ -430,8 +433,8 @@ impl<T: Item> ItemHandle for ViewHandle<T> {
         self.read(cx).project_path(cx)
     }
 
-    fn project_entry_id(&self, cx: &AppContext) -> Option<ProjectEntryId> {
-        self.read(cx).project_entry_id(cx)
+    fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[ProjectEntryId; 3]> {
+        self.read(cx).project_entry_ids(cx)
     }
 
     fn boxed_clone(&self) -> Box<dyn ItemHandle> {
@@ -884,28 +887,76 @@ impl Workspace {
     }
 
     fn close(&mut self, _: &CloseWindow, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
-        let mut tasks = Vec::new();
-        for pane in self.panes.clone() {
-            tasks.push(Pane::close_all_items(self, pane, cx));
-        }
+        let save_all = self.save_all_internal(true, cx);
         Some(cx.spawn(|this, mut cx| async move {
-            for task in tasks {
-                task.await?;
-            }
-            this.update(&mut cx, |this, cx| {
-                if this
-                    .panes
-                    .iter()
-                    .all(|pane| pane.read(cx).items().next().is_none())
-                {
+            if save_all.await? {
+                this.update(&mut cx, |_, cx| {
                     let window_id = cx.window_id();
                     cx.remove_window(window_id);
-                }
-            });
+                });
+            }
             Ok(())
         }))
     }
 
+    fn save_all(&mut self, _: &SaveAll, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
+        let save_all = self.save_all_internal(false, cx);
+        Some(cx.foreground().spawn(async move {
+            save_all.await?;
+            Ok(())
+        }))
+    }
+
+    fn save_all_internal(
+        &mut self,
+        should_prompt_to_save: bool,
+        cx: &mut ViewContext<Self>,
+    ) -> Task<Result<bool>> {
+        let dirty_items = self
+            .panes
+            .iter()
+            .flat_map(|pane| {
+                pane.read(cx).items().filter_map(|item| {
+                    if item.is_dirty(cx) {
+                        Some((pane.clone(), item.boxed_clone()))
+                    } else {
+                        None
+                    }
+                })
+            })
+            .collect::<Vec<_>>();
+
+        let project = self.project.clone();
+        cx.spawn_weak(|_, mut cx| async move {
+            let mut saved_project_entry_ids = HashSet::default();
+            for (pane, item) in dirty_items {
+                let project_entry_ids = cx.read(|cx| item.project_entry_ids(cx));
+                if project_entry_ids
+                    .into_iter()
+                    .any(|entry_id| saved_project_entry_ids.insert(entry_id))
+                {
+                    if let Some(ix) =
+                        pane.read_with(&cx, |pane, _| pane.index_for_item(item.as_ref()))
+                    {
+                        if !Pane::save_item(
+                            project.clone(),
+                            &pane,
+                            ix,
+                            &item,
+                            should_prompt_to_save,
+                            &mut cx,
+                        )
+                        .await?
+                        {
+                            return Ok(false);
+                        }
+                    }
+                }
+            }
+            Ok(true)
+        })
+    }
+
     pub fn open_paths(
         &mut self,
         mut abs_paths: Vec<PathBuf>,
@@ -2356,3 +2407,301 @@ fn open_new(app_state: &Arc<AppState>, cx: &mut MutableAppContext) {
     });
     cx.dispatch_action(window_id, vec![workspace.id()], &NewFile);
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::AppState;
+    use gpui::{ModelHandle, TestAppContext, ViewContext};
+    use project::{FakeFs, Project, ProjectEntryId};
+    use serde_json::json;
+    use std::sync::atomic::AtomicUsize;
+
+    #[gpui::test]
+    async fn test_save_all(cx: &mut TestAppContext) {
+        cx.foreground().forbid_parking();
+        cx.update(|cx| {
+            let settings = Settings::test(cx);
+            cx.set_global(settings);
+        });
+
+        let fs = FakeFs::new(cx.background());
+        fs.insert_tree("/root", json!({ "one": ""})).await;
+        let project = Project::test(fs, ["root".as_ref()], cx).await;
+        let (window_id, workspace) = cx.add_window(|cx| Workspace::new(project.clone(), cx));
+
+        // When there are no dirty items, there's nothing to do.
+        let item1 = cx.add_view(window_id, |_| TestItem::new());
+        workspace.update(cx, |w, cx| w.add_item(Box::new(item1.clone()), cx));
+        let save_all = workspace.update(cx, |w, cx| w.save_all_internal(true, cx));
+        assert_eq!(save_all.await.unwrap(), true);
+
+        // When there are dirty untitled items, prompt to save each one. If the user
+        // cancels any prompt, then abort.
+        let item2 = cx.add_view(window_id, |_| {
+            let mut item = TestItem::new();
+            item.is_dirty = true;
+            item
+        });
+        let item3 = cx.add_view(window_id, |_| {
+            let mut item = TestItem::new();
+            item.is_dirty = true;
+            item
+        });
+        workspace.update(cx, |w, cx| {
+            w.add_item(Box::new(item1.clone()), cx);
+            w.add_item(Box::new(item2.clone()), cx);
+            w.split_pane(w.active_pane().clone(), SplitDirection::Right, cx);
+            w.add_item(Box::new(item3.clone()), cx);
+        });
+
+        eprintln!("save_all 2");
+        let save_all = workspace.update(cx, |w, cx| w.save_all_internal(true, cx));
+        cx.foreground().run_until_parked();
+        cx.simulate_prompt_answer(window_id, 2);
+        cx.foreground().run_until_parked();
+        assert!(!cx.has_pending_prompt(window_id));
+        assert_eq!(save_all.await.unwrap(), false);
+    }
+
+    #[gpui::test]
+    async fn test_close_pane_items(cx: &mut TestAppContext) {
+        cx.foreground().forbid_parking();
+
+        let app_state = cx.update(AppState::test);
+        let project = Project::test(app_state.fs.clone(), None, cx).await;
+        let (window_id, workspace) = cx.add_window(|cx| Workspace::new(project, cx));
+        let item1 = cx.add_view(window_id, |_| {
+            let mut item = TestItem::new();
+            item.is_dirty = true;
+            item
+        });
+        let item2 = cx.add_view(window_id, |_| {
+            let mut item = TestItem::new();
+            item.is_dirty = true;
+            item.has_conflict = true;
+            item
+        });
+        let item3 = cx.add_view(window_id, |_| {
+            let mut item = TestItem::new();
+            item.is_dirty = true;
+            item.has_conflict = true;
+            item
+        });
+        let item4 = cx.add_view(window_id, |_| {
+            let mut item = TestItem::new();
+            item.is_dirty = true;
+            item.can_save = false;
+            item
+        });
+        let pane = workspace.update(cx, |workspace, cx| {
+            workspace.add_item(Box::new(item1.clone()), cx);
+            workspace.add_item(Box::new(item2.clone()), cx);
+            workspace.add_item(Box::new(item3.clone()), cx);
+            workspace.add_item(Box::new(item4.clone()), cx);
+            workspace.active_pane().clone()
+        });
+
+        let close_items = workspace.update(cx, |workspace, cx| {
+            pane.update(cx, |pane, cx| {
+                pane.activate_item(1, true, true, cx);
+                assert_eq!(pane.active_item().unwrap().id(), item2.id());
+            });
+
+            let item1_id = item1.id();
+            let item3_id = item3.id();
+            let item4_id = item4.id();
+            Pane::close_items(workspace, pane.clone(), cx, move |id| {
+                [item1_id, item3_id, item4_id].contains(&id)
+            })
+        });
+
+        cx.foreground().run_until_parked();
+        pane.read_with(cx, |pane, _| {
+            assert_eq!(pane.items().count(), 4);
+            assert_eq!(pane.active_item().unwrap().id(), item1.id());
+        });
+
+        cx.simulate_prompt_answer(window_id, 0);
+        cx.foreground().run_until_parked();
+        pane.read_with(cx, |pane, cx| {
+            assert_eq!(item1.read(cx).save_count, 1);
+            assert_eq!(item1.read(cx).save_as_count, 0);
+            assert_eq!(item1.read(cx).reload_count, 0);
+            assert_eq!(pane.items().count(), 3);
+            assert_eq!(pane.active_item().unwrap().id(), item3.id());
+        });
+
+        cx.simulate_prompt_answer(window_id, 1);
+        cx.foreground().run_until_parked();
+        pane.read_with(cx, |pane, cx| {
+            assert_eq!(item3.read(cx).save_count, 0);
+            assert_eq!(item3.read(cx).save_as_count, 0);
+            assert_eq!(item3.read(cx).reload_count, 1);
+            assert_eq!(pane.items().count(), 2);
+            assert_eq!(pane.active_item().unwrap().id(), item4.id());
+        });
+
+        cx.simulate_prompt_answer(window_id, 0);
+        cx.foreground().run_until_parked();
+        cx.simulate_new_path_selection(|_| Some(Default::default()));
+        close_items.await.unwrap();
+        pane.read_with(cx, |pane, cx| {
+            assert_eq!(item4.read(cx).save_count, 0);
+            assert_eq!(item4.read(cx).save_as_count, 1);
+            assert_eq!(item4.read(cx).reload_count, 0);
+            assert_eq!(pane.items().count(), 1);
+            assert_eq!(pane.active_item().unwrap().id(), item2.id());
+        });
+    }
+
+    #[gpui::test]
+    async fn test_prompting_only_on_last_item_for_entry(cx: &mut TestAppContext) {
+        cx.foreground().forbid_parking();
+
+        let app_state = cx.update(AppState::test);
+        let project = Project::test(app_state.fs.clone(), [], cx).await;
+        let (window_id, workspace) = cx.add_window(|cx| Workspace::new(project, cx));
+        let item = cx.add_view(window_id, |_| {
+            let mut item = TestItem::new();
+            item.is_dirty = true;
+            item.project_entry_id = Some(ProjectEntryId::new(&AtomicUsize::new(1)));
+            item
+        });
+
+        let (left_pane, right_pane) = workspace.update(cx, |workspace, cx| {
+            workspace.add_item(Box::new(item.clone()), cx);
+            let left_pane = workspace.active_pane().clone();
+            let right_pane = workspace.split_pane(left_pane.clone(), SplitDirection::Right, cx);
+            (left_pane, right_pane)
+        });
+
+        workspace
+            .update(cx, |workspace, cx| {
+                let item = right_pane.read(cx).active_item().unwrap();
+                Pane::close_item(workspace, right_pane.clone(), item.id(), cx)
+            })
+            .await
+            .unwrap();
+        workspace.read_with(cx, |workspace, _| {
+            assert_eq!(workspace.panes(), [left_pane.clone()]);
+        });
+
+        let close_item = workspace.update(cx, |workspace, cx| {
+            let item = left_pane.read(cx).active_item().unwrap();
+            Pane::close_item(workspace, left_pane.clone(), item.id(), cx)
+        });
+        cx.foreground().run_until_parked();
+        cx.simulate_prompt_answer(window_id, 0);
+        close_item.await.unwrap();
+        left_pane.read_with(cx, |pane, _| {
+            assert_eq!(pane.items().count(), 0);
+        });
+    }
+
+    #[derive(Clone)]
+    struct TestItem {
+        save_count: usize,
+        save_as_count: usize,
+        reload_count: usize,
+        is_dirty: bool,
+        has_conflict: bool,
+        can_save: bool,
+        project_entry_id: Option<ProjectEntryId>,
+    }
+
+    impl TestItem {
+        fn new() -> Self {
+            Self {
+                save_count: 0,
+                save_as_count: 0,
+                reload_count: 0,
+                is_dirty: false,
+                has_conflict: false,
+                can_save: true,
+                project_entry_id: None,
+            }
+        }
+    }
+
+    impl Entity for TestItem {
+        type Event = ();
+    }
+
+    impl View for TestItem {
+        fn ui_name() -> &'static str {
+            "TestItem"
+        }
+
+        fn render(&mut self, _: &mut RenderContext<Self>) -> ElementBox {
+            Empty::new().boxed()
+        }
+    }
+
+    impl Item for TestItem {
+        fn tab_content(&self, _: &theme::Tab, _: &AppContext) -> ElementBox {
+            Empty::new().boxed()
+        }
+
+        fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
+            None
+        }
+
+        fn project_entry_ids(&self, _: &AppContext) -> SmallVec<[ProjectEntryId; 3]> {
+            self.project_entry_id.into_iter().collect()
+        }
+
+        fn set_nav_history(&mut self, _: ItemNavHistory, _: &mut ViewContext<Self>) {}
+
+        fn clone_on_split(&self, _: &mut ViewContext<Self>) -> Option<Self>
+        where
+            Self: Sized,
+        {
+            Some(self.clone())
+        }
+
+        fn is_dirty(&self, _: &AppContext) -> bool {
+            self.is_dirty
+        }
+
+        fn has_conflict(&self, _: &AppContext) -> bool {
+            self.has_conflict
+        }
+
+        fn can_save(&self, _: &AppContext) -> bool {
+            self.can_save
+        }
+
+        fn save(
+            &mut self,
+            _: ModelHandle<Project>,
+            _: &mut ViewContext<Self>,
+        ) -> Task<anyhow::Result<()>> {
+            self.save_count += 1;
+            Task::ready(Ok(()))
+        }
+
+        fn can_save_as(&self, _: &AppContext) -> bool {
+            true
+        }
+
+        fn save_as(
+            &mut self,
+            _: ModelHandle<Project>,
+            _: std::path::PathBuf,
+            _: &mut ViewContext<Self>,
+        ) -> Task<anyhow::Result<()>> {
+            self.save_as_count += 1;
+            Task::ready(Ok(()))
+        }
+
+        fn reload(
+            &mut self,
+            _: ModelHandle<Project>,
+            _: &mut ViewContext<Self>,
+        ) -> Task<anyhow::Result<()>> {
+            self.reload_count += 1;
+            Task::ready(Ok(()))
+        }
+    }
+}

crates/zed/src/menus.rs 🔗

@@ -225,5 +225,12 @@ pub fn menus() -> Vec<Menu<'static>> {
                 },
             ],
         },
+        Menu {
+            name: "Help",
+            items: vec![MenuItem::Action {
+                name: "Command Palette",
+                action: Box::new(command_palette::Toggle),
+            }],
+        },
     ]
 }