From e72f5cea227168bbbcc89f683a6182b739d012d1 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 20 May 2022 11:01:20 -0700 Subject: [PATCH 1/8] Add "New Window" command --- assets/keymaps/default.json | 3 ++- crates/editor/src/editor.rs | 6 +++--- crates/workspace/src/workspace.rs | 15 ++++++++++++--- crates/zed/src/main.rs | 6 +++--- crates/zed/src/menus.rs | 6 +++++- crates/zed/src/zed.rs | 10 +++++----- 6 files changed, 30 insertions(+), 16 deletions(-) diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index 0f2589e31d94b44325573ffc735e3cac8037f6fb..bdf7ab179660b782556c4613a98b7218729a6f78 100644 --- a/assets/keymaps/default.json +++ b/assets/keymaps/default.json @@ -20,7 +20,8 @@ "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" } }, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 31dc6df357ead5f76c84fb2397f3eccbf8d72d8e..e5a80e44f4014127cb5cb76090bfd04a1092e65b 100644 --- a/crates/editor/src/editor.rs +++ b/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, ) { let project = workspace.project().clone(); diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index d12c8a2eea7d32bb6a8c683600e7ad387b5a5569..b18acee233bec15d63713eb2b30cadd389daaf7a 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -75,7 +75,8 @@ actions!( workspace, [ Open, - OpenNew, + NewFile, + NewWindow, Unfollow, Save, ActivatePreviousPane, @@ -114,7 +115,15 @@ pub fn init(app_state: Arc, 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) } @@ -2287,5 +2296,5 @@ fn open_new(app_state: &Arc, 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); } diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 8231d2ac3ff947ac19704c3cb9008645aab18daa..3e21e454f2f9f08f93fec6886938e98eb03d8647 100644 --- a/crates/zed/src/main.rs +++ b/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 { diff --git a/crates/zed/src/menus.rs b/crates/zed/src/menus.rs index c5ea7b73912b2dac02239cbc41c869c190c3b4ae..45104399c530c956abfdeb5fbd08fcff33ded23c 100644 --- a/crates/zed/src/menus.rs +++ b/crates/zed/src/menus.rs @@ -31,7 +31,11 @@ pub fn menus() -> Vec> { 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 { diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 4f0f8e21ccfe7ac536f0a7859c99516987e3f0c6..b3d831c9ccac19409f3b0bb37a1e2a763647129d 100644 --- a/crates/zed/src/zed.rs +++ b/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::(window_id).unwrap(); let editor = workspace.update(cx, |workspace, 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) @@ -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) From 8ed33cadeb79ea9139950daac2ee680dc73e19d5 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 20 May 2022 16:19:43 -0700 Subject: [PATCH 2/8] Add "Add Folder to Project" command to application menu --- crates/workspace/src/workspace.rs | 23 +++++++++++++++++++++++ crates/zed/src/menus.rs | 4 ++++ 2 files changed, 27 insertions(+) diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index b18acee233bec15d63713eb2b30cadd389daaf7a..d5ec34ad6992b924688d82202140615ec4025eb5 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -77,6 +77,7 @@ actions!( Open, NewFile, NewWindow, + AddFolderToProject, Unfollow, Save, ActivatePreviousPane, @@ -140,6 +141,7 @@ pub fn init(app_state: Arc, cx: &mut MutableAppContext) { cx.add_async_action(Workspace::toggle_follow); cx.add_async_action(Workspace::follow_next_collaborator); + cx.add_action(Workspace::add_folder_to_project); cx.add_action( |workspace: &mut Workspace, _: &Unfollow, cx: &mut ViewContext| { let pane = workspace.active_pane().clone(); @@ -921,6 +923,27 @@ impl Workspace { }) } + fn add_folder_to_project(&mut self, _: &AddFolderToProject, cx: &mut ViewContext) { + 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, diff --git a/crates/zed/src/menus.rs b/crates/zed/src/menus.rs index 45104399c530c956abfdeb5fbd08fcff33ded23c..6267ac77c9594991dc7e4947a67a99985e6f4f6b 100644 --- a/crates/zed/src/menus.rs +++ b/crates/zed/src/menus.rs @@ -42,6 +42,10 @@ pub fn menus() -> Vec> { 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), From b08cad9ef562aded0011f7736c8dcd8e8111a62c Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 20 May 2022 16:24:42 -0700 Subject: [PATCH 3/8] Add "Save As" command --- assets/keymaps/default.json | 1 + crates/workspace/src/workspace.rs | 16 +++++++++++++--- crates/zed/src/menus.rs | 4 ++++ crates/zed/src/zed.rs | 10 +++++----- 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index bdf7ab179660b782556c4613a98b7218729a6f78..e80cd8a87f226ca5d34bfc3ac96c18e8f0d137c3 100644 --- a/assets/keymaps/default.json +++ b/assets/keymaps/default.json @@ -16,6 +16,7 @@ "cmd-w": "pane::CloseActiveItem", "alt-cmd-w": "pane::CloseInactiveItems", "cmd-s": "workspace::Save", + "cmd-shift-S": "workspace::SaveAs", "cmd-=": "zed::IncreaseBufferFontSize", "cmd--": "zed::DecreaseBufferFontSize", "cmd-,": "zed::OpenSettings", diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index d5ec34ad6992b924688d82202140615ec4025eb5..3a4ee0f2111b86c957f549a635a42180db1f39bc 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -80,6 +80,7 @@ actions!( AddFolderToProject, Unfollow, Save, + SaveAs, ActivatePreviousPane, ActivateNextPane, FollowNextCollaborator, @@ -150,7 +151,12 @@ pub fn init(app_state: Arc, cx: &mut MutableAppContext) { ); cx.add_action( |workspace: &mut Workspace, _: &Save, cx: &mut ViewContext| { - 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.save_active_item(true, cx).detach_and_log_err(cx); }, ); cx.add_action(Workspace::toggle_sidebar_item); @@ -1064,10 +1070,14 @@ impl Workspace { self.active_item(cx).and_then(|item| item.project_path(cx)) } - pub fn save_active_item(&mut self, cx: &mut ViewContext) -> Task> { + pub fn save_active_item( + &mut self, + force_name_change: bool, + cx: &mut ViewContext, + ) -> Task> { 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?"; diff --git a/crates/zed/src/menus.rs b/crates/zed/src/menus.rs index 6267ac77c9594991dc7e4947a67a99985e6f4f6b..b7aabffd188364527af3375ed3b85638523f16c9 100644 --- a/crates/zed/src/menus.rs +++ b/crates/zed/src/menus.rs @@ -50,6 +50,10 @@ pub fn menus() -> Vec> { name: "Save", action: Box::new(workspace::Save), }, + MenuItem::Action { + name: "Save As…", + action: Box::new(workspace::SaveAs), + }, MenuItem::Action { name: "Close Editor", action: Box::new(workspace::CloseActiveItem), diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index b3d831c9ccac19409f3b0bb37a1e2a763647129d..c2f6c60ea673a48b90e1d38c65648ca489615d86 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -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| { @@ -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| { @@ -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. From 21206800bcfee08d7ebdd87208389d1190c99f7e Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 20 May 2022 16:53:03 -0700 Subject: [PATCH 4/8] Add "Close Window" command --- assets/keymaps/default.json | 1 + crates/workspace/src/pane.rs | 8 ++++++++ crates/workspace/src/workspace.rs | 25 +++++++++++++++++++++++++ crates/zed/src/menus.rs | 4 ++++ 4 files changed, 38 insertions(+) diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index e80cd8a87f226ca5d34bfc3ac96c18e8f0d137c3..40ac3d6fe322fee3bf61ff43cbbc7f3e77db2140 100644 --- a/assets/keymaps/default.json +++ b/assets/keymaps/default.json @@ -14,6 +14,7 @@ "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", diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 97bb8a2bc066c7800dc2bd956fb02e5876490b45..a00ddef9b77026a9bab9c704f72b85bc77464b01 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -482,6 +482,14 @@ impl Pane { }) } + pub fn close_all_items( + workspace: &mut Workspace, + pane: ViewHandle, + cx: &mut ViewContext, + ) -> Task> { + Self::close_items(workspace, pane, cx, |_| true) + } + pub fn close_items( workspace: &mut Workspace, pane: ViewHandle, diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 3a4ee0f2111b86c957f549a635a42180db1f39bc..5805a66bd5e6a7172f4941e746b584d8b196a83a 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -77,6 +77,7 @@ actions!( Open, NewFile, NewWindow, + CloseWindow, AddFolderToProject, Unfollow, Save, @@ -142,6 +143,7 @@ pub fn init(app_state: Arc, 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_action(Workspace::add_folder_to_project); cx.add_action( |workspace: &mut Workspace, _: &Unfollow, cx: &mut ViewContext| { @@ -881,6 +883,29 @@ impl Workspace { } } + fn close(&mut self, _: &CloseWindow, cx: &mut ViewContext) -> Option>> { + let mut tasks = Vec::new(); + for pane in self.panes.clone() { + tasks.push(Pane::close_all_items(self, pane, 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()) + { + let window_id = cx.window_id(); + cx.remove_window(window_id); + } + }); + Ok(()) + })) + } + pub fn open_paths( &mut self, mut abs_paths: Vec, diff --git a/crates/zed/src/menus.rs b/crates/zed/src/menus.rs index b7aabffd188364527af3375ed3b85638523f16c9..8fafddb79e71e6a2bdbb100f2f5811b61a675695 100644 --- a/crates/zed/src/menus.rs +++ b/crates/zed/src/menus.rs @@ -58,6 +58,10 @@ pub fn menus() -> Vec> { name: "Close Editor", action: Box::new(workspace::CloseActiveItem), }, + MenuItem::Action { + name: "Close Window", + action: Box::new(workspace::CloseWindow), + }, ], }, Menu { From fbd589b58960d3cfd9a9cede87f5ac2dcff4803d Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Sun, 22 May 2022 16:48:33 -0700 Subject: [PATCH 5/8] Start work on handling multibuffers properly when closing unsaved buffers --- 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(-) diff --git a/Cargo.lock b/Cargo.lock index 454db4c7b1da191e8c7c652b39bd4738a876ab0d..ed1bb4346f1052662e3acc30e07d1dc1260d1bf2 100644 --- a/Cargo.lock +++ b/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", diff --git a/crates/diagnostics/Cargo.toml b/crates/diagnostics/Cargo.toml index 4f59ffc68c64cb313360056a09ca176117769eaa..616f69117f199bfb70220b939ba88f11c759b6f5 100644 --- a/crates/diagnostics/Cargo.toml +++ b/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" } diff --git a/crates/diagnostics/src/diagnostics.rs b/crates/diagnostics/src/diagnostics.rs index 66d101ac3321012d990559f6baaf4440ad6bd1b3..5c6a39480727de41f29270ac778b2fb22bc5c58a 100644 --- a/crates/diagnostics/src/diagnostics.rs +++ b/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 { - None + fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[project::ProjectEntryId; 3]> { + self.editor.project_entry_ids(cx) } fn navigate(&mut self, data: Box, cx: &mut ViewContext) -> bool { diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 4b4df09c3a5d50d2b0387cdd1b572b4bd44b6324..3874d384d383c208e6a86146c146b9a0ce2ba4da 100644 --- a/crates/editor/src/items.rs +++ b/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 { - 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 { - 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) -> Option diff --git a/crates/editor/src/multi_buffer.rs b/crates/editor/src/multi_buffer.rs index c6796159487f1f65ceb3a8c8e8e25a447e3fa2a3..7ef1bf1f91d4e6ee656bfc349d99b6265e334780 100644 --- a/crates/editor/src/multi_buffer.rs +++ b/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)] diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index a7ff52e19e1349b5ff4e21523167cea23d2a2f04..eb4b9650a67dbc0568f754abb72322df659cc06b 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -521,12 +521,27 @@ impl TestAppContext { .downcast_mut::() .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::() + .unwrap(); + let prompts = test_window.pending_prompts.borrow_mut(); + !prompts.is_empty() + } + #[cfg(any(test, feature = "test-support"))] pub fn leak_detector(&self) -> Arc> { self.cx.borrow().leak_detector() diff --git a/crates/gpui/src/platform/test.rs b/crates/gpui/src/platform/test.rs index 30ceec335e6dc219bb43f4adefed6a91a9ee0f18..a3d5cc540678ca744e1eac7d0f5ed77572b32929 100644 --- a/crates/gpui/src/platform/test.rs +++ b/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>, resize_handlers: Vec>, close_handlers: Vec>, - pub(crate) last_prompt: Cell>>, + pub(crate) pending_prompts: RefCell>>, } #[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 { 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 } diff --git a/crates/search/Cargo.toml b/crates/search/Cargo.toml index 39dbd54b5c95fbf2f1eb7fb061752a099e4bd4f4..40cf85d30a9aac4b6d26198103404e8c374dcb00 100644 --- a/crates/search/Cargo.toml +++ b/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"] } diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 97c2d3201e7a02df7871daec52eae08bb5362681..1bc60facde12863f68be1d70b55ce6391b954e2f 100644 --- a/crates/search/src/project_search.rs +++ b/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 { - 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 { diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index a00ddef9b77026a9bab9c704f72b85bc77464b01..85d38f204b71c43e8bbd9e4b9ff3f27a0ffaf63d 100644 --- a/crates/workspace/src/pane.rs +++ b/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 { 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 { - 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> { 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, item_id_to_close: usize, cx: &mut ViewContext, - ) -> Task> { + ) -> Task> { 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, - cx: &mut ViewContext, - ) -> Task> { - Self::close_items(workspace, pane, cx, |_| true) - } - pub fn close_items( workspace: &mut Workspace, pane: ViewHandle, cx: &mut ViewContext, should_close: impl 'static + Fn(usize) -> bool, - ) -> Task> { - 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> { 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, + pane: &ViewHandle, + item_ix: usize, + item: &Box, + should_prompt_for_save: bool, + cx: &mut AsyncAppContext, + ) -> 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?"; + + 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) { 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, - } - - 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) -> 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 { - None - } - - fn project_entry_id(&self, _: &AppContext) -> Option { - self.project_entry_id - } - - fn set_nav_history(&mut self, _: ItemNavHistory, _: &mut ViewContext) {} - - fn clone_on_split(&self, _: &mut ViewContext) -> Option - 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, - _: &mut ViewContext, - ) -> Task> { - self.save_count += 1; - Task::ready(Ok(())) - } - - fn can_save_as(&self, _: &AppContext) -> bool { - true - } - - fn save_as( - &mut self, - _: ModelHandle, - _: std::path::PathBuf, - _: &mut ViewContext, - ) -> Task> { - self.save_as_count += 1; - Task::ready(Ok(())) - } - - fn reload( - &mut self, - _: ModelHandle, - _: &mut ViewContext, - ) -> Task> { - self.reload_count += 1; - Task::ready(Ok(())) - } - } -} diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 5805a66bd5e6a7172f4941e746b584d8b196a83a..86dfc4749839863ed2f8f48961d2272fe96a0f8e 100644 --- a/crates/workspace/src/workspace.rs +++ b/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, 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| { @@ -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; - fn project_entry_id(&self, cx: &AppContext) -> Option; + fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[ProjectEntryId; 3]>; fn set_nav_history(&mut self, _: ItemNavHistory, _: &mut ViewContext); fn clone_on_split(&self, _: &mut ViewContext) -> Option where @@ -369,7 +372,7 @@ impl FollowableItemHandle for ViewHandle { pub trait ItemHandle: 'static + fmt::Debug { fn tab_content(&self, style: &theme::Tab, cx: &AppContext) -> ElementBox; fn project_path(&self, cx: &AppContext) -> Option; - fn project_entry_id(&self, cx: &AppContext) -> Option; + fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[ProjectEntryId; 3]>; fn boxed_clone(&self) -> Box; fn set_nav_history(&self, nav_history: Rc>, cx: &mut MutableAppContext); fn clone_on_split(&self, cx: &mut MutableAppContext) -> Option>; @@ -430,8 +433,8 @@ impl ItemHandle for ViewHandle { self.read(cx).project_path(cx) } - fn project_entry_id(&self, cx: &AppContext) -> Option { - 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 { @@ -884,28 +887,76 @@ impl Workspace { } fn close(&mut self, _: &CloseWindow, cx: &mut ViewContext) -> Option>> { - 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) -> Option>> { + 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, + ) -> Task> { + 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::>(); + + 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, @@ -2356,3 +2407,301 @@ fn open_new(app_state: &Arc, 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, + } + + 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) -> 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 { + 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) {} + + fn clone_on_split(&self, _: &mut ViewContext) -> Option + 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, + _: &mut ViewContext, + ) -> Task> { + self.save_count += 1; + Task::ready(Ok(())) + } + + fn can_save_as(&self, _: &AppContext) -> bool { + true + } + + fn save_as( + &mut self, + _: ModelHandle, + _: std::path::PathBuf, + _: &mut ViewContext, + ) -> Task> { + self.save_as_count += 1; + Task::ready(Ok(())) + } + + fn reload( + &mut self, + _: ModelHandle, + _: &mut ViewContext, + ) -> Task> { + self.reload_count += 1; + Task::ready(Ok(())) + } + } +} diff --git a/crates/zed/src/menus.rs b/crates/zed/src/menus.rs index 8fafddb79e71e6a2bdbb100f2f5811b61a675695..8cb0e1ae1714f9945bd42eef16f430548e23883e 100644 --- a/crates/zed/src/menus.rs +++ b/crates/zed/src/menus.rs @@ -225,5 +225,12 @@ pub fn menus() -> Vec> { }, ], }, + Menu { + name: "Help", + items: vec![MenuItem::Action { + name: "Command Palette", + action: Box::new(command_palette::Toggle), + }], + }, ] } From 0becbe482affb17951bde080dfa36235f83ccdc6 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Mon, 23 May 2022 16:03:00 -0700 Subject: [PATCH 6/8] Distinguish between singleton and non-singleton workspace items * Prompt to save singleton items before non-singleton ones * Don't prompt to save multi-buffers if they contain excerpts to items that are open elsewhere and not being closed. Co-authored-by: Nathan Sobo --- crates/diagnostics/src/diagnostics.rs | 8 +- crates/editor/src/items.rs | 8 +- crates/search/src/project_search.rs | 8 +- crates/settings/src/settings.rs | 8 ++ crates/workspace/src/pane.rs | 75 +++++----- crates/workspace/src/workspace.rs | 194 +++++++++++++++++--------- 6 files changed, 188 insertions(+), 113 deletions(-) diff --git a/crates/diagnostics/src/diagnostics.rs b/crates/diagnostics/src/diagnostics.rs index 5c6a39480727de41f29270ac778b2fb22bc5c58a..b5361b4e5b0d7595bde196064c3e18bceca0500c 100644 --- a/crates/diagnostics/src/diagnostics.rs +++ b/crates/diagnostics/src/diagnostics.rs @@ -484,6 +484,10 @@ impl workspace::Item for ProjectDiagnosticsEditor { self.editor.project_entry_ids(cx) } + fn is_singleton(&self, _: &AppContext) -> bool { + false + } + fn navigate(&mut self, data: Box, cx: &mut ViewContext) -> bool { self.editor .update(cx, |editor, cx| editor.navigate(data, cx)) @@ -517,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, diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 3874d384d383c208e6a86146c146b9a0ce2ba4da..0d8cbf1c6b47d66a19853fc3334b444b499fb895 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -311,6 +311,10 @@ impl Item for Editor { .collect() } + fn is_singleton(&self, cx: &AppContext) -> bool { + self.buffer.read(cx).is_singleton() + } + fn clone_on_split(&self, cx: &mut ViewContext) -> Option where Self: Sized, @@ -380,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, diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 1bc60facde12863f68be1d70b55ce6391b954e2f..4549aa4f90388bb441bcbbf6cad428136061eccd 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -240,6 +240,10 @@ impl Item for ProjectSearchView { self.results_editor.project_entry_ids(cx) } + fn is_singleton(&self, _: &AppContext) -> bool { + false + } + fn can_save(&self, _: &gpui::AppContext) -> bool { true } @@ -261,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, diff --git a/crates/settings/src/settings.rs b/crates/settings/src/settings.rs index 5d55338e67498a4e346168a46fa493ee9b6af5f5..9b5ed124412d697166b52217488b0f67e6263bf4 100644 --- a/crates/settings/src/settings.rs +++ b/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, diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 85d38f204b71c43e8bbd9e4b9ff3f27a0ffaf63d..e1ce1e7db85a6db0bdcba7bbe406584a5e01fd34 100644 --- a/crates/workspace/src/pane.rs +++ b/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, @@ -361,7 +361,7 @@ impl Pane { cx: &AppContext, ) -> Option> { self.items.iter().find_map(|item| { - if item.project_entry_ids(cx).as_slice() == &[entry_id] { + if item.is_singleton(cx) && item.project_entry_ids(cx).as_slice() == &[entry_id] { Some(item.boxed_clone()) } else { None @@ -484,55 +484,62 @@ impl Pane { ) -> Task> { let project = workspace.project().clone(); - // Find which items to close. + // 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()); } } + 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() { - let (item_ix, project_entry_ids) = pane.read_with(&cx, |pane, cx| { - ( - pane.index_for_item(item.as_ref()), - item.project_entry_ids(cx), - ) + // 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; }; - // 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)); + let should_save = if project_entry_ids.is_empty() { + true + } else { + // Find the project entries that aren't open anywhere else in the workspace. + 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)); + } } - } - }); - if !project_entry_ids_to_save.is_empty() { - should_save = true; - } + }); + project_entry_ids + .iter() + .any(|id| saved_project_entry_ids.insert(*id)) + }; - if should_save - && !Self::save_item(project.clone(), &pane, item_ix, &item, true, &mut cx) + // If any of these project entries have not already been saved by an earlier item, + // then this item must be saved. + if should_save { + if !Self::save_item(project.clone(), &pane, item_ix, &item, true, &mut cx) .await? - { - break; + { + 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 { @@ -582,12 +589,12 @@ impl Pane { 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| { + 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.can_save_as(cx), + item.is_singleton(cx), ) }); @@ -605,7 +612,7 @@ impl Pane { Some(1) => cx.update(|cx| item.reload(project, cx)).await?, _ => return Ok(false), } - } else if is_dirty && (can_save || can_save_as) { + } 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); @@ -627,7 +634,7 @@ impl Pane { if should_save { if can_save { cx.update(|cx| item.save(project, cx)).await?; - } else if can_save_as { + } else if is_singleton { let start_abs_path = project .read_with(cx, |project, cx| { let worktree = project.visible_worktrees(cx).next()?; diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 86dfc4749839863ed2f8f48961d2272fe96a0f8e..e9f0efa31115dac5d98eb13826526f4dc96994ec 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -223,6 +223,7 @@ pub trait Item: View { fn tab_content(&self, style: &theme::Tab, cx: &AppContext) -> ElementBox; fn project_path(&self, cx: &AppContext) -> Option; 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); fn clone_on_split(&self, _: &mut ViewContext) -> Option where @@ -242,7 +243,6 @@ pub trait Item: View { project: ModelHandle, cx: &mut ViewContext, ) -> Task>; - fn can_save_as(&self, cx: &AppContext) -> bool; fn save_as( &mut self, project: ModelHandle, @@ -373,6 +373,7 @@ pub trait ItemHandle: 'static + fmt::Debug { fn tab_content(&self, style: &theme::Tab, cx: &AppContext) -> ElementBox; fn project_path(&self, cx: &AppContext) -> Option; fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[ProjectEntryId; 3]>; + fn is_singleton(&self, cx: &AppContext) -> bool; fn boxed_clone(&self) -> Box; fn set_nav_history(&self, nav_history: Rc>, cx: &mut MutableAppContext); fn clone_on_split(&self, cx: &mut MutableAppContext) -> Option>; @@ -389,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, cx: &mut MutableAppContext) -> Task>; fn save_as( &self, @@ -437,6 +437,10 @@ impl ItemHandle for ViewHandle { 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 { Box::new(self.clone()) } @@ -562,10 +566,6 @@ impl ItemHandle for ViewHandle { 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, cx: &mut MutableAppContext) -> Task> { self.update(cx, |item, cx| item.save(project, cx)) } @@ -887,9 +887,9 @@ impl Workspace { } fn close(&mut self, _: &CloseWindow, cx: &mut ViewContext) -> Option>> { - let save_all = self.save_all_internal(true, cx); + let prepare = self.prepare_to_close(cx); Some(cx.spawn(|this, mut cx| async move { - if save_all.await? { + if prepare.await? { this.update(&mut cx, |_, cx| { let window_id = cx.window_id(); cx.remove_window(window_id); @@ -899,6 +899,10 @@ impl Workspace { })) } + fn prepare_to_close(&mut self, cx: &mut ViewContext) -> Task> { + self.save_all_internal(true, cx) + } + fn save_all(&mut self, _: &SaveAll, cx: &mut ViewContext) -> Option>> { let save_all = self.save_all_internal(false, cx); Some(cx.foreground().spawn(async move { @@ -928,13 +932,11 @@ impl Workspace { let project = self.project.clone(); cx.spawn_weak(|_, mut cx| async move { - let mut saved_project_entry_ids = HashSet::default(); + // 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)) - { + 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())) { @@ -1172,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()) @@ -2411,30 +2413,25 @@ fn open_new(app_state: &Arc, cx: &mut MutableAppContext) { #[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) { + async fn test_close_window(cx: &mut TestAppContext) { cx.foreground().forbid_parking(); - cx.update(|cx| { - let settings = Settings::test(cx); - cx.set_global(settings); - }); - + Settings::test_async(cx); let fs = FakeFs::new(cx.background()); - fs.insert_tree("/root", json!({ "one": ""})).await; + 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); + 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. @@ -2446,52 +2443,65 @@ mod tests { 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(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); }); + 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); - eprintln!("save_all 2"); - let save_all = workspace.update(cx, |w, cx| w.save_all_internal(true, cx)); + // 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); + cx.simulate_prompt_answer(window_id, 2 /* cancel */); cx.foreground().run_until_parked(); assert!(!cx.has_pending_prompt(window_id)); - assert_eq!(save_all.await.unwrap(), false); + 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 app_state = cx.update(AppState::test); - let project = Project::test(app_state.fs.clone(), None, cx).await; + 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.can_save = false; item }); let pane = workspace.update(cx, |workspace, cx| { @@ -2556,44 +2566,94 @@ mod tests { } #[gpui::test] - async fn test_prompting_only_on_last_item_for_entry(cx: &mut TestAppContext) { + 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 app_state = cx.update(AppState::test); - let project = Project::test(app_state.fs.clone(), [], cx).await; + let project = Project::test(fs, [], cx).await; let (window_id, workspace) = cx.add_window(|cx| Workspace::new(project, cx)); - let item = cx.add_view(window_id, |_| { + + // 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::>(); + let item_2_3 = { let mut item = TestItem::new(); item.is_dirty = true; - item.project_entry_id = Some(ProjectEntryId::new(&AtomicUsize::new(1))); + item.is_singleton = false; + item.project_entry_ids = + vec![ProjectEntryId::from_proto(2), ProjectEntryId::from_proto(3)]; item - }); + }; + let item_3_4 = { + let mut item = TestItem::new(); + item.is_dirty = true; + item.is_singleton = false; + item.project_entry_ids = + vec![ProjectEntryId::from_proto(3), ProjectEntryId::from_proto(4)]; + item + }; - let (left_pane, right_pane) = workspace.update(cx, |workspace, cx| { - workspace.add_item(Box::new(item.clone()), cx); + // 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); - (left_pane, right_pane) + + 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 }); - 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()]); + // 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) }); - 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(); + 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); - close_item.await.unwrap(); + + cx.foreground().run_until_parked(); + close.await.unwrap(); left_pane.read_with(cx, |pane, _| { assert_eq!(pane.items().count(), 0); }); @@ -2606,8 +2666,8 @@ mod tests { reload_count: usize, is_dirty: bool, has_conflict: bool, - can_save: bool, - project_entry_id: Option, + project_entry_ids: Vec, + is_singleton: bool, } impl TestItem { @@ -2618,8 +2678,8 @@ mod tests { reload_count: 0, is_dirty: false, has_conflict: false, - can_save: true, - project_entry_id: None, + project_entry_ids: Vec::new(), + is_singleton: true, } } } @@ -2648,7 +2708,11 @@ mod tests { } fn project_entry_ids(&self, _: &AppContext) -> SmallVec<[ProjectEntryId; 3]> { - self.project_entry_id.into_iter().collect() + 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) {} @@ -2669,7 +2733,7 @@ mod tests { } fn can_save(&self, _: &AppContext) -> bool { - self.can_save + self.project_entry_ids.len() > 0 } fn save( @@ -2681,10 +2745,6 @@ mod tests { Task::ready(Ok(())) } - fn can_save_as(&self, _: &AppContext) -> bool { - true - } - fn save_as( &mut self, _: ModelHandle, From 7f92401bcacfea6f4a797f861b853f2391634369 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Mon, 23 May 2022 16:06:56 -0700 Subject: [PATCH 7/8] Add key binding and menu item for Save All --- assets/keymaps/default.json | 3 ++- crates/zed/src/menus.rs | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index 40ac3d6fe322fee3bf61ff43cbbc7f3e77db2140..1049e216f3d6452641da021f23e6b7efc9084e43 100644 --- a/assets/keymaps/default.json +++ b/assets/keymaps/default.json @@ -220,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 diff --git a/crates/zed/src/menus.rs b/crates/zed/src/menus.rs index 8cb0e1ae1714f9945bd42eef16f430548e23883e..cfe4ca082688b37ac73d720cfd47d5e8a32c4cd2 100644 --- a/crates/zed/src/menus.rs +++ b/crates/zed/src/menus.rs @@ -54,6 +54,10 @@ pub fn menus() -> Vec> { 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), From ece8604547e84ad68f1db0c05309b1f4cb738865 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Mon, 23 May 2022 18:05:09 -0700 Subject: [PATCH 8/8] Fix comments in Pane::close_items --- crates/workspace/src/pane.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index e1ce1e7db85a6db0bdcba7bbe406584a5e01fd34..8b97ef1a80476d5c33b4ff8dc18aaaa1ecf9bd88 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -491,6 +491,11 @@ impl Pane { items_to_close.push(item.boxed_clone()); } } + + // 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 { @@ -508,10 +513,14 @@ impl Pane { 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 { - // Find the project entries that aren't open anywhere else in the workspace. workspace.read_with(&cx, |workspace, cx| { for item in workspace.items(cx) { if !items_to_close @@ -529,8 +538,6 @@ impl Pane { .any(|id| saved_project_entry_ids.insert(*id)) }; - // If any of these project entries have not already been saved by an earlier item, - // then this item must be saved. if should_save { if !Self::save_item(project.clone(), &pane, item_ix, &item, true, &mut cx) .await?