diff --git a/Cargo.lock b/Cargo.lock index e9f385b173e428f332775e584b8e4295a88d2d5c..f8d92c2ff27b331e62e93e3fecec53c1edb19160 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1245,6 +1245,7 @@ dependencies = [ "project", "serde_json", "settings", + "smallvec", "theme", "unindent", "util", @@ -4149,6 +4150,7 @@ dependencies = [ "serde", "serde_json", "settings", + "smallvec", "theme", "unindent", "util", diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index 0f2589e31d94b44325573ffc735e3cac8037f6fb..1049e216f3d6452641da021f23e6b7efc9084e43 100644 --- a/assets/keymaps/default.json +++ b/assets/keymaps/default.json @@ -14,13 +14,16 @@ "shift-cmd-{": "pane::ActivatePrevItem", "shift-cmd-}": "pane::ActivateNextItem", "cmd-w": "pane::CloseActiveItem", + "cmd-shift-W": "workspace::CloseWindow", "alt-cmd-w": "pane::CloseInactiveItems", "cmd-s": "workspace::Save", + "cmd-shift-S": "workspace::SaveAs", "cmd-=": "zed::IncreaseBufferFontSize", "cmd--": "zed::DecreaseBufferFontSize", "cmd-,": "zed::OpenSettings", "cmd-q": "zed::Quit", - "cmd-n": "workspace::OpenNew", + "cmd-n": "workspace::NewFile", + "cmd-shift-N": "workspace::NewWindow", "cmd-o": "workspace::Open" } }, @@ -217,7 +220,8 @@ "cmd-t": "project_symbols::Toggle", "cmd-p": "file_finder::Toggle", "cmd-shift-P": "command_palette::Toggle", - "cmd-shift-M": "diagnostics::Deploy" + "cmd-shift-M": "diagnostics::Deploy", + "cmd-alt-s": "workspace::SaveAll" } }, // Bindings from Sublime Text 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..b5361b4e5b0d7595bde196064c3e18bceca0500c 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,12 @@ 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 is_singleton(&self, _: &AppContext) -> bool { + false } fn navigate(&mut self, data: Box, cx: &mut ViewContext) -> bool { @@ -516,10 +521,6 @@ impl workspace::Item for ProjectDiagnosticsEditor { self.editor.reload(project, cx) } - fn can_save_as(&self, _: &AppContext) -> bool { - false - } - fn save_as( &mut self, _: ModelHandle, 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/editor/src/items.rs b/crates/editor/src/items.rs index 4b4df09c3a5d50d2b0387cdd1b572b4bd44b6324..0d8cbf1c6b47d66a19853fc3334b444b499fb895 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,25 @@ 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 is_singleton(&self, cx: &AppContext) -> bool { + self.buffer.read(cx).is_singleton() } fn clone_on_split(&self, cx: &mut ViewContext) -> Option @@ -372,10 +384,6 @@ impl Item for Editor { }) } - fn can_save_as(&self, cx: &AppContext) -> bool { - self.buffer().read(cx).is_singleton() - } - fn save_as( &mut self, project: ModelHandle, diff --git a/crates/editor/src/multi_buffer.rs b/crates/editor/src/multi_buffer.rs index a11cb22adf426b4af01aeea58f5e3304a13a65d4..0b8824be80f95f4b6bdd00610a7493ee29a01a58 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..4549aa4f90388bb441bcbbf6cad428136061eccd 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,12 @@ 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 is_singleton(&self, _: &AppContext) -> bool { + false } fn can_save(&self, _: &gpui::AppContext) -> bool { @@ -259,10 +265,6 @@ impl Item for ProjectSearchView { .update(cx, |editor, cx| editor.save(project, cx)) } - fn can_save_as(&self, _: &gpui::AppContext) -> bool { - false - } - fn save_as( &mut self, _: ModelHandle, 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 97bb8a2bc066c7800dc2bd956fb02e5876490b45..8b97ef1a80476d5c33b4ff8dc18aaaa1ecf9bd88 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, @@ -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.is_singleton(cx) && item.project_entry_ids(cx).as_slice() == &[entry_id] { Some(item.boxed_clone()) } else { None @@ -445,12 +435,13 @@ impl Pane { None } else { let item_id_to_close = pane.items[pane.active_item_index].id(); - Some(Self::close_items( - workspace, - pane_handle, - cx, - move |item_id| item_id == item_id_to_close, - )) + let task = Self::close_items(workspace, pane_handle, cx, move |item_id| { + item_id == item_id_to_close + }); + Some(cx.foreground().spawn(async move { + task.await?; + Ok(()) + })) } } @@ -465,8 +456,11 @@ impl Pane { None } else { let active_item_id = pane.items[pane.active_item_index].id(); - Some(Self::close_items(workspace, pane_handle, cx, move |id| { - id != active_item_id + let task = + Self::close_items(workspace, pane_handle, cx, move |id| id != active_item_id); + Some(cx.foreground().spawn(async move { + task.await?; + Ok(()) })) } } @@ -476,7 +470,7 @@ impl Pane { pane: ViewHandle, 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 }) @@ -487,108 +481,72 @@ impl Pane { 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 - }); - if is_last_item_for_entry { - if cx.read(|cx| item.has_conflict(cx) && item.can_save(cx)) { - let mut answer = pane.update(&mut cx, |pane, cx| { - pane.activate_item(item_to_close_ix, true, true, cx); - cx.prompt( - PromptLevel::Warning, - CONFLICT_MESSAGE, - &["Overwrite", "Discard", "Cancel"], - ) - }); + // Find the items to close. + let mut items_to_close = Vec::new(); + for item in &pane.read(cx).items { + if should_close(item.id()) { + items_to_close.push(item.boxed_clone()); + } + } - match answer.next().await { - Some(0) => { - cx.update(|cx| item.save(project.clone(), cx)).await?; - } - Some(1) => { - cx.update(|cx| item.reload(project.clone(), cx)).await?; - } - _ => break, - } - } else if cx.read(|cx| item.is_dirty(cx)) { - if cx.read(|cx| item.can_save(cx)) { - let mut answer = pane.update(&mut cx, |pane, cx| { - pane.activate_item(item_to_close_ix, true, true, cx); - cx.prompt( - PromptLevel::Warning, - DIRTY_MESSAGE, - &["Save", "Don't Save", "Cancel"], - ) - }); - - match answer.next().await { - Some(0) => { - cx.update(|cx| item.save(project.clone(), cx)).await?; - } - Some(1) => {} - _ => break, - } - } else if cx.read(|cx| item.can_save_as(cx)) { - let mut answer = pane.update(&mut cx, |pane, cx| { - pane.activate_item(item_to_close_ix, true, true, cx); - cx.prompt( - PromptLevel::Warning, - DIRTY_MESSAGE, - &["Save", "Don't Save", "Cancel"], - ) - }); - - match answer.next().await { - Some(0) => { - let start_abs_path = project - .read_with(&cx, |project, cx| { - let worktree = project.visible_worktrees(cx).next()?; - Some( - worktree - .read(cx) - .as_local()? - .abs_path() - .to_path_buf(), - ) - }) - .unwrap_or(Path::new("").into()); - - let mut abs_path = - cx.update(|cx| cx.prompt_for_new_path(&start_abs_path)); - if let Some(abs_path) = abs_path.next().await.flatten() { - cx.update(|cx| item.save_as(project.clone(), abs_path, cx)) - .await?; - } else { - break; - } - } - Some(1) => {} - _ => break, + // If a buffer is open both in a singleton editor and in a multibuffer, make sure + // to focus the singleton buffer when prompting to save that buffer, as opposed + // to focusing the multibuffer, because this gives the user a more clear idea + // of what content they would be saving. + items_to_close.sort_by_key(|item| !item.is_singleton(cx)); + + cx.spawn(|workspace, mut cx| async move { + let mut saved_project_entry_ids = HashSet::default(); + for item in items_to_close.clone() { + // Find the item's current index and its set of project entries. Avoid + // storing these in advance, in case they have changed since this task + // was started. + let (item_ix, mut project_entry_ids) = pane.read_with(&cx, |pane, cx| { + (pane.index_for_item(&*item), item.project_entry_ids(cx)) + }); + let item_ix = if let Some(ix) = item_ix { + ix + } else { + continue; + }; + + // If an item hasn't yet been associated with a project entry, then always + // prompt to save it before closing it. Otherwise, check if the item has + // any project entries that are not open anywhere else in the workspace, + // AND that the user has not already been prompted to save. If there are + // any such project entries, prompt the user to save this item. + let should_save = if project_entry_ids.is_empty() { + true + } else { + workspace.read_with(&cx, |workspace, cx| { + for item in workspace.items(cx) { + if !items_to_close + .iter() + .any(|item_to_close| item_to_close.id() == item.id()) + { + let other_project_entry_ids = item.project_entry_ids(cx); + project_entry_ids + .retain(|id| !other_project_entry_ids.contains(&id)); } } + }); + project_entry_ids + .iter() + .any(|id| saved_project_entry_ids.insert(*id)) + }; + + if should_save { + if !Self::save_item(project.clone(), &pane, item_ix, &item, true, &mut cx) + .await? + { + break; } } + // Remove the item from the pane. pane.update(&mut cx, |pane, cx| { if let Some(item_ix) = pane.items.iter().position(|i| i.id() == item.id()) { if item_ix == pane.active_item_index { @@ -621,10 +579,88 @@ impl Pane { } pane.update(&mut cx, |_, cx| cx.notify()); - Ok(()) + Ok(true) }) } + pub async fn save_item( + project: ModelHandle, + 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, is_singleton) = cx.read(|cx| { + ( + item.has_conflict(cx), + item.is_dirty(cx), + item.can_save(cx), + item.is_singleton(cx), + ) + }); + + if has_conflict && can_save { + let mut answer = pane.update(cx, |pane, cx| { + pane.activate_item(item_ix, true, true, cx); + cx.prompt( + PromptLevel::Warning, + CONFLICT_MESSAGE, + &["Overwrite", "Discard", "Cancel"], + ) + }); + match answer.next().await { + Some(0) => cx.update(|cx| item.save(project, cx)).await?, + Some(1) => cx.update(|cx| item.reload(project, cx)).await?, + _ => return Ok(false), + } + } else if is_dirty && (can_save || is_singleton) { + let should_save = if should_prompt_for_save { + let mut answer = pane.update(cx, |pane, cx| { + pane.activate_item(item_ix, true, true, cx); + cx.prompt( + PromptLevel::Warning, + DIRTY_MESSAGE, + &["Save", "Don't Save", "Cancel"], + ) + }); + match answer.next().await { + Some(0) => true, + Some(1) => false, + _ => return Ok(false), + } + } else { + true + }; + + if should_save { + if can_save { + cx.update(|cx| item.save(project, cx)).await?; + } else if is_singleton { + let start_abs_path = project + .read_with(cx, |project, cx| { + let worktree = project.visible_worktrees(cx).next()?; + Some(worktree.read(cx).as_local()?.abs_path().to_path_buf()) + }) + .unwrap_or(Path::new("").into()); + + let mut abs_path = cx.update(|cx| cx.prompt_for_new_path(&start_abs_path)); + if let Some(abs_path) = abs_path.next().await.flatten() { + cx.update(|cx| item.save_as(project, abs_path, cx)).await?; + } else { + return Ok(false); + } + } + } + } + Ok(true) + } + pub fn focus_active_item(&mut self, cx: &mut ViewContext) { if let Some(active_item) = self.active_item() { cx.focus(active_item); @@ -916,253 +952,3 @@ impl NavHistory { } } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::AppState; - use gpui::{ModelHandle, TestAppContext, ViewContext}; - use project::Project; - use std::sync::atomic::AtomicUsize; - - #[gpui::test] - async fn test_close_items(cx: &mut TestAppContext) { - cx.foreground().forbid_parking(); - - let app_state = cx.update(AppState::test); - let project = Project::test(app_state.fs.clone(), None, cx).await; - let (window_id, workspace) = cx.add_window(|cx| Workspace::new(project, cx)); - let item1 = cx.add_view(window_id, |_| { - let mut item = TestItem::new(); - item.is_dirty = true; - item - }); - let item2 = cx.add_view(window_id, |_| { - let mut item = TestItem::new(); - item.is_dirty = true; - item.has_conflict = true; - item - }); - let item3 = cx.add_view(window_id, |_| { - let mut item = TestItem::new(); - item.is_dirty = true; - item.has_conflict = true; - item - }); - let item4 = cx.add_view(window_id, |_| { - let mut item = TestItem::new(); - item.is_dirty = true; - item.can_save = false; - item - }); - let pane = workspace.update(cx, |workspace, cx| { - workspace.add_item(Box::new(item1.clone()), cx); - workspace.add_item(Box::new(item2.clone()), cx); - workspace.add_item(Box::new(item3.clone()), cx); - workspace.add_item(Box::new(item4.clone()), cx); - workspace.active_pane().clone() - }); - - let close_items = workspace.update(cx, |workspace, cx| { - pane.update(cx, |pane, cx| { - pane.activate_item(1, true, true, cx); - assert_eq!(pane.active_item().unwrap().id(), item2.id()); - }); - - let item1_id = item1.id(); - let item3_id = item3.id(); - let item4_id = item4.id(); - Pane::close_items(workspace, pane.clone(), cx, move |id| { - [item1_id, item3_id, item4_id].contains(&id) - }) - }); - - cx.foreground().run_until_parked(); - pane.read_with(cx, |pane, _| { - assert_eq!(pane.items.len(), 4); - assert_eq!(pane.active_item().unwrap().id(), item1.id()); - }); - - cx.simulate_prompt_answer(window_id, 0); - cx.foreground().run_until_parked(); - pane.read_with(cx, |pane, cx| { - assert_eq!(item1.read(cx).save_count, 1); - assert_eq!(item1.read(cx).save_as_count, 0); - assert_eq!(item1.read(cx).reload_count, 0); - assert_eq!(pane.items.len(), 3); - assert_eq!(pane.active_item().unwrap().id(), item3.id()); - }); - - cx.simulate_prompt_answer(window_id, 1); - cx.foreground().run_until_parked(); - pane.read_with(cx, |pane, cx| { - assert_eq!(item3.read(cx).save_count, 0); - assert_eq!(item3.read(cx).save_as_count, 0); - assert_eq!(item3.read(cx).reload_count, 1); - assert_eq!(pane.items.len(), 2); - assert_eq!(pane.active_item().unwrap().id(), item4.id()); - }); - - cx.simulate_prompt_answer(window_id, 0); - cx.foreground().run_until_parked(); - cx.simulate_new_path_selection(|_| Some(Default::default())); - close_items.await.unwrap(); - pane.read_with(cx, |pane, cx| { - assert_eq!(item4.read(cx).save_count, 0); - assert_eq!(item4.read(cx).save_as_count, 1); - assert_eq!(item4.read(cx).reload_count, 0); - assert_eq!(pane.items.len(), 1); - assert_eq!(pane.active_item().unwrap().id(), item2.id()); - }); - } - - #[gpui::test] - async fn test_prompting_only_on_last_item_for_entry(cx: &mut TestAppContext) { - cx.foreground().forbid_parking(); - - let app_state = cx.update(AppState::test); - let project = Project::test(app_state.fs.clone(), [], cx).await; - let (window_id, workspace) = cx.add_window(|cx| Workspace::new(project, cx)); - let item = cx.add_view(window_id, |_| { - let mut item = TestItem::new(); - item.is_dirty = true; - item.project_entry_id = Some(ProjectEntryId::new(&AtomicUsize::new(1))); - item - }); - - let (left_pane, right_pane) = workspace.update(cx, |workspace, cx| { - workspace.add_item(Box::new(item.clone()), cx); - let left_pane = workspace.active_pane().clone(); - let right_pane = workspace.split_pane(left_pane.clone(), SplitDirection::Right, cx); - (left_pane, right_pane) - }); - - workspace - .update(cx, |workspace, cx| { - let item = right_pane.read(cx).active_item().unwrap(); - Pane::close_item(workspace, right_pane.clone(), item.id(), cx) - }) - .await - .unwrap(); - workspace.read_with(cx, |workspace, _| { - assert_eq!(workspace.panes(), [left_pane.clone()]); - }); - - let close_item = workspace.update(cx, |workspace, cx| { - let item = left_pane.read(cx).active_item().unwrap(); - Pane::close_item(workspace, left_pane.clone(), item.id(), cx) - }); - cx.foreground().run_until_parked(); - cx.simulate_prompt_answer(window_id, 0); - close_item.await.unwrap(); - left_pane.read_with(cx, |pane, _| { - assert_eq!(pane.items.len(), 0); - }); - } - - #[derive(Clone)] - struct TestItem { - save_count: usize, - save_as_count: usize, - reload_count: usize, - is_dirty: bool, - has_conflict: bool, - can_save: bool, - project_entry_id: Option, - } - - 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 d12c8a2eea7d32bb6a8c683600e7ad387b5a5569..e9f0efa31115dac5d98eb13826526f4dc96994ec 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::{ @@ -75,9 +76,14 @@ actions!( workspace, [ Open, - OpenNew, + NewFile, + NewWindow, + CloseWindow, + AddFolderToProject, Unfollow, Save, + SaveAs, + SaveAll, ActivatePreviousPane, ActivateNextPane, FollowNextCollaborator, @@ -114,7 +120,15 @@ pub fn init(app_state: Arc, cx: &mut MutableAppContext) { }); cx.add_global_action({ let app_state = Arc::downgrade(&app_state); - move |_: &OpenNew, cx: &mut MutableAppContext| { + move |_: &NewFile, cx: &mut MutableAppContext| { + if let Some(app_state) = app_state.upgrade() { + open_new(&app_state, cx) + } + } + }); + cx.add_global_action({ + let app_state = Arc::downgrade(&app_state); + move |_: &NewWindow, cx: &mut MutableAppContext| { if let Some(app_state) = app_state.upgrade() { open_new(&app_state, cx) } @@ -131,6 +145,9 @@ pub fn init(app_state: Arc, 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| { let pane = workspace.active_pane().clone(); @@ -139,7 +156,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); @@ -200,7 +222,8 @@ pub trait Item: View { } fn tab_content(&self, style: &theme::Tab, cx: &AppContext) -> ElementBox; fn project_path(&self, cx: &AppContext) -> Option; - fn project_entry_id(&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 @@ -220,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, @@ -350,7 +372,8 @@ 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 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>; @@ -367,7 +390,6 @@ pub trait ItemHandle: 'static + fmt::Debug { fn is_dirty(&self, cx: &AppContext) -> bool; fn has_conflict(&self, cx: &AppContext) -> bool; fn can_save(&self, cx: &AppContext) -> bool; - fn can_save_as(&self, cx: &AppContext) -> bool; fn save(&self, project: ModelHandle, cx: &mut MutableAppContext) -> Task>; fn save_as( &self, @@ -411,8 +433,12 @@ 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 is_singleton(&self, cx: &AppContext) -> bool { + self.read(cx).is_singleton(cx) } fn boxed_clone(&self) -> Box { @@ -540,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)) } @@ -864,6 +886,79 @@ impl Workspace { } } + fn close(&mut self, _: &CloseWindow, cx: &mut ViewContext) -> Option>> { + let prepare = self.prepare_to_close(cx); + Some(cx.spawn(|this, mut cx| async move { + if prepare.await? { + this.update(&mut cx, |_, cx| { + let window_id = cx.window_id(); + cx.remove_window(window_id); + }); + } + Ok(()) + })) + } + + fn prepare_to_close(&mut self, cx: &mut ViewContext) -> 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 { + 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 (is_singl, project_entry_ids) = + cx.read(|cx| (item.is_singleton(cx), item.project_entry_ids(cx))); + if is_singl || !project_entry_ids.is_empty() { + if let Some(ix) = + pane.read_with(&cx, |pane, _| pane.index_for_item(item.as_ref())) + { + if !Pane::save_item( + project.clone(), + &pane, + ix, + &item, + should_prompt_to_save, + &mut cx, + ) + .await? + { + return Ok(false); + } + } + } + } + Ok(true) + }) + } + pub fn open_paths( &mut self, mut abs_paths: Vec, @@ -912,6 +1007,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, @@ -1032,10 +1148,14 @@ impl Workspace { self.active_item(cx).and_then(|item| item.project_path(cx)) } - pub fn save_active_item(&mut self, cx: &mut ViewContext) -> 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?"; @@ -1054,7 +1174,7 @@ impl Workspace { } else { item.save(project, cx) } - } else if item.can_save_as(cx) { + } else if item.is_singleton(cx) { let worktree = self.worktrees(cx).next(); let start_abs_path = worktree .and_then(|w| w.read(cx).as_local()) @@ -2287,5 +2407,361 @@ fn open_new(app_state: &Arc, cx: &mut MutableAppContext) { (app_state.initialize_workspace)(&mut workspace, app_state, cx); workspace }); - cx.dispatch_action(window_id, vec![workspace.id()], &OpenNew); + cx.dispatch_action(window_id, vec![workspace.id()], &NewFile); +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{ModelHandle, TestAppContext, ViewContext}; + use project::{FakeFs, Project, ProjectEntryId}; + use serde_json::json; + + #[gpui::test] + async fn test_close_window(cx: &mut TestAppContext) { + cx.foreground().forbid_parking(); + Settings::test_async(cx); + let fs = FakeFs::new(cx.background()); + fs.insert_tree("/root", json!({ "one": "" })).await; + + let project = Project::test(fs, ["root".as_ref()], cx).await; + let (window_id, workspace) = cx.add_window(|cx| Workspace::new(project.clone(), cx)); + + // When there are no dirty items, there's nothing to do. + let item1 = cx.add_view(window_id, |_| TestItem::new()); + workspace.update(cx, |w, cx| w.add_item(Box::new(item1.clone()), cx)); + let task = workspace.update(cx, |w, cx| w.prepare_to_close(cx)); + assert_eq!(task.await.unwrap(), true); + + // When there are dirty untitled items, prompt to save each one. If the user + // cancels any prompt, then abort. + let item2 = cx.add_view(window_id, |_| { + let mut item = TestItem::new(); + item.is_dirty = true; + item + }); + let item3 = cx.add_view(window_id, |_| { + let mut item = TestItem::new(); + item.is_dirty = true; + item.project_entry_ids = vec![ProjectEntryId::from_proto(1)]; + item + }); + workspace.update(cx, |w, cx| { + w.add_item(Box::new(item2.clone()), cx); + w.add_item(Box::new(item3.clone()), cx); + }); + let task = workspace.update(cx, |w, cx| w.prepare_to_close(cx)); + cx.foreground().run_until_parked(); + cx.simulate_prompt_answer(window_id, 2 /* cancel */); + cx.foreground().run_until_parked(); + assert!(!cx.has_pending_prompt(window_id)); + assert_eq!(task.await.unwrap(), false); + + // If there are multiple dirty items representing the same project entry. + workspace.update(cx, |w, cx| { + w.add_item(Box::new(item2.clone()), cx); + w.add_item(Box::new(item3.clone()), cx); + }); + let task = workspace.update(cx, |w, cx| w.prepare_to_close(cx)); + cx.foreground().run_until_parked(); + cx.simulate_prompt_answer(window_id, 2 /* cancel */); + cx.foreground().run_until_parked(); + assert!(!cx.has_pending_prompt(window_id)); + assert_eq!(task.await.unwrap(), false); + } + + #[gpui::test] + async fn test_close_pane_items(cx: &mut TestAppContext) { + cx.foreground().forbid_parking(); + Settings::test_async(cx); + let fs = FakeFs::new(cx.background()); + + let project = Project::test(fs, None, cx).await; + let (window_id, workspace) = cx.add_window(|cx| Workspace::new(project, cx)); + + let item1 = cx.add_view(window_id, |_| { + let mut item = TestItem::new(); + item.is_dirty = true; + item.project_entry_ids = vec![ProjectEntryId::from_proto(1)]; + item + }); + let item2 = cx.add_view(window_id, |_| { + let mut item = TestItem::new(); + item.is_dirty = true; + item.has_conflict = true; + item.project_entry_ids = vec![ProjectEntryId::from_proto(2)]; + item + }); + let item3 = cx.add_view(window_id, |_| { + let mut item = TestItem::new(); + item.is_dirty = true; + item.has_conflict = true; + item.project_entry_ids = vec![ProjectEntryId::from_proto(3)]; + item + }); + let item4 = cx.add_view(window_id, |_| { + let mut item = TestItem::new(); + item.is_dirty = true; + item + }); + let pane = workspace.update(cx, |workspace, cx| { + workspace.add_item(Box::new(item1.clone()), cx); + workspace.add_item(Box::new(item2.clone()), cx); + workspace.add_item(Box::new(item3.clone()), cx); + workspace.add_item(Box::new(item4.clone()), cx); + workspace.active_pane().clone() + }); + + let close_items = workspace.update(cx, |workspace, cx| { + pane.update(cx, |pane, cx| { + pane.activate_item(1, true, true, cx); + assert_eq!(pane.active_item().unwrap().id(), item2.id()); + }); + + let item1_id = item1.id(); + let item3_id = item3.id(); + let item4_id = item4.id(); + Pane::close_items(workspace, pane.clone(), cx, move |id| { + [item1_id, item3_id, item4_id].contains(&id) + }) + }); + + cx.foreground().run_until_parked(); + pane.read_with(cx, |pane, _| { + assert_eq!(pane.items().count(), 4); + assert_eq!(pane.active_item().unwrap().id(), item1.id()); + }); + + cx.simulate_prompt_answer(window_id, 0); + cx.foreground().run_until_parked(); + pane.read_with(cx, |pane, cx| { + assert_eq!(item1.read(cx).save_count, 1); + assert_eq!(item1.read(cx).save_as_count, 0); + assert_eq!(item1.read(cx).reload_count, 0); + assert_eq!(pane.items().count(), 3); + assert_eq!(pane.active_item().unwrap().id(), item3.id()); + }); + + cx.simulate_prompt_answer(window_id, 1); + cx.foreground().run_until_parked(); + pane.read_with(cx, |pane, cx| { + assert_eq!(item3.read(cx).save_count, 0); + assert_eq!(item3.read(cx).save_as_count, 0); + assert_eq!(item3.read(cx).reload_count, 1); + assert_eq!(pane.items().count(), 2); + assert_eq!(pane.active_item().unwrap().id(), item4.id()); + }); + + cx.simulate_prompt_answer(window_id, 0); + cx.foreground().run_until_parked(); + cx.simulate_new_path_selection(|_| Some(Default::default())); + close_items.await.unwrap(); + pane.read_with(cx, |pane, cx| { + assert_eq!(item4.read(cx).save_count, 0); + assert_eq!(item4.read(cx).save_as_count, 1); + assert_eq!(item4.read(cx).reload_count, 0); + assert_eq!(pane.items().count(), 1); + assert_eq!(pane.active_item().unwrap().id(), item2.id()); + }); + } + + #[gpui::test] + async fn test_prompting_to_save_only_on_last_item_for_entry(cx: &mut TestAppContext) { + cx.foreground().forbid_parking(); + Settings::test_async(cx); + let fs = FakeFs::new(cx.background()); + + let project = Project::test(fs, [], cx).await; + let (window_id, workspace) = cx.add_window(|cx| Workspace::new(project, cx)); + + // Create several workspace items with single project entries, and two + // workspace items with multiple project entries. + let single_entry_items = (0..=4) + .map(|project_entry_id| { + let mut item = TestItem::new(); + item.is_dirty = true; + item.project_entry_ids = vec![ProjectEntryId::from_proto(project_entry_id)]; + item.is_singleton = true; + item + }) + .collect::>(); + let item_2_3 = { + let mut item = TestItem::new(); + item.is_dirty = true; + item.is_singleton = false; + item.project_entry_ids = + vec![ProjectEntryId::from_proto(2), ProjectEntryId::from_proto(3)]; + item + }; + let item_3_4 = { + let mut item = TestItem::new(); + item.is_dirty = true; + item.is_singleton = false; + item.project_entry_ids = + vec![ProjectEntryId::from_proto(3), ProjectEntryId::from_proto(4)]; + item + }; + + // Create two panes that contain the following project entries: + // left pane: + // multi-entry items: (2, 3) + // single-entry items: 0, 1, 2, 3, 4 + // right pane: + // single-entry items: 1 + // multi-entry items: (3, 4) + let left_pane = workspace.update(cx, |workspace, cx| { + let left_pane = workspace.active_pane().clone(); + let right_pane = workspace.split_pane(left_pane.clone(), SplitDirection::Right, cx); + + workspace.activate_pane(left_pane.clone(), cx); + workspace.add_item(Box::new(cx.add_view(|_| item_2_3.clone())), cx); + for item in &single_entry_items { + workspace.add_item(Box::new(cx.add_view(|_| item.clone())), cx); + } + + workspace.activate_pane(right_pane.clone(), cx); + workspace.add_item(Box::new(cx.add_view(|_| single_entry_items[1].clone())), cx); + workspace.add_item(Box::new(cx.add_view(|_| item_3_4.clone())), cx); + + left_pane + }); + + // When closing all of the items in the left pane, we should be prompted twice: + // once for project entry 0, and once for project entry 2. After those two + // prompts, the task should complete. + let close = workspace.update(cx, |workspace, cx| { + workspace.activate_pane(left_pane.clone(), cx); + Pane::close_items(workspace, left_pane.clone(), cx, |_| true) + }); + + cx.foreground().run_until_parked(); + left_pane.read_with(cx, |pane, cx| { + assert_eq!( + pane.active_item().unwrap().project_entry_ids(cx).as_slice(), + &[ProjectEntryId::from_proto(0)] + ); + }); + cx.simulate_prompt_answer(window_id, 0); + + cx.foreground().run_until_parked(); + left_pane.read_with(cx, |pane, cx| { + assert_eq!( + pane.active_item().unwrap().project_entry_ids(cx).as_slice(), + &[ProjectEntryId::from_proto(2)] + ); + }); + cx.simulate_prompt_answer(window_id, 0); + + cx.foreground().run_until_parked(); + close.await.unwrap(); + left_pane.read_with(cx, |pane, _| { + assert_eq!(pane.items().count(), 0); + }); + } + + #[derive(Clone)] + struct TestItem { + save_count: usize, + save_as_count: usize, + reload_count: usize, + is_dirty: bool, + has_conflict: bool, + project_entry_ids: Vec, + is_singleton: bool, + } + + impl TestItem { + fn new() -> Self { + Self { + save_count: 0, + save_as_count: 0, + reload_count: 0, + is_dirty: false, + has_conflict: false, + project_entry_ids: Vec::new(), + is_singleton: true, + } + } + } + + impl Entity for TestItem { + type Event = (); + } + + impl View for TestItem { + fn ui_name() -> &'static str { + "TestItem" + } + + fn render(&mut self, _: &mut RenderContext) -> 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_ids.iter().copied().collect() + } + + fn is_singleton(&self, _: &AppContext) -> bool { + self.is_singleton + } + + 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.project_entry_ids.len() > 0 + } + + fn save( + &mut self, + _: ModelHandle, + _: &mut ViewContext, + ) -> Task> { + self.save_count += 1; + Task::ready(Ok(())) + } + + 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/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..cfe4ca082688b37ac73d720cfd47d5e8a32c4cd2 100644 --- a/crates/zed/src/menus.rs +++ b/crates/zed/src/menus.rs @@ -31,21 +31,41 @@ 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 { name: "Open…", action: Box::new(workspace::Open), }, + MenuItem::Action { + name: "Add Folder to Project…", + action: Box::new(workspace::AddFolderToProject), + }, MenuItem::Action { name: "Save", action: Box::new(workspace::Save), }, + MenuItem::Action { + name: "Save As…", + action: Box::new(workspace::SaveAs), + }, + MenuItem::Action { + name: "Save All", + action: Box::new(workspace::SaveAll), + }, MenuItem::Action { name: "Close Editor", action: Box::new(workspace::CloseActiveItem), }, + MenuItem::Action { + name: "Close Window", + action: Box::new(workspace::CloseWindow), + }, ], }, Menu { @@ -209,5 +229,12 @@ pub fn menus() -> Vec> { }, ], }, + Menu { + name: "Help", + items: vec![MenuItem::Action { + name: "Command Palette", + action: Box::new(command_palette::Toggle), + }], + }, ] } diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 4f0f8e21ccfe7ac536f0a7859c99516987e3f0c6..c2f6c60ea673a48b90e1d38c65648ca489615d86 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| { @@ -391,7 +391,7 @@ mod tests { assert!(editor.text(cx).is_empty()); }); - let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(cx)); + let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(false, cx)); app_state.fs.as_fake().insert_dir("/root").await; cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/the-new-name"))); save_task.await.unwrap(); @@ -666,7 +666,7 @@ mod tests { .await; cx.read(|cx| assert!(editor.is_dirty(cx))); - let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(cx)); + let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(false, cx)); cx.simulate_prompt_answer(window_id, 0); save_task.await.unwrap(); editor.read_with(cx, |editor, cx| { @@ -686,7 +686,7 @@ mod tests { let worktree = cx.read(|cx| workspace.read(cx).worktrees(cx).next().unwrap()); // Create a new untitled buffer - cx.dispatch_action(window_id, OpenNew); + cx.dispatch_action(window_id, NewFile); let editor = workspace.read_with(cx, |workspace, cx| { workspace .active_item(cx) @@ -707,7 +707,7 @@ mod tests { }); // Save the buffer. This prompts for a filename. - let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(cx)); + let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(false, cx)); cx.simulate_new_path_selection(|parent_dir| { assert_eq!(parent_dir, Path::new("/root")); Some(parent_dir.join("the-new-name.rs")) @@ -731,7 +731,7 @@ mod tests { editor.handle_input(&editor::Input(" there".into()), cx); assert_eq!(editor.is_dirty(cx.as_ref()), true); }); - let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(cx)); + let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(false, cx)); save_task.await.unwrap(); assert!(!cx.did_prompt_for_new_path()); editor.read_with(cx, |editor, cx| { @@ -741,7 +741,7 @@ mod tests { // Open the same newly-created file in another pane item. The new editor should reuse // the same buffer. - cx.dispatch_action(window_id, OpenNew); + cx.dispatch_action(window_id, NewFile); workspace .update(cx, |workspace, cx| { workspace.split_pane(workspace.active_pane().clone(), SplitDirection::Right, cx); @@ -774,7 +774,7 @@ mod tests { let (window_id, workspace) = cx.add_window(|cx| Workspace::new(project, cx)); // Create a new untitled buffer - cx.dispatch_action(window_id, OpenNew); + cx.dispatch_action(window_id, NewFile); let editor = workspace.read_with(cx, |workspace, cx| { workspace .active_item(cx) @@ -793,7 +793,7 @@ mod tests { }); // Save the buffer. This prompts for a filename. - let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(cx)); + let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(false, cx)); cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/the-new-name.rs"))); save_task.await.unwrap(); // The buffer is not dirty anymore and the language is assigned based on the path.