diff --git a/Cargo.lock b/Cargo.lock index 878604f360..9dfa8bac7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8874,6 +8874,7 @@ dependencies = [ "async-trait", "collections", "command_palette", + "diagnostics", "editor", "futures 0.3.28", "gpui", @@ -8895,6 +8896,7 @@ dependencies = [ "tokio", "util", "workspace", + "zed-actions", ] [[package]] diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 9e69240d27..9453607ce9 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -18,6 +18,7 @@ } } ], + ":": "command_palette::Toggle", "h": "vim::Left", "left": "vim::Left", "backspace": "vim::Backspace", @@ -205,13 +206,13 @@ "shift-z shift-q": [ "pane::CloseActiveItem", { - "saveBehavior": "dontSave" + "saveIntent": "skip" } ], "shift-z shift-z": [ "pane::CloseActiveItem", { - "saveBehavior": "promptOnConflict" + "saveIntent": "saveAll" } ], // Count support @@ -318,7 +319,17 @@ "ctrl-w c": "pane::CloseAllItems", "ctrl-w ctrl-c": "pane::CloseAllItems", "ctrl-w q": "pane::CloseAllItems", - "ctrl-w ctrl-q": "pane::CloseAllItems" + "ctrl-w ctrl-q": "pane::CloseAllItems", + "ctrl-w o": "workspace::CloseInactiveTabsAndPanes", + "ctrl-w ctrl-o": "workspace::CloseInactiveTabsAndPanes", + "ctrl-w n": [ + "workspace::NewFileInDirection", + "Up" + ], + "ctrl-w ctrl-n": [ + "workspace::NewFileInDirection", + "Up" + ] } }, { diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index 4f9bb231ce..90c4481374 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -18,6 +18,15 @@ actions!(command_palette, [Toggle]); pub type CommandPalette = Picker; +pub type CommandPaletteInterceptor = + Box Option>; + +pub struct CommandInterceptResult { + pub action: Box, + pub string: String, + pub positions: Vec, +} + pub struct CommandPaletteDelegate { actions: Vec, matches: Vec, @@ -117,7 +126,7 @@ impl PickerDelegate for CommandPaletteDelegate { } }) .collect::>(); - let actions = cx.read(move |cx| { + let mut actions = cx.read(move |cx| { let hit_counts = cx.optional_global::(); actions.sort_by_key(|action| { ( @@ -136,7 +145,7 @@ impl PickerDelegate for CommandPaletteDelegate { char_bag: command.name.chars().collect(), }) .collect::>(); - let matches = if query.is_empty() { + let mut matches = if query.is_empty() { candidates .into_iter() .enumerate() @@ -158,6 +167,40 @@ impl PickerDelegate for CommandPaletteDelegate { ) .await }; + let intercept_result = cx.read(|cx| { + if cx.has_global::() { + cx.global::()(&query, cx) + } else { + None + } + }); + if let Some(CommandInterceptResult { + action, + string, + positions, + }) = intercept_result + { + if let Some(idx) = matches + .iter() + .position(|m| actions[m.candidate_id].action.id() == action.id()) + { + matches.remove(idx); + } + actions.push(Command { + name: string.clone(), + action, + keystrokes: vec![], + }); + matches.insert( + 0, + StringMatch { + candidate_id: actions.len() - 1, + string, + positions, + score: 0.0, + }, + ) + } picker .update(&mut cx, |picker, _| { let delegate = picker.delegate_mut(); diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 0c9d8de969..e523e52ba3 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -103,7 +103,7 @@ use sum_tree::TreeMap; use text::Rope; use theme::{DiagnosticStyle, Theme, ThemeSettings}; use util::{post_inc, RangeExt, ResultExt, TryFutureExt}; -use workspace::{ItemNavHistory, ViewId, Workspace}; +use workspace::{ItemNavHistory, SplitDirection, ViewId, Workspace}; use crate::git::diff_hunk_to_display; @@ -363,6 +363,7 @@ pub fn init_settings(cx: &mut AppContext) { pub fn init(cx: &mut AppContext) { init_settings(cx); cx.add_action(Editor::new_file); + cx.add_action(Editor::new_file_in_direction); cx.add_action(Editor::cancel); cx.add_action(Editor::newline); cx.add_action(Editor::newline_above); @@ -1631,6 +1632,26 @@ impl Editor { } } + pub fn new_file_in_direction( + workspace: &mut Workspace, + action: &workspace::NewFileInDirection, + cx: &mut ViewContext, + ) { + let project = workspace.project().clone(); + if project.read(cx).is_remote() { + cx.propagate_action(); + } else if let Some(buffer) = project + .update(cx, |project, cx| project.create_buffer("", None, cx)) + .log_err() + { + workspace.split_item( + action.0, + Box::new(cx.add_view(|cx| Editor::for_buffer(buffer, Some(project.clone()), cx))), + cx, + ); + } + } + pub fn replica_id(&self, cx: &AppContext) -> ReplicaId { self.buffer.read(cx).replica_id() } @@ -7134,7 +7155,7 @@ impl Editor { ); }); if split { - workspace.split_item(Box::new(editor), cx); + workspace.split_item(SplitDirection::Right, Box::new(editor), cx); } else { workspace.add_item(Box::new(editor), cx); } diff --git a/crates/editor/src/test/editor_test_context.rs b/crates/editor/src/test/editor_test_context.rs index 0bae32f1f7..0ef54dc3d5 100644 --- a/crates/editor/src/test/editor_test_context.rs +++ b/crates/editor/src/test/editor_test_context.rs @@ -3,8 +3,8 @@ use crate::{ }; use futures::Future; use gpui::{ - keymap_matcher::Keystroke, AnyWindowHandle, AppContext, ContextHandle, ModelContext, - ViewContext, ViewHandle, + executor::Foreground, keymap_matcher::Keystroke, AnyWindowHandle, AppContext, ContextHandle, + ModelContext, ViewContext, ViewHandle, }; use indoc::indoc; use language::{Buffer, BufferSnapshot}; @@ -114,6 +114,7 @@ impl<'a> EditorTestContext<'a> { let keystroke = Keystroke::parse(keystroke_text).unwrap(); self.cx.dispatch_keystroke(self.window, keystroke, false); + keystroke_under_test_handle } @@ -126,6 +127,16 @@ impl<'a> EditorTestContext<'a> { for keystroke_text in keystroke_texts.into_iter() { self.simulate_keystroke(keystroke_text); } + // it is common for keyboard shortcuts to kick off async actions, so this ensures that they are complete + // before returning. + // NOTE: we don't do this in simulate_keystroke() because a possible cause of bugs is that typing too + // quickly races with async actions. + if let Foreground::Deterministic { cx_id: _, executor } = self.cx.foreground().as_ref() { + executor.run_until_parked(); + } else { + unreachable!(); + } + keystrokes_under_test_handle } diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index c2d8cc52b2..64ef31cd30 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -1528,13 +1528,8 @@ mod tests { let active_pane = cx.read(|cx| workspace.read(cx).active_pane().clone()); active_pane .update(cx, |pane, cx| { - pane.close_active_item( - &workspace::CloseActiveItem { - save_behavior: None, - }, - cx, - ) - .unwrap() + pane.close_active_item(&workspace::CloseActiveItem { save_intent: None }, cx) + .unwrap() }) .await .unwrap(); diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index ecaee4534e..97175cb55e 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -507,7 +507,7 @@ impl FakeFs { state.emit_event(&[path]); } - fn write_file_internal(&self, path: impl AsRef, content: String) -> Result<()> { + pub fn write_file_internal(&self, path: impl AsRef, content: String) -> Result<()> { let mut state = self.state.lock(); let path = path.as_ref(); let inode = state.next_inode; diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index 700b69117a..89bfaa4b70 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -25,7 +25,8 @@ pub struct Picker { max_size: Vector2F, theme: Arc theme::Picker>>>, confirmed: bool, - pending_update_matches: Task>, + pending_update_matches: Option>>, + confirm_on_update: Option, has_focus: bool, } @@ -208,7 +209,8 @@ impl Picker { max_size: vec2f(540., 420.), theme, confirmed: false, - pending_update_matches: Task::ready(None), + pending_update_matches: None, + confirm_on_update: None, has_focus: false, }; this.update_matches(String::new(), cx); @@ -263,11 +265,13 @@ impl Picker { pub fn update_matches(&mut self, query: String, cx: &mut ViewContext) { let update = self.delegate.update_matches(query, cx); self.matches_updated(cx); - self.pending_update_matches = cx.spawn(|this, mut cx| async move { + self.pending_update_matches = Some(cx.spawn(|this, mut cx| async move { update.await; - this.update(&mut cx, |this, cx| this.matches_updated(cx)) - .log_err() - }); + this.update(&mut cx, |this, cx| { + this.matches_updated(cx); + }) + .log_err() + })); } fn matches_updated(&mut self, cx: &mut ViewContext) { @@ -278,6 +282,11 @@ impl Picker { ScrollTarget::Show(index) }; self.list_state.scroll_to(target); + self.pending_update_matches = None; + if let Some(secondary) = self.confirm_on_update.take() { + self.confirmed = true; + self.delegate.confirm(secondary, cx) + } cx.notify(); } @@ -331,13 +340,21 @@ impl Picker { } pub fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext) { - self.confirmed = true; - self.delegate.confirm(false, cx); + if self.pending_update_matches.is_some() { + self.confirm_on_update = Some(false) + } else { + self.confirmed = true; + self.delegate.confirm(false, cx); + } } pub fn secondary_confirm(&mut self, _: &SecondaryConfirm, cx: &mut ViewContext) { - self.confirmed = true; - self.delegate.confirm(true, cx); + if self.pending_update_matches.is_some() { + self.confirm_on_update = Some(true) + } else { + self.confirmed = true; + self.delegate.confirm(true, cx); + } } fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext) { diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index e076161256..72893656d6 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -539,6 +539,23 @@ impl BufferSearchBar { .map(|searchable_item| searchable_item.query_suggestion(cx)) } + pub fn set_replacement(&mut self, replacement: Option<&str>, cx: &mut ViewContext) { + if replacement.is_none() { + self.replace_is_active = false; + return; + } + self.replace_is_active = true; + self.replacement_editor + .update(cx, |replacement_editor, cx| { + replacement_editor + .buffer() + .update(cx, |replacement_buffer, cx| { + let len = replacement_buffer.len(cx); + replacement_buffer.edit([(0..len, replacement.unwrap())], None, cx); + }); + }); + } + pub fn search( &mut self, query: &str, @@ -679,6 +696,22 @@ impl BufferSearchBar { } } + pub fn select_last_match(&mut self, cx: &mut ViewContext) { + if let Some(searchable_item) = self.active_searchable_item.as_ref() { + if let Some(matches) = self + .searchable_items_with_matches + .get(&searchable_item.downgrade()) + { + if matches.len() == 0 { + return; + } + let new_match_index = matches.len() - 1; + searchable_item.update_matches(matches, cx); + searchable_item.activate_match(new_match_index, matches, cx); + } + } + } + fn select_next_match_on_pane( pane: &mut Pane, action: &SelectNextMatch, @@ -946,7 +979,7 @@ impl BufferSearchBar { cx.propagate_action(); } } - fn replace_all(&mut self, _: &ReplaceAll, cx: &mut ViewContext) { + pub fn replace_all(&mut self, _: &ReplaceAll, cx: &mut ViewContext) { if !self.dismissed && self.active_search.is_some() { if let Some(searchable_item) = self.active_searchable_item.as_ref() { if let Some(query) = self.active_search.as_ref() { diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index b79f655f81..cd939b5604 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -284,12 +284,7 @@ impl TerminalView { pub fn deploy_context_menu(&mut self, position: Vector2F, cx: &mut ViewContext) { let menu_entries = vec![ ContextMenuItem::action("Clear", Clear), - ContextMenuItem::action( - "Close", - pane::CloseActiveItem { - save_behavior: None, - }, - ), + ContextMenuItem::action("Close", pane::CloseActiveItem { save_intent: None }), ]; self.context_menu.update(cx, |menu, cx| { diff --git a/crates/vim/Cargo.toml b/crates/vim/Cargo.toml index 5d40032024..509efc5825 100644 --- a/crates/vim/Cargo.toml +++ b/crates/vim/Cargo.toml @@ -34,6 +34,8 @@ settings = { path = "../settings" } workspace = { path = "../workspace" } theme = { path = "../theme" } language_selector = { path = "../language_selector"} +diagnostics = { path = "../diagnostics" } +zed-actions = { path = "../zed-actions" } [dev-dependencies] indoc.workspace = true diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs new file mode 100644 index 0000000000..092d72c2fc --- /dev/null +++ b/crates/vim/src/command.rs @@ -0,0 +1,434 @@ +use command_palette::CommandInterceptResult; +use editor::{SortLinesCaseInsensitive, SortLinesCaseSensitive}; +use gpui::{impl_actions, Action, AppContext}; +use serde_derive::Deserialize; +use workspace::{SaveIntent, Workspace}; + +use crate::{ + motion::{EndOfDocument, Motion}, + normal::{ + move_cursor, + search::{FindCommand, ReplaceCommand}, + JoinLines, + }, + state::Mode, + Vim, +}; + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct GoToLine { + pub line: u32, +} + +impl_actions!(vim, [GoToLine]); + +pub fn init(cx: &mut AppContext) { + cx.add_action(|_: &mut Workspace, action: &GoToLine, cx| { + Vim::update(cx, |vim, cx| { + vim.switch_mode(Mode::Normal, false, cx); + move_cursor(vim, Motion::StartOfDocument, Some(action.line as usize), cx); + }); + }); +} + +pub fn command_interceptor(mut query: &str, _: &AppContext) -> Option { + // Note: this is a very poor simulation of vim's command palette. + // In the future we should adjust it to handle parsing range syntax, + // and then calling the appropriate commands with/without ranges. + // + // We also need to support passing arguments to commands like :w + // (ideally with filename autocompletion). + // + // For now, you can only do a replace on the % range, and you can + // only use a specific line number range to "go to line" + while query.starts_with(":") { + query = &query[1..]; + } + + let (name, action) = match query { + // save and quit + "w" | "wr" | "wri" | "writ" | "write" => ( + "write", + workspace::Save { + save_intent: Some(SaveIntent::Save), + } + .boxed_clone(), + ), + "w!" | "wr!" | "wri!" | "writ!" | "write!" => ( + "write!", + workspace::Save { + save_intent: Some(SaveIntent::Overwrite), + } + .boxed_clone(), + ), + "q" | "qu" | "qui" | "quit" => ( + "quit", + workspace::CloseActiveItem { + save_intent: Some(SaveIntent::Close), + } + .boxed_clone(), + ), + "q!" | "qu!" | "qui!" | "quit!" => ( + "quit!", + workspace::CloseActiveItem { + save_intent: Some(SaveIntent::Skip), + } + .boxed_clone(), + ), + "wq" => ( + "wq", + workspace::CloseActiveItem { + save_intent: Some(SaveIntent::Save), + } + .boxed_clone(), + ), + "wq!" => ( + "wq!", + workspace::CloseActiveItem { + save_intent: Some(SaveIntent::Overwrite), + } + .boxed_clone(), + ), + "x" | "xi" | "xit" | "exi" | "exit" => ( + "exit", + workspace::CloseActiveItem { + save_intent: Some(SaveIntent::SaveAll), + } + .boxed_clone(), + ), + "x!" | "xi!" | "xit!" | "exi!" | "exit!" => ( + "exit!", + workspace::CloseActiveItem { + save_intent: Some(SaveIntent::Overwrite), + } + .boxed_clone(), + ), + "up" | "upd" | "upda" | "updat" | "update" => ( + "update", + workspace::Save { + save_intent: Some(SaveIntent::SaveAll), + } + .boxed_clone(), + ), + "wa" | "wal" | "wall" => ( + "wall", + workspace::SaveAll { + save_intent: Some(SaveIntent::SaveAll), + } + .boxed_clone(), + ), + "wa!" | "wal!" | "wall!" => ( + "wall!", + workspace::SaveAll { + save_intent: Some(SaveIntent::Overwrite), + } + .boxed_clone(), + ), + "qa" | "qal" | "qall" | "quita" | "quital" | "quitall" => ( + "quitall", + workspace::CloseAllItemsAndPanes { + save_intent: Some(SaveIntent::Close), + } + .boxed_clone(), + ), + "qa!" | "qal!" | "qall!" | "quita!" | "quital!" | "quitall!" => ( + "quitall!", + workspace::CloseAllItemsAndPanes { + save_intent: Some(SaveIntent::Skip), + } + .boxed_clone(), + ), + "xa" | "xal" | "xall" => ( + "xall", + workspace::CloseAllItemsAndPanes { + save_intent: Some(SaveIntent::SaveAll), + } + .boxed_clone(), + ), + "xa!" | "xal!" | "xall!" => ( + "xall!", + workspace::CloseAllItemsAndPanes { + save_intent: Some(SaveIntent::Overwrite), + } + .boxed_clone(), + ), + "wqa" | "wqal" | "wqall" => ( + "wqall", + workspace::CloseAllItemsAndPanes { + save_intent: Some(SaveIntent::SaveAll), + } + .boxed_clone(), + ), + "wqa!" | "wqal!" | "wqall!" => ( + "wqall!", + workspace::CloseAllItemsAndPanes { + save_intent: Some(SaveIntent::Overwrite), + } + .boxed_clone(), + ), + "cq" | "cqu" | "cqui" | "cquit" | "cq!" | "cqu!" | "cqui!" | "cquit!" => { + ("cquit!", zed_actions::Quit.boxed_clone()) + } + + // pane management + "sp" | "spl" | "spli" | "split" => ("split", workspace::SplitUp.boxed_clone()), + "vs" | "vsp" | "vspl" | "vspli" | "vsplit" => { + ("vsplit", workspace::SplitLeft.boxed_clone()) + } + "new" => ( + "new", + workspace::NewFileInDirection(workspace::SplitDirection::Up).boxed_clone(), + ), + "vne" | "vnew" => ( + "vnew", + workspace::NewFileInDirection(workspace::SplitDirection::Left).boxed_clone(), + ), + "tabe" | "tabed" | "tabedi" | "tabedit" => ("tabedit", workspace::NewFile.boxed_clone()), + "tabnew" => ("tabnew", workspace::NewFile.boxed_clone()), + + "tabn" | "tabne" | "tabnex" | "tabnext" => { + ("tabnext", workspace::ActivateNextItem.boxed_clone()) + } + "tabp" | "tabpr" | "tabpre" | "tabprev" | "tabprevi" | "tabprevio" | "tabpreviou" + | "tabprevious" => ("tabprevious", workspace::ActivatePrevItem.boxed_clone()), + "tabN" | "tabNe" | "tabNex" | "tabNext" => { + ("tabNext", workspace::ActivatePrevItem.boxed_clone()) + } + "tabc" | "tabcl" | "tabclo" | "tabclos" | "tabclose" => ( + "tabclose", + workspace::CloseActiveItem { + save_intent: Some(SaveIntent::Close), + } + .boxed_clone(), + ), + + // quickfix / loclist (merged together for now) + "cl" | "cli" | "clis" | "clist" => ("clist", diagnostics::Deploy.boxed_clone()), + "cc" => ("cc", editor::Hover.boxed_clone()), + "ll" => ("ll", editor::Hover.boxed_clone()), + "cn" | "cne" | "cnex" | "cnext" => ("cnext", editor::GoToDiagnostic.boxed_clone()), + "lne" | "lnex" | "lnext" => ("cnext", editor::GoToDiagnostic.boxed_clone()), + + "cpr" | "cpre" | "cprev" | "cprevi" | "cprevio" | "cpreviou" | "cprevious" => { + ("cprevious", editor::GoToPrevDiagnostic.boxed_clone()) + } + "cN" | "cNe" | "cNex" | "cNext" => ("cNext", editor::GoToPrevDiagnostic.boxed_clone()), + "lp" | "lpr" | "lpre" | "lprev" | "lprevi" | "lprevio" | "lpreviou" | "lprevious" => { + ("lprevious", editor::GoToPrevDiagnostic.boxed_clone()) + } + "lN" | "lNe" | "lNex" | "lNext" => ("lNext", editor::GoToPrevDiagnostic.boxed_clone()), + + // modify the buffer (should accept [range]) + "j" | "jo" | "joi" | "join" => ("join", JoinLines.boxed_clone()), + "d" | "de" | "del" | "dele" | "delet" | "delete" | "dl" | "dell" | "delel" | "deletl" + | "deletel" | "dp" | "dep" | "delp" | "delep" | "deletp" | "deletep" => { + ("delete", editor::DeleteLine.boxed_clone()) + } + "sor" | "sor " | "sort" | "sort " => ("sort", SortLinesCaseSensitive.boxed_clone()), + "sor i" | "sort i" => ("sort i", SortLinesCaseInsensitive.boxed_clone()), + + // goto (other ranges handled under _ => ) + "$" => ("$", EndOfDocument.boxed_clone()), + + _ => { + if query.starts_with("/") || query.starts_with("?") { + ( + query, + FindCommand { + query: query[1..].to_string(), + backwards: query.starts_with("?"), + } + .boxed_clone(), + ) + } else if query.starts_with("%") { + ( + query, + ReplaceCommand { + query: query.to_string(), + } + .boxed_clone(), + ) + } else if let Ok(line) = query.parse::() { + (query, GoToLine { line }.boxed_clone()) + } else { + return None; + } + } + }; + + let string = ":".to_owned() + name; + let positions = generate_positions(&string, query); + + Some(CommandInterceptResult { + action, + string, + positions, + }) +} + +fn generate_positions(string: &str, query: &str) -> Vec { + let mut positions = Vec::new(); + let mut chars = query.chars().into_iter(); + + let Some(mut current) = chars.next() else { + return positions; + }; + + for (i, c) in string.chars().enumerate() { + if c == current { + positions.push(i); + if let Some(c) = chars.next() { + current = c; + } else { + break; + } + } + } + + positions +} + +#[cfg(test)] +mod test { + use std::path::Path; + + use crate::test::{NeovimBackedTestContext, VimTestContext}; + use gpui::{executor::Foreground, TestAppContext}; + use indoc::indoc; + + #[gpui::test] + async fn test_command_basics(cx: &mut TestAppContext) { + if let Foreground::Deterministic { cx_id: _, executor } = cx.foreground().as_ref() { + executor.run_until_parked(); + } + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state(indoc! {" + ˇa + b + c"}) + .await; + + cx.simulate_shared_keystrokes([":", "j", "enter"]).await; + + // hack: our cursor positionining after a join command is wrong + cx.simulate_shared_keystrokes(["^"]).await; + cx.assert_shared_state(indoc! { + "ˇa b + c" + }) + .await; + } + + #[gpui::test] + async fn test_command_goto(cx: &mut TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state(indoc! {" + ˇa + b + c"}) + .await; + cx.simulate_shared_keystrokes([":", "3", "enter"]).await; + cx.assert_shared_state(indoc! {" + a + b + ˇc"}) + .await; + } + + #[gpui::test] + async fn test_command_replace(cx: &mut TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state(indoc! {" + ˇa + b + c"}) + .await; + cx.simulate_shared_keystrokes([":", "%", "s", "/", "b", "/", "d", "enter"]) + .await; + cx.assert_shared_state(indoc! {" + a + ˇd + c"}) + .await; + cx.simulate_shared_keystrokes([ + ":", "%", "s", ":", ".", ":", "\\", "0", "\\", "0", "enter", + ]) + .await; + cx.assert_shared_state(indoc! {" + aa + dd + ˇcc"}) + .await; + } + + #[gpui::test] + async fn test_command_search(cx: &mut TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state(indoc! {" + ˇa + b + a + c"}) + .await; + cx.simulate_shared_keystrokes([":", "/", "b", "enter"]) + .await; + cx.assert_shared_state(indoc! {" + a + ˇb + a + c"}) + .await; + cx.simulate_shared_keystrokes([":", "?", "a", "enter"]) + .await; + cx.assert_shared_state(indoc! {" + ˇa + b + a + c"}) + .await; + } + + #[gpui::test] + async fn test_command_write(cx: &mut TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + let path = Path::new("/root/dir/file.rs"); + let fs = cx.workspace(|workspace, cx| workspace.project().read(cx).fs().clone()); + + cx.simulate_keystrokes(["i", "@", "escape"]); + cx.simulate_keystrokes([":", "w", "enter"]); + + assert_eq!(fs.load(&path).await.unwrap(), "@\n"); + + fs.as_fake() + .write_file_internal(path, "oops\n".to_string()) + .unwrap(); + + // conflict! + cx.simulate_keystrokes(["i", "@", "escape"]); + cx.simulate_keystrokes([":", "w", "enter"]); + let window = cx.window; + assert!(window.has_pending_prompt(cx.cx)); + // "Cancel" + window.simulate_prompt_answer(0, cx.cx); + assert_eq!(fs.load(&path).await.unwrap(), "oops\n"); + assert!(!window.has_pending_prompt(cx.cx)); + // force overwrite + cx.simulate_keystrokes([":", "w", "!", "enter"]); + assert!(!window.has_pending_prompt(cx.cx)); + assert_eq!(fs.load(&path).await.unwrap(), "@@\n"); + } + + #[gpui::test] + async fn test_command_quit(cx: &mut TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.simulate_keystrokes([":", "n", "e", "w", "enter"]); + cx.workspace(|workspace, cx| assert_eq!(workspace.items(cx).count(), 2)); + cx.simulate_keystrokes([":", "q", "enter"]); + cx.workspace(|workspace, cx| assert_eq!(workspace.items(cx).count(), 1)); + } +} diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index c8d12f8ee3..a23091c7a7 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -4,7 +4,7 @@ mod delete; mod paste; pub(crate) mod repeat; mod scroll; -mod search; +pub(crate) mod search; pub mod substitute; mod yank; @@ -168,7 +168,12 @@ pub fn normal_object(object: Object, cx: &mut WindowContext) { }) } -fn move_cursor(vim: &mut Vim, motion: Motion, times: Option, cx: &mut WindowContext) { +pub(crate) fn move_cursor( + vim: &mut Vim, + motion: Motion, + times: Option, + cx: &mut WindowContext, +) { vim.update_active_editor(cx, |editor, cx| { editor.change_selections(Some(Autoscroll::fit()), cx, |s| { s.move_cursors_with(|map, cursor, goal| { diff --git a/crates/vim/src/normal/search.rs b/crates/vim/src/normal/search.rs index c9c04007d1..f74625c8b3 100644 --- a/crates/vim/src/normal/search.rs +++ b/crates/vim/src/normal/search.rs @@ -3,7 +3,7 @@ use search::{buffer_search, BufferSearchBar, SearchMode, SearchOptions}; use serde_derive::Deserialize; use workspace::{searchable::Direction, Pane, Workspace}; -use crate::{state::SearchState, Vim}; +use crate::{motion::Motion, normal::move_cursor, state::SearchState, Vim}; #[derive(Clone, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] @@ -25,7 +25,29 @@ pub(crate) struct Search { backwards: bool, } -impl_actions!(vim, [MoveToNext, MoveToPrev, Search]); +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct FindCommand { + pub query: String, + pub backwards: bool, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct ReplaceCommand { + pub query: String, +} + +#[derive(Debug, Default)] +struct Replacement { + search: String, + replacement: String, + should_replace_all: bool, + is_case_sensitive: bool, +} + +impl_actions!( + vim, + [MoveToNext, MoveToPrev, Search, FindCommand, ReplaceCommand] +); actions!(vim, [SearchSubmit]); pub(crate) fn init(cx: &mut AppContext) { @@ -34,6 +56,9 @@ pub(crate) fn init(cx: &mut AppContext) { cx.add_action(search); cx.add_action(search_submit); cx.add_action(search_deploy); + + cx.add_action(find_command); + cx.add_action(replace_command); } fn move_to_next(workspace: &mut Workspace, action: &MoveToNext, cx: &mut ViewContext) { @@ -65,6 +90,7 @@ fn search(workspace: &mut Workspace, action: &Search, cx: &mut ViewContext) { + let pane = workspace.active_pane().clone(); + pane.update(cx, |pane, cx| { + if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() { + let search = search_bar.update(cx, |search_bar, cx| { + if !search_bar.show(cx) { + return None; + } + let mut query = action.query.clone(); + if query == "" { + query = search_bar.query(cx); + }; + + search_bar.activate_search_mode(SearchMode::Regex, cx); + Some(search_bar.search(&query, Some(SearchOptions::CASE_SENSITIVE), cx)) + }); + let Some(search) = search else { return }; + let search_bar = search_bar.downgrade(); + let direction = if action.backwards { + Direction::Prev + } else { + Direction::Next + }; + cx.spawn(|_, mut cx| async move { + search.await?; + search_bar.update(&mut cx, |search_bar, cx| { + search_bar.select_match(direction, 1, cx) + })?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + }) +} + +fn replace_command( + workspace: &mut Workspace, + action: &ReplaceCommand, + cx: &mut ViewContext, +) { + let replacement = parse_replace_all(&action.query); + let pane = workspace.active_pane().clone(); + pane.update(cx, |pane, cx| { + let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() else { + return; + }; + let search = search_bar.update(cx, |search_bar, cx| { + if !search_bar.show(cx) { + return None; + } + + let mut options = SearchOptions::default(); + if replacement.is_case_sensitive { + options.set(SearchOptions::CASE_SENSITIVE, true) + } + let search = if replacement.search == "" { + search_bar.query(cx) + } else { + replacement.search + }; + + search_bar.set_replacement(Some(&replacement.replacement), cx); + search_bar.activate_search_mode(SearchMode::Regex, cx); + Some(search_bar.search(&search, Some(options), cx)) + }); + let Some(search) = search else { return }; + let search_bar = search_bar.downgrade(); + cx.spawn(|_, mut cx| async move { + search.await?; + search_bar.update(&mut cx, |search_bar, cx| { + if replacement.should_replace_all { + search_bar.select_last_match(cx); + search_bar.replace_all(&Default::default(), cx); + Vim::update(cx, |vim, cx| { + move_cursor( + vim, + Motion::StartOfLine { + display_lines: false, + }, + None, + cx, + ) + }) + } + })?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + }) +} + +// convert a vim query into something more usable by zed. +// we don't attempt to fully convert between the two regex syntaxes, +// but we do flip \( and \) to ( and ) (and vice-versa) in the pattern, +// and convert \0..\9 to $0..$9 in the replacement so that common idioms work. +fn parse_replace_all(query: &str) -> Replacement { + let mut chars = query.chars(); + if Some('%') != chars.next() || Some('s') != chars.next() { + return Replacement::default(); + } + + let Some(delimeter) = chars.next() else { + return Replacement::default(); + }; + + let mut search = String::new(); + let mut replacement = String::new(); + let mut flags = String::new(); + + let mut buffer = &mut search; + + let mut escaped = false; + // 0 - parsing search + // 1 - parsing replacement + // 2 - parsing flags + let mut phase = 0; + + for c in chars { + if escaped { + escaped = false; + if phase == 1 && c.is_digit(10) { + buffer.push('$') + // unescape escaped parens + } else if phase == 0 && c == '(' || c == ')' { + } else if c != delimeter { + buffer.push('\\') + } + buffer.push(c) + } else if c == '\\' { + escaped = true; + } else if c == delimeter { + if phase == 0 { + buffer = &mut replacement; + phase = 1; + } else if phase == 1 { + buffer = &mut flags; + phase = 2; + } else { + break; + } + } else { + // escape unescaped parens + if phase == 0 && c == '(' || c == ')' { + buffer.push('\\') + } + buffer.push(c) + } + } + + let mut replacement = Replacement { + search, + replacement, + should_replace_all: true, + is_case_sensitive: true, + }; + + for c in flags.chars() { + match c { + 'g' | 'I' => {} + 'c' | 'n' => replacement.should_replace_all = false, + 'i' => replacement.is_case_sensitive = false, + _ => {} + } + } + + replacement +} + #[cfg(test)] mod test { use std::sync::Arc; diff --git a/crates/vim/src/test.rs b/crates/vim/src/test.rs index e43b0ab22b..82e4cc6863 100644 --- a/crates/vim/src/test.rs +++ b/crates/vim/src/test.rs @@ -186,9 +186,6 @@ async fn test_selection_on_search(cx: &mut gpui::TestAppContext) { assert_eq!(bar.query(cx), "cc"); }); - // wait for the query editor change event to fire. - search_bar.next_notification(&cx).await; - cx.update_editor(|editor, cx| { let highlights = editor.all_text_background_highlights(cx); assert_eq!(3, highlights.len()); diff --git a/crates/vim/src/test/neovim_backed_binding_test_context.rs b/crates/vim/src/test/neovim_backed_binding_test_context.rs index 18de029fdc..15fce99aad 100644 --- a/crates/vim/src/test/neovim_backed_binding_test_context.rs +++ b/crates/vim/src/test/neovim_backed_binding_test_context.rs @@ -1,7 +1,5 @@ use std::ops::{Deref, DerefMut}; -use gpui::ContextHandle; - use crate::state::Mode; use super::{ExemptionFeatures, NeovimBackedTestContext, SUPPORTED_FEATURES}; @@ -33,26 +31,17 @@ impl<'a, const COUNT: usize> NeovimBackedBindingTestContext<'a, COUNT> { self.consume().binding(keystrokes) } - pub async fn assert( - &mut self, - marked_positions: &str, - ) -> Option<(ContextHandle, ContextHandle)> { + pub async fn assert(&mut self, marked_positions: &str) { self.cx .assert_binding_matches(self.keystrokes_under_test, marked_positions) - .await + .await; } - pub async fn assert_exempted( - &mut self, - marked_positions: &str, - feature: ExemptionFeatures, - ) -> Option<(ContextHandle, ContextHandle)> { + pub async fn assert_exempted(&mut self, marked_positions: &str, feature: ExemptionFeatures) { if SUPPORTED_FEATURES.contains(&feature) { self.cx .assert_binding_matches(self.keystrokes_under_test, marked_positions) .await - } else { - None } } diff --git a/crates/vim/src/test/neovim_backed_test_context.rs b/crates/vim/src/test/neovim_backed_test_context.rs index e58f805a02..227d39bb63 100644 --- a/crates/vim/src/test/neovim_backed_test_context.rs +++ b/crates/vim/src/test/neovim_backed_test_context.rs @@ -106,26 +106,25 @@ impl<'a> NeovimBackedTestContext<'a> { pub async fn simulate_shared_keystrokes( &mut self, keystroke_texts: [&str; COUNT], - ) -> ContextHandle { + ) { for keystroke_text in keystroke_texts.into_iter() { self.recent_keystrokes.push(keystroke_text.to_string()); self.neovim.send_keystroke(keystroke_text).await; } - self.simulate_keystrokes(keystroke_texts) + self.simulate_keystrokes(keystroke_texts); } - pub async fn set_shared_state(&mut self, marked_text: &str) -> ContextHandle { + pub async fn set_shared_state(&mut self, marked_text: &str) { let mode = if marked_text.contains("»") { Mode::Visual } else { Mode::Normal }; - let context_handle = self.set_state(marked_text, mode); + self.set_state(marked_text, mode); self.last_set_state = Some(marked_text.to_string()); self.recent_keystrokes = Vec::new(); self.neovim.set_state(marked_text).await; self.is_dirty = true; - context_handle } pub async fn set_shared_wrap(&mut self, columns: u32) { @@ -288,18 +287,18 @@ impl<'a> NeovimBackedTestContext<'a> { &mut self, keystrokes: [&str; COUNT], initial_state: &str, - ) -> Option<(ContextHandle, ContextHandle)> { + ) { if let Some(possible_exempted_keystrokes) = self.exemptions.get(initial_state) { match possible_exempted_keystrokes { Some(exempted_keystrokes) => { if exempted_keystrokes.contains(&format!("{keystrokes:?}")) { // This keystroke was exempted for this insertion text - return None; + return; } } None => { // All keystrokes for this insertion text are exempted - return None; + return; } } } @@ -307,7 +306,6 @@ impl<'a> NeovimBackedTestContext<'a> { let _state_context = self.set_shared_state(initial_state).await; let _keystroke_context = self.simulate_shared_keystrokes(keystrokes).await; self.assert_state_matches().await; - Some((_state_context, _keystroke_context)) } pub async fn assert_binding_matches_all( diff --git a/crates/vim/src/test/neovim_connection.rs b/crates/vim/src/test/neovim_connection.rs index e44e8d0e4c..38af2d1555 100644 --- a/crates/vim/src/test/neovim_connection.rs +++ b/crates/vim/src/test/neovim_connection.rs @@ -65,7 +65,13 @@ impl NeovimConnection { // Ensure we don't create neovim connections in parallel let _lock = NEOVIM_LOCK.lock(); let (nvim, join_handle, child) = new_child_cmd( - &mut Command::new("nvim").arg("--embed").arg("--clean"), + &mut Command::new("nvim") + .arg("--embed") + .arg("--clean") + // disable swap (otherwise after about 1000 test runs you run out of swap file names) + .arg("-n") + // disable writing files (just in case) + .arg("-m"), handler, ) .await diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index e2fa6e989a..6ff997a161 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -1,6 +1,7 @@ #[cfg(test)] mod test; +mod command; mod editor_events; mod insert; mod mode_indicator; @@ -13,6 +14,7 @@ mod visual; use anyhow::Result; use collections::{CommandPaletteFilter, HashMap}; +use command_palette::CommandPaletteInterceptor; use editor::{movement, Editor, EditorMode, Event}; use gpui::{ actions, impl_actions, keymap_matcher::KeymapContext, keymap_matcher::MatchResult, Action, @@ -63,6 +65,7 @@ pub fn init(cx: &mut AppContext) { insert::init(cx); object::init(cx); motion::init(cx); + command::init(cx); // Vim Actions cx.add_action(|_: &mut Workspace, &SwitchMode(mode): &SwitchMode, cx| { @@ -469,6 +472,12 @@ impl Vim { } }); + if self.enabled { + cx.set_global::(Box::new(command::command_interceptor)); + } else if cx.has_global::() { + let _ = cx.remove_global::(); + } + cx.update_active_window(|cx| { if self.enabled { let active_editor = cx diff --git a/crates/vim/test_data/test_command_basics.json b/crates/vim/test_data/test_command_basics.json new file mode 100644 index 0000000000..669d34409f --- /dev/null +++ b/crates/vim/test_data/test_command_basics.json @@ -0,0 +1,6 @@ +{"Put":{"state":"ˇa\nb\nc"}} +{"Key":":"} +{"Key":"j"} +{"Key":"enter"} +{"Key":"^"} +{"Get":{"state":"ˇa b\nc","mode":"Normal"}} diff --git a/crates/vim/test_data/test_command_goto.json b/crates/vim/test_data/test_command_goto.json new file mode 100644 index 0000000000..2f7ed10eeb --- /dev/null +++ b/crates/vim/test_data/test_command_goto.json @@ -0,0 +1,5 @@ +{"Put":{"state":"ˇa\nb\nc"}} +{"Key":":"} +{"Key":"3"} +{"Key":"enter"} +{"Get":{"state":"a\nb\nˇc","mode":"Normal"}} diff --git a/crates/vim/test_data/test_command_replace.json b/crates/vim/test_data/test_command_replace.json new file mode 100644 index 0000000000..91827c0285 --- /dev/null +++ b/crates/vim/test_data/test_command_replace.json @@ -0,0 +1,29 @@ +{"Put":{"state":"ˇa\nb\nc"}} +{"Key":":"} +{"Key":"%"} +{"Key":"s"} +{"Key":"/"} +{"Key":"b"} +{"Key":"/"} +{"Key":"d"} +{"Key":"enter"} +{"Get":{"state":"a\nˇd\nc","mode":"Normal"}} +{"Key":":"} +{"Key":"%"} +{"Key":"s"} +{"Key":":"} +{"Key":"."} +{"Key":":"} +{"Key":"\\"} +{"Key":"0"} +{"Key":"\\"} +{"Key":"0"} +{"Key":"enter"} +{"Get":{"state":"aa\ndd\nˇcc","mode":"Normal"}} +{"Key":":"} +{"Key":"%"} +{"Key":"s"} +{"Key":"/"} +{"Key":"/"} +{"Key":"/"} +{"Key":"enter"} diff --git a/crates/vim/test_data/test_command_search.json b/crates/vim/test_data/test_command_search.json new file mode 100644 index 0000000000..705ce51fb7 --- /dev/null +++ b/crates/vim/test_data/test_command_search.json @@ -0,0 +1,11 @@ +{"Put":{"state":"ˇa\nb\na\nc"}} +{"Key":":"} +{"Key":"/"} +{"Key":"b"} +{"Key":"enter"} +{"Get":{"state":"a\nˇb\na\nc","mode":"Normal"}} +{"Key":":"} +{"Key":"?"} +{"Key":"a"} +{"Key":"enter"} +{"Get":{"state":"ˇa\nb\na\nc","mode":"Normal"}} diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index ea747b3a36..24bed4c8d1 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -475,11 +475,7 @@ impl ItemHandle for ViewHandle { match item_event { ItemEvent::CloseItem => { pane.update(cx, |pane, cx| { - pane.close_item_by_id( - item.id(), - crate::SaveBehavior::PromptOnWrite, - cx, - ) + pane.close_item_by_id(item.id(), crate::SaveIntent::Close, cx) }) .detach_and_log_err(cx); return; diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index a3e6a547dd..fbe018409b 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -45,15 +45,21 @@ use theme::{Theme, ThemeSettings}; #[derive(PartialEq, Clone, Copy, Deserialize, Debug)] #[serde(rename_all = "camelCase")] -pub enum SaveBehavior { - /// ask before overwriting conflicting files (used by default with %s) - PromptOnConflict, - /// ask before writing any file that wouldn't be auto-saved (used by default with %w) - PromptOnWrite, - /// never prompt, write on conflict (used with vim's :w!) - SilentlyOverwrite, - /// skip all save-related behaviour (used with vim's :cq) - DontSave, +pub enum SaveIntent { + /// write all files (even if unchanged) + /// prompt before overwriting on-disk changes + Save, + /// write any files that have local changes + /// prompt before overwriting on-disk changes + SaveAll, + /// always prompt for a new path + SaveAs, + /// prompt "you have unsaved changes" before writing + Close, + /// write all dirty files, don't prompt on conflict + Overwrite, + /// skip all save-related behavior + Skip, } #[derive(Clone, Deserialize, PartialEq)] @@ -78,8 +84,15 @@ pub struct CloseItemsToTheRightById { } #[derive(Clone, PartialEq, Debug, Deserialize, Default)] +#[serde(rename_all = "camelCase")] pub struct CloseActiveItem { - pub save_behavior: Option, + pub save_intent: Option, +} + +#[derive(Clone, PartialEq, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CloseAllItems { + pub save_intent: Option, } actions!( @@ -92,7 +105,6 @@ actions!( CloseCleanItems, CloseItemsToTheLeft, CloseItemsToTheRight, - CloseAllItems, GoBack, GoForward, ReopenClosedItem, @@ -103,7 +115,7 @@ actions!( ] ); -impl_actions!(pane, [ActivateItem, CloseActiveItem]); +impl_actions!(pane, [ActivateItem, CloseActiveItem, CloseAllItems]); const MAX_NAVIGATION_HISTORY_LEN: usize = 1024; @@ -722,7 +734,7 @@ impl Pane { let active_item_id = self.items[self.active_item_index].id(); Some(self.close_item_by_id( active_item_id, - action.save_behavior.unwrap_or(SaveBehavior::PromptOnWrite), + action.save_intent.unwrap_or(SaveIntent::Close), cx, )) } @@ -730,12 +742,10 @@ impl Pane { pub fn close_item_by_id( &mut self, item_id_to_close: usize, - save_behavior: SaveBehavior, + save_intent: SaveIntent, cx: &mut ViewContext, ) -> Task> { - self.close_items(cx, save_behavior, move |view_id| { - view_id == item_id_to_close - }) + self.close_items(cx, save_intent, move |view_id| view_id == item_id_to_close) } pub fn close_inactive_items( @@ -748,11 +758,9 @@ impl Pane { } let active_item_id = self.items[self.active_item_index].id(); - Some( - self.close_items(cx, SaveBehavior::PromptOnWrite, move |item_id| { - item_id != active_item_id - }), - ) + Some(self.close_items(cx, SaveIntent::Close, move |item_id| { + item_id != active_item_id + })) } pub fn close_clean_items( @@ -765,11 +773,9 @@ impl Pane { .filter(|item| !item.is_dirty(cx)) .map(|item| item.id()) .collect(); - Some( - self.close_items(cx, SaveBehavior::PromptOnWrite, move |item_id| { - item_ids.contains(&item_id) - }), - ) + Some(self.close_items(cx, SaveIntent::Close, move |item_id| { + item_ids.contains(&item_id) + })) } pub fn close_items_to_the_left( @@ -794,7 +800,7 @@ impl Pane { .take_while(|item| item.id() != item_id) .map(|item| item.id()) .collect(); - self.close_items(cx, SaveBehavior::PromptOnWrite, move |item_id| { + self.close_items(cx, SaveIntent::Close, move |item_id| { item_ids.contains(&item_id) }) } @@ -822,27 +828,31 @@ impl Pane { .take_while(|item| item.id() != item_id) .map(|item| item.id()) .collect(); - self.close_items(cx, SaveBehavior::PromptOnWrite, move |item_id| { + self.close_items(cx, SaveIntent::Close, move |item_id| { item_ids.contains(&item_id) }) } pub fn close_all_items( &mut self, - _: &CloseAllItems, + action: &CloseAllItems, cx: &mut ViewContext, ) -> Option>> { if self.items.is_empty() { return None; } - Some(self.close_items(cx, SaveBehavior::PromptOnWrite, |_| true)) + Some( + self.close_items(cx, action.save_intent.unwrap_or(SaveIntent::Close), |_| { + true + }), + ) } pub fn close_items( &mut self, cx: &mut ViewContext, - save_behavior: SaveBehavior, + save_intent: SaveIntent, should_close: impl 'static + Fn(usize) -> bool, ) -> Task> { // Find the items to close. @@ -900,7 +910,7 @@ impl Pane { &pane, item_ix, &*item, - save_behavior, + save_intent, &mut cx, ) .await? @@ -998,18 +1008,18 @@ impl Pane { pane: &WeakViewHandle, item_ix: usize, item: &dyn ItemHandle, - save_behavior: SaveBehavior, + save_intent: SaveIntent, cx: &mut AsyncAppContext, ) -> Result { const CONFLICT_MESSAGE: &str = "This file has changed on disk since you started editing it. Do you want to overwrite it?"; const DIRTY_MESSAGE: &str = "This file contains unsaved edits. Do you want to save it?"; - if save_behavior == SaveBehavior::DontSave { + if save_intent == SaveIntent::Skip { return Ok(true); } - let (has_conflict, is_dirty, can_save, is_singleton) = cx.read(|cx| { + let (mut has_conflict, mut is_dirty, mut can_save, can_save_as) = cx.read(|cx| { ( item.has_conflict(cx), item.is_dirty(cx), @@ -1018,67 +1028,76 @@ impl Pane { ) }); + // when saving a single buffer, we ignore whether or not it's dirty. + if save_intent == SaveIntent::Save { + is_dirty = true; + } + + if save_intent == SaveIntent::SaveAs { + is_dirty = true; + has_conflict = false; + can_save = false; + } + + if save_intent == SaveIntent::Overwrite { + has_conflict = false; + } + if has_conflict && can_save { - if save_behavior == SaveBehavior::SilentlyOverwrite { - pane.update(cx, |_, cx| item.save(project, cx))?.await?; - } else { - 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) => pane.update(cx, |_, cx| item.save(project, cx))?.await?, - Some(1) => pane.update(cx, |_, cx| item.reload(project, cx))?.await?, - _ => return Ok(false), + 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) => pane.update(cx, |_, cx| item.save(project, cx))?.await?, + Some(1) => pane.update(cx, |_, cx| item.reload(project, cx))?.await?, + _ => return Ok(false), + } + } else if is_dirty && (can_save || can_save_as) { + if save_intent == SaveIntent::Close { + let will_autosave = cx.read(|cx| { + matches!( + settings::get::(cx).autosave, + AutosaveSetting::OnFocusChange | AutosaveSetting::OnWindowChange + ) && Self::can_autosave_item(&*item, cx) + }); + if !will_autosave { + 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) => {} + Some(1) => return Ok(true), // Don't save this file + _ => return Ok(false), // Cancel + } } } - } else if is_dirty && (can_save || is_singleton) { - let will_autosave = cx.read(|cx| { - matches!( - settings::get::(cx).autosave, - AutosaveSetting::OnFocusChange | AutosaveSetting::OnWindowChange - ) && Self::can_autosave_item(&*item, cx) - }); - let should_save = if save_behavior == SaveBehavior::PromptOnWrite && !will_autosave { - 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 { - pane.update(cx, |_, 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_else(|| Path::new("").into()); + if can_save { + pane.update(cx, |_, 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_else(|| 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() { - pane.update(cx, |_, cx| item.save_as(project, abs_path, cx))? - .await?; - } else { - return Ok(false); - } + 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() { + pane.update(cx, |_, cx| item.save_as(project, abs_path, cx))? + .await?; + } else { + return Ok(false); } } } @@ -1167,15 +1186,16 @@ impl Pane { vec![ ContextMenuItem::action( "Close Active Item", - CloseActiveItem { - save_behavior: None, - }, + CloseActiveItem { save_intent: None }, ), ContextMenuItem::action("Close Inactive Items", CloseInactiveItems), ContextMenuItem::action("Close Clean Items", CloseCleanItems), ContextMenuItem::action("Close Items To The Left", CloseItemsToTheLeft), ContextMenuItem::action("Close Items To The Right", CloseItemsToTheRight), - ContextMenuItem::action("Close All Items", CloseAllItems), + ContextMenuItem::action( + "Close All Items", + CloseAllItems { save_intent: None }, + ), ] } else { // In the case of the user right clicking on a non-active tab, for some item-closing commands, we need to provide the id of the tab, for the others, we can reuse the existing command. @@ -1187,7 +1207,7 @@ impl Pane { pane.update(cx, |pane, cx| { pane.close_item_by_id( target_item_id, - SaveBehavior::PromptOnWrite, + SaveIntent::Close, cx, ) .detach_and_log_err(cx); @@ -1219,7 +1239,10 @@ impl Pane { } } }), - ContextMenuItem::action("Close All Items", CloseAllItems), + ContextMenuItem::action( + "Close All Items", + CloseAllItems { save_intent: None }, + ), ] }, cx, @@ -1339,12 +1362,8 @@ impl Pane { .on_click(MouseButton::Middle, { let item_id = item.id(); move |_, pane, cx| { - pane.close_item_by_id( - item_id, - SaveBehavior::PromptOnWrite, - cx, - ) - .detach_and_log_err(cx); + pane.close_item_by_id(item_id, SaveIntent::Close, cx) + .detach_and_log_err(cx); } }) .on_down( @@ -1552,7 +1571,7 @@ impl Pane { cx.window_context().defer(move |cx| { if let Some(pane) = pane.upgrade(cx) { pane.update(cx, |pane, cx| { - pane.close_item_by_id(item_id, SaveBehavior::PromptOnWrite, cx) + pane.close_item_by_id(item_id, SaveIntent::Close, cx) .detach_and_log_err(cx); }); } @@ -2155,12 +2174,7 @@ mod tests { pane.update(cx, |pane, cx| { assert!(pane - .close_active_item( - &CloseActiveItem { - save_behavior: None - }, - cx - ) + .close_active_item(&CloseActiveItem { save_intent: None }, cx) .is_none()) }); } @@ -2412,12 +2426,7 @@ mod tests { assert_item_labels(&pane, ["A", "B", "1*", "C", "D"], cx); pane.update(cx, |pane, cx| { - pane.close_active_item( - &CloseActiveItem { - save_behavior: None, - }, - cx, - ) + pane.close_active_item(&CloseActiveItem { save_intent: None }, cx) }) .unwrap() .await @@ -2428,12 +2437,7 @@ mod tests { assert_item_labels(&pane, ["A", "B", "C", "D*"], cx); pane.update(cx, |pane, cx| { - pane.close_active_item( - &CloseActiveItem { - save_behavior: None, - }, - cx, - ) + pane.close_active_item(&CloseActiveItem { save_intent: None }, cx) }) .unwrap() .await @@ -2441,12 +2445,7 @@ mod tests { assert_item_labels(&pane, ["A", "B*", "C"], cx); pane.update(cx, |pane, cx| { - pane.close_active_item( - &CloseActiveItem { - save_behavior: None, - }, - cx, - ) + pane.close_active_item(&CloseActiveItem { save_intent: None }, cx) }) .unwrap() .await @@ -2454,12 +2453,7 @@ mod tests { assert_item_labels(&pane, ["A", "C*"], cx); pane.update(cx, |pane, cx| { - pane.close_active_item( - &CloseActiveItem { - save_behavior: None, - }, - cx, - ) + pane.close_active_item(&CloseActiveItem { save_intent: None }, cx) }) .unwrap() .await @@ -2569,10 +2563,12 @@ mod tests { add_labeled_item(&pane, "C", false, cx); assert_item_labels(&pane, ["A", "B", "C*"], cx); - pane.update(cx, |pane, cx| pane.close_all_items(&CloseAllItems, cx)) - .unwrap() - .await - .unwrap(); + pane.update(cx, |pane, cx| { + pane.close_all_items(&CloseAllItems { save_intent: None }, cx) + }) + .unwrap() + .await + .unwrap(); assert_item_labels(&pane, [], cx); } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index feab53d094..092286973e 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -126,9 +126,8 @@ actions!( CloseInactiveTabsAndPanes, AddFolderToProject, Unfollow, - Save, SaveAs, - SaveAll, + ReloadActiveItem, ActivatePreviousPane, ActivateNextPane, FollowNextCollaborator, @@ -158,6 +157,27 @@ pub struct ActivatePane(pub usize); #[derive(Clone, Deserialize, PartialEq)] pub struct ActivatePaneInDirection(pub SplitDirection); +#[derive(Clone, Deserialize, PartialEq)] +pub struct NewFileInDirection(pub SplitDirection); + +#[derive(Clone, PartialEq, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveAll { + pub save_intent: Option, +} + +#[derive(Clone, PartialEq, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Save { + pub save_intent: Option, +} + +#[derive(Clone, PartialEq, Debug, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct CloseAllItemsAndPanes { + pub save_intent: Option, +} + #[derive(Deserialize)] pub struct Toast { id: usize, @@ -210,7 +230,16 @@ pub struct OpenTerminal { impl_actions!( workspace, - [ActivatePane, ActivatePaneInDirection, Toast, OpenTerminal] + [ + ActivatePane, + ActivatePaneInDirection, + NewFileInDirection, + Toast, + OpenTerminal, + SaveAll, + Save, + CloseAllItemsAndPanes, + ] ); pub type WorkspaceId = i64; @@ -251,6 +280,7 @@ pub fn init(app_state: Arc, cx: &mut AppContext) { cx.add_async_action(Workspace::follow_next_collaborator); cx.add_async_action(Workspace::close); cx.add_async_action(Workspace::close_inactive_items_and_panes); + cx.add_async_action(Workspace::close_all_items_and_panes); cx.add_global_action(Workspace::close_global); cx.add_global_action(restart); cx.add_async_action(Workspace::save_all); @@ -262,13 +292,17 @@ pub fn init(app_state: Arc, cx: &mut AppContext) { }, ); cx.add_action( - |workspace: &mut Workspace, _: &Save, cx: &mut ViewContext| { - workspace.save_active_item(false, cx).detach_and_log_err(cx); + |workspace: &mut Workspace, action: &Save, cx: &mut ViewContext| { + workspace + .save_active_item(action.save_intent.unwrap_or(SaveIntent::Save), 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); + workspace + .save_active_item(SaveIntent::SaveAs, cx) + .detach_and_log_err(cx); }, ); cx.add_action(|workspace: &mut Workspace, _: &ActivatePreviousPane, cx| { @@ -1317,14 +1351,19 @@ impl Workspace { Ok(this .update(&mut cx, |this, cx| { - this.save_all_internal(SaveBehavior::PromptOnWrite, cx) + this.save_all_internal(SaveIntent::Close, cx) })? .await?) }) } - fn save_all(&mut self, _: &SaveAll, cx: &mut ViewContext) -> Option>> { - let save_all = self.save_all_internal(SaveBehavior::PromptOnConflict, cx); + fn save_all( + &mut self, + action: &SaveAll, + cx: &mut ViewContext, + ) -> Option>> { + let save_all = + self.save_all_internal(action.save_intent.unwrap_or(SaveIntent::SaveAll), cx); Some(cx.foreground().spawn(async move { save_all.await?; Ok(()) @@ -1333,7 +1372,7 @@ impl Workspace { fn save_all_internal( &mut self, - save_behaviour: SaveBehavior, + save_intent: SaveIntent, cx: &mut ViewContext, ) -> Task> { if self.project.read(cx).is_read_only() { @@ -1368,7 +1407,7 @@ impl Workspace { &pane, ix, &*item, - save_behaviour, + save_intent, &mut cx, ) .await? @@ -1640,75 +1679,72 @@ impl Workspace { pub fn save_active_item( &mut self, - force_name_change: bool, + save_intent: SaveIntent, cx: &mut ViewContext, ) -> Task> { let project = self.project.clone(); - if let Some(item) = self.active_item(cx) { - if !force_name_change && item.can_save(cx) { - if item.has_conflict(cx) { - const CONFLICT_MESSAGE: &str = "This file has changed on disk since you started editing it. Do you want to overwrite it?"; + let pane = self.active_pane(); + let item_ix = pane.read(cx).active_item_index(); + let item = pane.read(cx).active_item(); + let pane = pane.downgrade(); - let mut answer = cx.prompt( - PromptLevel::Warning, - CONFLICT_MESSAGE, - &["Overwrite", "Cancel"], - ); - cx.spawn(|this, mut cx| async move { - let answer = answer.recv().await; - if answer == Some(0) { - this.update(&mut cx, |this, cx| item.save(this.project.clone(), cx))? - .await?; - } - Ok(()) - }) - } else { - item.save(self.project.clone(), 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()) - .map_or(Path::new(""), |w| w.abs_path()) - .to_path_buf(); - let mut abs_path = cx.prompt_for_new_path(&start_abs_path); - cx.spawn(|this, mut cx| async move { - if let Some(abs_path) = abs_path.recv().await.flatten() { - this.update(&mut cx, |_, cx| item.save_as(project, abs_path, cx))? - .await?; - } - Ok(()) - }) + cx.spawn(|_, mut cx| async move { + if let Some(item) = item { + Pane::save_item(project, &pane, item_ix, item.as_ref(), save_intent, &mut cx) + .await + .map(|_| ()) } else { - Task::ready(Ok(())) + Ok(()) } - } else { - Task::ready(Ok(())) - } + }) } pub fn close_inactive_items_and_panes( &mut self, _: &CloseInactiveTabsAndPanes, cx: &mut ViewContext, + ) -> Option>> { + self.close_all_internal(true, SaveIntent::Close, cx) + } + + pub fn close_all_items_and_panes( + &mut self, + action: &CloseAllItemsAndPanes, + cx: &mut ViewContext, + ) -> Option>> { + self.close_all_internal(false, action.save_intent.unwrap_or(SaveIntent::Close), cx) + } + + fn close_all_internal( + &mut self, + retain_active_pane: bool, + save_intent: SaveIntent, + cx: &mut ViewContext, ) -> Option>> { let current_pane = self.active_pane(); let mut tasks = Vec::new(); - if let Some(current_pane_close) = current_pane.update(cx, |pane, cx| { - pane.close_inactive_items(&CloseInactiveItems, cx) - }) { - tasks.push(current_pane_close); - }; + if retain_active_pane { + if let Some(current_pane_close) = current_pane.update(cx, |pane, cx| { + pane.close_inactive_items(&CloseInactiveItems, cx) + }) { + tasks.push(current_pane_close); + }; + } for pane in self.panes() { - if pane.id() == current_pane.id() { + if retain_active_pane && pane.id() == current_pane.id() { continue; } if let Some(close_pane_items) = pane.update(cx, |pane: &mut Pane, cx| { - pane.close_all_items(&CloseAllItems, cx) + pane.close_all_items( + &CloseAllItems { + save_intent: Some(save_intent), + }, + cx, + ) }) { tasks.push(close_pane_items) } @@ -1939,8 +1975,13 @@ impl Workspace { .update(cx, |pane, cx| pane.add_item(item, true, true, None, cx)); } - pub fn split_item(&mut self, item: Box, cx: &mut ViewContext) { - let new_pane = self.split_pane(self.active_pane.clone(), SplitDirection::Right, cx); + pub fn split_item( + &mut self, + split_direction: SplitDirection, + item: Box, + cx: &mut ViewContext, + ) { + let new_pane = self.split_pane(self.active_pane.clone(), split_direction, cx); new_pane.update(cx, move |new_pane, cx| { new_pane.add_item(item, true, true, None, cx) }) @@ -2118,7 +2159,7 @@ impl Workspace { } let item = cx.add_view(|cx| T::for_project_item(self.project().clone(), project_item, cx)); - self.split_item(Box::new(item.clone()), cx); + self.split_item(SplitDirection::Right, Box::new(item.clone()), cx); item } @@ -4372,7 +4413,7 @@ mod tests { let item1_id = item1.id(); let item3_id = item3.id(); let item4_id = item4.id(); - pane.close_items(cx, SaveBehavior::PromptOnWrite, move |id| { + pane.close_items(cx, SaveIntent::Close, move |id| { [item1_id, item3_id, item4_id].contains(&id) }) }); @@ -4510,7 +4551,7 @@ mod tests { // prompts, the task should complete. let close = left_pane.update(cx, |pane, cx| { - pane.close_items(cx, SaveBehavior::PromptOnWrite, move |_| true) + pane.close_items(cx, SaveIntent::Close, move |_| true) }); cx.foreground().run_until_parked(); left_pane.read_with(cx, |pane, cx| { @@ -4628,7 +4669,7 @@ mod tests { }); pane.update(cx, |pane, cx| { - pane.close_items(cx, SaveBehavior::PromptOnWrite, move |id| id == item_id) + pane.close_items(cx, SaveIntent::Close, move |id| id == item_id) }) .await .unwrap(); @@ -4651,7 +4692,7 @@ mod tests { // Ensure autosave is prevented for deleted files also when closing the buffer. let _close_items = pane.update(cx, |pane, cx| { - pane.close_items(cx, SaveBehavior::PromptOnWrite, move |id| id == item_id) + pane.close_items(cx, SaveIntent::Close, move |id| id == item_id) }); deterministic.run_until_parked(); assert!(window.has_pending_prompt(cx)); diff --git a/crates/zed/src/menus.rs b/crates/zed/src/menus.rs index 6b5f7b3a35..4e01693dbf 100644 --- a/crates/zed/src/menus.rs +++ b/crates/zed/src/menus.rs @@ -38,14 +38,12 @@ pub fn menus() -> Vec> { MenuItem::action("Open Recent...", recent_projects::OpenRecent), MenuItem::separator(), MenuItem::action("Add Folder to Project…", workspace::AddFolderToProject), - MenuItem::action("Save", workspace::Save), + MenuItem::action("Save", workspace::Save { save_intent: None }), MenuItem::action("Save As…", workspace::SaveAs), - MenuItem::action("Save All", workspace::SaveAll), + MenuItem::action("Save All", workspace::SaveAll { save_intent: None }), MenuItem::action( "Close Editor", - workspace::CloseActiveItem { - save_behavior: None, - }, + workspace::CloseActiveItem { save_intent: None }, ), MenuItem::action("Close Window", workspace::CloseWindow), ], diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index bdf060205a..e37df2a75c 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -744,7 +744,7 @@ mod tests { use theme::{ThemeRegistry, ThemeSettings}; use workspace::{ item::{Item, ItemHandle}, - open_new, open_paths, pane, NewFile, SaveBehavior, SplitDirection, WorkspaceHandle, + open_new, open_paths, pane, NewFile, SaveIntent, SplitDirection, WorkspaceHandle, }; #[gpui::test] @@ -945,10 +945,14 @@ mod tests { editor.update(cx, |editor, cx| { assert!(editor.text(cx).is_empty()); + assert!(!editor.is_dirty(cx)); }); - let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(false, cx)); + let save_task = workspace.update(cx, |workspace, cx| { + workspace.save_active_item(SaveIntent::Save, cx) + }); app_state.fs.create_dir(Path::new("/root")).await.unwrap(); + cx.foreground().run_until_parked(); cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/the-new-name"))); save_task.await.unwrap(); editor.read_with(cx, |editor, cx| { @@ -1311,7 +1315,10 @@ mod tests { .await; cx.read(|cx| assert!(editor.is_dirty(cx))); - let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(false, cx)); + let save_task = workspace.update(cx, |workspace, cx| { + workspace.save_active_item(SaveIntent::Save, cx) + }); + cx.foreground().run_until_parked(); window.simulate_prompt_answer(0, cx); save_task.await.unwrap(); editor.read_with(cx, |editor, cx| { @@ -1353,7 +1360,10 @@ mod tests { }); // Save the buffer. This prompts for a filename. - let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(false, cx)); + let save_task = workspace.update(cx, |workspace, cx| { + workspace.save_active_item(SaveIntent::Save, cx) + }); + cx.foreground().run_until_parked(); cx.simulate_new_path_selection(|parent_dir| { assert_eq!(parent_dir, Path::new("/root")); Some(parent_dir.join("the-new-name.rs")) @@ -1377,7 +1387,9 @@ mod tests { editor.handle_input(" there", cx); assert!(editor.is_dirty(cx)); }); - let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(false, cx)); + let save_task = workspace.update(cx, |workspace, cx| { + workspace.save_active_item(SaveIntent::Save, cx) + }); save_task.await.unwrap(); assert!(!cx.did_prompt_for_new_path()); editor.read_with(cx, |editor, cx| { @@ -1444,7 +1456,10 @@ mod tests { }); // Save the buffer. This prompts for a filename. - let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(false, cx)); + let save_task = workspace.update(cx, |workspace, cx| { + workspace.save_active_item(SaveIntent::Save, cx) + }); + cx.foreground().run_until_parked(); 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. @@ -1508,9 +1523,7 @@ mod tests { }); cx.dispatch_action( window.into(), - workspace::CloseActiveItem { - save_behavior: None, - }, + workspace::CloseActiveItem { save_intent: None }, ); cx.foreground().run_until_parked(); @@ -1521,9 +1534,7 @@ mod tests { cx.dispatch_action( window.into(), - workspace::CloseActiveItem { - save_behavior: None, - }, + workspace::CloseActiveItem { save_intent: None }, ); cx.foreground().run_until_parked(); window.simulate_prompt_answer(1, cx); @@ -1682,7 +1693,7 @@ mod tests { pane.update(cx, |pane, cx| { let editor3_id = editor3.id(); drop(editor3); - pane.close_item_by_id(editor3_id, SaveBehavior::PromptOnWrite, cx) + pane.close_item_by_id(editor3_id, SaveIntent::Close, cx) }) .await .unwrap(); @@ -1717,7 +1728,7 @@ mod tests { pane.update(cx, |pane, cx| { let editor2_id = editor2.id(); drop(editor2); - pane.close_item_by_id(editor2_id, SaveBehavior::PromptOnWrite, cx) + pane.close_item_by_id(editor2_id, SaveIntent::Close, cx) }) .await .unwrap(); @@ -1874,28 +1885,28 @@ mod tests { // Close all the pane items in some arbitrary order. pane.update(cx, |pane, cx| { - pane.close_item_by_id(file1_item_id, SaveBehavior::PromptOnWrite, cx) + pane.close_item_by_id(file1_item_id, SaveIntent::Close, cx) }) .await .unwrap(); assert_eq!(active_path(&workspace, cx), Some(file4.clone())); pane.update(cx, |pane, cx| { - pane.close_item_by_id(file4_item_id, SaveBehavior::PromptOnWrite, cx) + pane.close_item_by_id(file4_item_id, SaveIntent::Close, cx) }) .await .unwrap(); assert_eq!(active_path(&workspace, cx), Some(file3.clone())); pane.update(cx, |pane, cx| { - pane.close_item_by_id(file2_item_id, SaveBehavior::PromptOnWrite, cx) + pane.close_item_by_id(file2_item_id, SaveIntent::Close, cx) }) .await .unwrap(); assert_eq!(active_path(&workspace, cx), Some(file3.clone())); pane.update(cx, |pane, cx| { - pane.close_item_by_id(file3_item_id, SaveBehavior::PromptOnWrite, cx) + pane.close_item_by_id(file3_item_id, SaveIntent::Close, cx) }) .await .unwrap();