diff --git a/Cargo.lock b/Cargo.lock index 121e9a28dd160e9eef6d4d7497c7876196a57c88..d4b7beba7bd35541a5dd53c450f21fdae25880e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8829,12 +8829,14 @@ dependencies = [ "collections", "command_palette", "editor", + "futures 0.3.28", "gpui", "indoc", "itertools", "language", "language_selector", "log", + "lsp", "nvim-rs", "parking_lot 0.11.2", "project", diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index da094ea7e46ca4c8abc291a9381ac1318614bbd1..45891adee66385bc748439294bc51cd533eab2ef 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -316,6 +316,7 @@ { "context": "Editor && vim_mode == normal && (vim_operator == none || vim_operator == n) && !VimWaiting", "bindings": { + ".": "vim::Repeat", "c": [ "vim::PushOperator", "Change" @@ -326,15 +327,12 @@ "Delete" ], "shift-d": "vim::DeleteToEndOfLine", - "shift-j": "editor::JoinLines", + "shift-j": "vim::JoinLines", "y": [ "vim::PushOperator", "Yank" ], - "i": [ - "vim::SwitchMode", - "Insert" - ], + "i": "vim::InsertBefore", "shift-i": "vim::InsertFirstNonWhitespace", "a": "vim::InsertAfter", "shift-a": "vim::InsertEndOfLine", @@ -448,13 +446,12 @@ ], "s": "vim::Substitute", "shift-s": "vim::SubstituteLine", + "shift-r": "vim::SubstituteLine", "c": "vim::Substitute", "~": "vim::ChangeCase", - "shift-i": [ - "vim::SwitchMode", - "Insert" - ], + "shift-i": "vim::InsertBefore", "shift-a": "vim::InsertAfter", + "shift-j": "vim::JoinLines", "r": [ "vim::PushOperator", "Replace" diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index bdd29b04fa20e2ecd2492d6554f5541b2bc99540..50a382439a1d2fa824271d41476dad9f2944912d 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -572,7 +572,7 @@ pub struct Editor { project: Option>, focused: bool, blink_manager: ModelHandle, - show_local_selections: bool, + pub show_local_selections: bool, mode: EditorMode, replica_id_mapping: Option>, show_gutter: bool, @@ -2269,10 +2269,6 @@ impl Editor { if self.read_only { return; } - if !self.input_enabled { - cx.emit(Event::InputIgnored { text }); - return; - } let selections = self.selections.all_adjusted(cx); let mut brace_inserted = false; @@ -3207,17 +3203,30 @@ impl Editor { .count(); let snapshot = self.buffer.read(cx).snapshot(cx); + let mut range_to_replace: Option> = None; let mut ranges = Vec::new(); for selection in &selections { if snapshot.contains_str_at(selection.start.saturating_sub(lookbehind), &old_text) { let start = selection.start.saturating_sub(lookbehind); let end = selection.end + lookahead; + if selection.id == newest_selection.id { + range_to_replace = Some( + ((start + common_prefix_len) as isize - selection.start as isize) + ..(end as isize - selection.start as isize), + ); + } ranges.push(start + common_prefix_len..end); } else { common_prefix_len = 0; ranges.clear(); ranges.extend(selections.iter().map(|s| { if s.id == newest_selection.id { + range_to_replace = Some( + old_range.start.to_offset_utf16(&snapshot).0 as isize + - selection.start as isize + ..old_range.end.to_offset_utf16(&snapshot).0 as isize + - selection.start as isize, + ); old_range.clone() } else { s.start..s.end @@ -3228,6 +3237,11 @@ impl Editor { } let text = &text[common_prefix_len..]; + cx.emit(Event::InputHandled { + utf16_range_to_replace: range_to_replace, + text: text.into(), + }); + self.transact(cx, |this, cx| { if let Some(mut snippet) = snippet { snippet.text = text.to_string(); @@ -3685,6 +3699,10 @@ impl Editor { self.report_copilot_event(Some(completion.uuid.clone()), true, cx) } + cx.emit(Event::InputHandled { + utf16_range_to_replace: None, + text: suggestion.text.to_string().into(), + }); self.insert_with_autoindent_mode(&suggestion.text.to_string(), None, cx); cx.notify(); true @@ -8436,6 +8454,41 @@ impl Editor { pub fn inlay_hint_cache(&self) -> &InlayHintCache { &self.inlay_hint_cache } + + pub fn replay_insert_event( + &mut self, + text: &str, + relative_utf16_range: Option>, + cx: &mut ViewContext, + ) { + if !self.input_enabled { + cx.emit(Event::InputIgnored { text: text.into() }); + return; + } + if let Some(relative_utf16_range) = relative_utf16_range { + let selections = self.selections.all::(cx); + self.change_selections(None, cx, |s| { + let new_ranges = selections.into_iter().map(|range| { + let start = OffsetUtf16( + range + .head() + .0 + .saturating_add_signed(relative_utf16_range.start), + ); + let end = OffsetUtf16( + range + .head() + .0 + .saturating_add_signed(relative_utf16_range.end), + ); + start..end + }); + s.select_ranges(new_ranges); + }); + } + + self.handle_input(text, cx); + } } fn document_to_inlay_range( @@ -8524,6 +8577,10 @@ pub enum Event { InputIgnored { text: Arc, }, + InputHandled { + utf16_range_to_replace: Option>, + text: Arc, + }, ExcerptsAdded { buffer: ModelHandle, predecessor: ExcerptId, @@ -8744,29 +8801,51 @@ impl View for Editor { text: &str, cx: &mut ViewContext, ) { + if !self.input_enabled { + cx.emit(Event::InputIgnored { text: text.into() }); + return; + } + self.transact(cx, |this, cx| { - if this.input_enabled { - let new_selected_ranges = if let Some(range_utf16) = range_utf16 { - let range_utf16 = OffsetUtf16(range_utf16.start)..OffsetUtf16(range_utf16.end); - Some(this.selection_replacement_ranges(range_utf16, cx)) - } else { - this.marked_text_ranges(cx) - }; + let new_selected_ranges = if let Some(range_utf16) = range_utf16 { + let range_utf16 = OffsetUtf16(range_utf16.start)..OffsetUtf16(range_utf16.end); + Some(this.selection_replacement_ranges(range_utf16, cx)) + } else { + this.marked_text_ranges(cx) + }; - if let Some(new_selected_ranges) = new_selected_ranges { - this.change_selections(None, cx, |selections| { - selections.select_ranges(new_selected_ranges) - }); - } + let range_to_replace = new_selected_ranges.as_ref().and_then(|ranges_to_replace| { + let newest_selection_id = this.selections.newest_anchor().id; + this.selections + .all::(cx) + .iter() + .zip(ranges_to_replace.iter()) + .find_map(|(selection, range)| { + if selection.id == newest_selection_id { + Some( + (range.start.0 as isize - selection.head().0 as isize) + ..(range.end.0 as isize - selection.head().0 as isize), + ) + } else { + None + } + }) + }); + + cx.emit(Event::InputHandled { + utf16_range_to_replace: range_to_replace, + text: text.into(), + }); + + if let Some(new_selected_ranges) = new_selected_ranges { + this.change_selections(None, cx, |selections| { + selections.select_ranges(new_selected_ranges) + }); } this.handle_input(text, cx); }); - if !self.input_enabled { - return; - } - if let Some(transaction) = self.ime_transaction { self.buffer.update(cx, |buffer, cx| { buffer.group_until_transaction(transaction, cx); @@ -8784,6 +8863,7 @@ impl View for Editor { cx: &mut ViewContext, ) { if !self.input_enabled { + cx.emit(Event::InputIgnored { text: text.into() }); return; } @@ -8808,6 +8888,29 @@ impl View for Editor { None }; + let range_to_replace = ranges_to_replace.as_ref().and_then(|ranges_to_replace| { + let newest_selection_id = this.selections.newest_anchor().id; + this.selections + .all::(cx) + .iter() + .zip(ranges_to_replace.iter()) + .find_map(|(selection, range)| { + if selection.id == newest_selection_id { + Some( + (range.start.0 as isize - selection.head().0 as isize) + ..(range.end.0 as isize - selection.head().0 as isize), + ) + } else { + None + } + }) + }); + + cx.emit(Event::InputHandled { + utf16_range_to_replace: range_to_replace, + text: text.into(), + }); + if let Some(ranges) = ranges_to_replace { this.change_selections(None, cx, |s| s.select_ranges(ranges)); } diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 74bd67e03a70d20afeac7be9efdbd56eee77fc73..f11639a770193e905509d2bf142eec6564b84b46 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -7807,7 +7807,7 @@ fn assert_selection_ranges(marked_text: &str, view: &mut Editor, cx: &mut ViewCo /// Handle completion request passing a marked string specifying where the completion /// should be triggered from using '|' character, what range should be replaced, and what completions /// should be returned using '<' and '>' to delimit the range -fn handle_completion_request<'a>( +pub fn handle_completion_request<'a>( cx: &mut EditorLspTestContext<'a>, marked_string: &str, completions: Vec<&'static str>, diff --git a/crates/gpui/src/app/window.rs b/crates/gpui/src/app/window.rs index 4b8b0534d53ae70c16093125b6afa7bbd38b5c7c..09744579a939ed0bb558ba0c040dcd51d3043c02 100644 --- a/crates/gpui/src/app/window.rs +++ b/crates/gpui/src/app/window.rs @@ -1110,7 +1110,7 @@ impl<'a> WindowContext<'a> { self.window.is_fullscreen } - pub(crate) fn dispatch_action(&mut self, view_id: Option, action: &dyn Action) -> bool { + pub fn dispatch_action(&mut self, view_id: Option, action: &dyn Action) -> bool { if let Some(view_id) = view_id { self.halt_action_dispatch = false; self.visit_dispatch_path(view_id, |view_id, capture_phase, cx| { diff --git a/crates/terminal_view/README.md b/crates/terminal_view/README.md index 019460067ebf20f991b18a9cd5ce262ae7675504..ca48f545427993caf97f6f7670a23e3e309dc214 100644 --- a/crates/terminal_view/README.md +++ b/crates/terminal_view/README.md @@ -2,13 +2,13 @@ Design notes: This crate is split into two conceptual halves: - The terminal.rs file and the src/mappings/ folder, these contain the code for interacting with Alacritty and maintaining the pty event loop. Some behavior in this file is constrained by terminal protocols and standards. The Zed init function is also placed here. -- Everything else. These other files integrate the `Terminal` struct created in terminal.rs into the rest of GPUI. The main entry point for GPUI is the terminal_view.rs file and the modal.rs file. +- Everything else. These other files integrate the `Terminal` struct created in terminal.rs into the rest of GPUI. The main entry point for GPUI is the terminal_view.rs file and the modal.rs file. ttys are created externally, and so can fail in unexpected ways. However, GPUI currently does not have an API for models than can fail to instantiate. `TerminalBuilder` solves this by using Rust's type system to split tty instantiation into a 2 step process: first attempt to create the file handles with `TerminalBuilder::new()`, check the result, then call `TerminalBuilder::subscribe(cx)` from within a model context. The TerminalView struct abstracts over failed and successful terminals, passing focus through to the associated view and allowing clients to build a terminal without worrying about errors. -#Input +#Input There are currently many distinct paths for getting keystrokes to the terminal: @@ -18,6 +18,6 @@ There are currently many distinct paths for getting keystrokes to the terminal: 3. IME text. When the special character mappings fail, we pass the keystroke back to GPUI to hand it to the IME system. This comes back to us in the `View::replace_text_in_range()` method, and we then send that to the terminal directly, bypassing `try_keystroke()`. -4. Pasted text has a separate pathway. +4. Pasted text has a separate pathway. -Generally, there's a distinction between 'keystrokes that need to be mapped' and 'strings which need to be written'. I've attempted to unify these under the '.try_keystroke()' API and the `.input()` API (which try_keystroke uses) so we have consistent input handling across the terminal \ No newline at end of file +Generally, there's a distinction between 'keystrokes that need to be mapped' and 'strings which need to be written'. I've attempted to unify these under the '.try_keystroke()' API and the `.input()` API (which try_keystroke uses) so we have consistent input handling across the terminal diff --git a/crates/vim/Cargo.toml b/crates/vim/Cargo.toml index 2d394e3dcf8c127d48a010ce54c045d7d87a4aed..5d40032024b5f78758e25d6ba0a6e865c827cf5b 100644 --- a/crates/vim/Cargo.toml +++ b/crates/vim/Cargo.toml @@ -38,6 +38,7 @@ language_selector = { path = "../language_selector"} [dev-dependencies] indoc.workspace = true parking_lot.workspace = true +futures.workspace = true editor = { path = "../editor", features = ["test-support"] } gpui = { path = "../gpui", features = ["test-support"] } @@ -47,3 +48,4 @@ util = { path = "../util", features = ["test-support"] } settings = { path = "../settings" } workspace = { path = "../workspace", features = ["test-support"] } theme = { path = "../theme", features = ["test-support"] } +lsp = { path = "../lsp", features = ["test-support"] } diff --git a/crates/vim/src/editor_events.rs b/crates/vim/src/editor_events.rs index 994a09aaf9439a57ed9e8cc6bd7682894d9edf19..da5c7d46eda3813c784eeff238c965528fb3cca3 100644 --- a/crates/vim/src/editor_events.rs +++ b/crates/vim/src/editor_events.rs @@ -34,6 +34,7 @@ fn focused(EditorFocused(editor): &EditorFocused, cx: &mut AppContext) { fn blurred(EditorBlurred(editor): &EditorBlurred, cx: &mut AppContext) { editor.window().update(cx, |cx| { Vim::update(cx, |vim, cx| { + vim.workspace_state.recording = false; if let Some(previous_editor) = vim.active_editor.clone() { if previous_editor == editor.clone() { vim.active_editor = None; diff --git a/crates/vim/src/insert.rs b/crates/vim/src/insert.rs index 537f6a15f167718d7731d5c002916abf4a5510f7..9141a02ab3550c29262f235348e9beadfde15d9e 100644 --- a/crates/vim/src/insert.rs +++ b/crates/vim/src/insert.rs @@ -11,8 +11,9 @@ pub fn init(cx: &mut AppContext) { } fn normal_before(_: &mut Workspace, _: &NormalBefore, cx: &mut ViewContext) { - Vim::update(cx, |state, cx| { - state.update_active_editor(cx, |editor, cx| { + Vim::update(cx, |vim, cx| { + vim.stop_recording(); + vim.update_active_editor(cx, |editor, cx| { editor.change_selections(Some(Autoscroll::fit()), cx, |s| { s.move_cursors_with(|map, mut cursor, _| { *cursor.column_mut() = cursor.column().saturating_sub(1); @@ -20,7 +21,7 @@ fn normal_before(_: &mut Workspace, _: &NormalBefore, cx: &mut ViewContext 1 { + // 2J joins two lines together (same as J or 1J) + times -= 1; + } + + vim.update_active_editor(cx, |editor, cx| { + editor.transact(cx, |editor, cx| { + for _ in 0..times { + editor.join_lines(&Default::default(), cx) + } + }) + }) + }) + }) } pub fn normal_motion( @@ -151,6 +182,7 @@ fn move_cursor(vim: &mut Vim, motion: Motion, times: Option, cx: &mut Win fn insert_after(_: &mut Workspace, _: &InsertAfter, cx: &mut ViewContext) { Vim::update(cx, |vim, cx| { + vim.start_recording(cx); vim.switch_mode(Mode::Insert, false, cx); vim.update_active_editor(cx, |editor, cx| { editor.change_selections(Some(Autoscroll::fit()), cx, |s| { @@ -162,12 +194,20 @@ fn insert_after(_: &mut Workspace, _: &InsertAfter, cx: &mut ViewContext) { + Vim::update(cx, |vim, cx| { + vim.start_recording(cx); + vim.switch_mode(Mode::Insert, false, cx); + }); +} + fn insert_first_non_whitespace( _: &mut Workspace, _: &InsertFirstNonWhitespace, cx: &mut ViewContext, ) { Vim::update(cx, |vim, cx| { + vim.start_recording(cx); vim.switch_mode(Mode::Insert, false, cx); vim.update_active_editor(cx, |editor, cx| { editor.change_selections(Some(Autoscroll::fit()), cx, |s| { @@ -184,6 +224,7 @@ fn insert_first_non_whitespace( fn insert_end_of_line(_: &mut Workspace, _: &InsertEndOfLine, cx: &mut ViewContext) { Vim::update(cx, |vim, cx| { + vim.start_recording(cx); vim.switch_mode(Mode::Insert, false, cx); vim.update_active_editor(cx, |editor, cx| { editor.change_selections(Some(Autoscroll::fit()), cx, |s| { @@ -197,6 +238,7 @@ fn insert_end_of_line(_: &mut Workspace, _: &InsertEndOfLine, cx: &mut ViewConte fn insert_line_above(_: &mut Workspace, _: &InsertLineAbove, cx: &mut ViewContext) { Vim::update(cx, |vim, cx| { + vim.start_recording(cx); vim.switch_mode(Mode::Insert, false, cx); vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { @@ -229,6 +271,7 @@ fn insert_line_above(_: &mut Workspace, _: &InsertLineAbove, cx: &mut ViewContex fn insert_line_below(_: &mut Workspace, _: &InsertLineBelow, cx: &mut ViewContext) { Vim::update(cx, |vim, cx| { + vim.start_recording(cx); vim.switch_mode(Mode::Insert, false, cx); vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { @@ -260,6 +303,7 @@ fn insert_line_below(_: &mut Workspace, _: &InsertLineBelow, cx: &mut ViewContex pub(crate) fn normal_replace(text: Arc, cx: &mut WindowContext) { Vim::update(cx, |vim, cx| { + vim.stop_recording(); vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { editor.set_clip_at_line_ends(false, cx); diff --git a/crates/vim/src/normal/case.rs b/crates/vim/src/normal/case.rs index 90967949bb99215ff25612b5d7c536a59369f19a..12fd8dbd2b66df8ef94ea61e9f80718769c6a28c 100644 --- a/crates/vim/src/normal/case.rs +++ b/crates/vim/src/normal/case.rs @@ -7,6 +7,7 @@ use crate::{normal::ChangeCase, state::Mode, Vim}; pub fn change_case(_: &mut Workspace, _: &ChangeCase, cx: &mut ViewContext) { Vim::update(cx, |vim, cx| { + vim.record_current_action(cx); let count = vim.pop_number_operator(cx).unwrap_or(1) as u32; vim.update_active_editor(cx, |editor, cx| { let mut ranges = Vec::new(); @@ -21,10 +22,16 @@ pub fn change_case(_: &mut Workspace, _: &ChangeCase, cx: &mut ViewContext { + Mode::Visual => { ranges.push(selection.start..selection.end); cursor_positions.push(selection.start..selection.start); } + Mode::VisualBlock => { + ranges.push(selection.start..selection.end); + if cursor_positions.len() == 0 { + cursor_positions.push(selection.start..selection.start); + } + } Mode::Insert | Mode::Normal => { let start = selection.start; let mut end = start; @@ -96,6 +103,11 @@ mod test { cx.simulate_shared_keystrokes(["shift-v", "~"]).await; cx.assert_shared_state("ˇABc\n").await; + // works in visual block mode + cx.set_shared_state("ˇaa\nbb\ncc").await; + cx.simulate_shared_keystrokes(["ctrl-v", "j", "~"]).await; + cx.assert_shared_state("ˇAa\nBb\ncc").await; + // works with multiple cursors (zed only) cx.set_state("aˇßcdˇe\n", Mode::Normal); cx.simulate_keystroke("~"); diff --git a/crates/vim/src/normal/delete.rs b/crates/vim/src/normal/delete.rs index 56fef78e1da3ce837fa6b5d9ae2a5e23fc4f3f26..ae85acaab55a5344281ebf8ad268f9ddc54b1f27 100644 --- a/crates/vim/src/normal/delete.rs +++ b/crates/vim/src/normal/delete.rs @@ -4,6 +4,7 @@ use editor::{display_map::ToDisplayPoint, scroll::autoscroll::Autoscroll, Bias}; use gpui::WindowContext; pub fn delete_motion(vim: &mut Vim, motion: Motion, times: Option, cx: &mut WindowContext) { + vim.stop_recording(); vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { editor.set_clip_at_line_ends(false, cx); @@ -37,6 +38,7 @@ pub fn delete_motion(vim: &mut Vim, motion: Motion, times: Option, cx: &m } pub fn delete_object(vim: &mut Vim, object: Object, around: bool, cx: &mut WindowContext) { + vim.stop_recording(); vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { editor.set_clip_at_line_ends(false, cx); diff --git a/crates/vim/src/normal/paste.rs b/crates/vim/src/normal/paste.rs index 3c437f91779ba27f2f2f36c555e6574b2158094b..dda8dea1e480fcbf07a7df7f66b7846b27ee3d32 100644 --- a/crates/vim/src/normal/paste.rs +++ b/crates/vim/src/normal/paste.rs @@ -28,6 +28,7 @@ pub(crate) fn init(cx: &mut AppContext) { fn paste(_: &mut Workspace, action: &Paste, cx: &mut ViewContext) { Vim::update(cx, |vim, cx| { + vim.record_current_action(cx); vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { editor.set_clip_at_line_ends(false, cx); diff --git a/crates/vim/src/normal/repeat.rs b/crates/vim/src/normal/repeat.rs new file mode 100644 index 0000000000000000000000000000000000000000..1a7c789aad9a680eddef208eed182b5b237ca286 --- /dev/null +++ b/crates/vim/src/normal/repeat.rs @@ -0,0 +1,427 @@ +use crate::{ + motion::Motion, + state::{Mode, RecordedSelection, ReplayableAction}, + visual::visual_motion, + Vim, +}; +use gpui::{actions, Action, AppContext}; +use workspace::Workspace; + +actions!(vim, [Repeat, EndRepeat,]); + +fn should_replay(action: &Box) -> bool { + // skip so that we don't leave the character palette open + if editor::ShowCharacterPalette.id() == action.id() { + return false; + } + true +} + +pub(crate) fn init(cx: &mut AppContext) { + cx.add_action(|_: &mut Workspace, _: &EndRepeat, cx| { + Vim::update(cx, |vim, cx| { + vim.workspace_state.replaying = false; + vim.update_active_editor(cx, |editor, _| { + editor.show_local_selections = true; + }); + vim.switch_mode(Mode::Normal, false, cx) + }); + }); + + cx.add_action(|_: &mut Workspace, _: &Repeat, cx| { + let Some((actions, editor, selection)) = Vim::update(cx, |vim, cx| { + let actions = vim.workspace_state.recorded_actions.clone(); + let Some(editor) = vim.active_editor.clone() else { + return None; + }; + let count = vim.pop_number_operator(cx); + + vim.workspace_state.replaying = true; + + let selection = vim.workspace_state.recorded_selection.clone(); + match selection { + RecordedSelection::SingleLine { .. } | RecordedSelection::Visual { .. } => { + vim.workspace_state.recorded_count = None; + vim.switch_mode(Mode::Visual, false, cx) + } + RecordedSelection::VisualLine { .. } => { + vim.workspace_state.recorded_count = None; + vim.switch_mode(Mode::VisualLine, false, cx) + } + RecordedSelection::VisualBlock { .. } => { + vim.workspace_state.recorded_count = None; + vim.switch_mode(Mode::VisualBlock, false, cx) + } + RecordedSelection::None => { + if let Some(count) = count { + vim.workspace_state.recorded_count = Some(count); + } + } + } + + if let Some(editor) = editor.upgrade(cx) { + editor.update(cx, |editor, _| { + editor.show_local_selections = false; + }) + } else { + return None; + } + + Some((actions, editor, selection)) + }) else { + return; + }; + + match selection { + RecordedSelection::SingleLine { cols } => { + if cols > 1 { + visual_motion(Motion::Right, Some(cols as usize - 1), cx) + } + } + RecordedSelection::Visual { rows, cols } => { + visual_motion( + Motion::Down { + display_lines: false, + }, + Some(rows as usize), + cx, + ); + visual_motion( + Motion::StartOfLine { + display_lines: false, + }, + None, + cx, + ); + if cols > 1 { + visual_motion(Motion::Right, Some(cols as usize - 1), cx) + } + } + RecordedSelection::VisualBlock { rows, cols } => { + visual_motion( + Motion::Down { + display_lines: false, + }, + Some(rows as usize), + cx, + ); + if cols > 1 { + visual_motion(Motion::Right, Some(cols as usize - 1), cx); + } + } + RecordedSelection::VisualLine { rows } => { + visual_motion( + Motion::Down { + display_lines: false, + }, + Some(rows as usize), + cx, + ); + } + RecordedSelection::None => {} + } + + let window = cx.window(); + cx.app_context() + .spawn(move |mut cx| async move { + for action in actions { + match action { + ReplayableAction::Action(action) => { + if should_replay(&action) { + window + .dispatch_action(editor.id(), action.as_ref(), &mut cx) + .ok_or_else(|| anyhow::anyhow!("window was closed")) + } 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) + }), + }? + } + window + .dispatch_action(editor.id(), &EndRepeat, &mut cx) + .ok_or_else(|| anyhow::anyhow!("window was closed")) + }) + .detach_and_log_err(cx); + }); +} + +#[cfg(test)] +mod test { + use std::sync::Arc; + + use editor::test::editor_lsp_test_context::EditorLspTestContext; + use futures::StreamExt; + use indoc::indoc; + + use gpui::{executor::Deterministic, View}; + + use crate::{ + state::Mode, + test::{NeovimBackedTestContext, VimTestContext}, + }; + + #[gpui::test] + async fn test_dot_repeat(deterministic: Arc, cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + // "o" + cx.set_shared_state("ˇhello").await; + cx.simulate_shared_keystrokes(["o", "w", "o", "r", "l", "d", "escape"]) + .await; + cx.assert_shared_state("hello\nworlˇd").await; + cx.simulate_shared_keystrokes(["."]).await; + deterministic.run_until_parked(); + cx.assert_shared_state("hello\nworld\nworlˇd").await; + + // "d" + cx.simulate_shared_keystrokes(["^", "d", "f", "o"]).await; + cx.simulate_shared_keystrokes(["g", "g", "."]).await; + deterministic.run_until_parked(); + cx.assert_shared_state("ˇ\nworld\nrld").await; + + // "p" (note that it pastes the current clipboard) + cx.simulate_shared_keystrokes(["j", "y", "y", "p"]).await; + cx.simulate_shared_keystrokes(["shift-g", "y", "y", "."]) + .await; + deterministic.run_until_parked(); + cx.assert_shared_state("\nworld\nworld\nrld\nˇrld").await; + + // "~" (note that counts apply to the action taken, not . itself) + cx.set_shared_state("ˇthe quick brown fox").await; + cx.simulate_shared_keystrokes(["2", "~", "."]).await; + deterministic.run_until_parked(); + cx.set_shared_state("THE ˇquick brown fox").await; + cx.simulate_shared_keystrokes(["3", "."]).await; + deterministic.run_until_parked(); + cx.set_shared_state("THE QUIˇck brown fox").await; + deterministic.run_until_parked(); + cx.simulate_shared_keystrokes(["."]).await; + deterministic.run_until_parked(); + cx.set_shared_state("THE QUICK ˇbrown fox").await; + } + + #[gpui::test] + async fn test_repeat_ime(deterministic: Arc, cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state("hˇllo", Mode::Normal); + cx.simulate_keystrokes(["i"]); + + // simulate brazilian input for ä. + cx.update_editor(|editor, cx| { + editor.replace_and_mark_text_in_range(None, "\"", Some(1..1), cx); + editor.replace_text_in_range(None, "ä", cx); + }); + cx.simulate_keystrokes(["escape"]); + cx.assert_state("hˇällo", Mode::Normal); + cx.simulate_keystrokes(["."]); + deterministic.run_until_parked(); + cx.assert_state("hˇäällo", Mode::Normal); + } + + #[gpui::test] + async fn test_repeat_completion( + deterministic: Arc, + cx: &mut gpui::TestAppContext, + ) { + let cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + trigger_characters: Some(vec![".".to_string(), ":".to_string()]), + resolve_provider: Some(true), + ..Default::default() + }), + ..Default::default() + }, + cx, + ) + .await; + let mut cx = VimTestContext::new_with_lsp(cx, true); + + cx.set_state( + indoc! {" + onˇe + two + three + "}, + Mode::Normal, + ); + + let mut request = + cx.handle_request::(move |_, params, _| async move { + let position = params.text_document_position.position; + Ok(Some(lsp::CompletionResponse::Array(vec![ + lsp::CompletionItem { + label: "first".to_string(), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range::new(position.clone(), position.clone()), + new_text: "first".to_string(), + })), + ..Default::default() + }, + lsp::CompletionItem { + label: "second".to_string(), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range::new(position.clone(), position.clone()), + new_text: "second".to_string(), + })), + ..Default::default() + }, + ]))) + }); + cx.simulate_keystrokes(["a", "."]); + request.next().await; + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + cx.simulate_keystrokes(["down", "enter", "!", "escape"]); + + cx.assert_state( + indoc! {" + one.secondˇ! + two + three + "}, + Mode::Normal, + ); + cx.simulate_keystrokes(["j", "."]); + deterministic.run_until_parked(); + cx.assert_state( + indoc! {" + one.second! + two.secondˇ! + three + "}, + Mode::Normal, + ); + } + + #[gpui::test] + async fn test_repeat_visual(deterministic: Arc, cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + // single-line (3 columns) + cx.set_shared_state(indoc! { + "ˇthe quick brown + fox jumps over + the lazy dog" + }) + .await; + cx.simulate_shared_keystrokes(["v", "i", "w", "s", "o", "escape"]) + .await; + cx.assert_shared_state(indoc! { + "ˇo quick brown + fox jumps over + the lazy dog" + }) + .await; + cx.simulate_shared_keystrokes(["j", "w", "."]).await; + deterministic.run_until_parked(); + cx.assert_shared_state(indoc! { + "o quick brown + fox ˇops over + the lazy dog" + }) + .await; + cx.simulate_shared_keystrokes(["f", "r", "."]).await; + deterministic.run_until_parked(); + cx.assert_shared_state(indoc! { + "o quick brown + fox ops oveˇothe lazy dog" + }) + .await; + + // visual + cx.set_shared_state(indoc! { + "the ˇquick brown + fox jumps over + fox jumps over + fox jumps over + the lazy dog" + }) + .await; + cx.simulate_shared_keystrokes(["v", "j", "x"]).await; + cx.assert_shared_state(indoc! { + "the ˇumps over + fox jumps over + fox jumps over + the lazy dog" + }) + .await; + cx.simulate_shared_keystrokes(["."]).await; + deterministic.run_until_parked(); + cx.assert_shared_state(indoc! { + "the ˇumps over + fox jumps over + the lazy dog" + }) + .await; + cx.simulate_shared_keystrokes(["w", "."]).await; + deterministic.run_until_parked(); + cx.assert_shared_state(indoc! { + "the umps ˇumps over + the lazy dog" + }) + .await; + cx.simulate_shared_keystrokes(["j", "."]).await; + deterministic.run_until_parked(); + cx.assert_shared_state(indoc! { + "the umps umps over + the ˇog" + }) + .await; + + // block mode (3 rows) + cx.set_shared_state(indoc! { + "ˇthe quick brown + fox jumps over + the lazy dog" + }) + .await; + cx.simulate_shared_keystrokes(["ctrl-v", "j", "j", "shift-i", "o", "escape"]) + .await; + cx.assert_shared_state(indoc! { + "ˇothe quick brown + ofox jumps over + othe lazy dog" + }) + .await; + cx.simulate_shared_keystrokes(["j", "4", "l", "."]).await; + deterministic.run_until_parked(); + cx.assert_shared_state(indoc! { + "othe quick brown + ofoxˇo jumps over + otheo lazy dog" + }) + .await; + + // line mode + cx.set_shared_state(indoc! { + "ˇthe quick brown + fox jumps over + the lazy dog" + }) + .await; + cx.simulate_shared_keystrokes(["shift-v", "shift-r", "o", "escape"]) + .await; + cx.assert_shared_state(indoc! { + "ˇo + fox jumps over + the lazy dog" + }) + .await; + cx.simulate_shared_keystrokes(["j", "."]).await; + deterministic.run_until_parked(); + cx.assert_shared_state(indoc! { + "o + ˇo + the lazy dog" + }) + .await; + } +} diff --git a/crates/vim/src/normal/substitute.rs b/crates/vim/src/normal/substitute.rs index 23b545abd84f4435a92d10b107fbb8b4e09f20ed..d0dbb9e3068c9de28b2fe90258ef22112f9c8bb3 100644 --- a/crates/vim/src/normal/substitute.rs +++ b/crates/vim/src/normal/substitute.rs @@ -10,6 +10,7 @@ actions!(vim, [Substitute, SubstituteLine]); pub(crate) fn init(cx: &mut AppContext) { cx.add_action(|_: &mut Workspace, _: &Substitute, cx| { Vim::update(cx, |vim, cx| { + vim.start_recording(cx); let count = vim.pop_number_operator(cx); substitute(vim, count, vim.state().mode == Mode::VisualLine, cx); }) @@ -17,6 +18,7 @@ pub(crate) fn init(cx: &mut AppContext) { cx.add_action(|_: &mut Workspace, _: &SubstituteLine, cx| { Vim::update(cx, |vim, cx| { + vim.start_recording(cx); if matches!(vim.state().mode, Mode::VisualBlock | Mode::Visual) { vim.switch_mode(Mode::VisualLine, false, cx) } diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index aacd3d26e079515e19a48d870b76c3c4d69319af..7359178f0eba91b06780301f4ddc6b00c03b97e5 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -1,4 +1,6 @@ -use gpui::keymap_matcher::KeymapContext; +use std::{ops::Range, sync::Arc}; + +use gpui::{keymap_matcher::KeymapContext, Action}; use language::CursorShape; use serde::{Deserialize, Serialize}; use workspace::searchable::Direction; @@ -48,10 +50,61 @@ pub struct EditorState { pub operator_stack: Vec, } +#[derive(Default, Clone, Debug)] +pub enum RecordedSelection { + #[default] + None, + Visual { + rows: u32, + cols: u32, + }, + SingleLine { + cols: u32, + }, + VisualBlock { + rows: u32, + cols: u32, + }, + VisualLine { + rows: u32, + }, +} + #[derive(Default, Clone)] pub struct WorkspaceState { pub search: SearchState, pub last_find: Option, + + pub recording: bool, + pub stop_recording_after_next_action: bool, + pub replaying: bool, + pub recorded_count: Option, + pub recorded_actions: Vec, + pub recorded_selection: RecordedSelection, +} + +#[derive(Debug)] +pub enum ReplayableAction { + Action(Box), + Insertion { + text: Arc, + utf16_range_to_replace: Option>, + }, +} + +impl Clone for ReplayableAction { + fn clone(&self) -> Self { + match self { + Self::Action(action) => Self::Action(action.boxed_clone()), + Self::Insertion { + text, + utf16_range_to_replace, + } => Self::Insertion { + text: text.clone(), + utf16_range_to_replace: utf16_range_to_replace.clone(), + }, + } + } } #[derive(Clone)] diff --git a/crates/vim/src/test.rs b/crates/vim/src/test.rs index 54bbe6c56ea468932e08899bfcad8ed602c545b9..9aa9fffc0a76905e65ab49d6fda6a85c64dad484 100644 --- a/crates/vim/src/test.rs +++ b/crates/vim/src/test.rs @@ -286,6 +286,55 @@ async fn test_word_characters(cx: &mut gpui::TestAppContext) { ) } +#[gpui::test] +async fn test_join_lines(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state(indoc! {" + ˇone + two + three + four + five + six + "}) + .await; + cx.simulate_shared_keystrokes(["shift-j"]).await; + cx.assert_shared_state(indoc! {" + oneˇ two + three + four + five + six + "}) + .await; + cx.simulate_shared_keystrokes(["3", "shift-j"]).await; + cx.assert_shared_state(indoc! {" + one two threeˇ four + five + six + "}) + .await; + + cx.set_shared_state(indoc! {" + ˇone + two + three + four + five + six + "}) + .await; + cx.simulate_shared_keystrokes(["j", "v", "3", "j", "shift-j"]) + .await; + cx.assert_shared_state(indoc! {" + one + two three fourˇ five + six + "}) + .await; +} + #[gpui::test] async fn test_wrapped_lines(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; diff --git a/crates/vim/src/test/vim_test_context.rs b/crates/vim/src/test/vim_test_context.rs index 9b0373957035213b6de019425f4484aaa647920a..7cee32037393e05e4b097826f71eaeb25f27bc65 100644 --- a/crates/vim/src/test/vim_test_context.rs +++ b/crates/vim/src/test/vim_test_context.rs @@ -3,7 +3,9 @@ use std::ops::{Deref, DerefMut}; use editor::test::{ editor_lsp_test_context::EditorLspTestContext, editor_test_context::EditorTestContext, }; +use futures::Future; use gpui::ContextHandle; +use lsp::request; use search::{BufferSearchBar, ProjectSearchBar}; use crate::{state::Operator, *}; @@ -124,6 +126,19 @@ impl<'a> VimTestContext<'a> { assert_eq!(self.mode(), mode_after, "{}", self.assertion_context()); assert_eq!(self.active_operator(), None, "{}", self.assertion_context()); } + + pub fn handle_request( + &self, + handler: F, + ) -> futures::channel::mpsc::UnboundedReceiver<()> + where + T: 'static + request::Request, + T::Params: 'static + Send, + F: 'static + Send + FnMut(lsp::Url, T::Params, gpui::AsyncAppContext) -> Fut, + Fut: 'static + Send + Future>, + { + self.cx.handle_request::(handler) + } } impl<'a> Deref for VimTestContext<'a> { diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index be3d04d3ee0c28d3238c4627f793103d8f62a0c2..03a74d46ce07f76ade67875d578d7f1551a30563 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -18,17 +18,19 @@ use gpui::{ actions, impl_actions, keymap_matcher::KeymapContext, keymap_matcher::MatchResult, AppContext, Subscription, ViewContext, ViewHandle, WeakViewHandle, WindowContext, }; -use language::{CursorShape, Selection, SelectionGoal}; +use language::{CursorShape, Point, Selection, SelectionGoal}; pub use mode_indicator::ModeIndicator; use motion::Motion; use normal::normal_replace; use serde::Deserialize; use settings::{Setting, SettingsStore}; -use state::{EditorState, Mode, Operator, WorkspaceState}; -use std::sync::Arc; +use state::{EditorState, Mode, Operator, RecordedSelection, WorkspaceState}; +use std::{ops::Range, sync::Arc}; use visual::{visual_block_motion, visual_replace}; use workspace::{self, Workspace}; +use crate::state::ReplayableAction; + struct VimModeSetting(bool); #[derive(Clone, Deserialize, PartialEq)] @@ -102,6 +104,19 @@ pub fn observe_keystrokes(cx: &mut WindowContext) { return true; } if let Some(handled_by) = handled_by { + Vim::update(cx, |vim, _| { + if vim.workspace_state.recording { + vim.workspace_state + .recorded_actions + .push(ReplayableAction::Action(handled_by.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; + } + } + }); + // Keystroke is handled by the vim system, so continue forward if handled_by.namespace() == "vim" { return true; @@ -156,7 +171,12 @@ impl Vim { } Event::InputIgnored { text } => { Vim::active_editor_input_ignored(text.clone(), cx); + Vim::record_insertion(text, None, cx) } + Event::InputHandled { + text, + utf16_range_to_replace: range_to_replace, + } => Vim::record_insertion(text, range_to_replace.clone(), cx), _ => {} })); @@ -176,6 +196,27 @@ 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( &self, cx: &mut WindowContext, @@ -184,6 +225,71 @@ impl Vim { let editor = self.active_editor.clone()?.upgrade(cx)?; Some(editor.update(cx, update)) } + // ~, shift-j, x, shift-x, p + // shift-c, shift-d, shift-i, i, a, o, shift-o, s + // c, d + // r + + // TODO: shift-j? + // + pub fn start_recording(&mut self, cx: &mut WindowContext) { + if !self.workspace_state.replaying { + self.workspace_state.recording = true; + self.workspace_state.recorded_actions = Default::default(); + self.workspace_state.recorded_count = + if let Some(Operator::Number(number)) = self.active_operator() { + Some(number) + } else { + None + }; + + let selections = self + .active_editor + .and_then(|editor| editor.upgrade(cx)) + .map(|editor| { + let editor = editor.read(cx); + ( + editor.selections.oldest::(cx), + editor.selections.newest::(cx), + ) + }); + + if let Some((oldest, newest)) = selections { + self.workspace_state.recorded_selection = match self.state().mode { + Mode::Visual if newest.end.row == newest.start.row => { + RecordedSelection::SingleLine { + cols: newest.end.column - newest.start.column, + } + } + Mode::Visual => RecordedSelection::Visual { + rows: newest.end.row - newest.start.row, + cols: newest.end.column, + }, + Mode::VisualLine => RecordedSelection::VisualLine { + rows: newest.end.row - newest.start.row, + }, + Mode::VisualBlock => RecordedSelection::VisualBlock { + rows: newest.end.row.abs_diff(oldest.start.row), + cols: newest.end.column.abs_diff(oldest.start.column), + }, + _ => RecordedSelection::None, + } + } else { + self.workspace_state.recorded_selection = RecordedSelection::None; + } + } + } + + pub fn stop_recording(&mut self) { + if self.workspace_state.recording { + self.workspace_state.stop_recording_after_next_action = true; + } + } + + pub fn record_current_action(&mut self, cx: &mut WindowContext) { + self.start_recording(cx); + self.stop_recording(); + } fn switch_mode(&mut self, mode: Mode, leave_selections: bool, cx: &mut WindowContext) { let state = self.state(); @@ -247,6 +353,12 @@ impl Vim { } fn push_operator(&mut self, operator: Operator, cx: &mut WindowContext) { + if matches!( + operator, + Operator::Change | Operator::Delete | Operator::Replace + ) { + self.start_recording(cx) + }; self.update_state(|state| state.operator_stack.push(operator)); self.sync_vim_settings(cx); } @@ -272,6 +384,12 @@ impl Vim { } fn pop_number_operator(&mut self, cx: &mut WindowContext) -> Option { + if self.workspace_state.replaying { + if let Some(number) = self.workspace_state.recorded_count { + return Some(number); + } + } + if let Some(Operator::Number(number)) = self.active_operator() { self.pop_operator(cx); return Some(number); diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index ee46a0d209f348f3fc25c2252ca9a8a06921fd68..acd55a0954ac9d4c9a5214237e7fd451f81be795 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -277,6 +277,7 @@ pub fn other_end(_: &mut Workspace, _: &OtherEnd, cx: &mut ViewContext) { Vim::update(cx, |vim, cx| { + vim.record_current_action(cx); vim.update_active_editor(cx, |editor, cx| { let mut original_columns: HashMap<_, _> = Default::default(); let line_mode = editor.selections.line_mode; @@ -339,6 +340,7 @@ pub fn yank(_: &mut Workspace, _: &VisualYank, cx: &mut ViewContext) pub(crate) fn visual_replace(text: Arc, cx: &mut WindowContext) { Vim::update(cx, |vim, cx| { + vim.stop_recording(); vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { let (display_map, selections) = editor.selections.all_adjusted_display(cx); diff --git a/crates/vim/test_data/test_change_case.json b/crates/vim/test_data/test_change_case.json index 1c0cad0b935a91b31ad9e54f3548b9af117e929c..10eb93b2279339b5f032569878f479efa701b718 100644 --- a/crates/vim/test_data/test_change_case.json +++ b/crates/vim/test_data/test_change_case.json @@ -16,3 +16,8 @@ {"Key":"shift-v"} {"Key":"~"} {"Get":{"state":"ˇABc\n","mode":"Normal"}} +{"Put":{"state":"ˇaa\nbb\ncc"}} +{"Key":"ctrl-v"} +{"Key":"j"} +{"Key":"~"} +{"Get":{"state":"ˇAa\nBb\ncc","mode":"Normal"}} diff --git a/crates/vim/test_data/test_dot_repeat.json b/crates/vim/test_data/test_dot_repeat.json new file mode 100644 index 0000000000000000000000000000000000000000..f1a1a3c138509420d6e0e92daf679fb347a6e673 --- /dev/null +++ b/crates/vim/test_data/test_dot_repeat.json @@ -0,0 +1,38 @@ +{"Put":{"state":"ˇhello"}} +{"Key":"o"} +{"Key":"w"} +{"Key":"o"} +{"Key":"r"} +{"Key":"l"} +{"Key":"d"} +{"Key":"escape"} +{"Get":{"state":"hello\nworlˇd","mode":"Normal"}} +{"Key":"."} +{"Get":{"state":"hello\nworld\nworlˇd","mode":"Normal"}} +{"Key":"^"} +{"Key":"d"} +{"Key":"f"} +{"Key":"o"} +{"Key":"g"} +{"Key":"g"} +{"Key":"."} +{"Get":{"state":"ˇ\nworld\nrld","mode":"Normal"}} +{"Key":"j"} +{"Key":"y"} +{"Key":"y"} +{"Key":"p"} +{"Key":"shift-g"} +{"Key":"y"} +{"Key":"y"} +{"Key":"."} +{"Get":{"state":"\nworld\nworld\nrld\nˇrld","mode":"Normal"}} +{"Put":{"state":"ˇthe quick brown fox"}} +{"Key":"2"} +{"Key":"~"} +{"Key":"."} +{"Put":{"state":"THE ˇquick brown fox"}} +{"Key":"3"} +{"Key":"."} +{"Put":{"state":"THE QUIˇck brown fox"}} +{"Key":"."} +{"Put":{"state":"THE QUICK ˇbrown fox"}} diff --git a/crates/vim/test_data/test_join_lines.json b/crates/vim/test_data/test_join_lines.json new file mode 100644 index 0000000000000000000000000000000000000000..b4bc5c30e1ce7a96e2fa9ada59efa48e0aa83a0e --- /dev/null +++ b/crates/vim/test_data/test_join_lines.json @@ -0,0 +1,13 @@ +{"Put":{"state":"ˇone\ntwo\nthree\nfour\nfive\nsix\n"}} +{"Key":"shift-j"} +{"Get":{"state":"oneˇ two\nthree\nfour\nfive\nsix\n","mode":"Normal"}} +{"Key":"3"} +{"Key":"shift-j"} +{"Get":{"state":"one two threeˇ four\nfive\nsix\n","mode":"Normal"}} +{"Put":{"state":"ˇone\ntwo\nthree\nfour\nfive\nsix\n"}} +{"Key":"j"} +{"Key":"v"} +{"Key":"3"} +{"Key":"j"} +{"Key":"shift-j"} +{"Get":{"state":"one\ntwo three fourˇ five\nsix\n","mode":"Normal"}} diff --git a/crates/vim/test_data/test_repeat_visual.json b/crates/vim/test_data/test_repeat_visual.json new file mode 100644 index 0000000000000000000000000000000000000000..cb83addcfb412e51c4477be189a95b7c72eee39c --- /dev/null +++ b/crates/vim/test_data/test_repeat_visual.json @@ -0,0 +1,51 @@ +{"Put":{"state":"ˇthe quick brown\nfox jumps over\nthe lazy dog"}} +{"Key":"v"} +{"Key":"i"} +{"Key":"w"} +{"Key":"s"} +{"Key":"o"} +{"Key":"escape"} +{"Get":{"state":"ˇo quick brown\nfox jumps over\nthe lazy dog","mode":"Normal"}} +{"Key":"j"} +{"Key":"w"} +{"Key":"."} +{"Get":{"state":"o quick brown\nfox ˇops over\nthe lazy dog","mode":"Normal"}} +{"Key":"f"} +{"Key":"r"} +{"Key":"."} +{"Get":{"state":"o quick brown\nfox ops oveˇothe lazy dog","mode":"Normal"}} +{"Put":{"state":"the ˇquick brown\nfox jumps over\nfox jumps over\nfox jumps over\nthe lazy dog"}} +{"Key":"v"} +{"Key":"j"} +{"Key":"x"} +{"Get":{"state":"the ˇumps over\nfox jumps over\nfox jumps over\nthe lazy dog","mode":"Normal"}} +{"Key":"."} +{"Get":{"state":"the ˇumps over\nfox jumps over\nthe lazy dog","mode":"Normal"}} +{"Key":"w"} +{"Key":"."} +{"Get":{"state":"the umps ˇumps over\nthe lazy dog","mode":"Normal"}} +{"Key":"j"} +{"Key":"."} +{"Get":{"state":"the umps umps over\nthe ˇog","mode":"Normal"}} +{"Put":{"state":"ˇthe quick brown\nfox jumps over\nthe lazy dog"}} +{"Key":"ctrl-v"} +{"Key":"j"} +{"Key":"j"} +{"Key":"shift-i"} +{"Key":"o"} +{"Key":"escape"} +{"Get":{"state":"ˇothe quick brown\nofox jumps over\nothe lazy dog","mode":"Normal"}} +{"Key":"j"} +{"Key":"4"} +{"Key":"l"} +{"Key":"."} +{"Get":{"state":"othe quick brown\nofoxˇo jumps over\notheo lazy dog","mode":"Normal"}} +{"Put":{"state":"ˇthe quick brown\nfox jumps over\nthe lazy dog"}} +{"Key":"shift-v"} +{"Key":"shift-r"} +{"Key":"o"} +{"Key":"escape"} +{"Get":{"state":"ˇo\nfox jumps over\nthe lazy dog","mode":"Normal"}} +{"Key":"j"} +{"Key":"."} +{"Get":{"state":"o\nˇo\nthe lazy dog","mode":"Normal"}}