diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index cd43178522..498fd018fe 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -126,10 +126,7 @@ } } ], - "m": [ - "vim::PushOperator", - "Mark" - ], + "m": ["vim::PushOperator", "Mark"], "'": [ "vim::PushOperator", { @@ -151,14 +148,8 @@ "ctrl-o": "pane::GoBack", "ctrl-i": "pane::GoForward", "ctrl-]": "editor::GoToDefinition", - "escape": [ - "vim::SwitchMode", - "Normal" - ], - "ctrl-[": [ - "vim::SwitchMode", - "Normal" - ], + "escape": ["vim::SwitchMode", "Normal"], + "ctrl-[": ["vim::SwitchMode", "Normal"], "v": "vim::ToggleVisual", "shift-v": "vim::ToggleVisualLine", "ctrl-v": "vim::ToggleVisualBlock", @@ -284,10 +275,7 @@ // z commands "z t": "editor::ScrollCursorTop", "z z": "editor::ScrollCursorCenter", - "z .": [ - "workspace::SendKeystrokes", - "z z ^" - ], + "z .": ["workspace::SendKeystrokes", "z z ^"], "z b": "editor::ScrollCursorBottom", "z c": "editor::Fold", "z o": "editor::UnfoldLines", @@ -305,123 +293,36 @@ } ], // Count support - "1": [ - "vim::Number", - 1 - ], - "2": [ - "vim::Number", - 2 - ], - "3": [ - "vim::Number", - 3 - ], - "4": [ - "vim::Number", - 4 - ], - "5": [ - "vim::Number", - 5 - ], - "6": [ - "vim::Number", - 6 - ], - "7": [ - "vim::Number", - 7 - ], - "8": [ - "vim::Number", - 8 - ], - "9": [ - "vim::Number", - 9 - ], + "1": ["vim::Number", 1], + "2": ["vim::Number", 2], + "3": ["vim::Number", 3], + "4": ["vim::Number", 4], + "5": ["vim::Number", 5], + "6": ["vim::Number", 6], + "7": ["vim::Number", 7], + "8": ["vim::Number", 8], + "9": ["vim::Number", 9], // window related commands (ctrl-w X) - "ctrl-w left": [ - "workspace::ActivatePaneInDirection", - "Left" - ], - "ctrl-w right": [ - "workspace::ActivatePaneInDirection", - "Right" - ], - "ctrl-w up": [ - "workspace::ActivatePaneInDirection", - "Up" - ], - "ctrl-w down": [ - "workspace::ActivatePaneInDirection", - "Down" - ], - "ctrl-w h": [ - "workspace::ActivatePaneInDirection", - "Left" - ], - "ctrl-w l": [ - "workspace::ActivatePaneInDirection", - "Right" - ], - "ctrl-w k": [ - "workspace::ActivatePaneInDirection", - "Up" - ], - "ctrl-w j": [ - "workspace::ActivatePaneInDirection", - "Down" - ], - "ctrl-w ctrl-h": [ - "workspace::ActivatePaneInDirection", - "Left" - ], - "ctrl-w ctrl-l": [ - "workspace::ActivatePaneInDirection", - "Right" - ], - "ctrl-w ctrl-k": [ - "workspace::ActivatePaneInDirection", - "Up" - ], - "ctrl-w ctrl-j": [ - "workspace::ActivatePaneInDirection", - "Down" - ], - "ctrl-w shift-left": [ - "workspace::SwapPaneInDirection", - "Left" - ], - "ctrl-w shift-right": [ - "workspace::SwapPaneInDirection", - "Right" - ], - "ctrl-w shift-up": [ - "workspace::SwapPaneInDirection", - "Up" - ], - "ctrl-w shift-down": [ - "workspace::SwapPaneInDirection", - "Down" - ], - "ctrl-w shift-h": [ - "workspace::SwapPaneInDirection", - "Left" - ], - "ctrl-w shift-l": [ - "workspace::SwapPaneInDirection", - "Right" - ], - "ctrl-w shift-k": [ - "workspace::SwapPaneInDirection", - "Up" - ], - "ctrl-w shift-j": [ - "workspace::SwapPaneInDirection", - "Down" - ], + "ctrl-w left": ["workspace::ActivatePaneInDirection", "Left"], + "ctrl-w right": ["workspace::ActivatePaneInDirection", "Right"], + "ctrl-w up": ["workspace::ActivatePaneInDirection", "Up"], + "ctrl-w down": ["workspace::ActivatePaneInDirection", "Down"], + "ctrl-w h": ["workspace::ActivatePaneInDirection", "Left"], + "ctrl-w l": ["workspace::ActivatePaneInDirection", "Right"], + "ctrl-w k": ["workspace::ActivatePaneInDirection", "Up"], + "ctrl-w j": ["workspace::ActivatePaneInDirection", "Down"], + "ctrl-w ctrl-h": ["workspace::ActivatePaneInDirection", "Left"], + "ctrl-w ctrl-l": ["workspace::ActivatePaneInDirection", "Right"], + "ctrl-w ctrl-k": ["workspace::ActivatePaneInDirection", "Up"], + "ctrl-w ctrl-j": ["workspace::ActivatePaneInDirection", "Down"], + "ctrl-w shift-left": ["workspace::SwapPaneInDirection", "Left"], + "ctrl-w shift-right": ["workspace::SwapPaneInDirection", "Right"], + "ctrl-w shift-up": ["workspace::SwapPaneInDirection", "Up"], + "ctrl-w shift-down": ["workspace::SwapPaneInDirection", "Down"], + "ctrl-w shift-h": ["workspace::SwapPaneInDirection", "Left"], + "ctrl-w shift-l": ["workspace::SwapPaneInDirection", "Right"], + "ctrl-w shift-k": ["workspace::SwapPaneInDirection", "Up"], + "ctrl-w shift-j": ["workspace::SwapPaneInDirection", "Down"], "ctrl-w g t": "pane::ActivateNextItem", "ctrl-w ctrl-g t": "pane::ActivateNextItem", "ctrl-w g shift-t": "pane::ActivatePrevItem", @@ -443,14 +344,8 @@ "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" - ], + "ctrl-w n": ["workspace::NewFileInDirection", "Up"], + "ctrl-w ctrl-n": ["workspace::NewFileInDirection", "Up"], "ctrl-w d": "editor::GoToDefinitionSplit", "ctrl-w g d": "editor::GoToDefinitionSplit", "ctrl-w shift-d": "editor::GoToTypeDefinitionSplit", @@ -472,21 +367,12 @@ "context": "Editor && vim_mode == normal && vim_operator == none && !VimWaiting", "bindings": { ".": "vim::Repeat", - "c": [ - "vim::PushOperator", - "Change" - ], + "c": ["vim::PushOperator", "Change"], "shift-c": "vim::ChangeToEndOfLine", - "d": [ - "vim::PushOperator", - "Delete" - ], + "d": ["vim::PushOperator", "Delete"], "shift-d": "vim::DeleteToEndOfLine", "shift-j": "vim::JoinLines", - "y": [ - "vim::PushOperator", - "Yank" - ], + "y": ["vim::PushOperator", "Yank"], "shift-y": "vim::YankLine", "i": "vim::InsertBefore", "shift-i": "vim::InsertFirstNonWhitespace", @@ -508,36 +394,18 @@ ], "u": "editor::Undo", "ctrl-r": "editor::Redo", - "r": [ - "vim::PushOperator", - "Replace" - ], + "r": ["vim::PushOperator", "Replace"], "s": "vim::Substitute", "shift-s": "vim::SubstituteLine", - ">": [ - "vim::PushOperator", - "Indent" - ], - "<": [ - "vim::PushOperator", - "Outdent" - ], - "g u": [ - "vim::PushOperator", - "Lowercase" - ], - "g shift-u": [ - "vim::PushOperator", - "Uppercase" - ], - "g ~": [ - "vim::PushOperator", - "OppositeCase" - ], - "\"": [ - "vim::PushOperator", - "Register" - ], + ">": ["vim::PushOperator", "Indent"], + "<": ["vim::PushOperator", "Outdent"], + "g u": ["vim::PushOperator", "Lowercase"], + "g shift-u": ["vim::PushOperator", "Uppercase"], + "g ~": ["vim::PushOperator", "OppositeCase"], + "\"": ["vim::PushOperator", "Register"], + "q": "vim::ToggleRecord", + "shift-q": "vim::ReplayLastRecording", + "@": ["vim::PushOperator", "ReplayRegister"], "ctrl-pagedown": "pane::ActivateNextItem", "ctrl-pageup": "pane::ActivatePrevItem", // tree-sitter related commands @@ -552,10 +420,7 @@ { "context": "Editor && vim_mode == visual && vim_operator == none && !VimWaiting", "bindings": { - "\"": [ - "vim::PushOperator", - "Register" - ], + "\"": ["vim::PushOperator", "Register"], // tree-sitter related commands "[ x": "editor::SelectLargerSyntaxNode", "] x": "editor::SelectSmallerSyntaxNode" @@ -564,10 +429,7 @@ { "context": "Editor && VimCount && vim_mode != insert", "bindings": { - "0": [ - "vim::Number", - 0 - ] + "0": ["vim::Number", 0] } }, { @@ -618,10 +480,7 @@ { "context": "Editor && vim_mode == normal && vim_operator == d", "bindings": { - "s": [ - "vim::PushOperator", - "DeleteSurrounds" - ] + "s": ["vim::PushOperator", "DeleteSurrounds"] } }, { @@ -743,22 +602,10 @@ "shift-i": "vim::InsertBefore", "shift-a": "vim::InsertAfter", "shift-j": "vim::JoinLines", - "r": [ - "vim::PushOperator", - "Replace" - ], - "ctrl-c": [ - "vim::SwitchMode", - "Normal" - ], - "escape": [ - "vim::SwitchMode", - "Normal" - ], - "ctrl-[": [ - "vim::SwitchMode", - "Normal" - ], + "r": ["vim::PushOperator", "Replace"], + "ctrl-c": ["vim::SwitchMode", "Normal"], + "escape": ["vim::SwitchMode", "Normal"], + "ctrl-[": ["vim::SwitchMode", "Normal"], ">": "vim::Indent", "<": "vim::Outdent", "i": [ @@ -806,10 +653,7 @@ "ctrl-u": "editor::DeleteToBeginningOfLine", "ctrl-t": "vim::Indent", "ctrl-d": "vim::Outdent", - "ctrl-r": [ - "vim::PushOperator", - "Register" - ] + "ctrl-r": ["vim::PushOperator", "Register"] } }, { @@ -828,14 +672,8 @@ "bindings": { "tab": "vim::Tab", "enter": "vim::Enter", - "escape": [ - "vim::SwitchMode", - "Normal" - ], - "ctrl-[": [ - "vim::SwitchMode", - "Normal" - ] + "escape": ["vim::SwitchMode", "Normal"], + "ctrl-[": ["vim::SwitchMode", "Normal"] } }, { diff --git a/crates/vim/src/insert.rs b/crates/vim/src/insert.rs index ad97a4af3d..fbf2d5fc77 100644 --- a/crates/vim/src/insert.rs +++ b/crates/vim/src/insert.rs @@ -23,7 +23,7 @@ fn normal_before(_: &mut Workspace, action: &NormalBefore, cx: &mut ViewContext< } let count = vim.take_count(cx).unwrap_or(1); vim.stop_recording_immediately(action.boxed_clone()); - if count <= 1 || vim.workspace_state.replaying { + if count <= 1 || vim.workspace_state.dot_replaying { create_mark(vim, "^".into(), false, cx); vim.update_active_editor(cx, |_, editor, cx| { editor.dismiss_menus_and_popups(false, cx); diff --git a/crates/vim/src/mode_indicator.rs b/crates/vim/src/mode_indicator.rs index 32b38b2bea..1cfb598e7f 100644 --- a/crates/vim/src/mode_indicator.rs +++ b/crates/vim/src/mode_indicator.rs @@ -61,10 +61,11 @@ impl ModeIndicator { } fn current_operators_description(&self, vim: &Vim) -> String { - vim.state() - .pre_count - .map(|count| format!("{}", count)) + vim.workspace_state + .recording_register + .map(|reg| format!("recording @{reg} ")) .into_iter() + .chain(vim.state().pre_count.map(|count| format!("{}", count))) .chain(vim.state().selected_register.map(|reg| format!("\"{reg}"))) .chain( vim.state() diff --git a/crates/vim/src/normal/repeat.rs b/crates/vim/src/normal/repeat.rs index b9f5055162..fd4365b006 100644 --- a/crates/vim/src/normal/repeat.rs +++ b/crates/vim/src/normal/repeat.rs @@ -1,14 +1,17 @@ +use std::{cell::RefCell, ops::Range, rc::Rc, sync::Arc}; + use crate::{ insert::NormalBefore, motion::Motion, - state::{Mode, RecordedSelection, ReplayableAction}, + state::{Mode, Operator, RecordedSelection, ReplayableAction}, visual::visual_motion, Vim, }; use gpui::{actions, Action, ViewContext, WindowContext}; +use util::ResultExt; use workspace::Workspace; -actions!(vim, [Repeat, EndRepeat]); +actions!(vim, [Repeat, EndRepeat, ToggleRecord, ReplayLastRecording]); fn should_replay(action: &Box) -> bool { // skip so that we don't leave the character palette open @@ -44,24 +47,148 @@ fn repeatable_insert(action: &ReplayableAction) -> Option> { pub(crate) fn register(workspace: &mut Workspace, _: &mut ViewContext) { workspace.register_action(|_: &mut Workspace, _: &EndRepeat, cx| { Vim::update(cx, |vim, cx| { - vim.workspace_state.replaying = false; + vim.workspace_state.dot_replaying = false; vim.switch_mode(Mode::Normal, false, cx) }); }); workspace.register_action(|_: &mut Workspace, _: &Repeat, cx| repeat(cx, false)); + workspace.register_action(|_: &mut Workspace, _: &ToggleRecord, cx| { + Vim::update(cx, |vim, cx| { + if let Some(char) = vim.workspace_state.recording_register.take() { + vim.workspace_state.last_recorded_register = Some(char) + } else { + vim.push_operator(Operator::RecordRegister, cx); + } + }) + }); + + workspace.register_action(|_: &mut Workspace, _: &ReplayLastRecording, cx| { + let Some(register) = Vim::read(cx).workspace_state.last_recorded_register else { + return; + }; + replay_register(register, cx) + }); +} + +pub struct ReplayerState { + actions: Vec, + running: bool, + ix: usize, +} + +#[derive(Clone)] +pub struct Replayer(Rc>); + +impl Replayer { + pub fn new() -> Self { + Self(Rc::new(RefCell::new(ReplayerState { + actions: vec![], + running: false, + ix: 0, + }))) + } + + pub fn replay(&mut self, actions: Vec, cx: &mut WindowContext) { + let mut lock = self.0.borrow_mut(); + let range = lock.ix..lock.ix; + lock.actions.splice(range, actions); + if lock.running { + return; + } + lock.running = true; + let this = self.clone(); + cx.defer(move |cx| this.next(cx)) + } + + pub fn stop(self) { + self.0.borrow_mut().actions.clear() + } + + pub fn next(self, cx: &mut WindowContext) { + let mut lock = self.0.borrow_mut(); + let action = if lock.ix < 10000 { + lock.actions.get(lock.ix).cloned() + } else { + log::error!("Aborting replay after 10000 actions"); + None + }; + lock.ix += 1; + drop(lock); + let Some(action) = action else { + Vim::update(cx, |vim, _| vim.workspace_state.replayer.take()); + return; + }; + match action { + ReplayableAction::Action(action) => { + if should_replay(&action) { + cx.dispatch_action(action.boxed_clone()); + cx.defer(move |cx| observe_action(action.boxed_clone(), cx)); + } + } + ReplayableAction::Insertion { + text, + utf16_range_to_replace, + } => { + if let Some(editor) = Vim::read(cx).active_editor.clone() { + editor + .update(cx, |editor, cx| { + editor.replay_insert_event(&text, utf16_range_to_replace.clone(), cx) + }) + .log_err(); + } + } + } + cx.defer(move |cx| self.next(cx)); + } +} + +pub(crate) fn record_register(register: char, cx: &mut WindowContext) { + Vim::update(cx, |vim, cx| { + vim.workspace_state.recording_register = Some(register); + vim.workspace_state.recordings.remove(®ister); + vim.workspace_state.ignore_current_insertion = true; + vim.clear_operator(cx) + }) +} + +pub(crate) fn replay_register(mut register: char, cx: &mut WindowContext) { + Vim::update(cx, |vim, cx| { + let mut count = vim.take_count(cx).unwrap_or(1); + vim.clear_operator(cx); + + if register == '@' { + let Some(last) = vim.workspace_state.last_replayed_register else { + return; + }; + register = last; + } + let Some(actions) = vim.workspace_state.recordings.get(®ister) else { + return; + }; + + let mut repeated_actions = vec![]; + while count > 0 { + repeated_actions.extend(actions.iter().cloned()); + count -= 1 + } + + vim.workspace_state.last_replayed_register = Some(register); + + vim.workspace_state + .replayer + .get_or_insert_with(|| Replayer::new()) + .replay(repeated_actions, cx); + }); } pub(crate) fn repeat(cx: &mut WindowContext, from_insert_mode: bool) { - let Some((mut actions, editor, selection)) = Vim::update(cx, |vim, cx| { + let Some((mut actions, selection)) = Vim::update(cx, |vim, cx| { let actions = vim.workspace_state.recorded_actions.clone(); if actions.is_empty() { return None; } - let Some(editor) = vim.active_editor.clone() else { - return None; - }; let count = vim.take_count(cx); let selection = vim.workspace_state.recorded_selection.clone(); @@ -85,7 +212,17 @@ pub(crate) fn repeat(cx: &mut WindowContext, from_insert_mode: bool) { } } - Some((actions, editor, selection)) + if vim.workspace_state.replayer.is_none() { + if let Some(recording_register) = vim.workspace_state.recording_register { + vim.workspace_state + .recordings + .entry(recording_register) + .or_default() + .push(ReplayableAction::Action(Repeat.boxed_clone())); + } + } + + Some((actions, selection)) }) else { return; }; @@ -167,42 +304,75 @@ pub(crate) fn repeat(cx: &mut WindowContext, from_insert_mode: bool) { actions = new_actions; } - Vim::update(cx, |vim, _| vim.workspace_state.replaying = true); - let window = cx.window_handle(); - cx.spawn(move |mut cx| async move { - editor.update(&mut cx, |editor, _| { - editor.show_local_selections = false; - })?; - for action in actions { - if !matches!( - cx.update(|cx| Vim::read(cx).workspace_state.replaying), - Ok(true) - ) { - break; - } + actions.push(ReplayableAction::Action(EndRepeat.boxed_clone())); - match action { - ReplayableAction::Action(action) => { - if should_replay(&action) { - window.update(&mut cx, |_, cx| cx.dispatch_action(action)) - } else { - Ok(()) - } - } - ReplayableAction::Insertion { - text, - utf16_range_to_replace, - } => editor.update(&mut cx, |editor, cx| { - editor.replay_insert_event(&text, utf16_range_to_replace.clone(), cx) - }), - }? - } - editor.update(&mut cx, |editor, _| { - editor.show_local_selections = true; - })?; - window.update(&mut cx, |_, cx| cx.dispatch_action(EndRepeat.boxed_clone())) + Vim::update(cx, |vim, cx| { + vim.workspace_state.dot_replaying = true; + + vim.workspace_state + .replayer + .get_or_insert_with(|| Replayer::new()) + .replay(actions, cx); }) - .detach_and_log_err(cx); +} + +pub(crate) fn observe_action(action: Box, cx: &mut WindowContext) { + Vim::update(cx, |vim, _| { + if vim.workspace_state.dot_recording { + vim.workspace_state + .recorded_actions + .push(ReplayableAction::Action(action.boxed_clone())); + + if vim.workspace_state.stop_recording_after_next_action { + vim.workspace_state.dot_recording = false; + vim.workspace_state.stop_recording_after_next_action = false; + } + } + if vim.workspace_state.replayer.is_none() { + if let Some(recording_register) = vim.workspace_state.recording_register { + vim.workspace_state + .recordings + .entry(recording_register) + .or_default() + .push(ReplayableAction::Action(action)); + } + } + }) +} + +pub(crate) fn observe_insertion( + text: &Arc, + range_to_replace: Option>, + cx: &mut WindowContext, +) { + Vim::update(cx, |vim, _| { + if vim.workspace_state.ignore_current_insertion { + vim.workspace_state.ignore_current_insertion = false; + return; + } + if vim.workspace_state.dot_recording { + vim.workspace_state + .recorded_actions + .push(ReplayableAction::Insertion { + text: text.clone(), + utf16_range_to_replace: range_to_replace.clone(), + }); + if vim.workspace_state.stop_recording_after_next_action { + vim.workspace_state.dot_recording = false; + vim.workspace_state.stop_recording_after_next_action = false; + } + } + if let Some(recording_register) = vim.workspace_state.recording_register { + vim.workspace_state + .recordings + .entry(recording_register) + .or_default() + .push(ReplayableAction::Insertion { + text: text.clone(), + utf16_range_to_replace: range_to_replace, + }); + } + }); } #[cfg(test)] @@ -510,4 +680,76 @@ mod test { cx.simulate_shared_keystrokes("u").await; cx.shared_state().await.assert_eq("hellˇo"); } + + #[gpui::test] + async fn test_record_replay(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state("ˇhello world").await; + cx.simulate_shared_keystrokes("q w c w j escape q").await; + cx.shared_state().await.assert_eq("ˇj world"); + cx.simulate_shared_keystrokes("2 l @ w").await; + cx.shared_state().await.assert_eq("j ˇj"); + } + + #[gpui::test] + async fn test_record_replay_count(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state("ˇhello world!!").await; + cx.simulate_shared_keystrokes("q a v 3 l s 0 escape l q") + .await; + cx.shared_state().await.assert_eq("0ˇo world!!"); + cx.simulate_shared_keystrokes("2 @ a").await; + cx.shared_state().await.assert_eq("000ˇ!"); + } + + #[gpui::test] + async fn test_record_replay_dot(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state("ˇhello world").await; + cx.simulate_shared_keystrokes("q a r a l r b l q").await; + cx.shared_state().await.assert_eq("abˇllo world"); + cx.simulate_shared_keystrokes(".").await; + cx.shared_state().await.assert_eq("abˇblo world"); + cx.simulate_shared_keystrokes("shift-q").await; + cx.shared_state().await.assert_eq("ababˇo world"); + cx.simulate_shared_keystrokes(".").await; + cx.shared_state().await.assert_eq("ababˇb world"); + } + + #[gpui::test] + async fn test_record_replay_of_dot(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state("ˇhello world").await; + cx.simulate_shared_keystrokes("r o q w . q").await; + cx.shared_state().await.assert_eq("ˇoello world"); + cx.simulate_shared_keystrokes("d l").await; + cx.shared_state().await.assert_eq("ˇello world"); + cx.simulate_shared_keystrokes("@ w").await; + cx.shared_state().await.assert_eq("ˇllo world"); + } + + #[gpui::test] + async fn test_record_replay_interleaved(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state("ˇhello world").await; + cx.simulate_shared_keystrokes("q z r a l q").await; + cx.shared_state().await.assert_eq("aˇello world"); + cx.simulate_shared_keystrokes("q b @ z @ z q").await; + cx.shared_state().await.assert_eq("aaaˇlo world"); + cx.simulate_shared_keystrokes("@ @").await; + cx.shared_state().await.assert_eq("aaaaˇo world"); + cx.simulate_shared_keystrokes("@ b").await; + cx.shared_state().await.assert_eq("aaaaaaˇworld"); + cx.simulate_shared_keystrokes("@ @").await; + cx.shared_state().await.assert_eq("aaaaaaaˇorld"); + cx.simulate_shared_keystrokes("q z r b l q").await; + cx.shared_state().await.assert_eq("aaaaaaabˇrld"); + cx.simulate_shared_keystrokes("@ b").await; + cx.shared_state().await.assert_eq("aaaaaaabbbˇd"); + } } diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index eef88e297b..8c724228a9 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -1,5 +1,6 @@ use std::{fmt::Display, ops::Range, sync::Arc}; +use crate::normal::repeat::Replayer; use crate::surrounds::SurroundsType; use crate::{motion::Motion, object::Object}; use collections::HashMap; @@ -68,6 +69,8 @@ pub enum Operator { Uppercase, OppositeCase, Register, + RecordRegister, + ReplayRegister, } #[derive(Default, Clone)] @@ -155,15 +158,23 @@ impl From for Register { pub struct WorkspaceState { pub last_find: Option, - pub recording: bool, + pub dot_recording: bool, + pub dot_replaying: bool, + pub stop_recording_after_next_action: bool, - pub replaying: bool, + pub ignore_current_insertion: bool, pub recorded_count: Option, pub recorded_actions: Vec, pub recorded_selection: RecordedSelection, + pub recording_register: Option, + pub last_recorded_register: Option, + pub last_replayed_register: Option, + pub replayer: Option, + pub last_yank: Option, pub registers: HashMap, + pub recordings: HashMap>, } #[derive(Debug)] @@ -228,6 +239,8 @@ impl EditorState { | Some(Operator::FindBackward { .. }) | Some(Operator::Mark) | Some(Operator::Register) + | Some(Operator::RecordRegister) + | Some(Operator::ReplayRegister) | Some(Operator::Jump { .. }) ) } @@ -322,6 +335,8 @@ impl Operator { Operator::Lowercase => "gu", Operator::OppositeCase => "g~", Operator::Register => "\"", + Operator::RecordRegister => "q", + Operator::ReplayRegister => "@", } } @@ -333,6 +348,8 @@ impl Operator { | Operator::Jump { .. } | Operator::FindBackward { .. } | Operator::Register + | Operator::RecordRegister + | Operator::ReplayRegister | Operator::Replace | Operator::AddSurrounds { target: Some(_) } | Operator::ChangeSurrounds { .. } diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index db758c1fea..be7244f7f0 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -31,7 +31,11 @@ use gpui::{ use language::{CursorShape, Point, SelectionGoal, TransactionId}; pub use mode_indicator::ModeIndicator; use motion::Motion; -use normal::{mark::create_visual_marks, normal_replace}; +use normal::{ + mark::create_visual_marks, + normal_replace, + repeat::{observe_action, observe_insertion, record_register, replay_register}, +}; use replace::multi_replace; use schemars::JsonSchema; use serde::Deserialize; @@ -170,18 +174,7 @@ fn observe_keystrokes(keystroke_event: &KeystrokeEvent, cx: &mut WindowContext) .as_ref() .map(|action| action.boxed_clone()) { - Vim::update(cx, |vim, _| { - if vim.workspace_state.recording { - vim.workspace_state - .recorded_actions - .push(ReplayableAction::Action(action.boxed_clone())); - - if vim.workspace_state.stop_recording_after_next_action { - vim.workspace_state.recording = false; - vim.workspace_state.stop_recording_after_next_action = false; - } - } - }); + observe_action(action.boxed_clone(), cx); // Keystroke is handled by the vim system, so continue forward if action.name().starts_with("vim::") { @@ -201,7 +194,9 @@ fn observe_keystrokes(keystroke_event: &KeystrokeEvent, cx: &mut WindowContext) | Operator::DeleteSurrounds | Operator::Mark | Operator::Jump { .. } - | Operator::Register, + | Operator::Register + | Operator::RecordRegister + | Operator::ReplayRegister, ) => {} Some(_) => { vim.clear_operator(cx); @@ -254,12 +249,12 @@ impl Vim { } EditorEvent::InputIgnored { text } => { Vim::active_editor_input_ignored(text.clone(), cx); - Vim::record_insertion(text, None, cx) + observe_insertion(text, None, cx) } EditorEvent::InputHandled { text, utf16_range_to_replace: range_to_replace, - } => Vim::record_insertion(text, range_to_replace.clone(), cx), + } => observe_insertion(text, range_to_replace.clone(), cx), EditorEvent::TransactionBegun { transaction_id } => Vim::update(cx, |vim, cx| { vim.transaction_begun(*transaction_id, cx); }), @@ -288,27 +283,6 @@ impl Vim { self.sync_vim_settings(cx); } - fn record_insertion( - text: &Arc, - range_to_replace: Option>, - cx: &mut WindowContext, - ) { - Vim::update(cx, |vim, _| { - if vim.workspace_state.recording { - vim.workspace_state - .recorded_actions - .push(ReplayableAction::Insertion { - text: text.clone(), - utf16_range_to_replace: range_to_replace, - }); - if vim.workspace_state.stop_recording_after_next_action { - vim.workspace_state.recording = false; - vim.workspace_state.stop_recording_after_next_action = false; - } - } - }); - } - fn update_active_editor( &mut self, cx: &mut WindowContext, @@ -333,8 +307,8 @@ impl Vim { /// When doing an action that modifies the buffer, we start recording so that `.` /// will replay the action. pub fn start_recording(&mut self, cx: &mut WindowContext) { - if !self.workspace_state.replaying { - self.workspace_state.recording = true; + if !self.workspace_state.dot_replaying { + self.workspace_state.dot_recording = true; self.workspace_state.recorded_actions = Default::default(); self.workspace_state.recorded_count = None; @@ -376,15 +350,18 @@ impl Vim { } } - pub fn stop_replaying(&mut self) { - self.workspace_state.replaying = false; + pub fn stop_replaying(&mut self, _: &mut WindowContext) { + self.workspace_state.dot_replaying = false; + if let Some(replayer) = self.workspace_state.replayer.take() { + replayer.stop(); + } } /// When finishing an action that modifies the buffer, stop recording. /// as you usually call this within a keystroke handler we also ensure that /// the current action is recorded. pub fn stop_recording(&mut self) { - if self.workspace_state.recording { + if self.workspace_state.dot_recording { self.workspace_state.stop_recording_after_next_action = true; } } @@ -394,11 +371,11 @@ impl Vim { /// /// This doesn't include the current action. pub fn stop_recording_immediately(&mut self, action: Box) { - if self.workspace_state.recording { + if self.workspace_state.dot_recording { self.workspace_state .recorded_actions .push(ReplayableAction::Action(action.boxed_clone())); - self.workspace_state.recording = false; + self.workspace_state.dot_recording = false; self.workspace_state.stop_recording_after_next_action = false; } } @@ -511,7 +488,7 @@ impl Vim { } fn take_count(&mut self, cx: &mut WindowContext) -> Option { - if self.workspace_state.replaying { + if self.workspace_state.dot_replaying { return self.workspace_state.recorded_count; } @@ -522,7 +499,7 @@ impl Vim { state.post_count.take().unwrap_or(1) * state.pre_count.take().unwrap_or(1) })) }; - if self.workspace_state.recording { + if self.workspace_state.dot_recording { self.workspace_state.recorded_count = count; } self.sync_vim_settings(cx); @@ -898,6 +875,8 @@ impl Vim { Some(Operator::Mark) => Vim::update(cx, |vim, cx| { normal::mark::create_mark(vim, text, false, cx) }), + Some(Operator::RecordRegister) => record_register(text.chars().next().unwrap(), cx), + Some(Operator::ReplayRegister) => replay_register(text.chars().next().unwrap(), cx), Some(Operator::Register) => Vim::update(cx, |vim, cx| match vim.state().mode { Mode::Insert => { vim.update_active_editor(cx, |vim, editor, cx| { diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index e6f5d29560..379e2972b4 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -610,7 +610,7 @@ pub fn select_match( }); if !match_exists { vim.clear_operator(cx); - vim.stop_replaying(); + vim.stop_replaying(cx); return; } vim.update_active_editor(cx, |_, editor, cx| { diff --git a/crates/vim/test_data/test_record_replay.json b/crates/vim/test_data/test_record_replay.json new file mode 100644 index 0000000000..8346d9ad8b --- /dev/null +++ b/crates/vim/test_data/test_record_replay.json @@ -0,0 +1,14 @@ +{"Put":{"state":"ˇhello world"}} +{"Key":"q"} +{"Key":"w"} +{"Key":"c"} +{"Key":"w"} +{"Key":"j"} +{"Key":"escape"} +{"Key":"q"} +{"Get":{"state":"ˇj world","mode":"Normal"}} +{"Key":"2"} +{"Key":"l"} +{"Key":"@"} +{"Key":"w"} +{"Get":{"state":"j ˇj","mode":"Normal"}} diff --git a/crates/vim/test_data/test_record_replay_count.json b/crates/vim/test_data/test_record_replay_count.json new file mode 100644 index 0000000000..78023ef350 --- /dev/null +++ b/crates/vim/test_data/test_record_replay_count.json @@ -0,0 +1,16 @@ +{"Put":{"state":"ˇhello world!!"}} +{"Key":"q"} +{"Key":"a"} +{"Key":"v"} +{"Key":"3"} +{"Key":"l"} +{"Key":"s"} +{"Key":"0"} +{"Key":"escape"} +{"Key":"l"} +{"Key":"q"} +{"Get":{"state":"0ˇo world!!","mode":"Normal"}} +{"Key":"2"} +{"Key":"@"} +{"Key":"a"} +{"Get":{"state":"000ˇ!","mode":"Normal"}} diff --git a/crates/vim/test_data/test_record_replay_dot.json b/crates/vim/test_data/test_record_replay_dot.json new file mode 100644 index 0000000000..9cc565f160 --- /dev/null +++ b/crates/vim/test_data/test_record_replay_dot.json @@ -0,0 +1,17 @@ +{"Put":{"state":"ˇhello world"}} +{"Key":"q"} +{"Key":"a"} +{"Key":"r"} +{"Key":"a"} +{"Key":"l"} +{"Key":"r"} +{"Key":"b"} +{"Key":"l"} +{"Key":"q"} +{"Get":{"state":"abˇllo world","mode":"Normal"}} +{"Key":"."} +{"Get":{"state":"abˇblo world","mode":"Normal"}} +{"Key":"shift-q"} +{"Get":{"state":"ababˇo world","mode":"Normal"}} +{"Key":"."} +{"Get":{"state":"ababˇb world","mode":"Normal"}} diff --git a/crates/vim/test_data/test_record_replay_interleaved.json b/crates/vim/test_data/test_record_replay_interleaved.json new file mode 100644 index 0000000000..aefb5eac2a --- /dev/null +++ b/crates/vim/test_data/test_record_replay_interleaved.json @@ -0,0 +1,35 @@ +{"Put":{"state":"ˇhello world"}} +{"Key":"q"} +{"Key":"z"} +{"Key":"r"} +{"Key":"a"} +{"Key":"l"} +{"Key":"q"} +{"Get":{"state":"aˇello world","mode":"Normal"}} +{"Key":"q"} +{"Key":"b"} +{"Key":"@"} +{"Key":"z"} +{"Key":"@"} +{"Key":"z"} +{"Key":"q"} +{"Get":{"state":"aaaˇlo world","mode":"Normal"}} +{"Key":"@"} +{"Key":"@"} +{"Get":{"state":"aaaaˇo world","mode":"Normal"}} +{"Key":"@"} +{"Key":"b"} +{"Get":{"state":"aaaaaaˇworld","mode":"Normal"}} +{"Key":"@"} +{"Key":"@"} +{"Get":{"state":"aaaaaaaˇorld","mode":"Normal"}} +{"Key":"q"} +{"Key":"z"} +{"Key":"r"} +{"Key":"b"} +{"Key":"l"} +{"Key":"q"} +{"Get":{"state":"aaaaaaabˇrld","mode":"Normal"}} +{"Key":"@"} +{"Key":"b"} +{"Get":{"state":"aaaaaaabbbˇd","mode":"Normal"}} diff --git a/crates/vim/test_data/test_record_replay_of_dot.json b/crates/vim/test_data/test_record_replay_of_dot.json new file mode 100644 index 0000000000..f4cce4bb3d --- /dev/null +++ b/crates/vim/test_data/test_record_replay_of_dot.json @@ -0,0 +1,14 @@ +{"Put":{"state":"ˇhello world"}} +{"Key":"r"} +{"Key":"o"} +{"Key":"q"} +{"Key":"w"} +{"Key":"."} +{"Key":"q"} +{"Get":{"state":"ˇoello world","mode":"Normal"}} +{"Key":"d"} +{"Key":"l"} +{"Get":{"state":"ˇello world","mode":"Normal"}} +{"Key":"@"} +{"Key":"w"} +{"Get":{"state":"ˇllo world","mode":"Normal"}} diff --git a/docs/src/vim.md b/docs/src/vim.md index 4718129ef7..e8289ee780 100644 --- a/docs/src/vim.md +++ b/docs/src/vim.md @@ -6,7 +6,7 @@ Zed includes a vim emulation layer known as "vim mode". This document aims to de Vim mode in Zed is supposed to primarily "do what you expect": it mostly tries to copy vim exactly, but will use Zed-specific functionality when available to make things smoother. -This means Zed will never be 100% Vim compatible, but should be 100% Vim familiar! We expect that our Vim mode already copes with 90% of your workflow, and we'd like to keep improving it. If you find things that you can’t yet do in Vim mode, but which you rely on in your current workflow, please leave feedback in the editor itself (`:feedback`), or [file an issue](https://github.com/zed-industries/zed/issues). +This means Zed will never be 100% Vim compatible, but should be 100% Vim familiar! We expect that our Vim mode already copes with 90% of your workflow, and we'd like to keep improving it. If you find things that you can’t yet do in Vim mode, but which you rely on in your current workflow, please [file an issue](https://github.com/zed-industries/zed/issues). ## Zed-specific features @@ -78,6 +78,8 @@ Vim mode uses Zed to define concepts like "brackets" (for the `%` key) and "word Vim mode emulates visual block mode using Zed's multiple cursor support. This again leads to some differences, but is much more powerful. +Vim's macro support (`q` and `@`) is implemented using Zed's actions. This lets us support recording and replaying of autocompleted code, etc. Unlike Vim, Zed does not re-use the yank registers for recording macros, they are two separate namespaces. + Finally, Vim mode's search and replace functionality is backed by Zed's. This means that the pattern syntax is slightly different, see the section on [Regex differences](#regex-differences) for details. ## Custom key bindings