vim . to replay (#2936)

Release Notes:

- vim: Add `.` to replay
([#946](https://github.com/zed-industries/community/issues/946))
- vim: Fix `J` in visual mode, and with counts.
This commit is contained in:
Conrad Irwin 2023-09-08 11:52:35 -06:00 committed by GitHub
commit 5d782b6cf0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 991 additions and 53 deletions

2
Cargo.lock generated
View File

@ -8829,12 +8829,14 @@ dependencies = [
"collections", "collections",
"command_palette", "command_palette",
"editor", "editor",
"futures 0.3.28",
"gpui", "gpui",
"indoc", "indoc",
"itertools", "itertools",
"language", "language",
"language_selector", "language_selector",
"log", "log",
"lsp",
"nvim-rs", "nvim-rs",
"parking_lot 0.11.2", "parking_lot 0.11.2",
"project", "project",

View File

@ -316,6 +316,7 @@
{ {
"context": "Editor && vim_mode == normal && (vim_operator == none || vim_operator == n) && !VimWaiting", "context": "Editor && vim_mode == normal && (vim_operator == none || vim_operator == n) && !VimWaiting",
"bindings": { "bindings": {
".": "vim::Repeat",
"c": [ "c": [
"vim::PushOperator", "vim::PushOperator",
"Change" "Change"
@ -326,15 +327,12 @@
"Delete" "Delete"
], ],
"shift-d": "vim::DeleteToEndOfLine", "shift-d": "vim::DeleteToEndOfLine",
"shift-j": "editor::JoinLines", "shift-j": "vim::JoinLines",
"y": [ "y": [
"vim::PushOperator", "vim::PushOperator",
"Yank" "Yank"
], ],
"i": [ "i": "vim::InsertBefore",
"vim::SwitchMode",
"Insert"
],
"shift-i": "vim::InsertFirstNonWhitespace", "shift-i": "vim::InsertFirstNonWhitespace",
"a": "vim::InsertAfter", "a": "vim::InsertAfter",
"shift-a": "vim::InsertEndOfLine", "shift-a": "vim::InsertEndOfLine",
@ -448,13 +446,12 @@
], ],
"s": "vim::Substitute", "s": "vim::Substitute",
"shift-s": "vim::SubstituteLine", "shift-s": "vim::SubstituteLine",
"shift-r": "vim::SubstituteLine",
"c": "vim::Substitute", "c": "vim::Substitute",
"~": "vim::ChangeCase", "~": "vim::ChangeCase",
"shift-i": [ "shift-i": "vim::InsertBefore",
"vim::SwitchMode",
"Insert"
],
"shift-a": "vim::InsertAfter", "shift-a": "vim::InsertAfter",
"shift-j": "vim::JoinLines",
"r": [ "r": [
"vim::PushOperator", "vim::PushOperator",
"Replace" "Replace"

View File

@ -572,7 +572,7 @@ pub struct Editor {
project: Option<ModelHandle<Project>>, project: Option<ModelHandle<Project>>,
focused: bool, focused: bool,
blink_manager: ModelHandle<BlinkManager>, blink_manager: ModelHandle<BlinkManager>,
show_local_selections: bool, pub show_local_selections: bool,
mode: EditorMode, mode: EditorMode,
replica_id_mapping: Option<HashMap<ReplicaId, ReplicaId>>, replica_id_mapping: Option<HashMap<ReplicaId, ReplicaId>>,
show_gutter: bool, show_gutter: bool,
@ -2269,10 +2269,6 @@ impl Editor {
if self.read_only { if self.read_only {
return; return;
} }
if !self.input_enabled {
cx.emit(Event::InputIgnored { text });
return;
}
let selections = self.selections.all_adjusted(cx); let selections = self.selections.all_adjusted(cx);
let mut brace_inserted = false; let mut brace_inserted = false;
@ -3207,17 +3203,30 @@ impl Editor {
.count(); .count();
let snapshot = self.buffer.read(cx).snapshot(cx); let snapshot = self.buffer.read(cx).snapshot(cx);
let mut range_to_replace: Option<Range<isize>> = None;
let mut ranges = Vec::new(); let mut ranges = Vec::new();
for selection in &selections { for selection in &selections {
if snapshot.contains_str_at(selection.start.saturating_sub(lookbehind), &old_text) { if snapshot.contains_str_at(selection.start.saturating_sub(lookbehind), &old_text) {
let start = selection.start.saturating_sub(lookbehind); let start = selection.start.saturating_sub(lookbehind);
let end = selection.end + lookahead; 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); ranges.push(start + common_prefix_len..end);
} else { } else {
common_prefix_len = 0; common_prefix_len = 0;
ranges.clear(); ranges.clear();
ranges.extend(selections.iter().map(|s| { ranges.extend(selections.iter().map(|s| {
if s.id == newest_selection.id { 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() old_range.clone()
} else { } else {
s.start..s.end s.start..s.end
@ -3228,6 +3237,11 @@ impl Editor {
} }
let text = &text[common_prefix_len..]; 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| { self.transact(cx, |this, cx| {
if let Some(mut snippet) = snippet { if let Some(mut snippet) = snippet {
snippet.text = text.to_string(); snippet.text = text.to_string();
@ -3685,6 +3699,10 @@ impl Editor {
self.report_copilot_event(Some(completion.uuid.clone()), true, cx) 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); self.insert_with_autoindent_mode(&suggestion.text.to_string(), None, cx);
cx.notify(); cx.notify();
true true
@ -8436,6 +8454,41 @@ impl Editor {
pub fn inlay_hint_cache(&self) -> &InlayHintCache { pub fn inlay_hint_cache(&self) -> &InlayHintCache {
&self.inlay_hint_cache &self.inlay_hint_cache
} }
pub fn replay_insert_event(
&mut self,
text: &str,
relative_utf16_range: Option<Range<isize>>,
cx: &mut ViewContext<Self>,
) {
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::<OffsetUtf16>(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( fn document_to_inlay_range(
@ -8524,6 +8577,10 @@ pub enum Event {
InputIgnored { InputIgnored {
text: Arc<str>, text: Arc<str>,
}, },
InputHandled {
utf16_range_to_replace: Option<Range<isize>>,
text: Arc<str>,
},
ExcerptsAdded { ExcerptsAdded {
buffer: ModelHandle<Buffer>, buffer: ModelHandle<Buffer>,
predecessor: ExcerptId, predecessor: ExcerptId,
@ -8744,29 +8801,51 @@ impl View for Editor {
text: &str, text: &str,
cx: &mut ViewContext<Self>, cx: &mut ViewContext<Self>,
) { ) {
self.transact(cx, |this, cx| { if !self.input_enabled {
if this.input_enabled { cx.emit(Event::InputIgnored { text: text.into() });
let new_selected_ranges = if let Some(range_utf16) = range_utf16 { return;
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 { self.transact(cx, |this, cx| {
this.change_selections(None, cx, |selections| { let new_selected_ranges = if let Some(range_utf16) = range_utf16 {
selections.select_ranges(new_selected_ranges) 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 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::<OffsetUtf16>(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); this.handle_input(text, cx);
}); });
if !self.input_enabled {
return;
}
if let Some(transaction) = self.ime_transaction { if let Some(transaction) = self.ime_transaction {
self.buffer.update(cx, |buffer, cx| { self.buffer.update(cx, |buffer, cx| {
buffer.group_until_transaction(transaction, cx); buffer.group_until_transaction(transaction, cx);
@ -8784,6 +8863,7 @@ impl View for Editor {
cx: &mut ViewContext<Self>, cx: &mut ViewContext<Self>,
) { ) {
if !self.input_enabled { if !self.input_enabled {
cx.emit(Event::InputIgnored { text: text.into() });
return; return;
} }
@ -8808,6 +8888,29 @@ impl View for Editor {
None 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::<OffsetUtf16>(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 { if let Some(ranges) = ranges_to_replace {
this.change_selections(None, cx, |s| s.select_ranges(ranges)); this.change_selections(None, cx, |s| s.select_ranges(ranges));
} }

View File

@ -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 /// 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 triggered from using '|' character, what range should be replaced, and what completions
/// should be returned using '<' and '>' to delimit the range /// should be returned using '<' and '>' to delimit the range
fn handle_completion_request<'a>( pub fn handle_completion_request<'a>(
cx: &mut EditorLspTestContext<'a>, cx: &mut EditorLspTestContext<'a>,
marked_string: &str, marked_string: &str,
completions: Vec<&'static str>, completions: Vec<&'static str>,

View File

@ -1110,7 +1110,7 @@ impl<'a> WindowContext<'a> {
self.window.is_fullscreen self.window.is_fullscreen
} }
pub(crate) fn dispatch_action(&mut self, view_id: Option<usize>, action: &dyn Action) -> bool { pub fn dispatch_action(&mut self, view_id: Option<usize>, action: &dyn Action) -> bool {
if let Some(view_id) = view_id { if let Some(view_id) = view_id {
self.halt_action_dispatch = false; self.halt_action_dispatch = false;
self.visit_dispatch_path(view_id, |view_id, capture_phase, cx| { self.visit_dispatch_path(view_id, |view_id, capture_phase, cx| {

View File

@ -2,13 +2,13 @@ Design notes:
This crate is split into two conceptual halves: 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. - 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. 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. 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: 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()`. 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 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

View File

@ -38,6 +38,7 @@ language_selector = { path = "../language_selector"}
[dev-dependencies] [dev-dependencies]
indoc.workspace = true indoc.workspace = true
parking_lot.workspace = true parking_lot.workspace = true
futures.workspace = true
editor = { path = "../editor", features = ["test-support"] } editor = { path = "../editor", features = ["test-support"] }
gpui = { path = "../gpui", features = ["test-support"] } gpui = { path = "../gpui", features = ["test-support"] }
@ -47,3 +48,4 @@ util = { path = "../util", features = ["test-support"] }
settings = { path = "../settings" } settings = { path = "../settings" }
workspace = { path = "../workspace", features = ["test-support"] } workspace = { path = "../workspace", features = ["test-support"] }
theme = { path = "../theme", features = ["test-support"] } theme = { path = "../theme", features = ["test-support"] }
lsp = { path = "../lsp", features = ["test-support"] }

View File

@ -34,6 +34,7 @@ fn focused(EditorFocused(editor): &EditorFocused, cx: &mut AppContext) {
fn blurred(EditorBlurred(editor): &EditorBlurred, cx: &mut AppContext) { fn blurred(EditorBlurred(editor): &EditorBlurred, cx: &mut AppContext) {
editor.window().update(cx, |cx| { editor.window().update(cx, |cx| {
Vim::update(cx, |vim, cx| { Vim::update(cx, |vim, cx| {
vim.workspace_state.recording = false;
if let Some(previous_editor) = vim.active_editor.clone() { if let Some(previous_editor) = vim.active_editor.clone() {
if previous_editor == editor.clone() { if previous_editor == editor.clone() {
vim.active_editor = None; vim.active_editor = None;

View File

@ -11,8 +11,9 @@ pub fn init(cx: &mut AppContext) {
} }
fn normal_before(_: &mut Workspace, _: &NormalBefore, cx: &mut ViewContext<Workspace>) { fn normal_before(_: &mut Workspace, _: &NormalBefore, cx: &mut ViewContext<Workspace>) {
Vim::update(cx, |state, cx| { Vim::update(cx, |vim, cx| {
state.update_active_editor(cx, |editor, cx| { vim.stop_recording();
vim.update_active_editor(cx, |editor, cx| {
editor.change_selections(Some(Autoscroll::fit()), cx, |s| { editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
s.move_cursors_with(|map, mut cursor, _| { s.move_cursors_with(|map, mut cursor, _| {
*cursor.column_mut() = cursor.column().saturating_sub(1); *cursor.column_mut() = cursor.column().saturating_sub(1);
@ -20,7 +21,7 @@ fn normal_before(_: &mut Workspace, _: &NormalBefore, cx: &mut ViewContext<Works
}); });
}); });
}); });
state.switch_mode(Mode::Normal, false, cx); vim.switch_mode(Mode::Normal, false, cx);
}) })
} }

View File

@ -65,9 +65,9 @@ struct PreviousWordStart {
#[derive(Clone, Deserialize, PartialEq)] #[derive(Clone, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct Up { pub(crate) struct Up {
#[serde(default)] #[serde(default)]
display_lines: bool, pub(crate) display_lines: bool,
} }
#[derive(Clone, Deserialize, PartialEq)] #[derive(Clone, Deserialize, PartialEq)]
@ -93,9 +93,9 @@ struct EndOfLine {
#[derive(Clone, Deserialize, PartialEq)] #[derive(Clone, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct StartOfLine { pub struct StartOfLine {
#[serde(default)] #[serde(default)]
display_lines: bool, pub(crate) display_lines: bool,
} }
#[derive(Clone, Deserialize, PartialEq)] #[derive(Clone, Deserialize, PartialEq)]

View File

@ -2,6 +2,7 @@ mod case;
mod change; mod change;
mod delete; mod delete;
mod paste; mod paste;
mod repeat;
mod scroll; mod scroll;
mod search; mod search;
pub mod substitute; pub mod substitute;
@ -34,6 +35,7 @@ actions!(
vim, vim,
[ [
InsertAfter, InsertAfter,
InsertBefore,
InsertFirstNonWhitespace, InsertFirstNonWhitespace,
InsertEndOfLine, InsertEndOfLine,
InsertLineAbove, InsertLineAbove,
@ -44,32 +46,42 @@ actions!(
DeleteToEndOfLine, DeleteToEndOfLine,
Yank, Yank,
ChangeCase, ChangeCase,
JoinLines,
] ]
); );
pub fn init(cx: &mut AppContext) { pub fn init(cx: &mut AppContext) {
paste::init(cx);
repeat::init(cx);
scroll::init(cx);
search::init(cx);
substitute::init(cx);
cx.add_action(insert_after); cx.add_action(insert_after);
cx.add_action(insert_before);
cx.add_action(insert_first_non_whitespace); cx.add_action(insert_first_non_whitespace);
cx.add_action(insert_end_of_line); cx.add_action(insert_end_of_line);
cx.add_action(insert_line_above); cx.add_action(insert_line_above);
cx.add_action(insert_line_below); cx.add_action(insert_line_below);
cx.add_action(change_case); cx.add_action(change_case);
substitute::init(cx);
search::init(cx);
cx.add_action(|_: &mut Workspace, _: &DeleteLeft, cx| { cx.add_action(|_: &mut Workspace, _: &DeleteLeft, cx| {
Vim::update(cx, |vim, cx| { Vim::update(cx, |vim, cx| {
vim.record_current_action(cx);
let times = vim.pop_number_operator(cx); let times = vim.pop_number_operator(cx);
delete_motion(vim, Motion::Left, times, cx); delete_motion(vim, Motion::Left, times, cx);
}) })
}); });
cx.add_action(|_: &mut Workspace, _: &DeleteRight, cx| { cx.add_action(|_: &mut Workspace, _: &DeleteRight, cx| {
Vim::update(cx, |vim, cx| { Vim::update(cx, |vim, cx| {
vim.record_current_action(cx);
let times = vim.pop_number_operator(cx); let times = vim.pop_number_operator(cx);
delete_motion(vim, Motion::Right, times, cx); delete_motion(vim, Motion::Right, times, cx);
}) })
}); });
cx.add_action(|_: &mut Workspace, _: &ChangeToEndOfLine, cx| { cx.add_action(|_: &mut Workspace, _: &ChangeToEndOfLine, cx| {
Vim::update(cx, |vim, cx| { Vim::update(cx, |vim, cx| {
vim.start_recording(cx);
let times = vim.pop_number_operator(cx); let times = vim.pop_number_operator(cx);
change_motion( change_motion(
vim, vim,
@ -83,6 +95,7 @@ pub fn init(cx: &mut AppContext) {
}); });
cx.add_action(|_: &mut Workspace, _: &DeleteToEndOfLine, cx| { cx.add_action(|_: &mut Workspace, _: &DeleteToEndOfLine, cx| {
Vim::update(cx, |vim, cx| { Vim::update(cx, |vim, cx| {
vim.record_current_action(cx);
let times = vim.pop_number_operator(cx); let times = vim.pop_number_operator(cx);
delete_motion( delete_motion(
vim, vim,
@ -94,8 +107,26 @@ pub fn init(cx: &mut AppContext) {
); );
}) })
}); });
scroll::init(cx); cx.add_action(|_: &mut Workspace, _: &JoinLines, cx| {
paste::init(cx); Vim::update(cx, |vim, cx| {
vim.record_current_action(cx);
let mut times = vim.pop_number_operator(cx).unwrap_or(1);
if vim.state().mode.is_visual() {
times = 1;
} else if times > 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( pub fn normal_motion(
@ -151,6 +182,7 @@ fn move_cursor(vim: &mut Vim, motion: Motion, times: Option<usize>, cx: &mut Win
fn insert_after(_: &mut Workspace, _: &InsertAfter, cx: &mut ViewContext<Workspace>) { fn insert_after(_: &mut Workspace, _: &InsertAfter, cx: &mut ViewContext<Workspace>) {
Vim::update(cx, |vim, cx| { Vim::update(cx, |vim, cx| {
vim.start_recording(cx);
vim.switch_mode(Mode::Insert, false, cx); vim.switch_mode(Mode::Insert, false, cx);
vim.update_active_editor(cx, |editor, cx| { vim.update_active_editor(cx, |editor, cx| {
editor.change_selections(Some(Autoscroll::fit()), cx, |s| { editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
@ -162,12 +194,20 @@ fn insert_after(_: &mut Workspace, _: &InsertAfter, cx: &mut ViewContext<Workspa
}); });
} }
fn insert_before(_: &mut Workspace, _: &InsertBefore, cx: &mut ViewContext<Workspace>) {
Vim::update(cx, |vim, cx| {
vim.start_recording(cx);
vim.switch_mode(Mode::Insert, false, cx);
});
}
fn insert_first_non_whitespace( fn insert_first_non_whitespace(
_: &mut Workspace, _: &mut Workspace,
_: &InsertFirstNonWhitespace, _: &InsertFirstNonWhitespace,
cx: &mut ViewContext<Workspace>, cx: &mut ViewContext<Workspace>,
) { ) {
Vim::update(cx, |vim, cx| { Vim::update(cx, |vim, cx| {
vim.start_recording(cx);
vim.switch_mode(Mode::Insert, false, cx); vim.switch_mode(Mode::Insert, false, cx);
vim.update_active_editor(cx, |editor, cx| { vim.update_active_editor(cx, |editor, cx| {
editor.change_selections(Some(Autoscroll::fit()), cx, |s| { 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<Workspace>) { fn insert_end_of_line(_: &mut Workspace, _: &InsertEndOfLine, cx: &mut ViewContext<Workspace>) {
Vim::update(cx, |vim, cx| { Vim::update(cx, |vim, cx| {
vim.start_recording(cx);
vim.switch_mode(Mode::Insert, false, cx); vim.switch_mode(Mode::Insert, false, cx);
vim.update_active_editor(cx, |editor, cx| { vim.update_active_editor(cx, |editor, cx| {
editor.change_selections(Some(Autoscroll::fit()), cx, |s| { 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<Workspace>) { fn insert_line_above(_: &mut Workspace, _: &InsertLineAbove, cx: &mut ViewContext<Workspace>) {
Vim::update(cx, |vim, cx| { Vim::update(cx, |vim, cx| {
vim.start_recording(cx);
vim.switch_mode(Mode::Insert, false, cx); vim.switch_mode(Mode::Insert, false, cx);
vim.update_active_editor(cx, |editor, cx| { vim.update_active_editor(cx, |editor, cx| {
editor.transact(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<Workspace>) { fn insert_line_below(_: &mut Workspace, _: &InsertLineBelow, cx: &mut ViewContext<Workspace>) {
Vim::update(cx, |vim, cx| { Vim::update(cx, |vim, cx| {
vim.start_recording(cx);
vim.switch_mode(Mode::Insert, false, cx); vim.switch_mode(Mode::Insert, false, cx);
vim.update_active_editor(cx, |editor, cx| { vim.update_active_editor(cx, |editor, cx| {
editor.transact(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<str>, cx: &mut WindowContext) { pub(crate) fn normal_replace(text: Arc<str>, cx: &mut WindowContext) {
Vim::update(cx, |vim, cx| { Vim::update(cx, |vim, cx| {
vim.stop_recording();
vim.update_active_editor(cx, |editor, cx| { vim.update_active_editor(cx, |editor, cx| {
editor.transact(cx, |editor, cx| { editor.transact(cx, |editor, cx| {
editor.set_clip_at_line_ends(false, cx); editor.set_clip_at_line_ends(false, cx);

View File

@ -7,6 +7,7 @@ use crate::{normal::ChangeCase, state::Mode, Vim};
pub fn change_case(_: &mut Workspace, _: &ChangeCase, cx: &mut ViewContext<Workspace>) { pub fn change_case(_: &mut Workspace, _: &ChangeCase, cx: &mut ViewContext<Workspace>) {
Vim::update(cx, |vim, cx| { Vim::update(cx, |vim, cx| {
vim.record_current_action(cx);
let count = vim.pop_number_operator(cx).unwrap_or(1) as u32; let count = vim.pop_number_operator(cx).unwrap_or(1) as u32;
vim.update_active_editor(cx, |editor, cx| { vim.update_active_editor(cx, |editor, cx| {
let mut ranges = Vec::new(); let mut ranges = Vec::new();
@ -21,10 +22,16 @@ pub fn change_case(_: &mut Workspace, _: &ChangeCase, cx: &mut ViewContext<Works
ranges.push(start..end); ranges.push(start..end);
cursor_positions.push(start..start); cursor_positions.push(start..start);
} }
Mode::Visual | Mode::VisualBlock => { Mode::Visual => {
ranges.push(selection.start..selection.end); ranges.push(selection.start..selection.end);
cursor_positions.push(selection.start..selection.start); 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 => { Mode::Insert | Mode::Normal => {
let start = selection.start; let start = selection.start;
let mut end = start; let mut end = start;
@ -96,6 +103,11 @@ mod test {
cx.simulate_shared_keystrokes(["shift-v", "~"]).await; cx.simulate_shared_keystrokes(["shift-v", "~"]).await;
cx.assert_shared_state("ˇABc\n").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) // works with multiple cursors (zed only)
cx.set_state("aˇßcdˇe\n", Mode::Normal); cx.set_state("aˇßcdˇe\n", Mode::Normal);
cx.simulate_keystroke("~"); cx.simulate_keystroke("~");

View File

@ -4,6 +4,7 @@ use editor::{display_map::ToDisplayPoint, scroll::autoscroll::Autoscroll, Bias};
use gpui::WindowContext; use gpui::WindowContext;
pub fn delete_motion(vim: &mut Vim, motion: Motion, times: Option<usize>, cx: &mut WindowContext) { pub fn delete_motion(vim: &mut Vim, motion: Motion, times: Option<usize>, cx: &mut WindowContext) {
vim.stop_recording();
vim.update_active_editor(cx, |editor, cx| { vim.update_active_editor(cx, |editor, cx| {
editor.transact(cx, |editor, cx| { editor.transact(cx, |editor, cx| {
editor.set_clip_at_line_ends(false, cx); editor.set_clip_at_line_ends(false, cx);
@ -37,6 +38,7 @@ pub fn delete_motion(vim: &mut Vim, motion: Motion, times: Option<usize>, cx: &m
} }
pub fn delete_object(vim: &mut Vim, object: Object, around: bool, cx: &mut WindowContext) { pub fn delete_object(vim: &mut Vim, object: Object, around: bool, cx: &mut WindowContext) {
vim.stop_recording();
vim.update_active_editor(cx, |editor, cx| { vim.update_active_editor(cx, |editor, cx| {
editor.transact(cx, |editor, cx| { editor.transact(cx, |editor, cx| {
editor.set_clip_at_line_ends(false, cx); editor.set_clip_at_line_ends(false, cx);

View File

@ -28,6 +28,7 @@ pub(crate) fn init(cx: &mut AppContext) {
fn paste(_: &mut Workspace, action: &Paste, cx: &mut ViewContext<Workspace>) { fn paste(_: &mut Workspace, action: &Paste, cx: &mut ViewContext<Workspace>) {
Vim::update(cx, |vim, cx| { Vim::update(cx, |vim, cx| {
vim.record_current_action(cx);
vim.update_active_editor(cx, |editor, cx| { vim.update_active_editor(cx, |editor, cx| {
editor.transact(cx, |editor, cx| { editor.transact(cx, |editor, cx| {
editor.set_clip_at_line_ends(false, cx); editor.set_clip_at_line_ends(false, cx);

View File

@ -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<dyn Action>) -> 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<Deterministic>, 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<Deterministic>, 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<Deterministic>,
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::<lsp::request::Completion, _, _>(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<Deterministic>, 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;
}
}

View File

@ -10,6 +10,7 @@ actions!(vim, [Substitute, SubstituteLine]);
pub(crate) fn init(cx: &mut AppContext) { pub(crate) fn init(cx: &mut AppContext) {
cx.add_action(|_: &mut Workspace, _: &Substitute, cx| { cx.add_action(|_: &mut Workspace, _: &Substitute, cx| {
Vim::update(cx, |vim, cx| { Vim::update(cx, |vim, cx| {
vim.start_recording(cx);
let count = vim.pop_number_operator(cx); let count = vim.pop_number_operator(cx);
substitute(vim, count, vim.state().mode == Mode::VisualLine, 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| { cx.add_action(|_: &mut Workspace, _: &SubstituteLine, cx| {
Vim::update(cx, |vim, cx| { Vim::update(cx, |vim, cx| {
vim.start_recording(cx);
if matches!(vim.state().mode, Mode::VisualBlock | Mode::Visual) { if matches!(vim.state().mode, Mode::VisualBlock | Mode::Visual) {
vim.switch_mode(Mode::VisualLine, false, cx) vim.switch_mode(Mode::VisualLine, false, cx)
} }

View File

@ -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 language::CursorShape;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use workspace::searchable::Direction; use workspace::searchable::Direction;
@ -48,10 +50,61 @@ pub struct EditorState {
pub operator_stack: Vec<Operator>, pub operator_stack: Vec<Operator>,
} }
#[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)] #[derive(Default, Clone)]
pub struct WorkspaceState { pub struct WorkspaceState {
pub search: SearchState, pub search: SearchState,
pub last_find: Option<Motion>, pub last_find: Option<Motion>,
pub recording: bool,
pub stop_recording_after_next_action: bool,
pub replaying: bool,
pub recorded_count: Option<usize>,
pub recorded_actions: Vec<ReplayableAction>,
pub recorded_selection: RecordedSelection,
}
#[derive(Debug)]
pub enum ReplayableAction {
Action(Box<dyn Action>),
Insertion {
text: Arc<str>,
utf16_range_to_replace: Option<Range<isize>>,
},
}
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)] #[derive(Clone)]

View File

@ -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] #[gpui::test]
async fn test_wrapped_lines(cx: &mut gpui::TestAppContext) { async fn test_wrapped_lines(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await; let mut cx = NeovimBackedTestContext::new(cx).await;

View File

@ -3,7 +3,9 @@ use std::ops::{Deref, DerefMut};
use editor::test::{ use editor::test::{
editor_lsp_test_context::EditorLspTestContext, editor_test_context::EditorTestContext, editor_lsp_test_context::EditorLspTestContext, editor_test_context::EditorTestContext,
}; };
use futures::Future;
use gpui::ContextHandle; use gpui::ContextHandle;
use lsp::request;
use search::{BufferSearchBar, ProjectSearchBar}; use search::{BufferSearchBar, ProjectSearchBar};
use crate::{state::Operator, *}; use crate::{state::Operator, *};
@ -124,6 +126,19 @@ impl<'a> VimTestContext<'a> {
assert_eq!(self.mode(), mode_after, "{}", self.assertion_context()); assert_eq!(self.mode(), mode_after, "{}", self.assertion_context());
assert_eq!(self.active_operator(), None, "{}", self.assertion_context()); assert_eq!(self.active_operator(), None, "{}", self.assertion_context());
} }
pub fn handle_request<T, F, Fut>(
&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<Output = Result<T::Result>>,
{
self.cx.handle_request::<T, F, Fut>(handler)
}
} }
impl<'a> Deref for VimTestContext<'a> { impl<'a> Deref for VimTestContext<'a> {

View File

@ -18,17 +18,19 @@ use gpui::{
actions, impl_actions, keymap_matcher::KeymapContext, keymap_matcher::MatchResult, AppContext, actions, impl_actions, keymap_matcher::KeymapContext, keymap_matcher::MatchResult, AppContext,
Subscription, ViewContext, ViewHandle, WeakViewHandle, WindowContext, Subscription, ViewContext, ViewHandle, WeakViewHandle, WindowContext,
}; };
use language::{CursorShape, Selection, SelectionGoal}; use language::{CursorShape, Point, Selection, SelectionGoal};
pub use mode_indicator::ModeIndicator; pub use mode_indicator::ModeIndicator;
use motion::Motion; use motion::Motion;
use normal::normal_replace; use normal::normal_replace;
use serde::Deserialize; use serde::Deserialize;
use settings::{Setting, SettingsStore}; use settings::{Setting, SettingsStore};
use state::{EditorState, Mode, Operator, WorkspaceState}; use state::{EditorState, Mode, Operator, RecordedSelection, WorkspaceState};
use std::sync::Arc; use std::{ops::Range, sync::Arc};
use visual::{visual_block_motion, visual_replace}; use visual::{visual_block_motion, visual_replace};
use workspace::{self, Workspace}; use workspace::{self, Workspace};
use crate::state::ReplayableAction;
struct VimModeSetting(bool); struct VimModeSetting(bool);
#[derive(Clone, Deserialize, PartialEq)] #[derive(Clone, Deserialize, PartialEq)]
@ -102,6 +104,19 @@ pub fn observe_keystrokes(cx: &mut WindowContext) {
return true; return true;
} }
if let Some(handled_by) = handled_by { 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 // Keystroke is handled by the vim system, so continue forward
if handled_by.namespace() == "vim" { if handled_by.namespace() == "vim" {
return true; return true;
@ -156,7 +171,12 @@ impl Vim {
} }
Event::InputIgnored { text } => { Event::InputIgnored { text } => {
Vim::active_editor_input_ignored(text.clone(), cx); 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); self.sync_vim_settings(cx);
} }
fn record_insertion(
text: &Arc<str>,
range_to_replace: Option<Range<isize>>,
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<S>( fn update_active_editor<S>(
&self, &self,
cx: &mut WindowContext, cx: &mut WindowContext,
@ -184,6 +225,71 @@ impl Vim {
let editor = self.active_editor.clone()?.upgrade(cx)?; let editor = self.active_editor.clone()?.upgrade(cx)?;
Some(editor.update(cx, update)) 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::<Point>(cx),
editor.selections.newest::<Point>(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) { fn switch_mode(&mut self, mode: Mode, leave_selections: bool, cx: &mut WindowContext) {
let state = self.state(); let state = self.state();
@ -247,6 +353,12 @@ impl Vim {
} }
fn push_operator(&mut self, operator: Operator, cx: &mut WindowContext) { 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.update_state(|state| state.operator_stack.push(operator));
self.sync_vim_settings(cx); self.sync_vim_settings(cx);
} }
@ -272,6 +384,12 @@ impl Vim {
} }
fn pop_number_operator(&mut self, cx: &mut WindowContext) -> Option<usize> { fn pop_number_operator(&mut self, cx: &mut WindowContext) -> Option<usize> {
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() { if let Some(Operator::Number(number)) = self.active_operator() {
self.pop_operator(cx); self.pop_operator(cx);
return Some(number); return Some(number);

View File

@ -277,6 +277,7 @@ pub fn other_end(_: &mut Workspace, _: &OtherEnd, cx: &mut ViewContext<Workspace
pub fn delete(_: &mut Workspace, _: &VisualDelete, cx: &mut ViewContext<Workspace>) { pub fn delete(_: &mut Workspace, _: &VisualDelete, cx: &mut ViewContext<Workspace>) {
Vim::update(cx, |vim, cx| { Vim::update(cx, |vim, cx| {
vim.record_current_action(cx);
vim.update_active_editor(cx, |editor, cx| { vim.update_active_editor(cx, |editor, cx| {
let mut original_columns: HashMap<_, _> = Default::default(); let mut original_columns: HashMap<_, _> = Default::default();
let line_mode = editor.selections.line_mode; let line_mode = editor.selections.line_mode;
@ -339,6 +340,7 @@ pub fn yank(_: &mut Workspace, _: &VisualYank, cx: &mut ViewContext<Workspace>)
pub(crate) fn visual_replace(text: Arc<str>, cx: &mut WindowContext) { pub(crate) fn visual_replace(text: Arc<str>, cx: &mut WindowContext) {
Vim::update(cx, |vim, cx| { Vim::update(cx, |vim, cx| {
vim.stop_recording();
vim.update_active_editor(cx, |editor, cx| { vim.update_active_editor(cx, |editor, cx| {
editor.transact(cx, |editor, cx| { editor.transact(cx, |editor, cx| {
let (display_map, selections) = editor.selections.all_adjusted_display(cx); let (display_map, selections) = editor.selections.all_adjusted_display(cx);

View File

@ -16,3 +16,8 @@
{"Key":"shift-v"} {"Key":"shift-v"}
{"Key":"~"} {"Key":"~"}
{"Get":{"state":"ˇABc\n","mode":"Normal"}} {"Get":{"state":"ˇABc\n","mode":"Normal"}}
{"Put":{"state":"ˇaa\nbb\ncc"}}
{"Key":"ctrl-v"}
{"Key":"j"}
{"Key":"~"}
{"Get":{"state":"ˇAa\nBb\ncc","mode":"Normal"}}

View File

@ -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"}}

View File

@ -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"}}

View File

@ -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"}}