From 054225686e4d026905f7bb4b48fb2c9bbd57f7af Mon Sep 17 00:00:00 2001 From: Valdemar Lorenzen Date: Mon, 12 Oct 2026 14:45:49 +0200 Subject: [PATCH] fix: restore tab and diff context through jump history --- docs/architecture.md | 2 +- docs/configuration.md | 4 +- docs/editing.md | 6 +- examples/config.rb | 2 +- src/chrome.rs | 41 ++++- src/editor.rs | 373 +++++++++++++++++++++++++++++++++++++++--- src/tui.rs | 112 ++++++++++++- tests/lsp.rs | 178 +++++++++++++++++++- 8 files changed, 679 insertions(+), 39 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index cb4440d..d4f5bb2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -37,7 +37,7 @@ Frontends inspect `count()` for the optional pending numeric prefix; parsing, co Frontends inspect `sequence_pending()` for the cached display of a pending key sequence; prefix recognition, cancellation, and dispatch remain editor-owned. Symbolic leader routes and explicit key prefixes share the same binding storage and dispatcher; explicit prefixes do not change when the leader is reconfigured. Frontends inspect `g_pending()` for the built-in Normal/Visual `gt`/`gb` prefix and Normal-only `gf`. Document-edge motions share multi-target motion dispatch and viewport reveal, retaining Visual anchors and selection shape. 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. Frontends inspect `line_operator_pending()` for the optional Normal `d`/`c` prefix. Dedicated `dd`/`cc` actions project counted caret rows through existing linewise selection normalization, clipboard publication, and atomic range editing, without exposing a temporary Visual mode. Clipboard failures restore the original targets before returning. Repeat recipes retain the requested row count rather than the originally clamped range; change recipes share the existing Insert undo group. There is no general operator–motion parser. -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. +Editor-owned jump history is a bounded 100-location `VecDeque` of stable tab/pane identities, buffer IDs, grapheme cursors, and optional comparison identity/file/focus/binding metadata, with no copied paths/text. Successful high-level navigation records departure/destination once; traversal resolves a valid destination view before switching buffers, restores comparison binding and exact cursor visibility, and truncates forward entries only on a distinct new jump. Closed or replaced views fall back only to safe existing ordinary panes; history cannot resurrect a closed comparison or replace an unrelated one. Unavailable destinations are skipped without corrupting branch position. 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 `tab_id()` for stable identity, `tab_index()` for display order, `tab_count()`, and `tab_buffer(index)` for borrowed title metadata. `pane_layout()` describes only the active tab; globally unique pane IDs keep inactive tabs' viewports alive. Documents remain unique across all tabs, while independent pane/buffer views are parked and restored without copying text. Frontends borrow `top_bar_config()` and `bottom_bar_config()` for validated ordered left/center/right segment groups and effective segment styles, and `label(name)` for session-wide custom values. The TUI owns terminal-cell widths, full-row center anchoring, clipping, and accent styling. Label effects carry only a name and plain text; they are global presentation updates, not buffer/revision-bound results or render callbacks. diff --git a/docs/configuration.md b/docs/configuration.md index 4c893f5..e6adeb8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -63,7 +63,7 @@ Vamp.configure do |config| config.bar.bottom = { left: [:tabs], center: [], right: [:battery], tabs: { - title: "{index} {name}{modified}", padding: 1, + title: "{index} {view}{name}{modified}", padding: 1, leading_edge: "░▒▓", trailing_edge: "▓▒░", first_leading_edge: "" }, battery: { padding: 1, leading_edge: "░▒▓", trailing_edge: "" } @@ -75,7 +75,7 @@ Builtins are `:tabs`, `:battery`, `:mode`, `:path`, `:position`, `:language`, `: A bar with no visible content consumes no row. For example, `config.bar.top = { left: [], center: [], right: [] }` removes the top bar. A bar whose only visible content is `:tabs` is hidden with a single tab, whether above or below the workspace. Narrow layouts first omit inactive tabs, then optional segments from group ends to avoid overlap; the active tab drops decorations before its title is clipped. Commands and buffer searches use floating input; operation feedback uses a bottom-right popup. Neither reserves a footer row. -Each segment's `title` is a literal template supporting only `{index}`, `{name}`, `{path}`, and `{modified}`: the one-based tab index, filename, file path, and dirty marker (`" [+]"` when dirty, `""` when clean). Tabs use each tab's buffer metadata; other segments use the active buffer. Tabs default to `"{index} {name}{modified}"`; an empty title on another segment uses its builtin content or named label value. Templates are not Ruby code and never invoke a render callback. +Each segment's `title` is a literal template supporting only `{index}`, `{view}`, `{name}`, `{path}`, and `{modified}`: the one-based tab index, comparison-view marker, filename, file path, and dirty marker (`" [+]"` when dirty, `""` when clean). `{view}` expands to `"Diff · "` when the tab owns a comparison or revision selector, otherwise `""`. Tabs use each tab's view and buffer metadata; other segments use the active context. Tabs default to `"{index} {view}{name}{modified}"`; custom templates without `{view}` retain their output. An empty title on another segment uses its builtin content or named label value. Templates are not Ruby code and never invoke a render callback. `:path` and `{path}` abbreviate the actual home-directory prefix to `~/` (`~` for the home directory itself). This is display-only: save paths, buffer identity, and LSP file URIs retain the original path. Sibling directories with similar names are not abbreviated. diff --git a/docs/editing.md b/docs/editing.md index 7b011be..d03e285 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -359,11 +359,11 @@ Space begins a leader sequence in Normal/Visual by default. Explicit sequences s ## 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 inserts text outside snippets. Counts work in either direction, such as `3` followed by Ctrl+o, and stop at the available 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 outside comparisons while Insert Tab inserts text outside snippets. In a comparison, Ctrl+i/Tab retains its diff-side focus behavior; use the **Jump forward** palette action or another configured key such as `config.bind "ctrl+y", config.core.jump_forward`. 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. +The session keeps up to 100 primary-caret locations with their tab, pane, and comparison context, not just buffer coordinates. Successful Markdown links, accepted LSP definitions, file-picker opens, `:edit`, `:buffer`, `:bnext`/`:bprev`, and search `/`/`n`/`N` jumps record locations. Back/forward restores the original view, including its comparison file and side; an external LSP definition round-trip does not replace the comparison with an ordinary buffer. Identical buffer/cursor positions in different views remain distinct. Ordinary cursor/word motion, edits, failed navigation, manual tab/pane selection, 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. +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. An unavailable or replaced comparison is never resurrected or overwritten: history uses a safe existing ordinary pane for an available buffer, retaining snapshot read-only identity, or skips the destination if none exists. It never creates a replacement tab. Stored row/grapheme coordinates clamp to current text after edits; text, dirty state, undo/redo, and file contents remain unchanged. 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. diff --git a/examples/config.rb b/examples/config.rb index 66a8cc3..b580e3c 100644 --- a/examples/config.rb +++ b/examples/config.rb @@ -19,7 +19,7 @@ Vamp.configure do |config| config.bar.bottom = { left: [:tabs], center: [], right: [:battery], tabs: { - title: "{index} {name}{modified}", padding: 1, + title: "{index} {view}{name}{modified}", padding: 1, leading_edge: "░▒▓", trailing_edge: "▓▒░", first_leading_edge: "" }, battery: { padding: 1, leading_edge: "░▒▓", trailing_edge: "" } diff --git a/src/chrome.rs b/src/chrome.rs index 988f766..cfbb446 100644 --- a/src/chrome.rs +++ b/src/chrome.rs @@ -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 [ diff --git a/src/editor.rs b/src/editor.rs index 954847f..c33c831 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -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, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct JumpLocation { + tab_id: usize, + pane_id: usize, buffer_id: usize, cursor: Cursor, + diff: Option, } struct PendingLspNavigation { @@ -1367,6 +1379,7 @@ pub struct Editor { diff_owner: Option, parked_diff: Option, diff_pane_bounds: [(Focus, PaneRect); 3], + // Closed destinations stay until bounded eviction so they cannot shift jump_index. jumps: VecDeque, jump_index: usize, clipboard: Vec, @@ -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 { + 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(); diff --git a/src/tui.rs b/src/tui.rs index 22dba0d..e8fbb61 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -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(); diff --git a/tests/lsp.rs b/tests/lsp.rs index 29ccfa5..da337ca 100644 --- a/tests/lsp.rs +++ b/tests/lsp.rs @@ -682,6 +682,15 @@ fn shown_diff_lines(session: &EditorSession, focus: Focus) -> Vec { .collect() } +fn palette_action(session: &mut EditorSession, action: &str) { + session.handle(Input::Ctrl('p')); + for ch in action.chars() { + session.handle(Input::Char(ch)); + } + session.handle(Input::Enter); + assert!(session.editor().palette().is_none()); +} + impl Drop for Fixture { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.root); @@ -3630,6 +3639,10 @@ fn lsp_diff_definition_routes_exact_changed_lines_in_the_same_comparison() { open_comparison(&mut session, &fixture, &before, after.as_deref(), focus); let tab = session.editor().tab_id(); let comparison = session.editor().diff().unwrap().comparison_id; + let source_buffer = session.editor().buffer_id(); + let source_cursor = session.editor().cursor(); + let source_file = session.editor().diff().unwrap().file; + let source_excerpts = shown_diff_lines(&session, focus); let files = &session .editor() .diff() @@ -3691,6 +3704,125 @@ fn lsp_diff_definition_routes_exact_changed_lines_in_the_same_comparison() { ); assert_eq!(session.editor().diff().unwrap().comparison_id, comparison); assert_eq!(session.editor().tab_count(), 1); + session.handle(Input::Ctrl('o')); + assert_eq!(session.editor().cursor(), Cursor { row, column: 3 }); + assert_eq!(session.editor().diff().unwrap().file, target_index); + session.handle(Input::Ctrl('o')); + assert_eq!(session.editor().buffer_id(), source_buffer); + assert_eq!(session.editor().cursor(), source_cursor); + assert_eq!(session.editor().diff().unwrap().file, source_file); + assert_eq!(session.editor().diff().unwrap().focus, focus); + assert_eq!(shown_diff_lines(&session, focus), source_excerpts); + palette_action(&mut session, "core.jump_forward"); + assert_eq!(session.editor().cursor(), Cursor { row, column: 3 }); + assert_eq!(session.editor().diff().unwrap().file, target_index); + palette_action(&mut session, "core.jump_forward"); + assert_eq!( + session.editor().cursor(), + Cursor { + row: same_row, + column: 2 + } + ); + assert_eq!(session.editor().diff().unwrap().comparison_id, comparison); + assert_eq!(session.editor().tab_id(), tab); + assert_eq!(session.editor().tab_count(), 1); + } +} + +#[test] +fn lsp_diff_external_definition_jump_history_restores_the_exact_comparison_view() { + for (historical, focus) in [ + (false, Focus::Before), + (false, Focus::After), + (true, Focus::Before), + (true, Focus::After), + ] { + let fixture = Fixture::new(); + let external = Fixture::new(); + let (before, after) = diff_repository(&fixture, historical); + let mut session = fixture.open("controlled-diff"); + session.execute(&format!("edit {}", external.source.display())); + session.handle(Input::Char('i')); + session.handle(Input::Paste("dirty ".into())); + session.handle(Input::Escape); + let external_buffer = session.editor().buffer_id(); + let external_text = session.editor().context().text; + session.execute("buffer 1"); + let sibling = session.editor().pane_id(); + let sibling_buffer = session.editor().buffer_id(); + session.execute("pane.split_vertical"); + open_comparison(&mut session, &fixture, &before, after.as_deref(), focus); + if !historical && focus == Focus::After { + session.handle(Input::Char('A')); + session.handle(Input::Char('!')); + session.handle(Input::Escape); + } + let source_tab = session.editor().tab_id(); + let source_pane = session.editor().pane_id(); + let source_buffer = session.editor().buffer_id(); + let source_cursor = session.editor().cursor(); + let source_text = session.editor().context().text; + let source_dirty = session.editor().dirty(); + let layout = format!("{:?}", session.editor().pane_layout()); + let diff = session.editor().diff().unwrap(); + let comparison = diff.comparison_id; + let file = diff.file; + let excerpts = shown_diff_lines(&session, focus); + request_position(&mut session, &fixture, "textDocument/definition"); + position_reply( + &mut session, + &fixture, + 1, + json!({ + "action": "definition-result", "path": external.source, "row": 0, "column": 11 + }), + ); + let external_tab = session.editor().tab_id(); + let external_pane = session.editor().pane_id(); + assert_ne!(external_tab, source_tab); + assert_eq!(session.editor().buffer_id(), external_buffer); + for _ in 0..2 { + session.handle(Input::Ctrl('o')); + assert_eq!(session.editor().tab_id(), source_tab); + assert_eq!(session.editor().pane_id(), source_pane); + assert_eq!(session.editor().buffer_id(), source_buffer); + assert_eq!(session.editor().cursor(), source_cursor); + assert_eq!(session.editor().context().text, source_text); + assert_eq!(session.editor().dirty(), source_dirty); + assert_eq!(format!("{:?}", session.editor().pane_layout()), layout); + assert_eq!(session.editor().diff().unwrap().comparison_id, comparison); + assert_eq!(session.editor().diff().unwrap().file, file); + assert_eq!(session.editor().diff().unwrap().focus, focus); + assert_eq!(shown_diff_lines(&session, focus), excerpts); + assert_eq!( + session.editor().pane_buffer_id(sibling), + Some(sibling_buffer) + ); + palette_action(&mut session, "core.jump_forward"); + assert_eq!(session.editor().tab_id(), external_tab); + assert_eq!(session.editor().pane_id(), external_pane); + assert_eq!(session.editor().buffer_id(), external_buffer); + assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 9 }); + assert_eq!(session.editor().context().text, external_text); + assert!(session.editor().dirty()); + assert!(session.editor().diff().is_none()); + assert_eq!(session.editor().tab_count(), 2); + assert!(session.editor().tab_is_diff(0)); + assert!(!session.editor().tab_is_diff(1)); + } + session.handle(Input::Char('u')); + assert_eq!(session.editor().context().text, "A😀e\u{301}Z\n"); + session.execute("tab.previous"); + assert_eq!(session.editor().diff().unwrap().comparison_id, comparison); + assert_eq!(session.editor().cursor(), source_cursor); + if source_dirty { + session.handle(Input::Char('u')); + assert_eq!( + session.editor().context().text, + comparison_text("source", "after") + ); + } } } @@ -3711,6 +3843,18 @@ fn lsp_diff_unchanged_context_and_omitted_symbols_open_shared_ordinary_tabs() { ] { session.handle(input); } + if row == 18 { + for input in [ + Input::Char('g'), + Input::Char('t'), + Input::Right, + Input::Right, + Input::Right, + ] { + session.handle(input); + } + assert_eq!(session.editor().cursor(), Cursor { row, column: 3 }); + } let text = session.editor().context().text; let source = session.editor().buffer_id(); let cursor = session.editor().cursor(); @@ -3729,6 +3873,7 @@ fn lsp_diff_unchanged_context_and_omitted_symbols_open_shared_ordinary_tabs() { "action": "definition-result", "path": fixture.source, "row": row, "column": 5 }), ); + let ordinary_tab = session.editor().tab_id(); assert_eq!(session.editor().tab_count(), 2); assert_ne!(session.editor().tab_id(), tab); assert!(session.editor().diff().is_none()); @@ -3736,13 +3881,22 @@ fn lsp_diff_unchanged_context_and_omitted_symbols_open_shared_ordinary_tabs() { assert_eq!(session.editor().cursor(), Cursor { row, column: 3 }); assert_eq!(session.editor().context().text, text); assert!(session.editor().dirty()); - session.execute("tab.previous"); + session.handle(Input::Ctrl('o')); assert_eq!(session.editor().tab_id(), tab); assert_eq!(session.editor().pane_id(), pane); assert_eq!(format!("{:?}", session.editor().pane_layout()), layout); assert_eq!(session.editor().cursor(), cursor); assert_eq!(session.editor().diff().unwrap().comparison_id, comparison); assert_eq!(shown_diff_lines(&session, Focus::After), excerpts); + palette_action(&mut session, "core.jump_forward"); + assert_eq!(session.editor().tab_id(), ordinary_tab); + assert_eq!(session.editor().buffer_id(), source); + assert_eq!(session.editor().cursor(), Cursor { row, column: 3 }); + assert!(session.editor().diff().is_none()); + assert_eq!(session.editor().tab_count(), 2); + session.handle(Input::Ctrl('o')); + assert_eq!(session.editor().tab_id(), tab); + assert_eq!(session.editor().cursor(), cursor); session.handle(Input::Char('u')); assert_eq!( session.editor().context().text, @@ -3817,6 +3971,7 @@ fn lsp_diff_unchanged_snapshot_uri_opens_an_immutable_tab_that_outlives_the_comp let snapshot = session.editor().buffer_id(); let comparison = session.editor().diff().unwrap().comparison_id; let cursor = session.editor().cursor(); + let source_tab = session.editor().tab_id(); request_position(&mut session, &fixture, "textDocument/definition"); position_reply( &mut session, @@ -3826,6 +3981,7 @@ fn lsp_diff_unchanged_snapshot_uri_opens_an_immutable_tab_that_outlives_the_comp "action": "definition-result", "uri": document["uri"], "row": 39, "column": 5 }), ); + let ordinary_tab = session.editor().tab_id(); assert_eq!(session.editor().tab_count(), 2); assert!(session.editor().diff().is_none()); assert_eq!(session.editor().buffer_id(), snapshot); @@ -3834,11 +3990,29 @@ fn lsp_diff_unchanged_snapshot_uri_opens_an_immutable_tab_that_outlives_the_comp session.editor().context().text, comparison_text("source", version) ); - session.execute("tab.previous"); + session.handle(Input::Ctrl('o')); + assert_eq!(session.editor().tab_id(), source_tab); assert_eq!(session.editor().cursor(), cursor); assert_eq!(session.editor().diff().unwrap().comparison_id, comparison); + palette_action(&mut session, "core.jump_forward"); + assert_eq!(session.editor().tab_id(), ordinary_tab); + assert_eq!(session.editor().cursor(), Cursor { row: 39, column: 3 }); + assert!(session.editor().diff().is_none()); + // Close the source without traversing away from the timeline's destination. + session.execute("tab.previous"); session.execute("diff.close"); session.execute("tab.next"); + session.handle(Input::Ctrl('o')); + assert_eq!(session.editor().tab_id(), source_tab); + assert_eq!(session.editor().buffer_id(), snapshot); + assert_eq!(session.editor().cursor(), cursor); + assert!(session.editor().diff().is_none()); + palette_action(&mut session, "core.jump_forward"); + assert_eq!(session.editor().tab_id(), ordinary_tab); + assert_eq!(session.editor().cursor(), Cursor { row: 39, column: 3 }); + assert_eq!(session.editor().tab_count(), 2); + assert!(!session.editor().tab_is_diff(0)); + assert!(!session.editor().tab_is_diff(1)); for input in [ Input::Char('i'), Input::Paste("bad".into()),