Merge pull request #1027 from zed-industries/missing-menu-commands

Max Brunsfeld created

Add missing File menu commands, improve handling of unsaved multibuffers

Change summary

Cargo.lock                            |   2 
assets/keymaps/default.json           |   8 
crates/diagnostics/Cargo.toml         |   1 
crates/diagnostics/src/diagnostics.rs |  13 
crates/editor/src/editor.rs           |   6 
crates/editor/src/items.rs            |  22 
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   |  16 
crates/settings/src/settings.rs       |   8 
crates/workspace/src/pane.rs          | 536 ++++++++--------------------
crates/workspace/src/workspace.rs     | 510 ++++++++++++++++++++++++++
crates/zed/src/main.rs                |   6 
crates/zed/src/menus.rs               |  29 +
crates/zed/src/zed.rs                 |  20 
17 files changed, 785 insertions(+), 444 deletions(-)

Detailed changes

Cargo.lock πŸ”—

@@ -1245,6 +1245,7 @@ dependencies = [
  "project",
  "serde_json",
  "settings",
+ "smallvec",
  "theme",
  "unindent",
  "util",
@@ -4149,6 +4150,7 @@ dependencies = [
  "serde",
  "serde_json",
  "settings",
+ "smallvec",
  "theme",
  "unindent",
  "util",

assets/keymaps/default.json πŸ”—

@@ -14,13 +14,16 @@
             "shift-cmd-{": "pane::ActivatePrevItem",
             "shift-cmd-}": "pane::ActivateNextItem",
             "cmd-w": "pane::CloseActiveItem",
+            "cmd-shift-W": "workspace::CloseWindow",
             "alt-cmd-w": "pane::CloseInactiveItems",
             "cmd-s": "workspace::Save",
+            "cmd-shift-S": "workspace::SaveAs",
             "cmd-=": "zed::IncreaseBufferFontSize",
             "cmd--": "zed::DecreaseBufferFontSize",
             "cmd-,": "zed::OpenSettings",
             "cmd-q": "zed::Quit",
-            "cmd-n": "workspace::OpenNew",
+            "cmd-n": "workspace::NewFile",
+            "cmd-shift-N": "workspace::NewWindow",
             "cmd-o": "workspace::Open"
         }
     },
@@ -217,7 +220,8 @@
             "cmd-t": "project_symbols::Toggle",
             "cmd-p": "file_finder::Toggle",
             "cmd-shift-P": "command_palette::Toggle",
