diff --git a/Cargo.lock b/Cargo.lock index 454db4c7b1..ed1bb4346f 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 4f59ffc68c..616f69117f 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 66d101ac33..5c6a394807 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 4b4df09c3a..3874d384d3 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 c679615948..7ef1bf1f91 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 a7ff52e19e..eb4b9650a6 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 30ceec335e..a3d5cc5406 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 39dbd54b5c..40cf85d30a 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 97c2d3201e..1bc60facde 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 a00ddef9b7..85d38f204b 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(); - 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 + // 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 { + 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"], - ) - }); + let item_ix = if let Some(ix) = item_ix { + ix + } else { + continue; + }; - 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, - } + // 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 5805a66bd5..86dfc47498 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 8fafddb79e..8cb0e1ae17 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), + }], + }, ] }