feat: repeat last native edit with dot

This commit is contained in:
Valdemar Lorenzen
2026-10-09 10:06:22 +02:00
parent 4fe3accc56
commit d61f0b750b
6 changed files with 1485 additions and 91 deletions
+371
View File
@@ -1006,6 +1006,377 @@ end
assert!(session.editor().should_quit());
}
#[test]
fn default_dot_replaces_fresh_words_and_each_repeat_has_one_undo() {
let fixture = Fixture::new();
let buffer = fixture.file("buffer.txt", "old elephant zebra");
let mut session = EditorSession::open(Some(buffer), vec!["text".into()], None).unwrap();
for key in ['v', 'e', 'c'] {
session.handle(Input::Char(key));
}
for ch in "item".chars() {
session.handle(Input::Char(ch));
}
session.handle(Input::Escape);
assert_eq!(session.editor().lines(), &["item elephant zebra"]);
for key in ['w', '3', '.'] {
session.handle(Input::Char(key));
}
assert_eq!(session.editor().lines(), &["item item zebra"]);
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["item elephant zebra"]);
session.handle(Input::Home);
for key in ['w', 'w', 'v', 'e', '.'] {
session.handle(Input::Char(key));
}
assert_eq!(session.editor().lines(), &["item elephant item"]);
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["item elephant zebra"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["old elephant zebra"]);
assert!(!session.editor().dirty());
}
#[test]
fn typed_repeat_remaps_use_resolved_motions_and_current_visual_ranges() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "old enormous tail end");
let mut session = fixture.open(
r#"
Vamp.configure do |config|
config.bind ".", nil
config.bind ".", nil, mode: config.modes.visual
config.bind "r", config.core.repeat_last_edit
config.bind "r", config.core.repeat_last_edit, mode: config.modes.visual
config.bind "e", config.core.left, mode: config.modes.visual
config.bind "ctrl+f", config.core.word_end, mode: config.modes.visual
end
"#,
);
session.handle(Input::Char('v'));
session.handle(Input::Ctrl('f'));
session.handle(Input::Char('c'));
for ch in "item".chars() {
session.handle(Input::Char(ch));
}
session.handle(Input::Escape);
session.handle(Input::Char('w'));
session.handle(Input::Char('.'));
assert_eq!(session.editor().lines(), &["item enormous tail end"]);
session.handle(Input::Char('r'));
assert_eq!(session.editor().lines(), &["item item tail end"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["item enormous tail end"]);
session.handle(Input::Home);
for key in ['w', 'v', '2'] {
session.handle(Input::Char(key));
}
session.handle(Input::Ctrl('f'));
session.handle(Input::Char('.'));
assert_eq!(session.editor().mode(), crate::editor::Mode::Visual);
assert_eq!(session.editor().lines(), &["item enormous tail end"]);
session.handle(Input::Char('r'));
assert_eq!(session.editor().lines(), &["item item end"]);
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["item enormous tail end"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["old enormous tail end"]);
}
#[test]
fn repeat_recipe_crosses_buffers_but_pending_insert_and_undo_stay_per_buffer() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "alpha");
let second = fixture.file("second.txt", "beta");
let mut session = fixture.open("");
session.handle(Input::Char('i'));
for ch in "one".chars() {
session.handle(Input::Char(ch));
}
session.execute(&format!("edit {}", second.display()));
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
assert_eq!(session.editor().lines(), &["beta"]);
session.handle(Input::Char('.'));
assert_eq!(session.editor().lines(), &["onebeta"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["beta"]);
session.execute("buffer 1");
assert_eq!(session.editor().lines(), &["onealpha"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["alpha"]);
session.handle(Input::Char('U'));
assert_eq!(session.editor().lines(), &["onealpha"]);
session.execute("buffer 2");
assert_eq!(session.editor().lines(), &["beta"]);
session.handle(Input::Char('U'));
assert_eq!(session.editor().lines(), &["onebeta"]);
session.handle(Input::Home);
session.handle(Input::Char('i'));
for ch in "two".chars() {
session.handle(Input::Char(ch));
}
session.execute("buffer 1");
session.handle(Input::Home);
session.handle(Input::Char('.'));
assert_eq!(session.editor().lines(), &["twoonealpha"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["onealpha"]);
session.execute("buffer 2");
assert_eq!(session.editor().lines(), &["twoonebeta"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["onebeta"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["beta"]);
}
#[test]
fn plugin_effects_commit_pending_typing_without_replacing_or_reinvoking_native_recipe() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "base");
fixture.file(
"effects.rb",
r##"
Vamp.plugin "effects", requires: ["text"]
calls = 0
Vamp.command("effect") do |editor, args|
calls += 1
File.open("calls.txt", "a") { |file| file.puts "#{calls} #{args}" }
case args
when "replace"
editor.replace "plugin"
when "insert"
editor.insert "?"
end
editor.message "calls=#{calls}"
end
"##,
);
let mut session = fixture.open(
r#"
Vamp.configure do |config|
config.plugin "effects.rb"
end
"#,
);
session.handle(Input::Char('i'));
session.handle(Input::Char('!'));
session.execute("effect replace");
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
assert_eq!(session.editor().lines(), &["plugin"]);
assert_eq!(session.editor().message(), "calls=1");
session.handle(Input::Home);
session.handle(Input::Char('.'));
assert_eq!(session.editor().lines(), &["!plugin"]);
assert_eq!(
fs::read_to_string(fixture.0.join("calls.txt")).unwrap(),
"1 replace\n"
);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["plugin"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["!base"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["base"]);
session.handle(Input::Home);
session.execute("effect insert");
assert_eq!(session.editor().lines(), &["?base"]);
assert_eq!(session.editor().message(), "calls=2");
session.handle(Input::Home);
session.handle(Input::Char('.'));
assert_eq!(session.editor().lines(), &["!?base"]);
assert_eq!(
fs::read_to_string(fixture.0.join("calls.txt")).unwrap(),
"1 replace\n2 insert\n"
);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["?base"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["base"]);
session.execute("effect message");
assert_eq!(session.editor().message(), "calls=3");
session.handle(Input::Home);
session.handle(Input::Char('.'));
assert_eq!(session.editor().lines(), &["!base"]);
assert_eq!(
fs::read_to_string(fixture.0.join("calls.txt")).unwrap(),
"1 replace\n2 insert\n3 message\n"
);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["base"]);
}
#[test]
fn explicit_save_commits_pending_change_and_repeat_does_not_save_again() {
let fixture = Fixture::new();
let buffer = fixture.file("buffer.txt", "old elephant");
let mut session = fixture.open("");
for key in ['v', 'e', 'c'] {
session.handle(Input::Char(key));
}
for ch in "item".chars() {
session.handle(Input::Char(ch));
}
session.execute("w");
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
assert_eq!(fs::read_to_string(&buffer).unwrap(), "item elephant");
session.handle(Input::Home);
session.handle(Input::Char('w'));
session.handle(Input::Char('.'));
assert_eq!(session.editor().lines(), &["item item"]);
assert!(session.editor().dirty());
assert_eq!(fs::read_to_string(&buffer).unwrap(), "item elephant");
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["item elephant"]);
assert!(!session.editor().dirty());
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["old elephant"]);
}
#[test]
fn dots_in_insert_and_focused_queries_are_literal_and_excluded_from_recipe() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "alpha");
let mut session = fixture.open(
r#"
Vamp.configure do |config|
config.file_explorer_view = "list"
config.file_picker_view = "list"
config.bind "ctrl+f", config.core.file_explorer
config.bind "ctrl+o", config.core.file_picker
end
"#,
);
session.handle(Input::Char('i'));
session.handle(Input::Char('X'));
session.handle(Input::Escape);
session.handle(Input::Home);
session.handle(Input::Ctrl('f'));
for ch in ".query".chars() {
session.handle(Input::Char(ch));
}
assert_eq!(session.editor().file_picker().unwrap().query, ".query");
assert_eq!(session.editor().lines(), &["Xalpha"]);
session.handle(Input::Escape);
assert!(!session.editor().file_picker().unwrap().focused);
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
session.handle(Input::Ctrl('o'));
session.handle(Input::Ctrl('u'));
session.handle(Input::Char('.'));
assert_eq!(session.editor().file_picker().unwrap().query, ".");
assert_eq!(session.editor().lines(), &["Xalpha"]);
session.handle(Input::Escape);
assert!(session.editor().file_picker().is_none());
session.handle(Input::Char('.'));
assert_eq!(session.editor().lines(), &["XXalpha"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["Xalpha"]);
session.handle(Input::Home);
session.handle(Input::Char('i'));
session.handle(Input::Char('.'));
session.handle(Input::Ctrl('p'));
assert!(
session
.editor()
.palette()
.unwrap()
.entries
.iter()
.any(|entry| entry.name == "core.repeat_last_edit")
);
session.handle(Input::Char('.'));
assert_eq!(session.editor().palette().unwrap().query, ".");
assert_eq!(session.editor().lines(), &[".Xalpha"]);
session.handle(Input::Escape);
session.handle(Input::Char('!'));
session.handle(Input::Escape);
assert_eq!(session.editor().lines(), &[".!Xalpha"]);
for prompt in [':', '/'] {
session.handle(Input::Char(prompt));
session.handle(Input::Char('.'));
assert_eq!(session.editor().command_line(), ".");
assert_eq!(session.editor().lines(), &[".!Xalpha"]);
session.handle(Input::Escape);
}
session.handle(Input::Home);
session.handle(Input::Char('.'));
assert_eq!(session.editor().lines(), &[".!.!Xalpha"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &[".!Xalpha"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["Xalpha"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["alpha"]);
}
#[test]
fn repeated_clipboard_paste_uses_captured_text_after_a_different_yank() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "one two three");
let mut session = fixture.open("");
for key in ['v', 'e', 'y'] {
session.handle(Input::Char(key));
}
session.handle(Input::Home);
session.handle(Input::Char('P'));
assert_eq!(session.editor().lines(), &["oneone two three"]);
for key in ['w', 'v', 'e', 'y'] {
session.handle(Input::Char(key));
}
session.handle(Input::Home);
session.handle(Input::Char('.'));
assert_eq!(session.editor().lines(), &["oneoneone two three"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["oneone two three"]);
session.handle(Input::Home);
session.handle(Input::Char('P'));
assert_eq!(session.editor().lines(), &["twooneone two three"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["oneone two three"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["one two three"]);
}
#[test]
fn literal_paste_repeats_without_dispatch_and_preserves_native_input_groups() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "seed");
let mut session = fixture.open("");
session.handle(Input::Char('i'));
session.handle(Input::Char('a'));
session.handle(Input::Paste(".:q!\r\n界".into()));
session.handle(Input::Char('b'));
session.handle(Input::Escape);
assert_eq!(session.editor().lines(), &["a.:q!", "界bseed"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["a.:q!", "界seed"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["aseed"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["seed"]);
session.handle(Input::Home);
session.handle(Input::Char('.'));
assert_eq!(session.editor().lines(), &["bseed"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["seed"]);
session.handle(Input::Home);
session.handle(Input::Char('i'));
session.handle(Input::Paste(".:q!\r\n界".into()));
session.handle(Input::Escape);
assert_eq!(session.editor().lines(), &[".:q!", "界seed"]);
session.handle(Input::Home);
session.handle(Input::Char('.'));
assert_eq!(session.editor().lines(), &[".:q!", ".:q!", "界界seed"]);
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
assert!(!session.editor().should_quit());
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &[".:q!", "界seed"]);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["seed"]);
}
#[test]
fn typed_visual_change_remaps_c_and_command_execution_commits_one_replacement() {
let fixture = Fixture::new();