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
+1
View File
@@ -29,6 +29,7 @@ Frontends use cached `selection_navigation_hint()` for command lists and `file_n
Frontends inspect `count()` for the optional pending motion prefix; parsing and counted motion dispatch remain editor-owned.
Frontends inspect `leader_pending()` for the cached display of a pending leader sequence; prefix recognition, cancellation, and dispatch remain editor-owned.
Frontends inspect `g_pending()` for the built-in Normal `gf` prefix. Markdown following preflights links and headings before buffer/caret mutation; an unloaded target is validated/prepared once, while loaded targets use their current unsaved text. File identity and activation stay editor-owned.
Editor-owned jump history is a bounded 100-location `VecDeque` of buffer IDs and grapheme cursors plus a current index, with no copied paths/text. Successful high-level navigation records departure/destination once; traversal uses unrecorded buffer activation and position placement, skips closed/clamped-duplicate locations, and truncates forward entries only on a distinct new jump. Session synchronization permanently invalidates pending LSP position requests after their source buffer/revision/caret/mode is left, so returning through history cannot revive an old definition reply.
Frontends inspect `buffer_id()`, `buffer_count()`, `has_buffer(id)`, and the ascending-ID `buffers()` iterator of borrowed path/dirty/active metadata. Existing text, cursor, selection, search, and mode methods describe only the active buffer.
Frontends inspect `file_picker()` for query, presentation, view, explorer-local INSERT/filter subfocus, selection, and errors, and iterate borrowed `FileRow` metadata (actual path, display label, directory flag, visible depth, expansion). Dock side is a logical preference. They allocate rectangles, add folder suffixes/colors, and render; hierarchy, filtering, parent/child navigation, focus transitions, and selected-file opening remain backend-owned.
Frontends inspect `diagnostics()` and `diagnostic_at_cursor()` for validated active-buffer annotations. Diagnostics are owned by core LSP or a plugin and buffer revision; text changes clear them. Native server retirement removes only that server's contribution, preserving sibling diagnostics. The TUI underlines annotated text, colors affected gutter numbers by severity, and displays the primary caret's diagnostic message without replacing search/selection backgrounds.
+3
View File
@@ -55,6 +55,8 @@ Key notation follows [Herdr's readable style](https://herdr.dev/docs/configurati
`ctrl+i` aliases `tab`, and `ctrl+m` aliases `enter`, including inside leader sequences. Ctrl+h and Ctrl+j are distinct control keys, not Backspace/Enter aliases. Only a single modifier on an ASCII letter is supported. Arbitrary non-leader key sequences, Alt/Cmd/Super, combined modifiers, modified special keys, and Vim-style angle-bracket notation are rejected rather than silently treated as another key.
Normal mode binds `ctrl+o` to `config.core.jump_back` and `ctrl+i`/`tab` to `config.core.jump_forward`. Insert-mode Tab remains literal text. Override or disable these keys with the ordinary per-mode binding interface; both jump actions also appear in the command palette.
The built-in Normal `gf` sequence follows Markdown file links. It is not a configurable general sequence prefix: bind `config.core.follow_link` to a single key or leader sequence to add another route. A configured or disabled Normal `g`, or choosing `g` as the leader, overrides the built-in prefix.
## Action reference
@@ -65,6 +67,7 @@ The built-in Normal `gf` sequence follows Markdown file links. It is not a confi
| Normal, Visual | `insert_first_nonblank`, `append_line_end`, `clone_above`, `clone_below` |
| Normal | `join_lines`, `undo`, `redo`, `command` |
| Normal | `follow_link` |
| Normal | `jump_back`, `jump_forward` |
| Normal | `lsp.definition`, `lsp.diagnostics`, `lsp.hover` |
| Normal | `search`, `paste_before`, `paste_after` |
| Normal | `paste_system_before`, `paste_system_after` |
+1
View File
@@ -34,6 +34,7 @@ Rust tests live alongside their modules; native LSP integration checks are in [`
- Editor checks cover Unicode editing, multi-target state, undo/redo, saving failures, and modal paste acceptance and rejection atomicity.
- Markdown navigation checks cover inline/reference destinations, escaped/percent-encoded paths, heading markup and duplicates, same-document fragments, dirty target reuse, failed navigation atomicity, primary-only following, and `gf` prefix cancellation/configuration overrides.
- Jump-history checks cover cross-buffer/same-document navigation, search and picker recording, counts and repeated visits, bounded history, branch truncation, closed buffers, edited-coordinate clamping, dirty text/undo preservation, Ctrl+i/Tab aliases, and custom bindings. Real LSP fixtures cover definition round-trips and delayed replies after leaving and returning to the same source location.
- Session and Ruby checks exercise real Ruby execution, command/capability ownership, effect validation, configuration fallback, plugin retirement, crash/timeout recovery, and inherited-pipe cleanup.
- Unix terminal-hook checks use isolated child processes and real PTYs to keep process-global hooks out of the parallel runner. They cover cleanup on entry failure, normal return, I/O errors, and panics, including original panic-payload propagation.
- Native LSP integration checks launch framed-protocol fixture subprocesses, covering initialization/settings, opt-in startup, synchronization, UTF-16/file-URI conversion, dirty targets, stale requests/diagnostics, and transport failures. Stack checks cover independent initialization, diagnostic merging/clearing, sibling failure isolation in active/parked buffers, same-revision rename/close identity, aggregate limits, first-server position routing, and both shutdowns. Semantic ordering uses protocol/file gates; elapsed deadlines only bound stuck subprocesses.
+11
View File
@@ -17,6 +17,7 @@ Default controls and editing behavior. For custom bindings, see [configuration](
| Normal | `h`/`j`/`k`/`l` or arrows move every caret; `0`/`$` or Home/End |
| Normal | `w`/`b`/`e` move every caret to next word start, previous/current word start, word end; `W`/`B`/`E` for whitespace-delimited WORDs |
| Normal | `gf` follows a Markdown file link or heading fragment at the primary caret |
| Normal | Ctrl+o goes to an older jump location; Ctrl+i/Tab goes forward; counts select multiple locations |
| Normal, Visual | Decimal prefixes repeat cursor/word/search motions: `5j`, `3k`, `2w`, `4e`, `2n`; Esc cancels a pending count |
| Normal | `J` (Shift+j) joins each targeted line with the next line |
| Normal | `x`/Delete delete; `u` undo; `U` (Shift+u) redo; `:` enter command mode |
@@ -171,6 +172,16 @@ Esc or Ctrl+p cancels without changing text, selections, the previous command/se
Space begins a leader sequence in Normal/Visual by default. Press the suffix keys in order; nested prefixes wait without a timeout. The status bar shows the pending sequence. Esc cancels the sequence and pending count without leaving Visual or collapsing carets. See [leader configuration](configuration.md#leader-key) for remapping and declaration rules.
## Jump history
Normal Ctrl+o moves back through previous locations; Ctrl+i moves forward. Traditional terminals encode Ctrl+i as Tab, so Normal Tab performs the same action while Insert Tab still inserts text. Counts work in either direction, such as `3` followed by Ctrl+o, and stop at the available history.
The session keeps up to 100 primary-caret locations across buffers. Successful Markdown links, accepted LSP definitions, file-picker opens, `:edit`, `:buffer`, `:bnext`/`:bprev`, and search `/`/`n`/`N` jumps record locations. Ordinary cursor/word motion, edits, failed navigation, and jumps that do not change location do not add entries. Traversal itself does not record another jump.
Moving away from a visited point before traversing saves the actual departure position for the return trip. A successful new jump after going back discards the forward branch; failed/no-op jumps preserve it. Closed buffers are skipped rather than reopened. Stored row/grapheme coordinates clamp to the current text after edits; text, dirty state, undo/redo, and file contents are unchanged by traversal. History navigation returns to Normal with one primary caret.
The typed actions are `config.core.jump_back` and `config.core.jump_forward`; custom bindings can use any supported single key or leader sequence. History is in memory for this session only.
## Markdown links
Normal `gf` or `:follow_link` follows the link under the primary caret, without an LSP. Inline links and full/collapsed/shortcut reference links are supported; reference definitions can appear elsewhere in the document. The label or destination can be under the caret. Images, code blocks, and ordinary text are not file links.
+514 -2
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> {
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();
+1
View File
@@ -18,6 +18,7 @@ Normal: i/a insert, I/A insert at first nonblank/end, o/O new line
h/j/k/l move, 0/$ line ends, x delete, J join lines
w/b/e word next/previous/end, W/B/E whitespace-delimited WORDs
gf follows Markdown file links/reference links and heading fragments (no LSP)
Ctrl+o older jump location; Ctrl+i/Tab newer (counts supported; Insert Tab unchanged)
Normal/Visual: prefix motions with counts (5j, 2w, 3n); Esc cancels count
u undo, U redo, : command, / search, n/N next/previous match
Normal/Visual: . repeats last native edit at current targets (one undo group)
+13
View File
@@ -237,6 +237,19 @@ impl EditorSession {
}
fn synchronize_documents(&mut self) -> bool {
// Leaving a request's source invalidates it permanently, even if history returns there.
if let Some(requests) = self.requests.get_mut("core")
&& requests.latest.is_some_and(|(_, source)| {
source.buffer_id != self.editor.buffer_id()
|| source.revision != self.editor.text_revision()
|| self.lsp_source_cursor != Some(self.editor.cursor())
|| self.editor.mode() != Mode::Normal
})
{
requests.latest = None;
self.lsp_source_cursor = None;
self.pending_hover = false;
}
if !self.lsp.is_enabled() && !self.plugins.values().any(RubyPlugin::is_async) {
self.documents.clear();
return false;
+113
View File
@@ -585,6 +585,17 @@ fn lsp_framing_lifecycle_diagnostics_and_unicode_dirty_definition() {
definition["params"]["position"],
json!({"line": 0, "character": 5})
);
session.handle(Input::Ctrl('o'));
assert_eq!(session.editor().buffer_id(), 1);
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 3 });
assert_eq!(session.editor().lines(), &["A😀e\u{301}Z", ""]);
session.handle(Input::Ctrl('i'));
assert_eq!(session.editor().buffer_id(), target_id);
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 1 });
assert_eq!(session.editor().lines(), &["😀e\u{301}disk", ""]);
assert!(session.editor().dirty());
session.poll();
assert_eq!(fixture.method_count("textDocument/definition"), 1);
session.execute("buffer 1");
session.execute("lsp.definition");
@@ -604,6 +615,13 @@ fn lsp_framing_lifecycle_diagnostics_and_unicode_dirty_definition() {
session.editor().message().contains("no definition found")
});
assert_eq!(session.editor().buffer_id(), 1);
session.handle(Input::Ctrl('o'));
assert_eq!(session.editor().buffer_id(), target_id);
assert_eq!(session.editor().cursor(), Cursor { row: 1, column: 0 });
session.handle(Input::Ctrl('i'));
assert_eq!(session.editor().buffer_id(), 1);
session.poll();
assert_eq!(fixture.method_count("textDocument/definition"), 3);
session.handle(Input::Char('A'));
session.handle(Input::Char('!'));
@@ -729,6 +747,14 @@ fn lsp_old_source_and_superseded_definitions_never_steal_focus() {
session.handle(Input::Escape);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["A😀e\u{301}Z", ""]);
let cursor = session.editor().cursor();
for input in [Input::Ctrl('o'), Input::Ctrl('i')] {
session.handle(input);
assert_eq!(session.editor().buffer_id(), 1);
assert_eq!(session.editor().cursor(), cursor);
}
session.poll();
assert_eq!(fixture.method_count("textDocument/definition"), 1);
drop(session);
let fixture = Fixture::new();
@@ -752,6 +778,49 @@ fn lsp_old_source_and_superseded_definitions_never_steal_focus() {
assert_eq!(session.editor().buffer_id(), active_id);
assert_eq!(session.editor().path(), Some(fixture.alternate.as_path()));
assert_eq!(session.editor().buffer_count(), 2);
session.handle(Input::Ctrl('o'));
assert_eq!(session.editor().buffer_id(), 1);
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 0 });
session.handle(Input::Ctrl('i'));
assert_eq!(session.editor().buffer_id(), active_id);
session.handle(Input::Ctrl('i'));
assert_eq!(session.editor().buffer_id(), active_id);
session.poll();
assert_eq!(fixture.method_count("textDocument/definition"), 1);
drop(session);
let fixture = Fixture::new();
let mut session = fixture.open("stale");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
let source_cursor = session.editor().cursor();
session.execute("lsp.definition");
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/definition") == 1
});
session.execute(&format!("edit {}", fixture.alternate.display()));
let alternate_id = session.editor().buffer_id();
session.handle(Input::Ctrl('o'));
assert_eq!(session.editor().buffer_id(), 1);
assert_eq!(session.editor().cursor(), source_cursor);
fixture.release();
until(&mut session, &fixture, |session| {
session
.editor()
.message()
.contains("stale response complete")
});
assert_eq!(session.editor().buffer_id(), 1);
assert_eq!(session.editor().cursor(), source_cursor);
assert_eq!(session.editor().buffer_count(), 2);
session.handle(Input::Ctrl('i'));
assert_eq!(session.editor().buffer_id(), alternate_id);
assert_eq!(session.editor().path(), Some(fixture.alternate.as_path()));
session.handle(Input::Ctrl('i'));
assert_eq!(session.editor().buffer_id(), alternate_id);
session.poll();
assert_eq!(fixture.method_count("textDocument/definition"), 1);
drop(session);
let fixture = Fixture::new();
@@ -772,6 +841,21 @@ fn lsp_old_source_and_superseded_definitions_never_steal_focus() {
.buffers()
.any(|buffer| buffer.path == Some(fixture.alternate.as_path()))
);
let target_id = session.editor().buffer_id();
let destination = session.editor().cursor();
session.handle(Input::Ctrl('o'));
assert_eq!(session.editor().buffer_id(), 1);
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 0 });
session.handle(Input::Ctrl('o'));
assert_eq!(session.editor().buffer_id(), 1);
session.handle(Input::Ctrl('i'));
assert_eq!(session.editor().buffer_id(), target_id);
assert_eq!(session.editor().cursor(), destination);
session.handle(Input::Ctrl('i'));
assert_eq!(session.editor().buffer_id(), target_id);
assert_eq!(session.editor().buffer_count(), 2);
session.poll();
assert_eq!(fixture.method_count("textDocument/definition"), 2);
}
#[test]
@@ -816,6 +900,15 @@ fn lsp_server_failures_and_unsupported_results_leave_native_editor_available() {
}
}
assert_eq!(session.editor().buffer_id(), 1, "{mode}");
let cursor = session.editor().cursor();
let definitions = fixture.method_count("textDocument/definition");
for input in [Input::Ctrl('o'), Input::Ctrl('i')] {
session.handle(input);
assert_eq!(session.editor().buffer_id(), 1, "{mode}");
assert_eq!(session.editor().cursor(), cursor, "{mode}");
}
session.poll();
assert_eq!(fixture.method_count("textDocument/definition"), definitions);
session.handle(Input::Char('A'));
session.handle(Input::Char('!'));
session.handle(Input::Escape);
@@ -907,6 +1000,19 @@ fn lsp_definition_reuses_dirty_targets_without_an_enabled_target_language() {
assert!(session.editor().dirty());
assert_eq!(fs::read_to_string(&fixture.target).unwrap(), "disk\n");
assert_eq!(fixture.method_count("textDocument/didOpen"), 1);
session.handle(Input::Ctrl('o'));
assert_eq!(session.editor().buffer_id(), 1);
session.handle(Input::Ctrl('i'));
assert_eq!(session.editor().buffer_id(), target_id);
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 1 });
assert_eq!(session.editor().lines(), &["😀e\u{301}disk", ""]);
assert!(session.editor().dirty());
session.poll();
assert_eq!(fixture.method_count("textDocument/definition"), 1);
assert_eq!(fixture.method_count("textDocument/didOpen"), 1);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["disk", ""]);
assert_eq!(fs::read_to_string(&fixture.target).unwrap(), "disk\n");
}
#[test]
@@ -931,6 +1037,13 @@ fn lsp_definition_does_not_move_a_caret_that_has_moved_since_the_request() {
assert_eq!(session.editor().buffer_id(), 1);
assert_eq!(session.editor().buffer_count(), 1);
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 1 });
for input in [Input::Ctrl('o'), Input::Ctrl('i')] {
session.handle(input);
assert_eq!(session.editor().buffer_id(), 1);
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 1 });
}
session.poll();
assert_eq!(fixture.method_count("textDocument/definition"), 1);
}
#[test]