diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index a695c8fd0c..069d8cffb3 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -16968,7 +16968,7 @@ impl Editor { now: Instant, window: &mut Window, cx: &mut Context, - ) { + ) -> Option { self.end_selection(window, cx); if let Some(tx_id) = self .buffer @@ -16978,7 +16978,10 @@ impl Editor { .insert_transaction(tx_id, self.selections.disjoint_anchors()); cx.emit(EditorEvent::TransactionBegun { transaction_id: tx_id, - }) + }); + Some(tx_id) + } else { + None } } @@ -17006,6 +17009,17 @@ impl Editor { } } + pub fn modify_transaction_selection_history( + &mut self, + transaction_id: TransactionId, + modify: impl FnOnce(&mut (Arc<[Selection]>, Option]>>)), + ) -> bool { + self.selection_history + .transaction_mut(transaction_id) + .map(modify) + .is_some() + } + pub fn set_mark(&mut self, _: &actions::SetMark, window: &mut Window, cx: &mut Context) { if self.selection_mark_mode { self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs index 23e04cae2c..7963db3571 100644 --- a/crates/vim/src/command.rs +++ b/crates/vim/src/command.rs @@ -6,7 +6,7 @@ use editor::{ actions::{SortLinesCaseInsensitive, SortLinesCaseSensitive}, display_map::ToDisplayPoint, }; -use gpui::{Action, App, AppContext as _, Context, Global, Window, actions}; +use gpui::{Action, App, AppContext as _, Context, Global, Keystroke, Window, actions}; use itertools::Itertools; use language::Point; use multi_buffer::MultiBufferRow; @@ -202,6 +202,7 @@ actions!( ArgumentRequired ] ); + /// Opens the specified file for editing. #[derive(Clone, PartialEq, Action)] #[action(namespace = vim, no_json, no_register)] @@ -209,6 +210,13 @@ struct VimEdit { pub filename: String, } +#[derive(Clone, PartialEq, Action)] +#[action(namespace = vim, no_json, no_register)] +struct VimNorm { + pub range: Option, + pub command: String, +} + #[derive(Debug)] struct WrappedAction(Box); @@ -447,6 +455,81 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { }); }); + Vim::action(editor, cx, |vim, action: &VimNorm, window, cx| { + let keystrokes = action + .command + .chars() + .map(|c| Keystroke::parse(&c.to_string()).unwrap()) + .collect(); + vim.switch_mode(Mode::Normal, true, window, cx); + let initial_selections = vim.update_editor(window, cx, |_, editor, _, _| { + editor.selections.disjoint_anchors() + }); + if let Some(range) = &action.range { + let result = vim.update_editor(window, cx, |vim, editor, window, cx| { + let range = range.buffer_range(vim, editor, window, cx)?; + editor.change_selections( + SelectionEffects::no_scroll().nav_history(false), + window, + cx, + |s| { + s.select_ranges( + (range.start.0..=range.end.0) + .map(|line| Point::new(line, 0)..Point::new(line, 0)), + ); + }, + ); + anyhow::Ok(()) + }); + if let Some(Err(err)) = result { + log::error!("Error selecting range: {}", err); + return; + } + }; + + let Some(workspace) = vim.workspace(window) else { + return; + }; + let task = workspace.update(cx, |workspace, cx| { + workspace.send_keystrokes_impl(keystrokes, window, cx) + }); + let had_range = action.range.is_some(); + + cx.spawn_in(window, async move |vim, cx| { + task.await; + vim.update_in(cx, |vim, window, cx| { + vim.update_editor(window, cx, |_, editor, window, cx| { + if had_range { + editor.change_selections(SelectionEffects::default(), window, cx, |s| { + s.select_anchor_ranges([s.newest_anchor().range()]); + }) + } + }); + if matches!(vim.mode, Mode::Insert | Mode::Replace) { + vim.normal_before(&Default::default(), window, cx); + } else { + vim.switch_mode(Mode::Normal, true, window, cx); + } + vim.update_editor(window, cx, |_, editor, _, cx| { + if let Some(first_sel) = initial_selections { + if let Some(tx_id) = editor + .buffer() + .update(cx, |multi, cx| multi.last_transaction_id(cx)) + { + let last_sel = editor.selections.disjoint_anchors(); + editor.modify_transaction_selection_history(tx_id, |old| { + old.0 = first_sel; + old.1 = Some(last_sel); + }); + } + } + }); + }) + .ok(); + }) + .detach(); + }); + Vim::action(editor, cx, |vim, _: &CountCommand, window, cx| { let Some(workspace) = vim.workspace(window) else { return; @@ -675,14 +758,15 @@ impl VimCommand { } else { return None; }; - if !args.is_empty() { + + let action = if args.is_empty() { + action + } else { // if command does not accept args and we have args then we should do no action - if let Some(args_fn) = &self.args { - args_fn.deref()(action, args) - } else { - None - } - } else if let Some(range) = range { + self.args.as_ref()?(action, args)? + }; + + if let Some(range) = range { self.range.as_ref().and_then(|f| f(action, range)) } else { Some(action) @@ -1061,6 +1145,27 @@ fn generate_commands(_: &App) -> Vec { save_intent: Some(SaveIntent::Skip), close_pinned: true, }), + VimCommand::new( + ("norm", "al"), + VimNorm { + command: "".into(), + range: None, + }, + ) + .args(|_, args| { + Some( + VimNorm { + command: args, + range: None, + } + .boxed_clone(), + ) + }) + .range(|action, range| { + let mut action: VimNorm = action.as_any().downcast_ref::().unwrap().clone(); + action.range.replace(range.clone()); + Some(Box::new(action)) + }), VimCommand::new(("bn", "ext"), workspace::ActivateNextItem).count(), VimCommand::new(("bN", "ext"), workspace::ActivatePreviousItem).count(), VimCommand::new(("bp", "revious"), workspace::ActivatePreviousItem).count(), @@ -2298,4 +2403,78 @@ mod test { }); assert!(mark.is_none()) } + + #[gpui::test] + async fn test_normal_command(cx: &mut TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state(indoc! {" + The quick + brown« fox + jumpsˇ» over + the lazy dog + "}) + .await; + + cx.simulate_shared_keystrokes(": n o r m space w C w o r d") + .await; + cx.simulate_shared_keystrokes("enter").await; + + cx.shared_state().await.assert_eq(indoc! {" + The quick + brown word + jumps worˇd + the lazy dog + "}); + + cx.simulate_shared_keystrokes(": n o r m space _ w c i w t e s t") + .await; + cx.simulate_shared_keystrokes("enter").await; + + cx.shared_state().await.assert_eq(indoc! {" + The quick + brown word + jumps tesˇt + the lazy dog + "}); + + cx.simulate_shared_keystrokes("_ l v l : n o r m space s l a") + .await; + cx.simulate_shared_keystrokes("enter").await; + + cx.shared_state().await.assert_eq(indoc! {" + The quick + brown word + lˇaumps test + the lazy dog + "}); + + cx.set_shared_state(indoc! {" + ˇThe quick + brown fox + jumps over + the lazy dog + "}) + .await; + + cx.simulate_shared_keystrokes("c i w M y escape").await; + + cx.shared_state().await.assert_eq(indoc! {" + Mˇy quick + brown fox + jumps over + the lazy dog + "}); + + cx.simulate_shared_keystrokes(": n o r m space u").await; + cx.simulate_shared_keystrokes("enter").await; + + cx.shared_state().await.assert_eq(indoc! {" + ˇThe quick + brown fox + jumps over + the lazy dog + "}); + // Once ctrl-v to input character literals is added there should be a test for redo + } } diff --git a/crates/vim/src/insert.rs b/crates/vim/src/insert.rs index 89c60adee7..0a370e16ba 100644 --- a/crates/vim/src/insert.rs +++ b/crates/vim/src/insert.rs @@ -21,7 +21,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { } impl Vim { - fn normal_before( + pub(crate) fn normal_before( &mut self, action: &NormalBefore, window: &mut Window, diff --git a/crates/vim/test_data/test_normal_command.json b/crates/vim/test_data/test_normal_command.json new file mode 100644 index 0000000000..efd1d532c4 --- /dev/null +++ b/crates/vim/test_data/test_normal_command.json @@ -0,0 +1,64 @@ +{"Put":{"state":"The quick\nbrown« fox\njumpsˇ» over\nthe lazy dog\n"}} +{"Key":":"} +{"Key":"n"} +{"Key":"o"} +{"Key":"r"} +{"Key":"m"} +{"Key":"space"} +{"Key":"w"} +{"Key":"C"} +{"Key":"w"} +{"Key":"o"} +{"Key":"r"} +{"Key":"d"} +{"Key":"enter"} +{"Get":{"state":"The quick\nbrown word\njumps worˇd\nthe lazy dog\n","mode":"Normal"}} +{"Key":":"} +{"Key":"n"} +{"Key":"o"} +{"Key":"r"} +{"Key":"m"} +{"Key":"space"} +{"Key":"_"} +{"Key":"w"} +{"Key":"c"} +{"Key":"i"} +{"Key":"w"} +{"Key":"t"} +{"Key":"e"} +{"Key":"s"} +{"Key":"t"} +{"Key":"enter"} +{"Get":{"state":"The quick\nbrown word\njumps tesˇt\nthe lazy dog\n","mode":"Normal"}} +{"Key":"_"} +{"Key":"l"} +{"Key":"v"} +{"Key":"l"} +{"Key":":"} +{"Key":"n"} +{"Key":"o"} +{"Key":"r"} +{"Key":"m"} +{"Key":"space"} +{"Key":"s"} +{"Key":"l"} +{"Key":"a"} +{"Key":"enter"} +{"Get":{"state":"The quick\nbrown word\nlˇaumps test\nthe lazy dog\n","mode":"Normal"}} +{"Put":{"state":"ˇThe quick\nbrown fox\njumps over\nthe lazy dog\n"}} +{"Key":"c"} +{"Key":"i"} +{"Key":"w"} +{"Key":"M"} +{"Key":"y"} +{"Key":"escape"} +{"Get":{"state":"Mˇy quick\nbrown fox\njumps over\nthe lazy dog\n","mode":"Normal"}} +{"Key":":"} +{"Key":"n"} +{"Key":"o"} +{"Key":"r"} +{"Key":"m"} +{"Key":"space"} +{"Key":"u"} +{"Key":"enter"} +{"Get":{"state":"ˇThe quick\nbrown fox\njumps over\nthe lazy dog\n","mode":"Normal"}} diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 4c70c52d5a..0ee8177dd8 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -32,7 +32,7 @@ use futures::{ mpsc::{self, UnboundedReceiver, UnboundedSender}, oneshot, }, - future::try_join_all, + future::{Shared, try_join_all}, }; use gpui::{ Action, AnyEntity, AnyView, AnyWeakView, App, AsyncApp, AsyncWindowContext, Bounds, Context, @@ -87,7 +87,7 @@ use std::{ borrow::Cow, cell::RefCell, cmp, - collections::hash_map::DefaultHasher, + collections::{VecDeque, hash_map::DefaultHasher}, env, hash::{Hash, Hasher}, path::{Path, PathBuf}, @@ -1043,6 +1043,13 @@ type PromptForOpenPath = Box< ) -> oneshot::Receiver>>, >; +#[derive(Default)] +struct DispatchingKeystrokes { + dispatched: HashSet>, + queue: VecDeque, + task: Option>>, +} + /// Collects everything project-related for a certain window opened. /// In some way, is a counterpart of a window, as the [`WindowHandle`] could be downcast into `Workspace`. /// @@ -1080,7 +1087,7 @@ pub struct Workspace { leader_updates_tx: mpsc::UnboundedSender<(PeerId, proto::UpdateFollowers)>, database_id: Option, app_state: Arc, - dispatching_keystrokes: Rc, Vec)>>, + dispatching_keystrokes: Rc>, _subscriptions: Vec, _apply_leader_updates: Task>, _observe_current_user: Task>, @@ -2311,49 +2318,65 @@ impl Workspace { window: &mut Window, cx: &mut Context, ) { - let mut state = self.dispatching_keystrokes.borrow_mut(); - if !state.0.insert(action.0.clone()) { - cx.propagate(); - return; - } - let mut keystrokes: Vec = action + let keystrokes: Vec = action .0 .split(' ') .flat_map(|k| Keystroke::parse(k).log_err()) .collect(); - keystrokes.reverse(); + let _ = self.send_keystrokes_impl(keystrokes, window, cx); + } - state.1.append(&mut keystrokes); - drop(state); + pub fn send_keystrokes_impl( + &mut self, + keystrokes: Vec, + window: &mut Window, + cx: &mut Context, + ) -> Shared> { + let mut state = self.dispatching_keystrokes.borrow_mut(); + if !state.dispatched.insert(keystrokes.clone()) { + cx.propagate(); + return state.task.clone().unwrap(); + } + + state.queue.extend(keystrokes); let keystrokes = self.dispatching_keystrokes.clone(); - window - .spawn(cx, async move |cx| { - // limit to 100 keystrokes to avoid infinite recursion. - for _ in 0..100 { - let Some(keystroke) = keystrokes.borrow_mut().1.pop() else { - keystrokes.borrow_mut().0.clear(); - return Ok(()); - }; - cx.update(|window, cx| { - let focused = window.focused(cx); - window.dispatch_keystroke(keystroke.clone(), cx); - if window.focused(cx) != focused { - // dispatch_keystroke may cause the focus to change. - // draw's side effect is to schedule the FocusChanged events in the current flush effect cycle - // And we need that to happen before the next keystroke to keep vim mode happy... - // (Note that the tests always do this implicitly, so you must manually test with something like: - // "bindings": { "g z": ["workspace::SendKeystrokes", ": j u"]} - // ) - window.draw(cx).clear(); + if state.task.is_none() { + state.task = Some( + window + .spawn(cx, async move |cx| { + // limit to 100 keystrokes to avoid infinite recursion. + for _ in 0..100 { + let mut state = keystrokes.borrow_mut(); + let Some(keystroke) = state.queue.pop_front() else { + state.dispatched.clear(); + state.task.take(); + return; + }; + drop(state); + cx.update(|window, cx| { + let focused = window.focused(cx); + window.dispatch_keystroke(keystroke.clone(), cx); + if window.focused(cx) != focused { + // dispatch_keystroke may cause the focus to change. + // draw's side effect is to schedule the FocusChanged events in the current flush effect cycle + // And we need that to happen before the next keystroke to keep vim mode happy... + // (Note that the tests always do this implicitly, so you must manually test with something like: + // "bindings": { "g z": ["workspace::SendKeystrokes", ": j u"]} + // ) + window.draw(cx).clear(); + } + }) + .ok(); } - })?; - } - *keystrokes.borrow_mut() = Default::default(); - anyhow::bail!("over 100 keystrokes passed to send_keystrokes"); - }) - .detach_and_log_err(cx); + *keystrokes.borrow_mut() = Default::default(); + log::error!("over 100 keystrokes passed to send_keystrokes"); + }) + .shared(), + ); + } + state.task.clone().unwrap() } fn save_all_internal(