feat: navigate jump history with Ctrl+o and Ctrl+i

This commit is contained in:
2026-10-09 23:19:21 +02:00
parent 2264dcb6e9
commit 9a96706c36
8 changed files with 658 additions and 3 deletions
+515 -3
View File
@@ -3,7 +3,7 @@ use crate::files::{DockSide, FilePicker, FilePresentation, FileView};
use crate::plugin::{Diagnostic, SemanticToken, TextPosition};
use serde::Serialize;
use std::borrow::Cow;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
use std::fmt;
use std::fs::{self, OpenOptions};
use std::io::{self, Write};
@@ -292,6 +292,8 @@ enum Action {
FilePicker,
FileExplorer,
FollowLink,
JumpBack,
JumpForward,
LspDefinition,
LspHover,
LspDiagnostics,
@@ -473,6 +475,8 @@ const CORE_ACTIONS: &[(&str, Action, &[Mode])] = &[
&[Mode::Normal, Mode::Visual],
),
("core.follow_link", Action::FollowLink, &[Mode::Normal]),
("core.jump_back", Action::JumpBack, &[Mode::Normal]),
("core.jump_forward", Action::JumpForward, &[Mode::Normal]),
(
"core.lsp.definition",
Action::LspDefinition,
@@ -627,6 +631,15 @@ fn default_bindings() -> HashMap<Mode, HashMap<Input, Binding>> {
.or_default()
.insert(Input::Char(key), Binding::Action(action));
}
for (key, action) in [
(Input::Ctrl('o'), JumpBack),
(Input::Char('\t'), JumpForward),
] {
bindings
.entry(Mode::Normal)
.or_default()
.insert(key, Binding::Action(action));
}
for mode in [Mode::Normal, Mode::Visual] {
for (key, action) in [
('h', Left),
@@ -864,10 +877,18 @@ impl Buffer {
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct JumpLocation {
buffer_id: usize,
cursor: Cursor,
}
pub struct Editor {
active: Buffer,
parked: BTreeMap<usize, Buffer>,
next_buffer_id: usize,
jumps: VecDeque<JumpLocation>,
jump_index: usize,
command_line: String,
clipboard: Vec<String>,
clipboard_mode: ClipboardMode,
@@ -908,6 +929,8 @@ impl Editor {
active: Buffer::open(1, path, identity, true)?,
parked: BTreeMap::new(),
next_buffer_id: 2,
jumps: VecDeque::new(),
jump_index: 0,
command_line: String::new(),
clipboard: Vec::new(),
clipboard_mode: ClipboardMode::System,
@@ -1292,6 +1315,7 @@ impl Editor {
/// Reuses loaded (including dirty) buffers and leaves the source unchanged on failure.
pub fn navigate_to(&mut self, path: PathBuf, position: TextPosition) -> Result<(), String> {
let source = self.jump_location();
self.cancel_leader();
self.clear_hover();
if path.as_os_str().is_empty() || path.to_string_lossy().chars().any(char::is_control) {
@@ -1300,6 +1324,7 @@ impl Editor {
self.edit_buffer(path, false)
.map_err(|error| error.to_string())?;
self.place_navigation(position);
self.record_jump(source);
Ok(())
}
@@ -1318,6 +1343,7 @@ impl Editor {
}
fn follow_link(&mut self) -> Result<(), String> {
let departure = self.jump_location();
if self
.active
.path
@@ -1341,6 +1367,7 @@ impl Editor {
None => 0,
};
self.place_navigation(TextPosition { row, column: 0 });
self.record_jump(departure);
return Ok(());
};
let path = if path.is_absolute() {
@@ -1381,6 +1408,7 @@ impl Editor {
row
};
self.place_navigation(TextPosition { row, column: 0 });
self.record_jump(departure);
Ok(())
}
@@ -2181,8 +2209,12 @@ impl Editor {
}
Input::Enter => {
let Some(path) = picker.accept() else { return };
let source = self.jump_location();
match self.edit_buffer(path, false) {
Ok(()) => self.blur_file_picker(),
Ok(()) => {
self.record_jump(source);
self.blur_file_picker();
}
Err(error) => {
self.file_picker
.as_mut()
@@ -2374,7 +2406,9 @@ impl Editor {
}
fn act(&mut self, action: Action) -> Option<Invocation> {
self.clear_hover();
if !matches!(action, Action::JumpBack | Action::JumpForward) {
self.clear_hover();
}
let count = self.count.take().unwrap_or(1);
self.clear_leader();
match action {
@@ -2394,6 +2428,8 @@ impl Editor {
self.set_message(error);
}
}
Action::JumpBack => self.traverse_jumps(false, count),
Action::JumpForward => self.traverse_jumps(true, count),
Action::LspDefinition => return self.execute("lsp.definition"),
Action::LspHover => return self.execute("lsp.hover"),
Action::LspDiagnostics => return self.execute("lsp.diagnostics"),
@@ -2544,6 +2580,7 @@ impl Editor {
}
fn seek_search(&mut self, forward: bool, count: usize) {
let source = self.jump_location();
self.active.search_match = None;
if self.active.search_query.is_empty() {
self.set_message("No previous search");
@@ -2605,6 +2642,80 @@ impl Editor {
self.active.visual_motions = None;
self.normalize_targets();
self.message.clear();
self.record_jump(source);
}
fn jump_location(&self) -> JumpLocation {
JumpLocation {
buffer_id: self.active.id,
cursor: self.cursor(),
}
}
fn record_jump(&mut self, source: JumpLocation) {
let destination = self.jump_location();
if source == destination {
return;
}
if self.jumps.is_empty() {
self.jumps.push_back(source);
} else {
self.jumps.truncate(self.jump_index + 1);
self.jumps[self.jump_index] = source;
}
self.jumps.push_back(destination);
if self.jumps.len() > 100 {
self.jumps.pop_front();
}
self.jump_index = self.jumps.len() - 1;
}
fn traverse_jumps(&mut self, forward: bool, count: usize) {
let departure = self.jump_location();
let mut destination = departure;
let mut index = self.jump_index;
let mut remaining = count;
let mut destination_index = None;
// The timeline bounds this scan, even for a saturated count.
while remaining > 0 {
let next = if forward {
index.checked_add(1).filter(|next| *next < self.jumps.len())
} else {
index.checked_sub(1)
};
let Some(next) = next else { break };
index = next;
let mut location = self.jumps[index];
let Some(buffer) = self.buffer(location.buffer_id) else {
continue;
};
location.cursor.row = location.cursor.row.min(buffer.lines.len() - 1);
location.cursor.column = location
.cursor
.column
.min(grapheme_count(&buffer.lines[location.cursor.row]).saturating_sub(1));
if location == destination {
continue;
}
destination = location;
destination_index = Some(index);
remaining -= 1;
}
let Some(index) = destination_index else {
self.set_message(if forward {
"No newer jump location"
} else {
"No older jump location"
});
return;
};
self.jumps[self.jump_index] = departure;
self.switch_buffer(destination.buffer_id);
self.place_navigation(TextPosition {
row: destination.cursor.row,
column: destination.cursor.column,
});
self.jump_index = index;
}
fn park_source(&mut self) {
@@ -2731,6 +2842,7 @@ impl Editor {
}
fn buffer_command(&mut self, name: &str, args: &str) -> bool {
let source = self.jump_location();
match name {
"edit" if args.is_empty() => self.set_message("Usage: :edit path"),
"edit" => {
@@ -2782,6 +2894,9 @@ impl Editor {
}
_ => return false,
}
if matches!(name, "edit" | "buffer" | "bnext" | "bprev") {
self.record_jump(source);
}
true
}
@@ -4552,6 +4667,403 @@ mod tests {
editor
}
#[test]
fn jump_history_follows_links_and_restores_primary_grapheme_locations() {
let directory = Directory::new();
let source = directory.path("source.md");
let target = directory.path("target.md");
fs::write(&source, "intro\n[go](target.md#destination)").unwrap();
fs::write(&target, "intro\n\n# Destination").unwrap();
let mut editor = Editor::open(Some(source), vec![]).unwrap();
type_text(&mut editor, "jl");
let departure = editor.cursor();
let source_id = editor.buffer_id();
type_text(&mut editor, "gf");
let target_id = editor.buffer_id();
assert_ne!(source_id, target_id);
assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 });
assert_eq!(editor.jumps.len(), 2);
fs::remove_file(target).unwrap();
editor.handle(Input::Ctrl('O'));
assert_eq!(editor.buffer_id(), source_id);
assert_eq!(editor.cursor(), departure);
editor.handle(Input::Ctrl('I'));
assert_eq!(editor.buffer_id(), target_id);
assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 });
assert_eq!(editor.lines(), ["intro", "", "# Destination"]);
assert_eq!(editor.buffer_count(), 2);
}
#[test]
fn jump_history_records_same_file_fragments_once() {
for link in ["#destination", "source.md#destination"] {
let directory = Directory::new();
let source = directory.path("source.md");
fs::write(&source, format!("[go]({link})\n\n# Destination")).unwrap();
let mut editor = Editor::open(Some(source), vec![]).unwrap();
type_text(&mut editor, "gf");
assert_eq!(editor.buffer_count(), 1);
assert_eq!(editor.cursor().row, 2);
editor.handle(Input::Ctrl('o'));
assert_eq!(editor.cursor(), Cursor::default());
let targets = editor.active.targets.clone();
editor.handle(Input::Ctrl('o'));
assert_eq!(editor.active.targets, targets);
editor.handle(Input::Char('\t'));
assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 });
}
}
#[test]
fn jump_history_records_edit_buffer_neighbors_and_picker_transactions() {
let directory = Directory::new();
let a = directory.path("a.txt");
let b = directory.path("b.txt");
fs::write(&a, "alpha").unwrap();
fs::write(&b, "bravo").unwrap();
fs::write(directory.path("c.txt"), "charlie").unwrap();
let mut editor = Editor::open(Some(a), vec![]).unwrap();
editor.project_root = directory.0.clone();
type_text(&mut editor, "ll");
editor.execute(&format!("edit {}", b.display()));
assert_eq!(editor.jumps.len(), 2);
editor.execute("bnext");
assert_eq!(editor.buffer_id(), 1);
assert_eq!(editor.cursor().column, 2);
editor.execute("bprev");
assert_eq!(editor.buffer_id(), 2);
editor.execute("buffer 1");
assert_eq!(editor.jumps.len(), 5);
editor.execute("buffer 1");
assert_eq!(editor.jumps.len(), 5);
editor.set_file_picker_view("list").unwrap();
type_text(&mut editor, " oc.txt\n");
assert_eq!(editor.buffer_id(), 3);
assert_eq!(editor.jumps.len(), 6);
editor.handle(Input::Ctrl('o'));
assert_eq!(editor.buffer_id(), 1);
assert_eq!(editor.cursor().column, 2);
type_text(&mut editor, "3");
editor.handle(Input::Ctrl('o'));
assert_eq!(editor.buffer_id(), 2);
type_text(&mut editor, "4");
editor.handle(Input::Ctrl('i'));
assert_eq!(editor.buffer_id(), 3);
}
#[test]
fn jump_history_records_search_without_losing_query_or_highlight_invariants() {
let mut editor = editor();
editor
.apply(vec![Effect::Replace("hit\n中👩‍💻 hit\nhit".into())])
.unwrap();
type_text(&mut editor, "/hit\n");
assert_eq!(editor.cursor(), Cursor { row: 1, column: 3 });
type_text(&mut editor, "nN");
assert_eq!(editor.jumps.len(), 4);
editor.handle(Input::Right);
let departure = editor.cursor();
editor.handle(Input::Ctrl('o'));
assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 });
assert!(editor.search_match().is_none());
assert_eq!(editor.active.search_query, "hit");
editor.handle(Input::Ctrl('i'));
assert_eq!(editor.cursor(), departure);
type_text(&mut editor, "2N");
assert_eq!(editor.cursor(), Cursor::default());
}
#[test]
fn jump_history_saves_roaming_departures_in_both_directions() {
let directory = Directory::new();
let a = directory.path("a.txt");
let b = directory.path("b.txt");
fs::write(&a, "abcdef").unwrap();
fs::write(&b, "first\nabcdef").unwrap();
let mut editor = Editor::open(Some(a), vec![]).unwrap();
editor
.navigate_to(b, TextPosition { row: 1, column: 0 })
.unwrap();
type_text(&mut editor, "ll");
editor.handle(Input::Ctrl('o'));
assert_eq!(editor.buffer_id(), 1);
type_text(&mut editor, "lll");
editor.handle(Input::Ctrl('i'));
assert_eq!(editor.buffer_id(), 2);
assert_eq!(editor.cursor(), Cursor { row: 1, column: 2 });
editor.handle(Input::Ctrl('o'));
assert_eq!(editor.cursor(), Cursor { row: 0, column: 3 });
}
#[test]
fn jump_history_branches_only_after_successful_distinct_jumps() {
let directory = Directory::new();
let path = directory.path("text.txt");
fs::write(&path, "zero\none\ntwo\nthree").unwrap();
let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap();
for row in [1, 2] {
editor
.navigate_to(path.clone(), TextPosition { row, column: 0 })
.unwrap();
}
editor.handle(Input::Ctrl('o'));
editor
.navigate_to(path.clone(), TextPosition { row: 1, column: 0 })
.unwrap();
editor
.navigate_to(
directory.path("missing"),
TextPosition { row: 0, column: 0 },
)
.unwrap_err();
editor.execute("buffer 999");
editor.execute("edit");
editor.execute("follow_link");
type_text(&mut editor, "/missing\n");
editor.handle(Input::Ctrl('i'));
assert_eq!(editor.cursor().row, 2);
editor.handle(Input::Ctrl('o'));
editor.handle(Input::Right);
editor
.navigate_to(path, TextPosition { row: 3, column: 0 })
.unwrap();
editor.handle(Input::Ctrl('i'));
assert_eq!(editor.cursor().row, 3);
editor.handle(Input::Ctrl('o'));
assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 });
}
#[test]
fn jump_history_is_bounded_and_counts_stop_at_available_locations() {
let directory = Directory::new();
let path = directory.path("text.txt");
fs::write(
&path,
(0..=120)
.map(|n| n.to_string())
.collect::<Vec<_>>()
.join("\n"),
)
.unwrap();
let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap();
for row in 1..=120 {
editor
.navigate_to(path.clone(), TextPosition { row, column: 0 })
.unwrap();
}
type_text(&mut editor, "2");
editor.handle(Input::Ctrl('o'));
assert_eq!(editor.cursor().row, 118);
type_text(&mut editor, &usize::MAX.to_string());
editor.handle(Input::Ctrl('o'));
assert_eq!(editor.cursor().row, 21);
let targets = editor.active.targets.clone();
let revision = editor.text_revision();
editor.handle(Input::Ctrl('o'));
assert_eq!(editor.active.targets, targets);
assert_eq!(editor.text_revision(), revision);
type_text(&mut editor, &usize::MAX.to_string());
editor.handle(Input::Ctrl('i'));
assert_eq!(editor.cursor().row, 120);
}
#[test]
fn jump_history_skips_closed_buffers_without_reopening_paths() {
let directory = Directory::new();
let mut editor = editor();
for name in ["b", "c"] {
let path = directory.path(name);
fs::write(&path, name).unwrap();
editor.execute(&format!("edit {}", path.display()));
}
editor.execute("bdelete 2");
editor.handle(Input::Ctrl('o'));
assert_eq!(editor.buffer_id(), 1);
editor.handle(Input::Ctrl('i'));
assert_eq!(editor.buffer_id(), 3);
assert_eq!(editor.buffer_count(), 2);
editor.execute("bdelete");
let targets = editor.active.targets.clone();
editor.handle(Input::Ctrl('o'));
assert_eq!(editor.buffer_id(), 1);
assert_eq!(editor.buffer_count(), 1);
assert_eq!(editor.active.targets, targets);
}
#[test]
fn jump_history_clamps_edited_locations_and_preserves_dirty_text_and_undo() {
let directory = Directory::new();
let a = directory.path("a.txt");
let b = directory.path("b.txt");
fs::write(&a, "original\nabcdef").unwrap();
fs::write(&b, "other").unwrap();
let mut editor = Editor::open(Some(a), vec![]).unwrap();
type_text(&mut editor, "jlllll");
editor
.navigate_to(b, TextPosition { row: 0, column: 0 })
.unwrap();
editor.parked.get_mut(&1).unwrap().lines = vec!["中👩‍💻".into()];
editor.parked.get_mut(&1).unwrap().text_changed();
editor.handle(Input::Ctrl('o'));
assert_eq!(editor.cursor(), Cursor { row: 0, column: 1 });
assert!(editor.dirty());
type_text(&mut editor, "iX");
editor.handle(Input::Escape);
let text = editor.context().text;
let undo = editor.active.undo.len();
let revision = editor.text_revision();
editor.handle(Input::Ctrl('i'));
editor.handle(Input::Ctrl('o'));
assert_eq!(editor.context().text, text);
assert_eq!(editor.active.undo.len(), undo);
assert_eq!(editor.text_revision(), revision);
assert!(editor.dirty());
type_text(&mut editor, "u");
assert_eq!(editor.context().text, "中👩‍💻");
let redo = editor.active.redo.len();
editor.handle(Input::Ctrl('i'));
editor.handle(Input::Ctrl('o'));
assert_eq!(editor.active.redo.len(), redo);
type_text(&mut editor, "U");
assert_eq!(editor.context().text, text);
}
#[test]
fn jump_history_skips_entries_clamped_to_the_current_location() {
let directory = Directory::new();
let path = directory.path("text.txt");
fs::write(&path, "zero\none\ntwo").unwrap();
let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap();
for row in [1, 2] {
editor
.navigate_to(path.clone(), TextPosition { row, column: 0 })
.unwrap();
}
editor.apply(vec![Effect::Replace("x".into())]).unwrap();
let targets = editor.active.targets.clone();
editor.handle(Input::Ctrl('o'));
assert_eq!(editor.active.targets, targets);
assert_eq!(editor.cursor(), Cursor::default());
}
#[test]
fn jump_history_counts_revisit_older_entries_at_the_same_coordinates() {
let directory = Directory::new();
let path = directory.path("text.txt");
fs::write(&path, "zero\none").unwrap();
let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap();
for row in [1, 0] {
editor
.navigate_to(path.clone(), TextPosition { row, column: 0 })
.unwrap();
}
type_text(&mut editor, "2");
editor.handle(Input::Ctrl('o'));
assert_eq!(editor.cursor().row, 0);
editor.handle(Input::Ctrl('i'));
assert_eq!(editor.cursor().row, 1);
}
#[test]
fn failed_jump_traversal_preserves_multiple_carets_search_and_edit_state() {
let mut editor = editor();
editor
.apply(vec![Effect::Replace("hit\nhit".into())])
.unwrap();
type_text(&mut editor, "/hit\n");
editor.handle(Input::Ctrl('k'));
assert_eq!(editor.carets().len(), 2);
let targets = editor.active.targets.clone();
let primary = editor.active.primary;
let search = editor.search_match();
let undo = editor.active.undo.len();
let revision = editor.text_revision();
let timeline = editor.jumps.clone();
for input in [Input::Ctrl('i'), Input::Ctrl('o')] {
editor.handle(input);
assert_eq!(editor.active.targets, targets);
assert_eq!(editor.active.primary, primary);
assert_eq!(editor.search_match(), search);
assert_eq!(editor.active.search_query, "hit");
assert_eq!(editor.active.undo.len(), undo);
assert_eq!(editor.text_revision(), revision);
assert_eq!(editor.jumps, timeline);
assert_eq!(editor.mode(), Mode::Normal);
assert!(editor.dirty());
}
}
#[test]
fn ordinary_motions_edits_and_failed_jumps_do_not_seed_history() {
let mut editor = editor();
editor
.apply(vec![Effect::Replace("alpha beta".into())])
.unwrap();
type_text(&mut editor, "wlhiX");
editor.handle(Input::Escape);
type_text(&mut editor, "uU");
editor.execute("follow_link");
editor.execute("bnext");
editor.execute("buffer 999");
let targets = editor.active.targets.clone();
let revision = editor.text_revision();
editor.handle(Input::Ctrl('o'));
editor.handle(Input::Ctrl('i'));
assert_eq!(editor.active.targets, targets);
assert_eq!(editor.text_revision(), revision);
}
#[test]
fn jump_keys_honor_control_aliases_custom_bindings_and_normal_mode_eligibility() {
let directory = Directory::new();
let path = directory.path("text.txt");
fs::write(&path, "zero\none\ntwo").unwrap();
let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap();
editor
.navigate_to(path, TextPosition { row: 1, column: 0 })
.unwrap();
editor
.bind("normal", "ctrl+o", Some("core.right".into()))
.unwrap();
editor.handle(Input::Ctrl('o'));
assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 });
editor
.bind("normal", "leader [", Some("core.jump_back".into()))
.unwrap();
type_text(&mut editor, " [");
assert_eq!(editor.cursor(), Cursor::default());
editor
.bind("normal", "ctrl+i", Some("core.right".into()))
.unwrap();
editor.handle(Input::Char('\t'));
assert_eq!(editor.cursor().column, 1);
editor
.bind("normal", "tab", Some("core.jump_forward".into()))
.unwrap();
editor.handle(Input::Ctrl('I'));
assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 });
for mode in ["insert", "visual", "command", "search"] {
for action in ["core.jump_back", "core.jump_forward"] {
assert!(editor.bind(mode, "z", Some(action.into())).is_err());
}
}
editor.handle(Input::Char('i'));
editor.handle(Input::Ctrl('i'));
editor.handle(Input::Char('\t'));
assert_eq!(editor.lines()[1], "o\t\tne");
assert_eq!(editor.mode(), Mode::Insert);
editor.handle(Input::Ctrl('p'));
for action in ["core.jump_back", "core.jump_forward"] {
assert!(!palette_entry(&editor, action).enabled);
}
editor.handle(Input::Escape);
editor.handle(Input::Escape);
editor.handle(Input::Ctrl('p'));
assert!(palette_entry(&editor, "core.jump_back").enabled);
assert!(palette_entry(&editor, "core.jump_forward").enabled);
assert!(!palette_entry(&editor, "core.jump_back").bindings.is_empty());
}
#[test]
fn follow_link_resolves_relative_paths_from_source_not_cwd_or_project_root() {
let directory = Directory::new();