-            "cmd-shift-M": "diagnostics::Deploy"
+            "cmd-shift-M": "diagnostics::Deploy",
+            "cmd-alt-s": "workspace::SaveAll"
         }
     },
     // Bindings from Sublime Text

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,12 @@ 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 is_singleton(&self, _: &AppContext) -> bool {
+        false
     }
 
     fn navigate(&mut self, data: Box<dyn Any>, cx: &mut ViewContext<Self>) -> bool {
@@ -516,10 +521,6 @@ impl workspace::Item for ProjectDiagnosticsEditor {
         self.editor.reload(project, cx)
     }
 
-    fn can_save_as(&self, _: &AppContext) -> bool {
-        false
-    }
-
     fn save_as(
         &mut self,
         _: ModelHandle<Project>,

crates/editor/src/editor.rs πŸ”—

@@ -216,7 +216,7 @@ pub enum Direction {
 }
 
 pub fn init(cx: &mut MutableAppContext) {
-    cx.add_action(Editor::open_new);
+    cx.add_action(Editor::new_file);
     cx.add_action(|this: &mut Editor, action: &Scroll, cx| this.set_scroll_position(action.0, cx));
     cx.add_action(Editor::select);
     cx.add_action(Editor::cancel);
@@ -1002,9 +1002,9 @@ impl Editor {
         this
     }
 
-    pub fn open_new(
+    pub fn new_file(
         workspace: &mut Workspace,
-        _: &workspace::OpenNew,
+        _: &workspace::NewFile,
         cx: &mut ViewContext<Workspace>,
     ) {
         let project = workspace.project().clone();

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,25 @@ 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 is_singleton(&self, cx: &AppContext) -> bool {
+        self.buffer.read(cx).is_singleton()
     }
 
     fn clone_on_split(&self, cx: &mut ViewContext<Self>) -> Option<Self>
@@ -372,10 +384,6 @@ impl Item for Editor {
         })
     }
 
-    fn can_save_as(&self, cx: &AppContext) -> bool {
-        self.buffer().read(cx).is_singleton()
-    }
-
     fn save_as(
         &mut self,
         project: ModelHandle<Project>,

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,12 @@ 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 is_singleton(&self, _: &AppContext) -> bool {
+        false
     }
 
     fn can_save(&self, _: &gpui::AppContext) -> bool {
@@ -259,10 +265,6 @@ impl Item for ProjectSearchView {
             .update(cx, |editor, cx| editor.save(project, cx))
     }
 
-    fn can_save_as(&self, _: &gpui::AppContext) -> bool {
-        false
-    }
-
     fn save_as(
         &mut self,
         _: ModelHandle<Project>,

crates/settings/src/settings.rs πŸ”—

@@ -136,6 +136,14 @@ impl Settings {
         }
     }
 
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn test_async(cx: &mut gpui::TestAppContext) {
+        cx.update(|cx| {
+            let settings = Self::test(cx);
+            cx.set_global(settings.clone());
+        });
+    }
+
     pub fn merge(
         &mut self,
         data: &SettingsFileContent,

crates/workspace/src/pane.rs πŸ”—

@@ -1,7 +1,7 @@
 use super::{ItemHandle, SplitDirection};
 use crate::{toolbar::Toolbar, Item, WeakItemHandle, Workspace};
 use anyhow::Result;
-use collections::{HashMap, VecDeque};
+use collections::{HashMap, HashSet, VecDeque};
 use futures::StreamExt;
 use gpui::{
     actions,
@@ -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.is_singleton(cx) && 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,7 +470,7 @@ 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
         })
@@ -487,108 +481,72 @@ impl Pane {
         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();
-        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
-                });
 
-                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"],
-                            )
-                        });
+        // Find the 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());
+            }
+        }
 
-                        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,
+        // If a buffer is open both in a singleton editor and in a multibuffer, make sure
+        // to focus the singleton buffer when prompting to save that buffer, as opposed
+        // to focusing the multibuffer, because this gives the user a more clear idea
+        // of what content they would be saving.
+        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();
+            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 = if let Some(ix) = item_ix {
+                    ix
+                } else {
+                    continue;
+                };
+
+                // If an item hasn't yet been associated with a project entry, then always
+                // prompt to save it before closing it. Otherwise, check if the item has
+                // 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));
                             }
                         }
+                    });
+                    project_entry_ids
+                        .iter()
+                        .any(|id| saved_project_entry_ids.insert(*id))
+                };
+
+                if should_save {
+                    if !Self::save_item(project.clone(), &pane, item_ix, &item, true, &mut cx)
+                        .await?
+                    {
+                        break;
                     }
                 }
 
