From ebbeaaca60e29e07a2b02d1f227c135cc7960cff Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:36:44 +0800 Subject: [PATCH] feat(editor): history follows edits, and paging is not a jump Places in the back/forward history now move with lines inserted or deleted above them, read from the editor's line-edit log on every change. The caret move a Page Up/Down, paste, undo or redo makes is heard through the keystroke that caused it and is not recorded as a jump. --- src/ui/code_editor.rs | 3 +- src/ui/code_editor/nav.rs | 201 +++++++++++++++++++++++++++++++++++--- 2 files changed, 188 insertions(+), 16 deletions(-) diff --git a/src/ui/code_editor.rs b/src/ui/code_editor.rs index f751c6a6..adc6b518 100644 --- a/src/ui/code_editor.rs +++ b/src/ui/code_editor.rs @@ -360,7 +360,7 @@ impl EditorPanelState { watched_dirs: HashSet::new(), watched_files: HashSet::new(), events_tx: tx, - nav: nav::EditorNav::default(), + nav: nav::EditorNav::new(cx), } } } @@ -1342,6 +1342,7 @@ impl Tty7App { } self.lsp_buffer_edited(id, cx); self.editor_gutter_note_edit(id, cx); + self.editor_nav_note_edit(id, cx); cx.notify(); } diff --git a/src/ui/code_editor/nav.rs b/src/ui/code_editor/nav.rs index 1d0e1e56..c3fc93cb 100644 --- a/src/ui/code_editor/nav.rs +++ b/src/ui/code_editor/nav.rs @@ -20,7 +20,7 @@ use std::time::{Duration, Instant}; use gpui::prelude::*; use gpui::{AnyElement, Context, Window, div, px}; -use gpui_component::input::Position; +use gpui_component::input::{LineEdit, Position}; use gpui_component::{ActiveTheme as _, h_flex}; use tty7_core::core::machine::TabId; @@ -125,6 +125,16 @@ impl NavHistory { Some(target) } + /// Moves the places in one file along with edits made to it, so a place + /// keeps pointing at the same code after lines above it come and go. + pub(crate) fn shift(&mut self, file: &NavLocation, edits: &[LineEdit]) { + for loc in self.back.iter_mut().chain(self.forward.iter_mut()) { + if loc.same_file(file) { + loc.pos.line = shift_line(loc.pos.line, edits); + } + } + } + /// Drops the places `keep` turns down, from both stacks. pub(crate) fn retain(&mut self, keep: impl Fn(&NavLocation) -> bool) { self.back.retain(&keep); @@ -137,6 +147,13 @@ impl NavHistory { } } +fn shift_line(line: u32, edits: &[LineEdit]) -> u32 { + edits + .iter() + .fold(line as usize, |line, edit| edit.shift(line)) + .min(u32::MAX as usize) as u32 +} + fn pop_past(stack: &mut Vec, current: Option<&NavLocation>) -> Option { loop { let top = stack.pop()?; @@ -166,7 +183,9 @@ pub(crate) struct TabNav { impl TabNav { /// Compares a fresh sample against the last one and records a jump. - pub(crate) fn observe(&mut self, now: Option, at: Instant) { + /// `quiet` says the caret was moved by a key that pages or pastes, which + /// can go a long way without anyone having jumped anywhere. + pub(crate) fn observe(&mut self, now: Option, at: Instant, quiet: bool) { if let Some((target, since)) = &self.pending { let arrived = now.as_ref().is_some_and(|s| s.at.same_file(target)); if arrived || at.duration_since(*since) > PENDING_TIMEOUT { @@ -186,7 +205,8 @@ impl TabNav { }; if let Some(last) = &self.last { let jumped = !last.at.same_file(&now.at) - || (last.text_len == now.text_len + || (!quiet + && last.text_len == now.text_len && last.at.pos.line.abs_diff(now.at.pos.line) >= JUMP_LINES); if jumped { self.history.push(last.at.clone()); @@ -195,6 +215,14 @@ impl TabNav { self.last = Some(now); } + /// Moves the history, and the last sample, along with edits to a file. + fn shift(&mut self, file: &NavLocation, edits: &[LineEdit]) { + self.history.shift(file, edits); + if let Some(last) = self.last.as_mut().filter(|l| l.at.same_file(file)) { + last.at.pos.line = shift_line(last.at.pos.line, edits); + } + } + fn expect(&mut self, target: NavLocation, at: Instant) { self.pending = Some((target, at)); } @@ -224,15 +252,48 @@ pub(crate) struct EditorNav { provided: HashMap>, preview: Option, refresh_scheduled: bool, + /// How far into each buffer's edit log the history has been moved. + edits_seen: HashMap, + /// A key that pages or pastes was just pressed in the editor: the caret + /// move it makes is not a jump. + quiet_move: bool, + _keystrokes: Option, +} + +/// Whether an action moves the caret a long way without jumping anywhere: +/// paging through the file, or pasting, undoing and redoing a large edit. +fn is_quiet_move(action: &dyn gpui::Action) -> bool { + use gpui_component::input::{MovePageDown, MovePageUp, Paste, Redo, Undo}; + let any = action.as_any(); + any.is::() + || any.is::() + || any.is::() + || any.is::() + || any.is::() } impl EditorNav { + pub(crate) fn new(cx: &mut Context) -> Self { + let keystrokes = cx.observe_keystrokes(|this, event, window, cx| { + if event.action.as_deref().is_some_and(is_quiet_move) + && this.editor_panel_has_focus(window, cx) + { + this.editor.nav.quiet_move = true; + } + }); + Self { + _keystrokes: Some(keystrokes), + ..Self::default() + } + } + pub(crate) fn forget_tab(&mut self, tab: TabId) { self.tabs.remove(&tab); } pub(crate) fn forget_buffer(&mut self, id: BufferId) { self.outlines.remove(&id); + self.edits_seen.remove(&id); self.provided.remove(&id); for nav in self.tabs.values_mut() { nav.history.retain(|l| l.buffer != Some(id)); @@ -270,12 +331,38 @@ impl Tty7App { } let Some(tab) = self.nav_tab() else { return }; let now = self.nav_here(cx); + let quiet = std::mem::take(&mut self.editor.nav.quiet_move); self.editor .nav .tabs .entry(tab) .or_default() - .observe(now, Instant::now()); + .observe(now, Instant::now(), quiet); + } + + /// A buffer's text changed: every tab's history of it moves with the + /// lines. A buffer edited so much the log has forgotten some of it keeps + /// its places where they were. + pub(crate) fn editor_nav_note_edit(&mut self, id: BufferId, cx: &mut Context) { + let Some(f) = self.buffer(id) else { return }; + let file = NavLocation { + host: f.host.id(), + path: f.path.clone(), + buffer: f.untitled.map(|_| id), + pos: Position::default(), + }; + let state = f.input.read(cx); + let version = state.edit_version(); + let seen = self.editor.nav.edits_seen.insert(id, version).unwrap_or(0); + let Some(edits) = state.line_edits_since(seen) else { + return; + }; + if edits.is_empty() { + return; + } + for nav in self.editor.nav.tabs.values_mut() { + nav.shift(&file, &edits); + } } /// Back (or, with `forward`, Forward) through the active tab's history. @@ -820,24 +907,24 @@ mod tests { fn the_tracker_records_jumps_but_not_walks_or_edits() { let t0 = Instant::now(); let mut nav = TabNav::default(); - nav.observe(sample("a.rs", 0, 100), t0); + nav.observe(sample("a.rs", 0, 100), t0, false); // Arrowing down a line at a time is not a jump. for line in 1..=30 { - nav.observe(sample("a.rs", line, 100), t0); + nav.observe(sample("a.rs", line, 100), t0, false); } assert_eq!(nav.history.lens(), (0, 0)); // Pasting forty lines moves the caret forty lines, and is an edit. - nav.observe(sample("a.rs", 70, 900), t0); + nav.observe(sample("a.rs", 70, 900), t0, false); assert_eq!(nav.history.lens(), (0, 0)); // A click far away is. - nav.observe(sample("a.rs", 300, 900), t0); + nav.observe(sample("a.rs", 300, 900), t0, false); assert_eq!(nav.history.back, [at("a.rs", 70)]); // So is another file coming forward. - nav.observe(sample("b.rs", 0, 10), t0); + nav.observe(sample("b.rs", 0, 10), t0, false); assert_eq!(nav.history.back, [at("a.rs", 70), at("a.rs", 300)]); // No buffer in front keeps the last place for the next one. - nav.observe(None, t0); - nav.observe(sample("c.rs", 0, 10), t0); + nav.observe(None, t0, false); + nav.observe(sample("c.rs", 0, 10), t0, false); assert_eq!(nav.history.back.last(), Some(&at("b.rs", 0))); } @@ -845,27 +932,80 @@ mod tests { fn a_pending_back_is_not_mistaken_for_a_jump() { let t0 = Instant::now(); let mut nav = TabNav::default(); - nav.observe(sample("a.rs", 0, 10), t0); - nav.observe(sample("b.rs", 50, 10), t0); + nav.observe(sample("a.rs", 0, 10), t0, false); + nav.observe(sample("b.rs", 50, 10), t0, false); let target = nav.history.go_back(Some(at("b.rs", 50))).unwrap(); nav.expect(target, t0); // The file is still opening: b.rs is on screen, and that is fine. - nav.observe(sample("b.rs", 50, 10), t0); - nav.observe(sample("a.rs", 0, 10), t0); + nav.observe(sample("b.rs", 50, 10), t0, false); + nav.observe(sample("a.rs", 0, 10), t0, false); assert_eq!(nav.history.lens(), (0, 1)); // A target that never arrives stops being waited on. nav.expect(at("gone.rs", 0), t0); nav.observe( sample("a.rs", 0, 10), t0 + PENDING_TIMEOUT + Duration::from_secs(1), + false, ); nav.observe( sample("b.rs", 0, 10), t0 + PENDING_TIMEOUT + Duration::from_secs(1), + false, ); assert_eq!(nav.history.back, [at("a.rs", 0)]); } + #[test] + fn paging_pasting_and_undoing_are_the_quiet_moves() { + use gpui_component::input::{MoveDown, MovePageDown, MovePageUp, Paste, Redo, Undo}; + assert!(is_quiet_move(&MovePageDown)); + assert!(is_quiet_move(&MovePageUp)); + assert!(is_quiet_move(&Paste)); + assert!(is_quiet_move(&Undo)); + assert!(is_quiet_move(&Redo)); + assert!(!is_quiet_move(&MoveDown)); + } + + #[test] + fn a_page_down_or_a_paste_is_not_a_jump() { + let t0 = Instant::now(); + let mut nav = TabNav::default(); + nav.observe(sample("a.rs", 0, 100), t0, false); + nav.observe(sample("a.rs", 60, 100), t0, true); + nav.observe(sample("a.rs", 120, 100), t0, true); + assert_eq!(nav.history.lens(), (0, 0)); + // Only the move the key made: the next one is judged on its own. + nav.observe(sample("a.rs", 400, 100), t0, false); + assert_eq!(nav.history.back, [at("a.rs", 120)]); + } + + #[test] + fn places_move_with_lines_inserted_or_deleted_above_them() { + let mut nav = TabNav::default(); + let t0 = Instant::now(); + nav.observe(sample("a.rs", 40, 10), t0, false); + nav.observe(sample("b.rs", 0, 10), t0, false); + nav.history.push(at("a.rs", 90)); + // Three lines pasted at line 10 of a.rs, then lines 20..=29 deleted. + let edits = [ + LineEdit { + start_line: 10, + end_line: 10, + new_lines: 3, + }, + LineEdit { + start_line: 20, + end_line: 30, + new_lines: 0, + }, + ]; + nav.shift(&at("a.rs", 0), &edits); + // b.rs is untouched; 40 → 43 → 33, 90 → 93 → 83. + assert_eq!(nav.history.back, [at("a.rs", 33), at("a.rs", 83)]); + nav.shift(&at("b.rs", 0), &edits[..1]); + assert_eq!(nav.last.as_ref().unwrap().at, at("b.rs", 0)); + } + #[test] fn breadcrumb_paths_start_at_their_project() { let roots = [PathBuf::from("/work/tty7")]; @@ -1089,4 +1229,35 @@ mod gpui_tests { }); assert_eq!(caret(&app, &mut vcx), Position::new(50, 3)); } + + #[gpui::test] + fn history_follows_edits_to_the_file(cx: &mut TestAppContext) { + use gpui::EntityInputHandler as _; + let (app, mut vcx, _streams) = harness_with_tabs(cx, 1); + crate::ui::i18n::set_locale("en"); + open_rs(&app, &mut vcx, "/nav-test/a.rs"); + put_caret(&app, &mut vcx, 5); + put_caret(&app, &mut vcx, 50); + // Three lines typed at the top of the file. + app.update_in(&mut vcx, |app, window, cx| { + let input = app.active_buffer().unwrap().input.clone(); + input.update(cx, |s, cx| { + s.replace_text_in_range(Some(0..0), "//\n//\n//\n", window, cx); + // Typing took the caret to the top; back to where it was + // reading, which has moved down with its line. + s.set_cursor_position(Position::new(53, 4), window, cx); + }); + }); + vcx.run_until_parked(); + draw(&app, &mut vcx); + app.update_in(&mut vcx, |app, window, cx| { + app.editor_navigate(false, window, cx) + }); + vcx.run_until_parked(); + assert_eq!( + caret(&app, &mut vcx).line, + 8, + "the place moved down with its code" + ); + } }