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.
This commit is contained in:
l0ng-ai
2026-09-28 17:36:44 +08:00
parent 59ea010347
commit ebbeaaca60
2 changed files with 188 additions and 16 deletions
+2 -1
View File
@@ -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();
}
+186 -15
View File
@@ -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<NavLocation>, current: Option<&NavLocation>) -> Option<NavLocation> {
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<Sample>, 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<Sample>, 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<BufferId, Arc<Outline>>,
preview: Option<SymbolPreview>,
refresh_scheduled: bool,
/// How far into each buffer's edit log the history has been moved.
edits_seen: HashMap<BufferId, u64>,
/// 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<gpui::Subscription>,
}
/// 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::<MovePageUp>()
|| any.is::<MovePageDown>()
|| any.is::<Paste>()
|| any.is::<Undo>()
|| any.is::<Redo>()
}
impl EditorNav {
pub(crate) fn new(cx: &mut Context<Tty7App>) -> 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<Self>) {
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"
);
}
}