+                // Remove the item from the pane.
                 pane.update(&mut cx, |pane, cx| {
                     if let Some(item_ix) = pane.items.iter().position(|i| i.id() == item.id()) {
                         if item_ix == pane.active_item_index {
@@ -621,10 +579,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, is_singleton) = cx.read(|cx| {
+            (
+                item.has_conflict(cx),
+                item.is_dirty(cx),
+                item.can_save(cx),
+                item.is_singleton(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 || is_singleton) {
+            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 is_singleton {
+                    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);
@@ -916,253 +952,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::{
@@ -75,9 +76,14 @@ actions!(
     workspace,
     [
         Open,
-        OpenNew,
+        NewFile,
+        NewWindow,
+        CloseWindow,
+        AddFolderToProject,
         Unfollow,
         Save,
+        SaveAs,
+        SaveAll,
         ActivatePreviousPane,
         ActivateNextPane,
         FollowNextCollaborator,
@@ -114,7 +120,15 @@ pub fn init(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
     });
     cx.add_global_action({
         let app_state = Arc::downgrade(&app_state);
-        move |_: &OpenNew, cx: &mut MutableAppContext| {
+        move |_: &NewFile, cx: &mut MutableAppContext| {
+            if let Some(app_state) = app_state.upgrade() {
+                open_new(&app_state, cx)
+            }
+        }
+    });
+    cx.add_global_action({
+        let app_state = Arc::downgrade(&app_state);
+        move |_: &NewWindow, cx: &mut MutableAppContext| {
             if let Some(app_state) = app_state.upgrade() {
                 open_new(&app_state, cx)
             }
@@ -131,6 +145,9 @@ 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>| {
             let pane = workspace.active_pane().clone();
@@ -139,7 +156,12 @@ pub fn init(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
     );
     cx.add_action(
         |workspace: &mut Workspace, _: &Save, cx: &mut ViewContext<Workspace>| {
-            workspace.save_active_item(cx).detach_and_log_err(cx);
+            workspace.save_active_item(false, cx).detach_and_log_err(cx);
+        },
+    );
+    cx.add_action(
+        |workspace: &mut Workspace, _: &SaveAs, cx: &mut ViewContext<Workspace>| {
+            workspace.save_active_item(true, cx).detach_and_log_err(cx);
         },
     );
     cx.add_action(Workspace::toggle_sidebar_item);
@@ -200,7 +222,8 @@ 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 is_singleton(&self, cx: &AppContext) -> bool;
     fn set_nav_history(&mut self, _: ItemNavHistory, _: &mut ViewContext<Self>);
     fn clone_on_split(&self, _: &mut ViewContext<Self>) -> Option<Self>
     where
@@ -220,7 +243,6 @@ pub trait Item: View {
         project: ModelHandle<Project>,
         cx: &mut ViewContext<Self>,
     ) -> Task<Result<()>>;
-    fn can_save_as(&self, cx: &AppContext) -> bool;
     fn save_as(
         &mut self,
         project: ModelHandle<Project>,
@@ -350,7 +372,8 @@ 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 is_singleton(&self, cx: &AppContext) -> bool;
     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>>;
@@ -367,7 +390,6 @@ pub trait ItemHandle: 'static + fmt::Debug {
     fn is_dirty(&self, cx: &AppContext) -> bool;
     fn has_conflict(&self, cx: &AppContext) -> bool;
     fn can_save(&self, cx: &AppContext) -> bool;
-    fn can_save_as(&self, cx: &AppContext) -> bool;
     fn save(&self, project: ModelHandle<Project>, cx: &mut MutableAppContext) -> Task<Result<()>>;
     fn save_as(
         &self,
@@ -411,8 +433,12 @@ 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 is_singleton(&self, cx: &AppContext) -> bool {
+        self.read(cx).is_singleton(cx)
     }
 
     fn boxed_clone(&self) -> Box<dyn ItemHandle> {
@@ -540,10 +566,6 @@ impl<T: Item> ItemHandle for ViewHandle<T> {
         self.read(cx).can_save(cx)
     }
 
-    fn can_save_as(&self, cx: &AppContext) -> bool {
-        self.read(cx).can_save_as(cx)
-    }
-
     fn save(&self, project: ModelHandle<Project>, cx: &mut MutableAppContext) -> Task<Result<()>> {
         self.update(cx, |item, cx| item.save(project, cx))
     }
@@ -864,6 +886,79 @@ impl Workspace {
         }
     }
 
+    fn close(&mut self, _: &CloseWindow, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
+        let prepare = self.prepare_to_close(cx);
+        Some(cx.spawn(|this, mut cx| async move {
+            if prepare.await? {
+                this.update(&mut cx, |_, cx| {
+                    let window_id = cx.window_id();
+                    cx.remove_window(window_id);
+                });
+            }
+            Ok(())
+        }))
+    }
+
+    fn prepare_to_close(&mut self, cx: &mut ViewContext<Self>) -> Task<Result<bool>> {
+        self.save_all_internal(true, cx)
+    }
+
+    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 (is_singl, project_entry_ids) =
+                    cx.read(|cx| (item.is_singleton(cx), item.project_entry_ids(cx)));
+                if is_singl || !project_entry_ids.is_empty() {
+                    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>,
@@ -912,6 +1007,27 @@ impl Workspace {
         })
     }
 
+    fn add_folder_to_project(&mut self, _: &AddFolderToProject, cx: &mut ViewContext<Self>) {
+        let mut paths = cx.prompt_for_paths(PathPromptOptions {
+            files: false,
+            directories: true,
+            multiple: true,
+        });
+        cx.spawn(|this, mut cx| async move {
+            if let Some(paths) = paths.recv().await.flatten() {
+                let results = this
+                    .update(&mut cx, |this, cx| this.open_paths(paths, cx))
+                    .await;
+                for result in results {
+                    if let Some(result) = result {
+                        result.log_err();
+                    }
+                }
+            }
+        })
+        .detach();
+    }
+
     fn project_path_for_path(
         &self,
         abs_path: &Path,
@@ -1032,10 +1148,14 @@ impl Workspace {
         self.active_item(cx).and_then(|item| item.project_path(cx))
     }
 
-    pub fn save_active_item(&mut self, cx: &mut ViewContext<Self>) -> Task<Result<()>> {
+    pub fn save_active_item(
+        &mut self,
+        force_name_change: bool,
+        cx: &mut ViewContext<Self>,
+    ) -> Task<Result<()>> {
         let project = self.project.clone();
         if let Some(item) = self.active_item(cx) {
-            if item.can_save(cx) {
+            if !force_name_change && item.can_save(cx) {
                 if item.has_conflict(cx.as_ref()) {
                     const CONFLICT_MESSAGE: &'static str = "This file has changed on disk since you started editing it. Do you want to overwrite it?";
 
@@ -1054,7 +1174,7 @@ impl Workspace {
                 } else {
                     item.save(project, cx)
                 }
-            } else if item.can_save_as(cx) {
+            } else if item.is_singleton(cx) {
                 let worktree = self.worktrees(cx).next();
                 let start_abs_path = worktree
                     .and_then(|w| w.read(cx).as_local())
@@ -2287,5 +2407,361 @@ fn open_new(app_state: &Arc<AppState>, cx: &mut MutableAppContext) {
         (app_state.initialize_workspace)(&mut workspace, app_state, cx);
         workspace
     });
-    cx.dispatch_action(window_id, vec![workspace.id()], &OpenNew);
+    cx.dispatch_action(window_id, vec![workspace.id()], &NewFile);
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use gpui::{ModelHandle, TestAppContext, ViewContext};
+    use project::{FakeFs, Project, ProjectEntryId};
+    use serde_json::json;
+
+    #[gpui::test]
+    async fn test_close_window(cx: &mut TestAppContext) {
+        cx.foreground().forbid_parking();
+        Settings::test_async(cx);
+        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 task = workspace.update(cx, |w, cx| w.prepare_to_close(cx));
+        assert_eq!(task.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.project_entry_ids = vec![ProjectEntryId::from_proto(1)];
+            item
+        });
+        workspace.update(cx, |w, cx| {
+            w.add_item(Box::new(item2.clone()), cx);
+            w.add_item(Box::new(item3.clone()), cx);
+        });
+        let task = workspace.update(cx, |w, cx| w.prepare_to_close(cx));
+        cx.foreground().run_until_parked();
+        cx.simulate_prompt_answer(window_id, 2 /* cancel */);
+        cx.foreground().run_until_parked();
+        assert!(!cx.has_pending_prompt(window_id));
+        assert_eq!(task.await.unwrap(), false);
+
+        // If there are multiple dirty items representing the same project entry.
+        workspace.update(cx, |w, cx| {
+            w.add_item(Box::new(item2.clone()), cx);
+            w.add_item(Box::new(item3.clone()), cx);
+        });
+        let task = workspace.update(cx, |w, cx| w.prepare_to_close(cx));
+        cx.foreground().run_until_parked();
+        cx.simulate_prompt_answer(window_id, 2 /* cancel */);
+        cx.foreground().run_until_parked();
+        assert!(!cx.has_pending_prompt(window_id));
+        assert_eq!(task.await.unwrap(), false);
+    }
+
+    #[gpui::test]
+    async fn test_close_pane_items(cx: &mut TestAppContext) {
+        cx.foreground().forbid_parking();
+        Settings::test_async(cx);
+        let fs = FakeFs::new(cx.background());
+
+        let project = Project::test(fs, 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.project_entry_ids = vec![ProjectEntryId::from_proto(1)];
+            item
+        });
+        let item2 = cx.add_view(window_id, |_| {
+            let mut item = TestItem::new();
+            item.is_dirty = true;
+            item.has_conflict = true;
+            item.project_entry_ids = vec![ProjectEntryId::from_proto(2)];
+            item
+        });
+        let item3 = cx.add_view(window_id, |_| {
+            let mut item = TestItem::new();
+            item.is_dirty = true;
+            item.has_conflict = true;
+            item.project_entry_ids = vec![ProjectEntryId::from_proto(3)];
+            item
+        });
+        let item4 = cx.add_view(window_id, |_| {
+            let mut item = TestItem::new();
+            item.is_dirty = true;
+            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_to_save_only_on_last_item_for_entry(cx: &mut TestAppContext) {
+        cx.foreground().forbid_parking();
+        Settings::test_async(cx);
+        let fs = FakeFs::new(cx.background());
+
+        let project = Project::test(fs, [], cx).await;
+        let (window_id, workspace) = cx.add_window(|cx| Workspace::new(project, cx));
+
+        // Create several workspace items with single project entries, and two
+        // 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
+            })
+            .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
+        };
+
+        // Create two panes that contain the following project entries:
+        //   left pane:
+        //     multi-entry items:   (2, 3)
+        //     single-entry items:  0, 1, 2, 3, 4
+        //   right pane:
+        //     single-entry items:  1
+        //     multi-entry items:   (3, 4)
+        let left_pane = workspace.update(cx, |workspace, cx| {
+            let left_pane = workspace.active_pane().clone();
+            let right_pane = workspace.split_pane(left_pane.clone(), SplitDirection::Right, cx);
+
+            workspace.activate_pane(left_pane.clone(), cx);
+            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.activate_pane(right_pane.clone(), cx);
+            workspace.add_item(Box::new(cx.add_view(|_| single_entry_items[1].clone())), cx);
+            workspace.add_item(Box::new(cx.add_view(|_| item_3_4.clone())), cx);
+
+            left_pane
+        });
+
+        // When closing all of the items in the left pane, we should be prompted twice:
+        // once for project entry 0, and once for project entry 2. After those two
+        // prompts, the task should complete.
+        let close = workspace.update(cx, |workspace, cx| {
+            workspace.activate_pane(left_pane.clone(), cx);
+            Pane::close_items(workspace, left_pane.clone(), cx, |_| true)
+        });
+
+        cx.foreground().run_until_parked();
+        left_pane.read_with(cx, |pane, cx| {
+            assert_eq!(
+                pane.active_item().unwrap().project_entry_ids(cx).as_slice(),
+                &[ProjectEntryId::from_proto(0)]
+            );
+        });
+        cx.simulate_prompt_answer(window_id, 0);
+
+        cx.foreground().run_until_parked();
+        left_pane.read_with(cx, |pane, cx| {
+            assert_eq!(
+                pane.active_item().unwrap().project_entry_ids(cx).as_slice(),
+                &[ProjectEntryId::from_proto(2)]
+            );
+        });
+        cx.simulate_prompt_answer(window_id, 0);
+
+        cx.foreground().run_until_parked();
+        close.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,
+        project_entry_ids: Vec<ProjectEntryId>,
+        is_singleton: bool,
+    }
+
+    impl TestItem {
+        fn new() -> Self {
+            Self {
+                save_count: 0,
+                save_as_count: 0,
+                reload_count: 0,
+                is_dirty: false,
+                has_conflict: false,
+                project_entry_ids: Vec::new(),
+                is_singleton: true,
+            }
+        }
+    }
+
+    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_ids.iter().copied().collect()
+        }
+
+        fn is_singleton(&self, _: &AppContext) -> bool {
+            self.is_singleton
+        }
+
+        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.project_entry_ids.len() > 0
+        }
+
+        fn save(
+            &mut self,
+            _: ModelHandle<Project>,
+            _: &mut ViewContext<Self>,
+        ) -> Task<anyhow::Result<()>> {
+            self.save_count += 1;
+            Task::ready(Ok(()))
+        }
+
+        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/main.rs πŸ”—

@@ -38,7 +38,7 @@ use std::{
 };
 use theme::{ThemeRegistry, DEFAULT_THEME_NAME};
 use util::{ResultExt, TryFutureExt};
-use workspace::{self, AppState, OpenNew, OpenPaths};
+use workspace::{self, AppState, NewFile, OpenPaths};
 use zed::{
     self, build_window_options,
     fs::RealFs,
@@ -206,7 +206,7 @@ fn main() {
             cx.platform().activate(true);
             let paths = collect_path_args();
             if paths.is_empty() {
-                cx.dispatch_global_action(OpenNew);
+                cx.dispatch_global_action(NewFile);
             } else {
                 cx.dispatch_global_action(OpenPaths { paths });
             }
@@ -215,7 +215,7 @@ fn main() {
                 cx.spawn(|cx| handle_cli_connection(connection, app_state.clone(), cx))
                     .detach();
             } else {
-                cx.dispatch_global_action(OpenNew);
+                cx.dispatch_global_action(NewFile);
             }
             cx.spawn(|cx| async move {
                 while let Some(connection) = cli_connections_rx.next().await {

crates/zed/src/menus.rs πŸ”—

@@ -31,21 +31,41 @@ pub fn menus() -> Vec<Menu<'static>> {
             items: vec![
                 MenuItem::Action {
                     name: "New",
-                    action: Box::new(workspace::OpenNew),
+                    action: Box::new(workspace::NewFile),
+                },
+                MenuItem::Action {
+                    name: "New Window",
+                    action: Box::new(workspace::NewWindow),
                 },
                 MenuItem::Separator,
                 MenuItem::Action {
                     name: "Open…",
                     action: Box::new(workspace::Open),
                 },
+                MenuItem::Action {
+                    name: "Add Folder to Project…",
+                    action: Box::new(workspace::AddFolderToProject),
+                },
                 MenuItem::Action {
                     name: "Save",
                     action: Box::new(workspace::Save),
                 },
+                MenuItem::Action {
+                    name: "Save As…",
+                    action: Box::new(workspace::SaveAs),
+                },
+                MenuItem::Action {
+                    name: "Save All",
+                    action: Box::new(workspace::SaveAll),
+                },
                 MenuItem::Action {
                     name: "Close Editor",
                     action: Box::new(workspace::CloseActiveItem),
                 },
+                MenuItem::Action {
+                    name: "Close Window",
+                    action: Box::new(workspace::CloseWindow),
+                },
             ],
         },
         Menu {
@@ -209,5 +229,12 @@ pub fn menus() -> Vec<Menu<'static>> {
                 },
             ],
         },
+        Menu {
+            name: "Help",
+            items: vec![MenuItem::Action {
+                name: "Command Palette",
+                action: Box::new(command_palette::Toggle),
+            }],
+        },
     ]
 }

crates/zed/src/zed.rs πŸ”—

@@ -314,7 +314,7 @@ mod tests {
     };
     use theme::{Theme, ThemeRegistry, DEFAULT_THEME_NAME};
     use workspace::{
-        open_paths, pane, Item, ItemHandle, OpenNew, Pane, SplitDirection, WorkspaceHandle,
+        open_paths, pane, Item, ItemHandle, NewFile, Pane, SplitDirection, WorkspaceHandle,
     };
 
     #[gpui::test]
@@ -376,7 +376,7 @@ mod tests {
     #[gpui::test]
     async fn test_new_empty_workspace(cx: &mut TestAppContext) {
         let app_state = init(cx);
-        cx.dispatch_global_action(workspace::OpenNew);
+        cx.dispatch_global_action(workspace::NewFile);
         let window_id = *cx.window_ids().first().unwrap();
         let workspace = cx.root_view::<Workspace>(window_id).unwrap();
         let editor = workspace.update(cx, |workspace, cx| {
@@ -391,7 +391,7 @@ mod tests {
             assert!(editor.text(cx).is_empty());
         });
 
-        let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(cx));
+        let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(false, cx));
         app_state.fs.as_fake().insert_dir("/root").await;
         cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/the-new-name")));
         save_task.await.unwrap();
@@ -666,7 +666,7 @@ mod tests {
             .await;
         cx.read(|cx| assert!(editor.is_dirty(cx)));
 
-        let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(cx));
+        let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(false, cx));
         cx.simulate_prompt_answer(window_id, 0);
         save_task.await.unwrap();
         editor.read_with(cx, |editor, cx| {
@@ -686,7 +686,7 @@ mod tests {
         let worktree = cx.read(|cx| workspace.read(cx).worktrees(cx).next().unwrap());
 
         // Create a new untitled buffer
-        cx.dispatch_action(window_id, OpenNew);
+        cx.dispatch_action(window_id, NewFile);
         let editor = workspace.read_with(cx, |workspace, cx| {
             workspace
                 .active_item(cx)
@@ -707,7 +707,7 @@ mod tests {
         });
 
         // Save the buffer. This prompts for a filename.
-        let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(cx));
+        let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(false, cx));
         cx.simulate_new_path_selection(|parent_dir| {
             assert_eq!(parent_dir, Path::new("/root"));
             Some(parent_dir.join("the-new-name.rs"))
@@ -731,7 +731,7 @@ mod tests {
             editor.handle_input(&editor::Input(" there".into()), cx);
             assert_eq!(editor.is_dirty(cx.as_ref()), true);
         });
-        let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(cx));
+        let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(false, cx));
         save_task.await.unwrap();
         assert!(!cx.did_prompt_for_new_path());
         editor.read_with(cx, |editor, cx| {
@@ -741,7 +741,7 @@ mod tests {
 
         // Open the same newly-created file in another pane item. The new editor should reuse
         // the same buffer.
-        cx.dispatch_action(window_id, OpenNew);
+        cx.dispatch_action(window_id, NewFile);
         workspace
             .update(cx, |workspace, cx| {
                 workspace.split_pane(workspace.active_pane().clone(), SplitDirection::Right, cx);
@@ -774,7 +774,7 @@ mod tests {
         let (window_id, workspace) = cx.add_window(|cx| Workspace::new(project, cx));
 
         // Create a new untitled buffer
-        cx.dispatch_action(window_id, OpenNew);
+        cx.dispatch_action(window_id, NewFile);
         let editor = workspace.read_with(cx, |workspace, cx| {
             workspace
                 .active_item(cx)
@@ -793,7 +793,7 @@ mod tests {
         });
 
         // Save the buffer. This prompts for a filename.
-        let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(cx));
+        let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(false, cx));
         cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/the-new-name.rs")));
         save_task.await.unwrap();
         // The buffer is not dirty anymore and the language is assigned based on the path.