Skip to content

feat(diff): fold files away, and let the tint carry the signal - #51

Merged
LeadcodeDev merged 7 commits into
mainfrom
feat/collapsible-diff-files
Aug 29, 2026
Merged

feat(diff): fold files away, and let the tint carry the signal#51
LeadcodeDev merged 7 commits into
mainfrom
feat/collapsible-diff-files

Conversation

@LeadcodeDev

Copy link
Copy Markdown
Collaborator

Stacked on #50 — it edits the row model and the custom Element that PR introduces, so it cannot merge before it. Review #50 first.

Four decisions were taken while building this, each after looking at the running view rather than at a mock-up.

The @@ line goes

@@ -12,7 +12,9 @@ fn reload said nothing the two gutters were not already saying. Row::HunkHeader becomes Row::Separator — a muted band with no text, so it produces no selection run and puts nothing on the clipboard. Emitted only between hunks; a file header already parts the first one.

Files fold from their header row

Clicking a header toggles that file. The collapse is applied at row derivation, not at render: rows(patch, collapsed) simply does not emit a collapsed file's body. The Element therefore knows nothing about folding — its visible-range windowing, its geometry and its selection all keep working on a shorter list, and none of the seven standing hazards is touched. The only element-side addition is a mouse-down hit test.

State is ephemeral, cleared whenever the detail changes, everything expanded initially. Keyed by file index rather than display path, because display_path() is an Option and two pathless files would otherwise fold together.

Selection is cleared on toggle: gpui-base anchors endpoints to pixel offsets, so a live selection would otherwise resolve onto rows the user never dragged across — and Cmd-C would copy them.

Scroll position is kept and clamped rather than zeroed. The element reads the offset in request_layout, one phase before the scrolling div clamps it, so an over-scrolled offset after a fold would give an empty visible range and paint nothing. Reusing gpui's own ScrollHandle::max_offset() would have been wrong — it carries the previous frame's pre-collapse height.

The header row, restyled and pinned

Chevron, status pastille and path flush left; a green+red change bar and a bold total right-aligned. The bar's total width is proportional to the file's changes measured against the most-changed file in the patch, so files compare at a glance. That scale is computed once at derivation, never per frame.

The row is pinned to the viewport and does not scroll horizontally at all. A file header is chrome, not content — and with no soft wrap a Rust diff is routinely wider than the dock, so the stats would otherwise sit off-screen until you scrolled right. An over-long path elides from the left: the tail identifies a file, the head is what you can lose.

Only the path is a selection run. Chevron, pastille, bar and count are painted directly, so copying a header yields a bare path.

The +/ markers go, column and all

The background tint carries the signal. code_left() shrinks by 16px and the code gains the width.

This reverses a decision recorded in the design spec, which had kept the marker on the argument that it carries the signal for anyone who reads green and red poorly. That bullet is rewritten rather than deleted — it now records what was decided instead, that accessibility was outweighed rather than answered, and names the cheapest mitigation if it is ever revisited: a 2px coloured edge on the row, which makes shape carry information without putting a character back into the text.

The bug worth knowing about

Review found one that no test would have caught. In copy_selection's visible branch, the selection range was measured by gpui-base against a string built in request_layout, then used to slice a string rebuilt in paint. The two agreed in practice — the elision budget is a field, fixed within a frame — but &str[range] panics off a char boundary rather than degrading. Both the text and the range now come from the same array by construction, with a symmetric fallback so the rare branch cannot split either.

Testing

414 tests, cargo clippy --workspace --all-targets -- -D warnings and cargo fmt --all --check clean. The bar geometry, the elision, the collapse derivation and the row/file hit mapping are pure and tested, including every degenerate case: a patch of pure renames (no division by zero), a file with only additions or only deletions, a one-change file beside a thousand-change one (a floor keeps its bar visible), and an elision cutting at a byte offset that is not the char index.

Please look before merging

