fix: hide diff scope labels for visible declarations

The visible declaration already provides scope context. Cache the actual
identifier point separately from the declaration extent/decorators and hide
labels when it is visible in that side of the projected viewport.

Refill reclaimed rows and anchor carets against the resulting layout.
Retain sticky context when the symbol scrolls or wraps out of view.

Verified: 951 tests passed, cargo build, and real PTY smoke for declaration
visibility/scroll restoration, wrapping, horizontal clipping, unified view,
and short horizontal-pane caret reflow.
This commit is contained in:
Valdemar Lorenzen
2026-10-15 14:47:51 +02:00
parent 39fd3a669b
commit a93afc7d16
5 changed files with 228 additions and 99 deletions
+1 -1
View File
@@ -8,8 +8,8 @@
- [x] diff view: show the function/class context that a change hunk belongs to.
- inline scope labels with sticky continuation, chosen before implementation
- each side uses its own syntax tree; scope changes within a hunk get separate labels
- [ ] diff view: automatically detect changes and update the view when relevant bookmarks move change ids (regards bookmark-based diff view; i.e., user selected at least one bookmark in the before/after picker)
- [ ] diff view: annotate folder with checkmark if all items under given folder has been checked AND the folder is collapsed.
- [ ] diff view: automatically detect changes and update the view when relevant bookmarks move change ids (regards bookmark-based diff view; i.e., user selected at least one bookmark in the before/after picker)
- [ ] diff view: github integration (code review) - add comments on a pr, add review comments at specific lines of files, request changes, approve pr, etc.
- [ ] diff view: local review comments (same features as for github reviews) that allows review of local changes to deliver specific reviews to ai agents used for coding.
- design question: would this require a server that the agent can interact with, or would local review files be fine? don't want to use server client stuff, if it would be easier and better to just use local files.
+1
View File
@@ -105,6 +105,7 @@ Each buffer has a frontend-owned parser/tree cache. Unchanged revision/path skip
Diff highlighting lazily owns two instances of the same syntax cache, keyed by the accepted comparison request ID and selected file. Before detects language from the old path when renamed; After uses the new path. Scrolling, focus changes, and explorer toggling reuse parsed spans without copying/reparsing snapshots. A fresh comparison identity prevents stale Working-checkout colors even when labels and paths are unchanged. Closing the view releases snapshot caches. Syntax styles patch foreground/modifiers over diff backgrounds; historical panes never receive live-buffer semantic tokens.
Scope context is a lazily built row index over the existing primary/injected syntax trees, invalidated by source or grammar changes and warmed by diff updates. Ordinary buffer highlighting does not build it. The frontend's visual-row projection inserts inline/sticky scope labels in wrapped and unwrapped diffs; source alignment and excerpt ranges contain no label rows. Coloring, native selection/caret overlays, and source navigation skip these presentation rows. Before/After labels use their independent trees rather than live-workspace symbol analysis.
Cached scope metadata records the declaration identifier's source point separately from its extent, including decorated definitions. The projection checks that point against each pane's actual source rows and column/wrap ranges, suppressing redundant labels and refilling reclaimed rows. Caret anchoring uses the resulting visual rows rather than a fixed label-height estimate.
## Clipboard providers
+2 -2
View File
@@ -438,8 +438,8 @@ Review marks belong only to the open comparison and survive filtering, file navi
Text panes show change excerpts with **10 unchanged lines above and below** each change, preserving original file line numbers. Overlapping or touching excerpts merge; omitted stretches show an unchanged-line count in both panes. Files with only metadata changes show their notice or “No text changes” rather than unrelated source.
**Scope labels** identify named enclosing functions/classes and containers, such as `impl Review › run`. Muted, unnumbered rows appear at excerpt starts and scope changes; the current scope stays at the top when its declaration scrolls out of view. Each split side uses its own source, so renamed functions keep distinct old/new names. Unified labels use `−` for Before, `+` for After, and `±` for shared names. Narrow panes prefer the innermost name. Labels are presentation only: motions, selections, copying, editing, and line jumps retain original source coordinates. Context comes from the existing Tree-sitter trees, including supported injected code, without requiring an LSP; unsupported syntax gets no guessed label.
Within an excerpt, additional hunks and alignment gaps do not repeat an unchanged scope label. A new excerpt or an actual scope change gets a fresh label; sticky context is retained independently for each side.
**Scope labels** identify named enclosing functions/classes and containers, such as `impl Review › run`. Muted, unnumbered rows supply context only when the declaration's symbol is outside that pane's viewport. Scrolling the symbol back into view removes the label and reclaims its row; decorators alone do not count as the declaration name. Visibility follows source columns and wrapped segments independently for each side. Each split side uses its own source, so renamed functions keep distinct old/new names. Unified labels use `−` for Before, `+` for After, and `±` for shared names. Narrow panes prefer the innermost name. Labels are presentation only: motions, selections, copying, editing, and line jumps retain original source coordinates. Context comes from the existing Tree-sitter trees, including supported injected code, without requiring an LSP; unsupported syntax gets no guessed label.
Within an excerpt, additional hunks and alignment gaps do not repeat an unchanged scope label. A new excerpt or an actual scope change gets a fresh label when needed; sticky context is retained independently for each side.
In **Normal mode** with a text pane focused, **`[` expands above** and **`]` expands below** the excerpt containing the caret, revealing up to **15 lines** per keypress in either pane. In editable After's Insert mode these keys insert literal text. Expansion clamps at file boundaries and merges excerpts when they meet. Existing visible source stays anchored. Opening a file resets its excerpts to the configured initial context. Live edits recompute changes while retaining expanded context; the caret stays visible even when the last text change is reverted.
+24 -9
View File
@@ -46,12 +46,14 @@ pub struct SyntaxSpan {
pub(crate) struct ScopeContext<'a> {
pub start_row: usize,
pub end_row: usize,
pub name: Point,
pub label: &'a str,
}
struct CachedScope {
start_row: usize,
end_row: usize,
name: Point,
label: String,
}
@@ -334,6 +336,7 @@ impl SyntaxCache {
Some(ScopeContext {
start_row: scope.start_row,
end_row: scope.end_row,
name: scope.name,
label: &scope.label,
})
}
@@ -581,8 +584,8 @@ fn collect_scopes(
{
ancestors.pop();
}
if let Some((extent, name)) =
scope_declaration(node, language, source).filter(|(extent, _)| {
if let Some((extent, name, label)) =
scope_declaration(node, language, source).filter(|(extent, _, _)| {
ranges.is_empty()
|| ranges.iter().any(|range| {
range.start_byte <= extent.start_byte()
@@ -594,9 +597,9 @@ fn collect_scopes(
.last()
.map_or("", |(index, _)| scopes[*index].label.as_str());
let label = if parent.is_empty() {
name
label
} else {
format!("{parent} › {name}")
format!("{parent} › {label}")
};
let start_row = extent.start_position().row;
let end = extent.end_position();
@@ -605,6 +608,7 @@ fn collect_scopes(
scopes.push(CachedScope {
start_row,
end_row,
name,
label,
});
let end_row = end_row.min(rows.len());
@@ -633,7 +637,7 @@ fn scope_declaration<'a>(
node: Node<'a>,
language: &str,
source: &str,
) -> Option<(Node<'a>, String)> {
) -> Option<(Node<'a>, Point, String)> {
if !node.is_named() || node.is_missing() {
return None;
}
@@ -646,7 +650,7 @@ fn scope_declaration<'a>(
Some(trait_name) => scope_label(&["impl", trait_name, "for", target]),
None => scope_label(&["impl", target]),
};
return Some((node, label));
return Some((node, field("type")?.start_position(), label));
}
(
"rust",
@@ -686,8 +690,12 @@ fn scope_declaration<'a>(
.named_child(0)?
.child_by_field_name("type")?;
let receiver = scope_text(receiver, source)?;
let name = scope_text(field("name")?, source)?;
return Some((node, scope_label(&[receiver, "›", name])));
let name = field("name")?;
return Some((
node,
name.start_position(),
scope_label(&[receiver, "›", scope_text(name, source)?]),
));
}
("go", "type_spec")
if field("type")
@@ -764,7 +772,11 @@ fn scope_declaration<'a>(
{
extent = parent;
}
Some((extent, scope_label(&[scope_text(name, source)?])))
Some((
extent,
name.start_position(),
scope_label(&[scope_text(name, source)?]),
))
}
fn scope_label(parts: &[&str]) -> String {
@@ -1567,6 +1579,7 @@ mod tests {
Some(ScopeContext {
start_row: 1,
end_row: 4,
name: Point::new(1, 7),
label: "impl State › old",
})
);
@@ -1578,6 +1591,7 @@ mod tests {
Some(ScopeContext {
start_row: 6,
end_row: 9,
name: Point::new(6, 3),
label: "next",
})
);
@@ -1605,6 +1619,7 @@ mod tests {
Some(ScopeContext {
start_row: 1,
end_row: 4,
name: Point::new(2, 8),
label: "State › run",
})
);
+175 -62
View File
@@ -30,6 +30,7 @@ use ratatui::{
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph, Wrap},
};
use tree_sitter::Point;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
@@ -3698,32 +3699,6 @@ fn draw_diff(
.then_some(height / 2)
});
let toggle_row = center_row.or(toggle_row);
if let Some(file) = selected.filter(|file| file.loaded)
&& let Some(syntax) = &panels.diff_syntax
{
let caret = editor
.filter(|editor| editor.diff_interacting() && diff.follow_caret)
.and_then(|editor| {
editor
.diff_buffer_focus()
.map(|focus| (focus, editor.cursor()))
});
let viewport = panels
.diff_wrap
.get_or_insert_with(DiffWrapViewport::default);
viewport.update(
diff,
file,
[before_area, after_area],
caret,
toggle_row,
editor.map_or(Some(0), Editor::scroll_distance),
syntax,
);
top = viewport.top.display;
} else {
panels.diff_wrap = None;
}
if let Some(editor) =
editor.filter(|editor| !wrapping && editor.diff_interacting() && diff.follow_caret)
&& let Some(focus) = editor.diff_buffer_focus()
@@ -3745,6 +3720,33 @@ fn draw_diff(
let column = display_column(&editor.lines()[editor.cursor().row], editor.cursor().column);
*left = scroll_offset(*left, column, width, editor.scroll_distance());
}
if let Some(file) = selected.filter(|file| file.loaded)
&& let Some(syntax) = &panels.diff_syntax
{
let caret = editor
.filter(|editor| editor.diff_interacting() && diff.follow_caret)
.and_then(|editor| {
editor
.diff_buffer_focus()
.map(|focus| (focus, editor.cursor()))
});
let viewport = panels
.diff_wrap
.get_or_insert_with(DiffWrapViewport::default);
viewport.lefts = [before_left, after_left];
viewport.update(
diff,
file,
[before_area, after_area],
caret,
toggle_row,
editor.map_or(Some(0), Editor::scroll_distance),
syntax,
);
top = viewport.top.display;
} else {
panels.diff_wrap = None;
}
if let Some(id) = diff.comparison_id
&& let Some(file) = selected.filter(|file| file.loaded)
{
@@ -4390,6 +4392,9 @@ struct DiffWrapViewport {
before: crate::wrap::WrapCache,
after: crate::wrap::WrapCache,
widths: [usize; 2],
lefts: [usize; 2],
heights: [usize; 2],
visible_names: [Vec<Point>; 2],
top: DiffWrapPosition,
rows: Vec<WrappedDiffRow>,
}
@@ -4532,6 +4537,37 @@ impl DiffWrapViewport {
position
}
fn declaration_visible(
&self,
diff: &DiffState,
file: &ChangedFile,
side: usize,
name: Point,
) -> bool {
let focus = if side == 0 {
Focus::Before
} else {
Focus::After
};
let source = if side == 0 { &file.before } else { &file.after };
let column = display_column(source.get(name.row).map_or("", String::as_str), name.column);
self.rows.iter().take(self.heights[side]).any(|row| {
if row.scope != [false; 2]
|| diff.display_source_row(focus, row.position.display) != Some(name.row)
{
return false;
}
let (left, right) = if row.unwrapped {
(self.lefts[side], self.lefts[side] + self.widths[side])
} else if let Some(segment) = row.segments[side] {
(segment.start, segment.end)
} else {
return false;
};
left <= column && column < right
})
}
fn fill(
&mut self,
diff: &DiffState,
@@ -4539,6 +4575,11 @@ impl DiffWrapViewport {
height: usize,
syntax: &DiffSyntaxCache,
) {
for names in &mut self.visible_names {
names.clear();
}
// ponytail: reflow only this viewport; index visual rows if tall terminals make it hot.
loop {
self.rows.clear();
if diff.display_rows.is_empty() {
return;
@@ -4546,7 +4587,13 @@ impl DiffWrapViewport {
let mut position = self.top;
let mut sticky = [true; 2];
while self.rows.len() < height {
for scope in syntax.labels(diff, position, sticky) {
let contexts = syntax.scopes(diff, position.display);
for mut scope in syntax.labels(diff, position, sticky) {
for side in 0..2 {
scope[side] &= !contexts[side].is_some_and(|context| {
self.visible_names[side].contains(&context.name)
});
}
if scope.iter().any(|side| *side) && self.rows.len() + 1 < height {
self.rows.push(WrappedDiffRow {
position,
@@ -4570,6 +4617,28 @@ impl DiffWrapViewport {
}
position = next;
}
let mut changed = false;
for index in 0..self.rows.len() {
let row = self.rows[index];
if row.scope == [false; 2] {
continue;
}
let contexts = syntax.scopes(diff, row.position.display);
for side in 0..2 {
if let Some(context) = contexts[side]
&& row.scope[side]
&& !self.visible_names[side].contains(&context.name)
&& self.declaration_visible(diff, file, side, context.name)
{
self.visible_names[side].push(context.name);
changed = true;
}
}
}
if !changed {
break;
}
}
}
fn caret_position(
@@ -4631,6 +4700,7 @@ impl DiffWrapViewport {
diff_pane_text_width(diff, file, areas[0], Focus::Before),
diff_pane_text_width(diff, file, areas[1], Focus::After),
];
self.heights = areas.map(|area| usize::from(diff_inner(area).height));
self.before.prepare((diff.file, id), self.widths[0]);
self.after
.prepare((diff.file, self.revision), self.widths[1]);
@@ -4673,27 +4743,30 @@ impl DiffWrapViewport {
if let Some(row) = anchor {
self.top = position;
let target = row.min(height.saturating_sub(1));
let label_count = |position, sticky| {
syntax
.labels(diff, position, [sticky; 2])
self.fill(diff, file, paint_height, syntax);
while self
.rows
.iter()
.filter(|scope| scope.iter().any(|side| *side))
.count()
.min(height.saturating_sub(1))
};
let mut screen = label_count(self.top, true);
while screen < target {
.position(|row| row.scope == [false; 2] && row.position == position)
.is_some_and(|screen| screen < target)
{
let previous = self.step(diff, file, self.top, false);
let next_screen =
screen + 1 + label_count(self.top, false) + label_count(previous, true)
- label_count(self.top, true);
if previous == self.top || next_screen > target {
if previous == self.top {
break;
}
let top = self.top;
self.top = previous;
screen = next_screen;
}
self.fill(diff, file, paint_height, syntax);
let screen = self
.rows
.iter()
.position(|row| row.scope == [false; 2] && row.position == position);
if screen.is_none_or(|screen| screen > target) {
self.top = top;
self.fill(diff, file, paint_height, syntax);
break;
}
}
}
}
}
@@ -7917,6 +7990,60 @@ mod tests {
}
}
#[test]
fn diff_scope_labels_follow_declaration_visibility() {
let before = "@decorate()\nclass PolicyActiveStatusEnum(BaseEnum):\n ACTIVE = 1\n EXPIRED = 2\n FUTURE = 3\n UNKNOWN = 4\n";
let after = before.replace(" FUTURE", " CANCELLED = 5\n FUTURE");
for (mode, axis, wrap) in [
(ViewMode::Split, SplitAxis::Vertical, false),
(ViewMode::Split, SplitAxis::Horizontal, true),
(ViewMode::Unified, SplitAxis::Vertical, true),
] {
let mut file = syntax_diff_file("status.py", before, &after);
file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false);
let mut diff = diff_comparison(vec![file]);
diff.files_visible = false;
diff.split_axis = axis;
diff.word_wrap = wrap;
diff.set_view_mode(mode);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(160, 32)).unwrap();
let mut panels = WheelPanels::default();
for (source, visible) in [(0, false), (2, true), (1, false), (2, true)] {
diff.top = diff.source_display_row(Focus::After, source).unwrap();
render_diff(&mut terminal, &diff, &mut panels);
let text = screen_text(&terminal);
let count = ["› PolicyActiveStatusEnum", "± PolicyActiveStatusEnum"]
.iter()
.map(|label| text.matches(label).count())
.sum::<usize>();
assert_eq!(
count,
if visible {
if mode == ViewMode::Split { 2 } else { 1 }
} else {
0
}
);
assert!(text.contains("ACTIVE = 1"));
}
}
let before = "def old_name():\n shared()\n tail()\n";
let after = before.replace("old_name", "new_name");
let mut file = syntax_diff_file("renamed.py", before, &after);
file.hunks = crate::diff::line_hunks(&file.before, false, &file.after, false);
let mut diff = diff_comparison(vec![file]);
diff.files_visible = false;
diff.set_view_mode(ViewMode::Unified);
diff.top = diff.source_display_row(Focus::After, 0).unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 16)).unwrap();
let mut panels = WheelPanels::default();
render_diff(&mut terminal, &diff, &mut panels);
let text = screen_text(&terminal);
assert!(text.contains("def new_name():"));
assert!(text.contains("− old_name"));
assert!(!text.contains("+ new_name"));
}
#[test]
fn diff_scope_labels_do_not_repeat_across_hunks_in_one_excerpt() {
let before = "def retry_cancellation_email():\n first()\n shared_one()\n old()\n shared_two()\n shared_three()\n tail()\n";
@@ -7927,11 +8054,12 @@ mod tests {
let mut diff = diff_comparison(vec![file]);
diff.files_visible = false;
diff.set_view_mode(mode);
diff.top = diff.source_display_row(Focus::After, 1).unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(160, 40)).unwrap();
let mut panels = WheelPanels::default();
render_diff(&mut terminal, &diff, &mut panels);
if mode == ViewMode::Split {
for top in [0, diff.source_display_row(Focus::After, 3).unwrap()] {
for top in [diff.top, diff.source_display_row(Focus::After, 3).unwrap()] {
diff.top = top;
render_diff(&mut terminal, &diff, &mut panels);
for side in [1, 2] {
@@ -7980,6 +8108,7 @@ mod tests {
diff.files_visible = false;
diff.split_axis = axis;
diff.word_wrap = wrapping;
diff.top = diff.source_display_row(Focus::Before, 2).unwrap();
let mut terminal =
Terminal::new(ratatui::backend::TestBackend::new(160, 48)).unwrap();
let mut panels = WheelPanels::default();
@@ -8003,7 +8132,8 @@ mod tests {
})
.collect::<Vec<_>>();
assert!(labels.iter().any(|label| label.contains(name)));
assert!(labels.iter().any(|label| label.contains("next")));
assert!(!labels.iter().any(|label| label.contains("next")));
assert!(pane_screen(&terminal, area).contains("fn next"));
assert!(!labels.iter().any(|label| label.contains(other)));
}
let syntax = panels.diff_syntax.as_ref().unwrap();
@@ -8027,6 +8157,7 @@ mod tests {
}
let mut narrow = diff_comparison(vec![syntax_diff_file("scope.rs", before, &after)]);
narrow.files_visible = false;
narrow.top = narrow.source_display_row(Focus::Before, 2).unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 30)).unwrap();
let mut panels = WheelPanels::default();
render_diff(&mut terminal, &narrow, &mut panels);
@@ -12825,24 +12956,6 @@ end
diff.display_source_row(diff.focus, rows[caret_row].position.display),
Some(source.row)
);
assert!(rows.iter().any(|row| row.scope != [false; 2]));
if diff.view_mode == ViewMode::Unified {
let labels = rows
.iter()
.enumerate()
.filter(|(_, row)| row.scope != [false; 2])
.map(|(screen, _)| {
(area.x..area.right())
.map(|x| {
terminal.backend().buffer()[(x, area.y + screen as u16)]
.symbol()
})
.collect::<String>()
})
.collect::<Vec<_>>();
assert!(labels.iter().any(|label| label.contains("− old_name")));
assert!(labels.iter().any(|label| label.contains("+ new_name")));
}
for (screen, row) in rows.iter().take(usize::from(area.height)).enumerate() {
if row.scope == [false; 2] {
continue;