diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index cd431785222ace3773d9c1fb25c390a4eed3a292..498fd018fe59e5d485ca9d0214959424a6617da6 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 ad97a4af3d95a6d0018ce552b55eee1aa9a7563f..fbf2d5fc7761eb44f817e31cfe13937d8fd86977 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 32b38b2beab62737bdb980fa95cbbf5699711e7a..1cfb598e7f4ff00fddc01837799be9e2a84caf0a 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 b9f5055162590ed010fef2f0c275848eeffa3a9d..fd4365b006bdedb401e388f6d8dd356711371315 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) - }), - }? + Vim::update(cx, |vim, cx| { + vim.workspace_state.dot_replaying = true; + + vim.workspace_state + .replayer + .get_or_insert_with(|| Replayer::new()) + .replay(actions, 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)); + } } - editor.update(&mut cx, |editor, _| { - editor.show_local_selections = true; - })?; - window.update(&mut cx, |_, cx| cx.dispatch_action(EndRepeat.boxed_clone())) }) - .detach_and_log_err(cx); +} + +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 eef88e297b072d3c564b826ca23681aa51cf834e..8c724228a91f71452401dc97bc57cd95138676cc 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 db758c1feafaf9ebe38841c74239a930d582f7fd..be7244f7f0fd069f2bae5abb5d37832f7331015f 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 e6f5d295600ff8d71bd90f5bea271e3acc23d756..379e2972b4b2fd78484565972775138c154254d9 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 0000000000000000000000000000000000000000..8346d9ad8b79c0520f61a263a8eda5e8a9a91e07 --- /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 0000000000000000000000000000000000000000..78023ef350a8975e4eb672f949d9d53f8e3f01d0 --- /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 0000000000000000000000000000000000000000..9cc565f16030a86c7faf5b6c61cac314f8c7f571 --- /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 0000000000000000000000000000000000000000..aefb5eac2a18a0a013a3aa2b94b9499375912c1a --- /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 0000000000000000000000000000000000000000..f4cce4bb3d7ffec90c995d5933f8415a11dda90e --- /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 4718129ef74e5d9ae9f9d16bb4f130325d0d1e8f..e8289ee780637b9c8725cdefdedcc098e01754de 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