Nothing in these seven commits has been seen on screen. The earlier state was, three commits back; the restyle, the pin and the marker removal have not been.

The one item both the implementer and the reviewer singled out: HEADER_TEXT_LEFT (36) and the split view's code_left() (44) are now 8px apart. A near-miss alignment can read worse than either a match or a clear offset, and no test can judge it.

Also unmeasured rather than reasoned: whether DISCLOSURE_WIDTH = 12 fits U+25BE in whatever fallback font serves it, and whether COUNT_WIDTH = 34 fits four digits of the mono font. Both fail as crowding rather than overlap.

Parked

  • path_budget is one frame stale against the pin during a drag-resize — both copy paths read the same values, so nothing drifts; the effect is one frame of slightly-wrong elision.
  • A deep path still extends the horizontal scroll range it can no longer reach, since content_width measures the unelided string. That is what keeps the frame-one layout provably wrap-free.
  • Rename and copy similarity percentages are no longer shown; the in the path and the pastille carry the signal.

Base automatically changed from feat/github-style-diff-view to main August 29, 2026 19:41
The @@ line went with it. Both gutters already carry the numbers it
repeated, so a hunk boundary is now an untexted muted band, between
hunks and never before the first, where the file header already parts
them. It yields no selection run and no text to a copy.

Collapsing is applied where the rows are derived, not where they are
painted: a collapsed file does not emit its body, so the element
windows, paints and selects over a shorter list and needs to know only
how to map a click to a row. The set of collapsed files is keyed by
index into the patch rather than by display path, because a file with
neither an old nor a new path renders as the empty string and two of
them would collapse together; it is cleared whenever the detail
changes, so an index never outlives what it indexes.

Toggling clears the window selection for the reason set_detail does —
gpui-base anchors an endpoint to a pixel offset, which after a collapse
resolves onto rows the reader never dragged across, and Cmd-C would
copy them. It keeps the scroll offset, pulling it up only as far as the
shortened content allows, because the element chooses its rows from
that offset one phase before the scrolling container gets to clamp it,
and an unclamped one leaves it laying out nothing at all.
In the header's text the marker was part of a selection run, so Cmd-C
over a header and Select All both emitted "▾ src/main.rs  +3 −1" —
against the rule the module doc states, that only code text becomes a
run and everything in the gutter is painted directly so line numbers
and markers cannot reach the clipboard. paint_gutter already returned
early for a header row, which leaves that gutter free: the glyph is
shaped and painted there, at the offset a line's +/− uses, and
header_line goes back to what it was.

A hunk carrying no lines put a separator directly under the file
header, which is what "between hunks only" exists to prevent, and two
of them stacked two bands. Only git's own output rules that out; the
parser accepts @@ -0,0 +0,0 @@, so the rows skip an empty hunk rather
than trusting it.

Row::FileHeader names its file `index`, leaving `file` free for the
&FilePatch it means everywhere else in these two modules, and the
collapsed lookup is hashed once per file rather than twice. Rows::full
answers the full-width row that file_at, cell_foreground and
paint_background each used to match out for themselves, and
Rows::marker_left names the offset the three marker sites share.
The two derivations share filled_hunks today, but they are separate
functions and one could be inlined or specialised without the other
noticing. The split view had only two empty hunks flanking a filled
one, which never puts an empty hunk between two filled ones — the
arrangement that would produce a doubled separator. It passes as
written.
…ge bar

The header row read as one line of text — path, status label, `+22 −0` — indented to
the code column, with only the disclosure marker painted separately in the gutter. It
now spans the row: chevron, status pastille and path flush left at the row's own left
edge, and a green/red bar with the total in bold right-aligned at the other end.

Only the path is a selection run. `header_line` reduces to the path, so `cell_strings`
— the one source both the runs and the clipboard read — carries nothing else, and
copying a header yields a bare path. The chevron, pastille, bar and count are painted
the way `paint_gutter` already paints line numbers and markers, which is what keeps
them out of every run.

