This commit is contained in:
Michael Sloan 2025-08-04 16:06:45 +00:00 committed by GitHub
parent d577ef52cb
commit 3ff42dd8c2
403 changed files with 8398 additions and 24176 deletions

View file

@ -6,7 +6,7 @@ use editor::{
actions::{SortLinesCaseInsensitive, SortLinesCaseSensitive},
display_map::ToDisplayPoint,
};
use gpui::{Action, App, AppContext as _, Context, Global, Keystroke, Window, actions};
use gpui::{Action, App, AppContext as _, Context, Global, Window, actions};
use itertools::Itertools;
use language::Point;
use multi_buffer::MultiBufferRow;
@ -202,7 +202,6 @@ actions!(
ArgumentRequired
]
);
/// Opens the specified file for editing.
#[derive(Clone, PartialEq, Action)]
#[action(namespace = vim, no_json, no_register)]
@ -210,13 +209,6 @@ struct VimEdit {
pub filename: String,
}
#[derive(Clone, PartialEq, Action)]
#[action(namespace = vim, no_json, no_register)]
struct VimNorm {
pub range: Option<CommandRange>,
pub command: String,
}
#[derive(Debug)]
struct WrappedAction(Box<dyn Action>);
@ -455,81 +447,6 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
});
});
Vim::action(editor, cx, |vim, action: &VimNorm, window, cx| {
let keystrokes = action
.command
.chars()
.map(|c| Keystroke::parse(&c.to_string()).unwrap())
.collect();
vim.switch_mode(Mode::Normal, true, window, cx);
let initial_selections = vim.update_editor(window, cx, |_, editor, _, _| {
editor.selections.disjoint_anchors()
});
if let Some(range) = &action.range {
let result = vim.update_editor(window, cx, |vim, editor, window, cx| {
let range = range.buffer_range(vim, editor, window, cx)?;
editor.change_selections(
SelectionEffects::no_scroll().nav_history(false),
window,
cx,
|s| {
s.select_ranges(
(range.start.0..=range.end.0)
.map(|line| Point::new(line, 0)..Point::new(line, 0)),
);
},
);
anyhow::Ok(())
});
if let Some(Err(err)) = result {
log::error!("Error selecting range: {}", err);
return;
}
};
let Some(workspace) = vim.workspace(window) else {
return;
};
let task = workspace.update(cx, |workspace, cx| {
workspace.send_keystrokes_impl(keystrokes, window, cx)
});
let had_range = action.range.is_some();
cx.spawn_in(window, async move |vim, cx| {
task.await;
vim.update_in(cx, |vim, window, cx| {
vim.update_editor(window, cx, |_, editor, window, cx| {
if had_range {
editor.change_selections(SelectionEffects::default(), window, cx, |s| {
s.select_anchor_ranges([s.newest_anchor().range()]);
})
}
});
if matches!(vim.mode, Mode::Insert | Mode::Replace) {
vim.normal_before(&Default::default(), window, cx);
} else {
vim.switch_mode(Mode::Normal, true, window, cx);
}
vim.update_editor(window, cx, |_, editor, _, cx| {
if let Some(first_sel) = initial_selections {
if let Some(tx_id) = editor
.buffer()
.update(cx, |multi, cx| multi.last_transaction_id(cx))
{
let last_sel = editor.selections.disjoint_anchors();
editor.modify_transaction_selection_history(tx_id, |old| {
old.0 = first_sel;
old.1 = Some(last_sel);
});
}
}
});
})
.ok();
})
.detach();
});
Vim::action(editor, cx, |vim, _: &CountCommand, window, cx| {
let Some(workspace) = vim.workspace(window) else {
return;
@ -758,15 +675,14 @@ impl VimCommand {
} else {
return None;
};
let action = if args.is_empty() {
action
} else {
if !args.is_empty() {
// if command does not accept args and we have args then we should do no action
self.args.as_ref()?(action, args)?
};
if let Some(range) = range {
if let Some(args_fn) = &self.args {
args_fn.deref()(action, args)
} else {
None
}
} else if let Some(range) = range {
self.range.as_ref().and_then(|f| f(action, range))
} else {
Some(action)
@ -1145,27 +1061,6 @@ fn generate_commands(_: &App) -> Vec<VimCommand> {
save_intent: Some(SaveIntent::Skip),
close_pinned: true,
}),
VimCommand::new(
("norm", "al"),
VimNorm {
command: "".into(),
range: None,
},
)
.args(|_, args| {
Some(
VimNorm {
command: args,
range: None,
}
.boxed_clone(),
)
})
.range(|action, range| {
let mut action: VimNorm = action.as_any().downcast_ref::<VimNorm>().unwrap().clone();
action.range.replace(range.clone());
Some(Box::new(action))
}),
VimCommand::new(("bn", "ext"), workspace::ActivateNextItem).count(),
VimCommand::new(("bN", "ext"), workspace::ActivatePreviousItem).count(),
VimCommand::new(("bp", "revious"), workspace::ActivatePreviousItem).count(),
@ -2403,78 +2298,4 @@ mod test {
});
assert!(mark.is_none())
}
#[gpui::test]
async fn test_normal_command(cx: &mut TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_shared_state(indoc! {"
The quick
brown« fox
jumpsˇ» over
the lazy dog
"})
.await;
cx.simulate_shared_keystrokes(": n o r m space w C w o r d")
.await;
cx.simulate_shared_keystrokes("enter").await;
cx.shared_state().await.assert_eq(indoc! {"
The quick
brown word
jumps worˇd
the lazy dog
"});
cx.simulate_shared_keystrokes(": n o r m space _ w c i w t e s t")
.await;
cx.simulate_shared_keystrokes("enter").await;
cx.shared_state().await.assert_eq(indoc! {"
The quick
brown word
jumps tesˇt
the lazy dog
"});
cx.simulate_shared_keystrokes("_ l v l : n o r m space s l a")
.await;
cx.simulate_shared_keystrokes("enter").await;
cx.shared_state().await.assert_eq(indoc! {"
The quick
brown word
lˇaumps test
the lazy dog
"});
cx.set_shared_state(indoc! {"
ˇThe quick
brown fox
jumps over
the lazy dog
"})
.await;
cx.simulate_shared_keystrokes("c i w M y escape").await;
cx.shared_state().await.assert_eq(indoc! {"
Mˇy quick
brown fox
jumps over
the lazy dog
"});
cx.simulate_shared_keystrokes(": n o r m space u").await;
cx.simulate_shared_keystrokes("enter").await;
cx.shared_state().await.assert_eq(indoc! {"
ˇThe quick
brown fox
jumps over
the lazy dog
"});
// Once ctrl-v to input character literals is added there should be a test for redo
}
}

View file

@ -1,31 +1,21 @@
use editor::{DisplayPoint, Editor, SelectionEffects, ToOffset, ToPoint, movement};
use editor::{DisplayPoint, Editor, movement};
use gpui::{Action, actions};
use gpui::{Context, Window};
use language::{CharClassifier, CharKind};
use text::{Bias, SelectionGoal};
use text::SelectionGoal;
use crate::{
Vim,
motion::{Motion, right},
state::Mode,
};
use crate::{Vim, motion::Motion, state::Mode};
actions!(
vim,
[
/// Switches to normal mode after the cursor (Helix-style).
HelixNormalAfter,
/// Inserts at the beginning of the selection.
HelixInsert,
/// Appends at the end of the selection.
HelixAppend,
HelixNormalAfter
]
);
pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
Vim::action(editor, cx, Vim::helix_normal_after);
Vim::action(editor, cx, Vim::helix_insert);
Vim::action(editor, cx, Vim::helix_append);
}
impl Vim {
@ -309,112 +299,6 @@ impl Vim {
_ => self.helix_move_and_collapse(motion, times, window, cx),
}
}
fn helix_insert(&mut self, _: &HelixInsert, window: &mut Window, cx: &mut Context<Self>) {
self.start_recording(cx);
self.update_editor(window, cx, |_, editor, window, cx| {
editor.change_selections(Default::default(), window, cx, |s| {
s.move_with(|_map, selection| {
// In helix normal mode, move cursor to start of selection and collapse
if !selection.is_empty() {
selection.collapse_to(selection.start, SelectionGoal::None);
}
});
});
});
self.switch_mode(Mode::Insert, false, window, cx);
}
fn helix_append(&mut self, _: &HelixAppend, window: &mut Window, cx: &mut Context<Self>) {
self.start_recording(cx);
self.switch_mode(Mode::Insert, false, window, cx);
self.update_editor(window, cx, |_, editor, window, cx| {
editor.change_selections(Default::default(), window, cx, |s| {
s.move_with(|map, selection| {
let point = if selection.is_empty() {
right(map, selection.head(), 1)
} else {
selection.end
};
selection.collapse_to(point, SelectionGoal::None);
});
});
});
}
pub fn helix_replace(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
self.update_editor(window, cx, |_, editor, window, cx| {
editor.transact(window, cx, |editor, window, cx| {
let (map, selections) = editor.selections.all_display(cx);
// Store selection info for positioning after edit
let selection_info: Vec<_> = selections
.iter()
.map(|selection| {
let range = selection.range();
let start_offset = range.start.to_offset(&map, Bias::Left);
let end_offset = range.end.to_offset(&map, Bias::Left);
let was_empty = range.is_empty();
let was_reversed = selection.reversed;
(
map.buffer_snapshot.anchor_at(start_offset, Bias::Left),
end_offset - start_offset,
was_empty,
was_reversed,
)
})
.collect();
let mut edits = Vec::new();
for selection in &selections {
let mut range = selection.range();
// For empty selections, extend to replace one character
if range.is_empty() {
range.end = movement::saturating_right(&map, range.start);
}
let byte_range = range.start.to_offset(&map, Bias::Left)
..range.end.to_offset(&map, Bias::Left);
if !byte_range.is_empty() {
let replacement_text = text.repeat(byte_range.len());
edits.push((byte_range, replacement_text));
}
}
editor.edit(edits, cx);
// Restore selections based on original info
let snapshot = editor.buffer().read(cx).snapshot(cx);
let ranges: Vec<_> = selection_info
.into_iter()
.map(|(start_anchor, original_len, was_empty, was_reversed)| {
let start_point = start_anchor.to_point(&snapshot);
if was_empty {
// For cursor-only, collapse to start
start_point..start_point
} else {
// For selections, span the replaced text
let replacement_len = text.len() * original_len;
let end_offset = start_anchor.to_offset(&snapshot) + replacement_len;
let end_point = snapshot.offset_to_point(end_offset);
if was_reversed {
end_point..start_point
} else {
start_point..end_point
}
}
})
.collect();
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_ranges(ranges);
});
});
});
self.switch_mode(Mode::HelixNormal, true, window, cx);
}
}
#[cfg(test)]
@ -613,94 +497,4 @@ mod test {
cx.assert_state("«ˇaa»\n", Mode::HelixNormal);
}
#[gpui::test]
async fn test_insert_selected(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
cx.set_state(
indoc! {"
«The ˇ»quick brown
fox jumps over
the lazy dog."},
Mode::HelixNormal,
);
cx.simulate_keystrokes("i");
cx.assert_state(
indoc! {"
ˇThe quick brown
fox jumps over
the lazy dog."},
Mode::Insert,
);
}
#[gpui::test]
async fn test_append(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
// test from the end of the selection
cx.set_state(
indoc! {"
«Theˇ» quick brown
fox jumps over
the lazy dog."},
Mode::HelixNormal,
);
cx.simulate_keystrokes("a");
cx.assert_state(
indoc! {"
Theˇ quick brown
fox jumps over
the lazy dog."},
Mode::Insert,
);
// test from the beginning of the selection
cx.set_state(
indoc! {"
«ˇThe» quick brown
fox jumps over
the lazy dog."},
Mode::HelixNormal,
);
cx.simulate_keystrokes("a");
cx.assert_state(
indoc! {"
Theˇ quick brown
fox jumps over
the lazy dog."},
Mode::Insert,
);
}
#[gpui::test]
async fn test_replace(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
// No selection (single character)
cx.set_state("ˇaa", Mode::HelixNormal);
cx.simulate_keystrokes("r x");
cx.assert_state("ˇxa", Mode::HelixNormal);
// Cursor at the beginning
cx.set_state("«ˇaa»", Mode::HelixNormal);
cx.simulate_keystrokes("r x");
cx.assert_state("«ˇxx»", Mode::HelixNormal);
// Cursor at the end
cx.set_state("«aaˇ»", Mode::HelixNormal);
cx.simulate_keystrokes("r x");
cx.assert_state("«xxˇ»", Mode::HelixNormal);
}
}

View file

@ -21,7 +21,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
}
impl Vim {
pub(crate) fn normal_before(
fn normal_before(
&mut self,
action: &NormalBefore,
window: &mut Window,

View file

@ -987,7 +987,7 @@ impl Motion {
SelectionGoal::None,
),
NextWordEnd { ignore_punctuation } => (
next_word_end(map, point, *ignore_punctuation, times, true, true),
next_word_end(map, point, *ignore_punctuation, times, true),
SelectionGoal::None,
),
PreviousWordStart { ignore_punctuation } => (
@ -1723,19 +1723,14 @@ pub(crate) fn next_word_end(
ignore_punctuation: bool,
times: usize,
allow_cross_newline: bool,
always_advance: bool,
) -> DisplayPoint {
let classifier = map
.buffer_snapshot
.char_classifier_at(point.to_point(map))
.ignore_punctuation(ignore_punctuation);
for _ in 0..times {
let new_point = next_char(map, point, allow_cross_newline);
let mut need_next_char = false;
let new_point = if always_advance {
next_char(map, point, allow_cross_newline)
} else {
point
};
let new_point = movement::find_boundary_exclusive(
map,
new_point,

View file

@ -51,7 +51,6 @@ impl Vim {
ignore_punctuation,
&text_layout_details,
motion == Motion::NextSubwordStart { ignore_punctuation },
!matches!(motion, Motion::NextWordStart { .. }),
)
}
_ => {
@ -149,7 +148,6 @@ fn expand_changed_word_selection(
ignore_punctuation: bool,
text_layout_details: &TextLayoutDetails,
use_subword: bool,
always_advance: bool,
) -> Option<MotionKind> {
let is_in_word = || {
let classifier = map
@ -175,14 +173,8 @@ fn expand_changed_word_selection(
selection.end =
motion::next_subword_end(map, selection.end, ignore_punctuation, 1, false);
} else {
selection.end = motion::next_word_end(
map,
selection.end,
ignore_punctuation,
1,
false,
always_advance,
);
selection.end =
motion::next_word_end(map, selection.end, ignore_punctuation, 1, false);
}
selection.end = motion::next_char(map, selection.end, false);
}
@ -279,10 +271,6 @@ mod test {
cx.simulate("c shift-w", "Test teˇst-test test")
.await
.assert_matches();
// on last character of word, `cw` doesn't eat subsequent punctuation
// see https://github.com/zed-industries/zed/issues/35269
cx.simulate("c w", "tesˇt-test").await.assert_matches();
}
#[gpui::test]

View file

@ -747,7 +747,7 @@ impl Vim {
Vim::action(
editor,
cx,
|vim, action: &editor::actions::AcceptEditPrediction, window, cx| {
|vim, action: &editor::AcceptEditPrediction, window, cx| {
vim.update_editor(window, cx, |_, editor, window, cx| {
editor.accept_edit_prediction(action, window, cx);
});
@ -1639,7 +1639,6 @@ impl Vim {
Mode::Visual | Mode::VisualLine | Mode::VisualBlock => {
self.visual_replace(text, window, cx)
}
Mode::HelixNormal => self.helix_replace(&text, window, cx),
_ => self.clear_operator(window, cx),
},
Some(Operator::Digraph { first_char }) => {

View file

@ -30,7 +30,3 @@
{"Key":"c"}
{"Key":"shift-w"}
{"Get":{"state":"Test teˇ test","mode":"Insert"}}
{"Put":{"state":"tesˇt-test"}}
{"Key":"c"}
{"Key":"w"}
{"Get":{"state":"tesˇ-test","mode":"Insert"}}

View file

@ -1,64 +0,0 @@
{"Put":{"state":"The quick\nbrown« fox\njumpsˇ» over\nthe lazy dog\n"}}
{"Key":":"}
{"Key":"n"}
{"Key":"o"}
{"Key":"r"}
{"Key":"m"}
{"Key":"space"}
{"Key":"w"}
{"Key":"C"}
{"Key":"w"}
{"Key":"o"}
{"Key":"r"}
{"Key":"d"}
{"Key":"enter"}
{"Get":{"state":"The quick\nbrown word\njumps worˇd\nthe lazy dog\n","mode":"Normal"}}
{"Key":":"}
{"Key":"n"}
{"Key":"o"}
{"Key":"r"}
{"Key":"m"}
{"Key":"space"}
{"Key":"_"}
{"Key":"w"}
{"Key":"c"}
{"Key":"i"}
{"Key":"w"}
{"Key":"t"}
{"Key":"e"}
{"Key":"s"}
{"Key":"t"}
{"Key":"enter"}
{"Get":{"state":"The quick\nbrown word\njumps tesˇt\nthe lazy dog\n","mode":"Normal"}}
{"Key":"_"}
{"Key":"l"}
{"Key":"v"}
{"Key":"l"}
{"Key":":"}
{"Key":"n"}
{"Key":"o"}
{"Key":"r"}
{"Key":"m"}
{"Key":"space"}
{"Key":"s"}
{"Key":"l"}
{"Key":"a"}
{"Key":"enter"}
{"Get":{"state":"The quick\nbrown word\nlˇaumps test\nthe lazy dog\n","mode":"Normal"}}
{"Put":{"state":"ˇThe quick\nbrown fox\njumps over\nthe lazy dog\n"}}
{"Key":"c"}
{"Key":"i"}
{"Key":"w"}
{"Key":"M"}
{"Key":"y"}
{"Key":"escape"}
{"Get":{"state":"Mˇy quick\nbrown fox\njumps over\nthe lazy dog\n","mode":"Normal"}}
{"Key":":"}
{"Key":"n"}
{"Key":"o"}
{"Key":"r"}
{"Key":"m"}
{"Key":"space"}
{"Key":"u"}
{"Key":"enter"}
{"Get":{"state":"ˇThe quick\nbrown fox\njumps over\nthe lazy dog\n","mode":"Normal"}}