feat: navigate jump history with Ctrl+o and Ctrl+i
This commit is contained in:
+515
-3
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user