diff --git a/Cargo.lock b/Cargo.lock index 3cced78c42..bfae8cae0b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8860,6 +8860,7 @@ dependencies = [ "async-trait", "collections", "command_palette", + "diagnostics", "editor", "futures 0.3.28", "gpui", @@ -8881,6 +8882,7 @@ dependencies = [ "tokio", "util", "workspace", + "zed-actions", ] [[package]] diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index 06f76bbc5c..90c155cdf8 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -126,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| { ( 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/gpui/src/app/test_app_context.rs b/crates/gpui/src/app/test_app_context.rs index 0dc1d1eba4..5b15b5274c 100644 --- a/crates/gpui/src/app/test_app_context.rs +++ b/crates/gpui/src/app/test_app_context.rs @@ -33,7 +33,7 @@ use super::{ #[derive(Clone)] pub struct TestAppContext { - cx: Rc>, + pub cx: Rc>, foreground_platform: Rc, condition_duration: Option, pub function_name: String, diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index 142b548a93..97f9d7a7c8 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,19 @@ 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()) + { + 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, @@ -934,7 +964,7 @@ impl BufferSearchBar { } } } - 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/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 index a1f4777ec6..10c2599dd9 100644 --- a/crates/vim/src/command.rs +++ b/crates/vim/src/command.rs @@ -1,16 +1,21 @@ -use command_palette::{humanize_action_name, CommandInterceptResult}; -use gpui::{actions, impl_actions, Action, AppContext, AsyncAppContext, ViewContext}; -use itertools::Itertools; -use serde::{Deserialize, Serialize}; +use command_palette::CommandInterceptResult; +use editor::{SortLinesCaseInsensitive, SortLinesCaseSensitive}; +use gpui::{impl_actions, Action, AppContext}; +use serde_derive::Deserialize; use workspace::{SaveBehavior, Workspace}; use crate::{ - motion::{motion, Motion}, - normal::JoinLines, + motion::{EndOfDocument, Motion}, + normal::{ + move_cursor, + search::{FindCommand, ReplaceCommand}, + JoinLines, + }, + state::Mode, Vim, }; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Deserialize)] pub struct GoToLine { pub line: u32, } @@ -20,19 +25,28 @@ impl_actions!(vim, [GoToLine]); pub fn init(cx: &mut AppContext) { cx.add_action(|_: &mut Workspace, action: &GoToLine, cx| { Vim::update(cx, |vim, cx| { - vim.push_operator(crate::state::Operator::Number(action.line as usize), cx) + vim.switch_mode(Mode::Normal, false, cx); + move_cursor(vim, Motion::StartOfDocument, Some(action.line as usize), cx); }); - motion(Motion::StartOfDocument, 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 { - // :w + // save and quit "w" | "wr" | "wri" | "writ" | "write" => ( "write", workspace::Save { @@ -41,14 +55,12 @@ pub fn command_interceptor(mut query: &str, _: &AppContext) -> Option ( - "write", + "write!", workspace::Save { save_behavior: Some(SaveBehavior::SilentlyOverwrite), } .boxed_clone(), ), - - // :q "q" | "qu" | "qui" | "quit" => ( "quit", workspace::CloseActiveItem { @@ -63,8 +75,6 @@ pub fn command_interceptor(mut query: &str, _: &AppContext) -> Option ( "wq", workspace::CloseActiveItem { @@ -79,7 +89,6 @@ pub fn command_interceptor(mut query: &str, _: &AppContext) -> Option ( "exit", workspace::CloseActiveItem { @@ -88,14 +97,12 @@ pub fn command_interceptor(mut query: &str, _: &AppContext) -> Option ( - "xit", + "exit!", workspace::CloseActiveItem { save_behavior: Some(SaveBehavior::SilentlyOverwrite), } .boxed_clone(), ), - - // :wa "wa" | "wal" | "wall" => ( "wall", workspace::SaveAll { @@ -110,8 +117,6 @@ pub fn command_interceptor(mut query: &str, _: &AppContext) -> Option ( "quitall", workspace::CloseAllItemsAndPanes { @@ -126,17 +131,6 @@ pub fn command_interceptor(mut query: &str, _: &AppContext) -> Option ( - "cquit!", - workspace::CloseAllItemsAndPanes { - save_behavior: Some(SaveBehavior::DontSave), - } - .boxed_clone(), - ), - - // :xa "xa" | "xal" | "xall" => ( "xall", workspace::CloseAllItemsAndPanes { @@ -145,14 +139,12 @@ pub fn command_interceptor(mut query: &str, _: &AppContext) -> Option ( - "zall!", + "xall!", workspace::CloseAllItemsAndPanes { save_behavior: Some(SaveBehavior::SilentlyOverwrite), } .boxed_clone(), ), - - // :wqa "wqa" | "wqal" | "wqall" => ( "wqall", workspace::CloseAllItemsAndPanes { @@ -167,18 +159,89 @@ pub fn command_interceptor(mut query: &str, _: &AppContext) -> Option { + ("cquit!", zed_actions::Quit.boxed_clone()) + } - "j" | "jo" | "joi" | "join" => ("join", JoinLines.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_behavior: Some(SaveBehavior::PromptOnWrite), + } + .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()), - "cp" | "cpr" | "cpre" | "cprev" => ("cprev", editor::GoToPrevDiagnostic.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 let Ok(line) = query.parse::() { + 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; @@ -217,3 +280,120 @@ fn generate_positions(string: &str, query: &str) -> Vec { 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_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..a9e5e66b9e 100644 --- a/crates/vim/src/normal/search.rs +++ b/crates/vim/src/normal/search.rs @@ -1,9 +1,9 @@ use gpui::{actions, impl_actions, AppContext, ViewContext}; use search::{buffer_search, BufferSearchBar, SearchMode, SearchOptions}; use serde_derive::Deserialize; -use workspace::{searchable::Direction, Pane, Workspace}; +use workspace::{searchable::Direction, Pane, Toast, 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)] +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(); + cx.spawn(|_, mut cx| async move { + search.await?; + search_bar.update(&mut cx, |search_bar, cx| { + search_bar.select_match(Direction::Next, 1, cx) + })?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + }) +} + +fn replace_command( + workspace: &mut Workspace, + action: &ReplaceCommand, + cx: &mut ViewContext, +) { + let replacement = match parse_replace_all(&action.query) { + Ok(replacement) => replacement, + Err(message) => { + cx.handle().update(cx, |workspace, cx| { + workspace.show_toast(Toast::new(1544, message), cx) + }); + return; + } + }; + 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 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); + } + }) +} + +fn parse_replace_all(query: &str) -> Result { + let mut chars = query.chars(); + if Some('%') != chars.next() || Some('s') != chars.next() { + return Err("unsupported pattern".to_string()); + } + + let Some(delimeter) = chars.next() else { + return Err("unsupported pattern".to_string()); + }; + if delimeter == '\\' || !delimeter.is_ascii_punctuation() { + return Err(format!("cannot use {:?} as a search delimeter", delimeter)); + } + + let mut search = String::new(); + let mut replacement = String::new(); + let mut flags = String::new(); + + let mut buffer = &mut search; + + let mut escaped = false; + let mut phase = 0; + + for c in chars { + if escaped { + escaped = false; + if phase == 1 && c.is_digit(10) { + // help vim users discover zed regex syntax + // (though we don't try and fix arbitrary patterns for them) + buffer.push('$') + } else if phase == 0 && c == '(' || c == ')' { + // un-escape parens + } 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 { + return Err("trailing characters".to_string()); + } + } else { + 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' => {} // defaults, + 'c' | 'n' => replacement.should_replace_all = false, + 'i' => replacement.is_case_sensitive = false, + _ => return Err(format!("unsupported flag {:?}", c)), + } + } + + Ok(replacement) +} + #[cfg(test)] mod test { use std::sync::Arc; 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/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..13928d5c7e --- /dev/null +++ b/crates/vim/test_data/test_command_replace.json @@ -0,0 +1,22 @@ +{"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"}} diff --git a/crates/zed/src/menus.rs b/crates/zed/src/menus.rs index da3f7e4c32..acffbc29ab 100644 --- a/crates/zed/src/menus.rs +++ b/crates/zed/src/menus.rs @@ -57,12 +57,7 @@ pub fn menus() -> Vec> { save_behavior: None, }, ), - MenuItem::action( - "Close Window", - workspace::CloseWindow { - save_behavior: None, - }, - ), + MenuItem::action("Close Window", workspace::CloseWindow), ], }, Menu { diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index d968a92646..72804cb523 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -947,7 +947,9 @@ mod tests { assert!(editor.text(cx).is_empty()); }); - 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(SaveBehavior::PromptOnConflict, cx) + }); app_state.fs.create_dir(Path::new("/root")).await.unwrap(); cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/the-new-name"))); save_task.await.unwrap(); @@ -1311,7 +1313,9 @@ 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(SaveBehavior::PromptOnConflict, cx) + }); window.simulate_prompt_answer(0, cx); save_task.await.unwrap(); editor.read_with(cx, |editor, cx| { @@ -1353,7 +1357,9 @@ 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(SaveBehavior::PromptOnConflict, cx) + }); cx.simulate_new_path_selection(|parent_dir| { assert_eq!(parent_dir, Path::new("/root")); Some(parent_dir.join("the-new-name.rs")) @@ -1377,7 +1383,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(SaveBehavior::PromptOnConflict, cx) + }); save_task.await.unwrap(); assert!(!cx.did_prompt_for_new_path()); editor.read_with(cx, |editor, cx| { @@ -1444,7 +1452,9 @@ 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(SaveBehavior::PromptOnConflict, 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.