A bar's total width is its file's share of the most-changed file in the patch, and that
maximum is derived in `content` alongside the rows and the strings rather than reached
for per frame: sizing a bar from the element would mean walking every file of the patch
on every repaint, and a selection drag repaints on each mouse move.

Green is `theme.green` and red `theme.red`, the pair `line_colors` already tints a `+`
and a `−` with, so a segment and a marker mean the same thing. The pastille is
`theme.green` for an addition, `theme.red` for a deletion and `theme.blue` for a
modification. Renamed, Copied and TypeChanged share `theme.muted_foreground`: none of
the three is a statement about content — they describe the file's identity or its kind
moving, and whatever content changed with it is already told by the bar beside them.
The similarity percentage those two carried in the old header text is dropped; the `→`
in the path still says a rename happened.

Four degenerate cases decide the geometry. A patch of pure renames has no scale, so
`max_changes` of zero draws no bar rather than dividing by it, and a file of no changes
draws none either. A file with only additions gets no red segment and one with only
deletions no green, each skipped rather than painted zero-wide. And a bar too short to
see is widened twice over: the bar takes a floor of `BAR_MIN_WIDTH`, so one change
beside a thousand is 4px rather than 0.064px, and a segment takes a floor of
`BAR_MIN_SEGMENT` by moving the boundary rather than growing the bar, so the sum stays
exactly proportional. `BAR_MIN_WIDTH` is written as twice `BAR_MIN_SEGMENT` because
that is what makes the two clamps impossible to invert.

The bar and count are right-aligned to the element's right edge, mirroring the left
side being flush to its left edge, rather than pinned to the viewport. Both ends of the
header therefore scroll with the content.

Only the header's geometry moves: `Rows::cell_left` answers `HEADER_TEXT_LEFT` for a
header and `code_left()` for every other row, so lines, separators and placeholders are
untouched. Both copy paths go through the same `cell_bounds_at`, so the projection and
the off-screen arithmetic still agree cell for cell. The click target is unchanged —
`on_mouse_down` reads only `y`.
A header row was laid out in document space: flush to the element's left edge and
right-aligned to the element's right edge. With no soft wrap, a Rust diff's content is
routinely wider than the dock, so the bar and the count sat off-screen until the reader
scrolled right. A statistic you have to go looking for is not a statistic.

The mistake was treating the header as document. The code rows scroll horizontally
because they are the thing being read; a header describes what is being read and belongs
to the frame around it. The chevron, pastille and path now pin to the viewport's left
edge and the bar and count to its right, inset by `Scrollbar::width()` — gpui-component's
own 16px, taken from the constant rather than copied, so it breaks loudly if it moves
instead of drifting into the overlay scrollbar. Only the background band still spans the
element, which is what keeps a scrolled header reading as one continuous row rather than
as a label floating over the code.

The viewport is unmeasured on the first frame. `row_window` already answers that with
`UNMEASURED_ROWS`, and this takes the same shape: `header_budget` returns `None`, the
header falls back to the element's own edges, nothing is elided, and the second frame
corrects it. One `Option` carries that state, so the pin, the elision and the cell bounds
cannot disagree about whether the viewport is known.

Pinning is what bounds the path, and the bound is one number per frame: the viewport less
the furniture at both ends, computed in `request_layout` beside the visible range. A path
wider than it is elided from the left — `…detail/diff/split.rs` — because the tail of a
path names the file and the head is the part that can be lost. `elide_path` binary-searches
the tails, so it shapes about six candidates rather than one per character.

The elided string is the one that becomes the run. Every reader of a cell's text now goes
through one method, `cell_string`, so the layout, the run, the select-all range and both
copy paths cannot disagree — a projected byte range indexing a different string than the
one it was measured against would slice a `String` off a character boundary and panic. The
price is the other half of that trade: copying a header whose path is too long for the
panel yields the elided form rather than the whole path. Highlight and clipboard staying
equal is worth more than the full path here, and it is the invariant the module already
existed to protect.

