fix: restore tab and diff context through jump history

This commit is contained in:
Valdemar Lorenzen
2026-10-12 14:45:49 +02:00
parent 6b7cfdea20
commit 054225686e
8 changed files with 679 additions and 39 deletions
+38 -3
View File
@@ -205,7 +205,7 @@ fn default_segment(name: &str) -> SegmentConfig {
segment.trailing_edge = "▓▒░".into();
}
if name == "tabs" {
segment.title = "{index} {name}{modified}".into();
segment.title = "{index} {view}{name}{modified}".into();
segment.first_leading_edge = Some(String::new());
}
segment
@@ -234,9 +234,9 @@ fn validate_title(title: &str) -> Result<(), String> {
}
let end = rest.find('}').ok_or("title contains an unmatched '{'")?;
let placeholder = &rest[1..end];
if !matches!(placeholder, "index" | "name" | "path" | "modified") {
if !matches!(placeholder, "index" | "view" | "name" | "path" | "modified") {
return Err(format!(
"unknown title placeholder {{{placeholder}}}; expected index, name, path, or modified"
"unknown title placeholder {{{placeholder}}}; expected index, view, name, path, or modified"
));
}
rest = &rest[end + 1..];
@@ -480,6 +480,41 @@ mod tests {
}
}
#[test]
fn view_placeholder_is_valid_in_every_segment_and_bar() {
for name in [
"tabs",
"mode",
"pending",
"buffer",
"diagnostics",
"path",
"selections",
"count",
"language",
"lsp",
"position",
"battery",
"workspace",
] {
let options = serde_json::json!({
"left": [name],
(name): {"title": "{index}:{view}{name}:{path}{modified}"}
});
let lines: Lines = serde_json::from_value(serde_json::json!({
"top": options,
"bottom": options,
}))
.unwrap();
for line in [&lines.top, &lines.bottom] {
assert_eq!(
line.segment(name).title,
"{index}:{view}{name}:{path}{modified}"
);
}
}
}
#[test]
fn malformed_chrome_is_rejected_at_the_boundary() {
for options in [
+353 -20
View File
@@ -1331,10 +1331,22 @@ impl Buffer {
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct DiffJumpLocation {
comparison_id: u64,
file: usize,
focus: Focus,
text_focus: Focus,
live_buffer: Option<usize>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct JumpLocation {
tab_id: usize,
pane_id: usize,
buffer_id: usize,
cursor: Cursor,
diff: Option<DiffJumpLocation>,
}
struct PendingLspNavigation {
@@ -1367,6 +1379,7 @@ pub struct Editor {
diff_owner: Option<usize>,
parked_diff: Option<DiffState>,
diff_pane_bounds: [(Focus, PaneRect); 3],
// Closed destinations stay until bounded eviction so they cannot shift jump_index.
jumps: VecDeque<JumpLocation>,
jump_index: usize,
clipboard: Vec<String>,
@@ -1601,6 +1614,12 @@ impl Editor {
.map(|buffer| buffer.info(id == self.tab_id))
}
pub fn tab_is_diff(&self, index: usize) -> bool {
self.tab_order
.get(index)
.is_some_and(|id| self.tab_diff(*id).is_some())
}
pub(crate) fn tab_diff(&self, id: usize) -> Option<&DiffState> {
if id == self.tab_id {
self.workspace_diff()
@@ -1979,8 +1998,6 @@ impl Editor {
for pane in self.panes.values_mut() {
pane.views.remove(&id);
}
self.jumps.retain(|jump| jump.buffer_id != id);
self.jump_index = self.jump_index.min(self.jumps.len().saturating_sub(1));
}
fn close_tab(&mut self, force: bool) {
@@ -2441,10 +2458,6 @@ impl Editor {
pane.views
.retain(|id, _| *id == self.active.id || self.parked.contains_key(id));
}
self.jumps.retain(|jump| {
self.active.id == jump.buffer_id || self.parked.contains_key(&jump.buffer_id)
});
self.jump_index = self.jump_index.min(self.jumps.len().saturating_sub(1));
self.diff_bound = None;
self.diff_return_buffer = None;
}
@@ -5767,9 +5780,22 @@ impl Editor {
}
fn jump_location(&self) -> JumpLocation {
let diff = self.diff_buffer_focus().and_then(|text_focus| {
let diff = self.diff.as_ref()?;
Some(DiffJumpLocation {
comparison_id: diff.comparison_id?,
file: diff.file,
focus: diff.focus,
text_focus,
live_buffer: diff.live_buffer,
})
});
JumpLocation {
tab_id: self.tab_id,
pane_id: self.pane_id,
buffer_id: self.active.id,
cursor: self.cursor(),
diff,
}
}
@@ -5791,6 +5817,113 @@ impl Editor {
self.jump_index = self.jumps.len() - 1;
}
fn resolve_jump(&self, mut location: JumpLocation) -> Option<JumpLocation> {
let buffer = self.buffer(location.buffer_id)?;
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));
let pane_tab = |pane| {
if self.pane_layout.contains(pane) {
Some(self.tab_id)
} else {
self.tabs
.iter()
.find(|(_, tab)| tab.pane_layout.contains(pane))
.map(|(id, _)| *id)
}
};
let owner = |tab| {
if tab == self.tab_id {
self.diff_owner
} else {
self.tabs.get(&tab).and_then(|tab| tab.diff_owner)
}
};
let original_pane = pane_tab(location.pane_id) == Some(location.tab_id);
if original_pane
&& let Some(context) = location.diff
&& owner(location.tab_id) == Some(location.pane_id)
&& self.tab_diff(location.tab_id).is_some_and(|diff| {
diff.phase == Phase::View
&& diff.comparison_id == Some(context.comparison_id)
&& diff
.comparison
.as_ref()
.and_then(|comparison| comparison.files.get(context.file))
.is_some_and(|file| file.loaded)
})
{
return Some(location);
}
let ordinary_pane = |pane| {
let tab = pane_tab(pane)?;
(owner(tab) != Some(pane)).then_some((tab, pane))
};
// Closed/replaced comparisons are never revived or displaced by history.
let (tab, pane) = original_pane
.then(|| ordinary_pane(location.pane_id))
.flatten()
.or_else(|| {
self.panes.iter().find_map(|(id, pane)| {
(pane.buffer_id == location.buffer_id)
.then(|| ordinary_pane(*id))
.flatten()
})
})
.or_else(|| ordinary_pane(self.pane_id))
.or_else(|| self.panes.keys().find_map(|id| ordinary_pane(*id)))?;
location.tab_id = tab;
location.pane_id = pane;
location.diff = None;
Some(location)
}
fn restore_jump(&mut self, location: JumpLocation) {
self.finish_pane_interaction();
self.select_tab(location.tab_id);
self.select_pane(location.pane_id);
self.switch_buffer(location.buffer_id);
if let Some(context) = location.diff {
let live_buffer = context.live_buffer.filter(|id| self.has_buffer(*id));
let diff = self.diff.as_mut().expect("resolved comparison exists");
if diff.file != context.file {
diff.select_file_index(context.file);
}
if diff.live_buffer != live_buffer {
diff.live_buffer = live_buffer;
diff.live_revision = None;
}
diff.focus = context.focus;
if context.focus == Focus::Files {
diff.files_visible = true;
}
diff.normalize_focus();
if let Some(files) = &mut diff.files {
files.focused = diff.files_visible && diff.focus == Focus::Files;
}
if context.text_focus == Focus::After && self.active.diff_snapshot.is_none() {
diff.live_buffer = Some(self.active.id);
}
self.diff_bound = Some((
context.comparison_id,
context.file,
context.text_focus,
self.active.id,
));
self.refresh_live_diff();
self.diff
.as_mut()
.expect("resolved comparison exists")
.reveal_source_row(context.text_focus, location.cursor.row);
}
self.place_navigation(TextPosition {
row: location.cursor.row,
column: location.cursor.column,
});
}
fn traverse_jumps(&mut self, forward: bool, count: usize) {
self.reveal_caret();
let departure = self.jump_location();
@@ -5807,15 +5940,9 @@ impl Editor {
};
let Some(next) = next else { break };
index = next;
let mut location = self.jumps[index];
let Some(buffer) = self.buffer(location.buffer_id) else {
let Some(location) = self.resolve_jump(self.jumps[index]) 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;
}
@@ -5832,11 +5959,7 @@ impl Editor {
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.restore_jump(destination);
self.jump_index = index;
}
@@ -5991,8 +6114,6 @@ impl Editor {
});
}
}
self.jumps.retain(|jump| jump.buffer_id != id);
self.jump_index = self.jump_index.min(self.jumps.len().saturating_sub(1));
self.set_message(format!(
"Deleted buffer {id}; active buffer {}",
self.active.id
@@ -11805,6 +11926,37 @@ mod tests {
assert_eq!(editor.active.view.targets, targets);
}
#[test]
fn deleting_older_jump_buffers_preserves_the_current_position_and_forward_branch() {
let directory = Directory::new();
let mut editor = editor();
for name in ["b", "c"] {
let path = directory.path(name);
fs::write(&path, name).unwrap();
editor
.navigate_to(path, TextPosition { row: 0, column: 0 })
.unwrap();
}
editor.handle(Input::Ctrl('o'));
assert_eq!(editor.context().text, "b");
editor.execute("bdelete 1");
editor.handle(Input::Ctrl('i'));
assert_eq!(editor.context().text, "c");
editor.handle(Input::Ctrl('o'));
let path = directory.path("d");
fs::write(&path, "d").unwrap();
editor
.navigate_to(path, TextPosition { row: 0, column: 0 })
.unwrap();
editor.handle(Input::Ctrl('i'));
assert_eq!(editor.context().text, "d");
type_text(&mut editor, &usize::MAX.to_string());
editor.handle(Input::Ctrl('o'));
assert_eq!(editor.context().text, "b");
editor.handle(Input::Ctrl('i'));
assert_eq!(editor.context().text, "d");
}
#[test]
fn jump_history_clamps_edited_locations_and_preserves_dirty_text_and_undo() {
let directory = Directory::new();
@@ -11843,6 +11995,187 @@ mod tests {
assert_eq!(editor.context().text, text);
}
#[test]
fn jump_history_distinguishes_tabs_and_panes_at_identical_buffer_coordinates() {
let mut editor = editor();
editor
.apply(vec![Effect::Replace("shared".into())])
.unwrap();
let source = editor.jump_location();
editor.execute("tab.new");
let tab = editor.tab_id();
let tab_pane = editor.pane_id();
editor.record_jump(source);
editor.execute("pane.split_vertical");
let pane = editor.pane_id();
editor.record_jump(JumpLocation {
tab_id: tab,
pane_id: tab_pane,
..editor.jump_location()
});
editor.handle(Input::Ctrl('o'));
assert_eq!((editor.tab_id(), editor.pane_id()), (tab, tab_pane));
editor.handle(Input::Ctrl('o'));
assert_eq!(
(editor.tab_id(), editor.pane_id()),
(source.tab_id, source.pane_id)
);
type_text(&mut editor, "2");
editor.handle(Input::Ctrl('i'));
assert_eq!((editor.tab_id(), editor.pane_id()), (tab, pane));
assert_eq!(editor.context().text, "shared");
assert!(editor.dirty());
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "");
}
#[test]
fn jump_history_closed_tabs_and_panes_use_surviving_ordinary_buffer_views() {
for close in ["tab.close", "pane.close"] {
let directory = Directory::new();
let path = directory.path("target.txt");
fs::write(&path, "target").unwrap();
let mut editor = editor();
editor.execute(if close == "tab.close" {
"tab.new"
} else {
"pane.split_vertical"
});
let closed = (editor.tab_id(), editor.pane_id());
editor
.navigate_to(path.clone(), TextPosition { row: 0, column: 2 })
.unwrap();
editor.handle(Input::Ctrl('o'));
editor.execute(close);
fs::remove_file(&path).unwrap();
editor.handle(Input::Ctrl('i'));
assert_ne!((editor.tab_id(), editor.pane_id()), closed);
assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 });
assert_eq!(editor.context().text, "target");
assert_eq!(editor.tab_count(), 1);
assert_eq!(editor.pane_layout().len(), 1);
assert_eq!(editor.buffer_count(), 2);
}
}
#[test]
fn jump_history_closed_comparison_falls_back_without_reopening_or_losing_readonly_identity() {
let directory = Directory::new();
let mut editor = editor();
historical_diff(
&mut editor,
&directory.0,
vec!["before".into()],
vec!["after".into()],
);
let snapshot = editor.buffer_id();
let document = editor.active.snapshot_document_path.clone();
let source = editor.jump_location();
editor.execute("tab.new");
editor.record_jump(source);
editor.execute("tab.previous");
editor.execute("diff.close");
editor.execute("tab.next");
editor.handle(Input::Ctrl('o'));
assert_eq!(editor.tab_id(), source.tab_id);
assert_eq!(editor.buffer_id(), snapshot);
assert_eq!(editor.active.snapshot_document_path, document);
assert!(editor.diff().is_none());
type_text(&mut editor, "ix");
assert_eq!(editor.context().text, "before");
assert!(!editor.dirty());
editor.handle(Input::Ctrl('i'));
assert_eq!(editor.tab_count(), 2);
assert_ne!(editor.tab_id(), source.tab_id);
}
#[test]
fn jump_history_never_replaces_a_new_comparison_in_an_old_destination_view() {
let directory = Directory::new();
let target = directory.path("external.txt");
fs::write(&target, "external").unwrap();
let mut editor = editor();
fs::write(directory.path("file.txt"), "working source").unwrap();
working_diff(&mut editor, &directory.0, &["file.txt"]);
let source_buffer = editor.buffer_id();
editor
.navigate_lsp_to(target, lsp_point(0, 2), None)
.unwrap();
let external_tab = editor.tab_id();
let external_buffer = editor.buffer_id();
editor.execute("tab.previous");
editor.execute("diff.close");
historical_diff(
&mut editor,
&directory.0,
vec!["new before".into()],
vec!["new after".into()],
);
let comparison = editor.diff().unwrap().comparison_id;
let snapshot = editor.buffer_id();
let cursor = editor.cursor();
editor.execute("tab.next");
editor.handle(Input::Ctrl('o'));
assert_eq!(editor.tab_id(), external_tab);
assert_eq!(editor.buffer_id(), source_buffer);
assert_eq!(editor.context().text, "working source");
assert!(editor.diff().is_none());
editor.handle(Input::Ctrl('i'));
assert_eq!(editor.buffer_id(), external_buffer);
assert_eq!(editor.cursor(), Cursor { row: 0, column: 2 });
editor.execute("tab.previous");
assert_eq!(editor.diff().unwrap().comparison_id, comparison);
assert_eq!(editor.buffer_id(), snapshot);
assert_eq!(editor.cursor(), cursor);
assert_eq!(editor.context().text, "new before");
}
#[test]
fn jump_history_skips_missing_contexts_without_mutating_the_only_comparison() {
let directory = Directory::new();
let path = directory.path("old.txt");
fs::write(&path, "old").unwrap();
let mut editor = editor();
editor
.navigate_to(path, TextPosition { row: 0, column: 1 })
.unwrap();
let source_buffer = editor.buffer_id();
historical_diff(
&mut editor,
&directory.0,
vec!["before".into()],
vec!["after".into()],
);
let comparison = editor.diff().unwrap().comparison_id;
let snapshot = editor.buffer_id();
let cursor = editor.cursor();
type_text(&mut editor, &usize::MAX.to_string());
editor.handle(Input::Ctrl('o'));
assert_eq!(editor.buffer_id(), snapshot);
assert_eq!(editor.cursor(), cursor);
assert_eq!(editor.diff().unwrap().comparison_id, comparison);
assert!(editor.has_buffer(source_buffer));
assert_eq!(editor.tab_count(), 1);
}
#[test]
fn comparison_tab_identity_includes_selector_and_parked_pane_states() {
let mut editor = editor();
assert!(!editor.tab_is_diff(0));
assert!(!editor.tab_is_diff(1));
editor.execute("diff");
assert!(editor.tab_is_diff(0));
editor.execute("pane.split_vertical");
assert!(editor.diff().is_none());
assert!(editor.tab_is_diff(0));
editor.execute("tab.new");
assert!(editor.tab_is_diff(0));
assert!(!editor.tab_is_diff(1));
editor.execute("tab.previous");
editor.execute("diff.close");
assert!(!editor.tab_is_diff(0));
}
#[test]
fn jump_history_skips_entries_clamped_to_the_current_location() {
let directory = Directory::new();
+105 -7
View File
@@ -1057,6 +1057,7 @@ fn chrome_title(
index: usize,
path: Option<&std::path::Path>,
dirty: bool,
is_diff: bool,
) -> String {
let name = path
.map(|path| {
@@ -1075,6 +1076,7 @@ fn chrome_title(
};
match field {
"index" => write!(title, "{index}").unwrap(),
"view" if is_diff => title.push_str("Diff · "),
"name" => title.push_str(&name),
"path" => title.push_str(&path),
"modified" if dirty => title.push_str(" [+]"),
@@ -1109,6 +1111,7 @@ fn chrome_items<'a>(
index + 1,
buffer.as_ref().and_then(|buffer| buffer.path),
buffer.is_some_and(|buffer| buffer.dirty),
editor.tab_is_diff(index),
)),
config,
tab: Some(index),
@@ -1133,6 +1136,7 @@ fn chrome_items<'a>(
editor.tab_index() + 1,
editor.path(),
editor.dirty(),
editor.tab_is_diff(editor.tab_index()),
)),
"position" => Cow::Owned(format!(
"{}:{}",
@@ -1207,6 +1211,7 @@ fn chrome_items<'a>(
editor.tab_index() + 1,
editor.path(),
editor.dirty(),
editor.tab_is_diff(editor.tab_index()),
));
} else if text.chars().any(char::is_control) {
text = Cow::Owned(display_text(&text));
@@ -5574,7 +5579,7 @@ mod tests {
let path = directory.file("λ\t{index}.rs", "source");
let editor = Editor::open(Some(path.clone()), vec![]).unwrap();
let groups = chrome_groups(&editor, editor.bottom_bar_config(), &SyntaxCache::new());
let title = chrome_title("{path}", 2, Some(&path), false);
let title = chrome_title("{path}", 2, Some(&path), false, false);
assert_eq!(title, display_text(&display_path(&path)));
assert!(groups.iter().flatten().any(|item| item.text == title));
assert_eq!(editor.path(), Some(path.as_path()));
@@ -6129,6 +6134,105 @@ mod tests {
}
}
#[test]
fn comparison_titles_distinguish_same_named_tabs_and_follow_current_context() {
let directory = Directory::new();
let path = directory.file("source.rs", "fn main() {}");
for phase in ["loading", "before", "after", "view"] {
let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap();
editor.execute("tab.new");
editor.execute("diff");
if phase != "loading" {
let (id, _) = editor.take_diff_request().unwrap();
editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog())));
}
if matches!(phase, "after" | "view") {
editor.handle(Input::Down);
editor.handle(Input::Enter);
}
if phase == "view" {
editor.handle(Input::Down);
editor.handle(Input::Enter);
let (id, _) = editor.take_diff_request().unwrap();
editor.apply_diff_result(
id,
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
root: directory.0.clone(),
editable_paths: Default::default(),
before: "baseline".into(),
after: "historic".into(),
files: vec![syntax_diff_file("source.rs", "fn old() {}", "fn main() {}")],
})),
);
}
let default_tabs = editor.top_bar_config().segment("tabs").clone();
for (template, ordinary, comparison, marker_count) in [
(None, "1 source.rs", "2 Diff · source.rs", 1),
(
Some("{index} {name}{modified}"),
"1 source.rs",
"2 source.rs",
0,
),
(
Some("T{index}:{view}{name}{modified}"),
"T1:source.rs",
"T2:Diff · source.rs",
1,
),
] {
configure_chrome(&mut editor, |top, bottom| {
top.segments.insert("tabs".into(), default_tabs.clone());
if let Some(template) = template {
top.segments.get_mut("tabs").unwrap().title = template.into();
}
bottom.left = vec!["mode".into()];
bottom.center.clear();
bottom.right.clear();
bottom.segments.get_mut("mode").unwrap().title = "Current:{view}{name}".into();
});
for comparison_active in [true, false] {
let mut terminal =
Terminal::new(ratatui::backend::TestBackend::new(160, 2)).unwrap();
terminal
.draw(|frame| {
for (line, y) in [
(editor.top_bar_config(), 0),
(editor.bottom_bar_config(), 1),
] {
draw_chrome(
frame,
chrome_groups(&editor, line, &SyntaxCache::new()),
Rect::new(0, y, 160, 1),
);
}
})
.unwrap();
let titles = pane_screen(&terminal, Rect::new(0, 0, 160, 1));
assert!(titles.contains(ordinary), "{phase}: {titles}");
assert!(titles.contains(comparison), "{phase}: {titles}");
assert_eq!(
titles.matches("Diff · ").count(),
marker_count,
"{phase}: {titles}"
);
let current = pane_screen(&terminal, Rect::new(0, 1, 160, 1));
let expected = if comparison_active {
"Current:Diff · source.rs"
} else {
"Current:source.rs"
};
assert!(current.contains(expected), "{phase}: {current}");
editor.execute(if comparison_active {
"tab.previous"
} else {
"tab.next"
});
}
}
}
}
#[test]
fn tab_titles_update_templates_without_reinterpreting_file_names() {
let directory = Directory::new();
@@ -6158,12 +6262,6 @@ mod tests {
let titles = pane_screen(&terminal, Rect::new(0, 0, 160, 1));
assert!(titles.contains("T1:λ{index}�.rs [+]"), "{titles}");
assert!(titles.contains("T2:λ{index}�.rs [+]"), "{titles}");
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(0, 0)].bg, Color::Reset);
let active = (0..159)
.find(|&x| buffer[(x, 0)].symbol() == "T" && buffer[(x + 1, 0)].symbol() == "2")
.unwrap();
assert_eq!(buffer[(active, 0)].bg, Color::Cyan);
configure_chrome(&mut editor, |top, _| {
top.segments.get_mut("tabs").unwrap().title = "{index}:{path}{modified}".into();