The click target is unmoved. `on_mouse_down` reads only `event.position.y` against a
hitbox that is the whole element, so the entire row width still toggles and still resolves
through `file_at` to the same file. Nothing else about a row changed: `content_width` still
measures the unelided strings, so a header cell is still laid out against a width that
exceeds it and a row is still one line tall.
The row's background tint carries the addition/deletion signal on its own,
which is the whole point of moving off the code editor: a full-width band
does what a tint hugging the glyphs could not. Seen running, the marker read
as leftover furniture between the line numbers and the code.

So the column goes rather than being blanked. code_left() is the gutters
alone now — 2 * GUTTER_WIDTH unified, GUTTER_WIDTH split — and every code
cell gains sixteen pixels. The gutter numbers do not move: paint_gutter
recovers the row's left edge as origin.x - code_left(), and both terms shrank
by the same sixteen.

LineColors::foreground had one caller, the marker's tint, so it is dead the
moment the glyph goes and a dead field is a -D warnings failure. line_colors
becomes line_background(origin, mode) -> Option<Hsla>; the theme argument
went with the field, since only the foreground arm ever read it. The four
tint constants and the two-bar contrast reasoning behind them are untouched.

The disclosure chevron stays. It positions itself from HEADER_PADDING and
DISCLOSURE_WIDTH, not from the marker geometry — verified before deleting
anything — so nothing in the header moved.

This reverses a Decisions bullet in the design spec, which kept the marker on
the grounds that it carried the signal for anyone who reads green and red
poorly. That argument was made against a mock-up and is outweighed rather
than answered, so the bullet is rewritten rather than dropped, and it records
the cheapest mitigation if it is revisited: a 2px coloured edge down the row,
which puts shape back to work without putting a character back in the text.
copy_selection's visible branch took its range from the projection, which
gpui-base computed against display[cell - visible.start] in request_layout,
and then sliced texts[offset], which this function rebuilt in paint with a
second cell_string call and its own Pen.

The two agree today: path_budget is a field, so it is fixed within a frame,
and the text style stack is the same in both phases. But that is two things
happening to agree, not an invariant. The cell_string funnel prevents the
strings being formatted differently; it cannot prevent them being measured
differently, and the failure mode is copy_text slicing on a char boundary
that the other string does not have — a panic, not a wrong character.

So the visible branch reads its string out of display as well. One closure
answers whether a cell is displayed, and both the text map and the range map
consult it, which also makes the fallback symmetric: a cell display has no
entry for falls through to cell_string in both maps together.

Five findings from the same review, none load-bearing:

header_budget and paint_header derived the same clearance from the same
constants independently. count_right_edge and bar_right_edge now name that
arithmetic, and a test pins the widest bar the painter can draw to exactly
BAR_GAP right of where the budget stops the path.

elide_path is careful about char boundaries and was only ever tested on
ASCII. The sweep gains an accented path, and one case pins that the cut is a
byte offset landing on a boundary rather than a char count.

"That last number" in diff/mod.rs pointed at max_changes until c76c5a6 put
the elision aside between them; it is named outright now. Reflowed the two
paragraphs my previous commit left ragged.

header_line was path.to_string(), called by row_text to build a string it
already held. Dropped rather than kept: the seam carried no decision, and
three of its four test assertions sat beside an identical assertion on the
path itself.
@LeadcodeDev
LeadcodeDev force-pushed the feat/collapsible-diff-files branch from c96c3da to ca9cd9e Compare August 29, 2026 19:41
@LeadcodeDev LeadcodeDev self-assigned this Aug 29, 2026
@LeadcodeDev LeadcodeDev added the enhancement New feature or request label Aug 29, 2026
@LeadcodeDev
LeadcodeDev merged commit be21071 into main Aug 29, 2026
3 checks passed
@LeadcodeDev
LeadcodeDev deleted the feat/collapsible-diff-files branch August 29, 2026 19:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant