feat(app): group the Changes flat file list by directory#2153
Conversation
|
| Filename | Overview |
|---|---|
| packages/app/src/git/diff-flat-items.ts | Adds dirLabel item type and underDirLabel field; flat-mode loop is clean and correctly handles root files and sort precondition (documented in JSDoc). |
| packages/app/src/git/diff-pane.tsx | DiffDirLabelRow and DiffFileIcon memo components added; height tracking, key extractor, and renderItem deps all correctly updated; viewMode correctly removed from renderItem deps. |
| packages/app/src/git/diff-flat-items.test.ts | Four new behavioral tests added covering the key cases: shared labels, no-label at root, indent-only-under-label, and tree mode isolation. All test through the module interface. |
| packages/app/e2e/diff-row-alignment.spec.ts | E2E helper updated to assert the dir-label test ID and verify the inline directory suffix is no longer on the file row. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["files: ParsedDiffFile[]<br/>(path-sorted)"] --> B{viewMode}
B -->|flat| C["Loop over files<br/>track currentDir"]
C --> D{dir changed?}
D -->|"yes, dir != ''"| E["emit dirLabel item"]
D -->|"yes, dir == ''"| F["no label (root-level)"]
D -->|no| G["same dir, continue"]
E --> H["pushFile(underDirLabel=true)"]
F --> I["pushFile(underDirLabel=false)"]
G --> H
B -->|tree| J["buildDiffTree → compress → flatten"]
J --> K["pushFile(depth, underDirLabel=false)"]
H --> L["DiffFileHeader w/ fileHeaderFlatIndent"]
I --> M["DiffFileHeader no indent"]
E --> Q["DiffDirLabelRow renders 'path/'"]
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
A["files: ParsedDiffFile[]<br/>(path-sorted)"] --> B{viewMode}
B -->|flat| C["Loop over files<br/>track currentDir"]
C --> D{dir changed?}
D -->|"yes, dir != ''"| E["emit dirLabel item"]
D -->|"yes, dir == ''"| F["no label (root-level)"]
D -->|no| G["same dir, continue"]
E --> H["pushFile(underDirLabel=true)"]
F --> I["pushFile(underDirLabel=false)"]
G --> H
B -->|tree| J["buildDiffTree → compress → flatten"]
J --> K["pushFile(depth, underDirLabel=false)"]
H --> L["DiffFileHeader w/ fileHeaderFlatIndent"]
I --> M["DiffFileHeader no indent"]
E --> Q["DiffDirLabelRow renders 'path/'"]
Reviews (2): Last reviewed commit: "feat(app): group the Changes flat file l..." | Re-trigger Greptile
The flat list showed one row per file: the filename plus a muted inline directory suffix, and no file-type icon. Icons arrived with the folder tree in getpaseo#1918 but were gated to tree mode, so the two view modes had drifted apart, and the directory string repeated on every row even when a dozen files shared it — truncating the filename first in a narrow sidebar, which is the part you actually read. Group the files by directory instead: each directory appears once as a path label with its files listed beneath it, and every row carries its file-type icon. Files sharing a directory share a label. Root-level files get no label, since there is nothing to group them under, and are not indented either — the indent is decided in buildDiffFlatItems next to the label itself (underDirLabel) rather than re-derived from the path at render time, so it cannot disagree with whether a label was actually emitted. The shape follows GitLab's flat merge-request file list, the way the tree's single-child compression follows VS Code and GitHub — the grouping only, not the status glyphs or middle-truncated paths. The cost is height: every group adds a label row. The label is kept cheap — smaller text, and padded on top only, so it separates from the group above while sitting tight to its own files — but it is not free, and it narrows the density gap with tree mode, which can at least collapse. Keeping the directory inline for single-file groups would win some of that back, but GitLab declines that too: rows would change shape live as an agent adds a second file to a directory. This also reaches the commit diff panel. SharedDiffView pins viewMode to "flat" for commit mode, so the commit-history file list is grouped and carries icons too, with no toggle to opt out of it. Client-only, built from the existing ParsedDiffFile paths. No protocol or server change, tree mode is untouched, and the icons reuse the existing getFileIconSvg. Labels carry their own measured height into getItemLayout so virtualization offsets stay correct, and sticky indices remain file-headers-only so a label can never enter the sticky stack. Flat grouping needs the path-sorted files array it already receives, so that precondition is now stated on the input rather than assumed. The icon moves into a small component of its own: it used to sit behind a conditional that kept the header's JSX inside the nesting limit, and showing it unconditionally pushed the tree past that limit. The diff-row-alignment e2e helper asserted the shape this replaces — a flat row containing its directory inline — and now asserts the label above the file, and that the row no longer carries it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
a79c35e to
ebaf6cc
Compare
Linked issue
None — opening directly, since the screenshots make the case better than a
description would. Happy to move it to a feature request if you'd rather
settle the shape first.
Your call: flat is the default view mode, so this changes the Changes
list everyone sees out of the box, and there's no way back to the old rows.
Type of change
What does this PR do
Groups the flat file list by directory, and gives every row its file-type
icon.
Before: one row per file —
filenameplus a muted inline directory suffix,no icon (icons landed in #1918, but only in tree mode). The directory
string repeated on every row, and it truncated the filename first in a
narrow sidebar.
After: each directory appears once as a path label with its files beneath
it. Files sharing a directory share a label; root files get none.
The shape is taken from GitLab's merge-request file list, used deliberately
as a best-practice reference — the same reasoning as the tree's single-child
compression in #1918 following VS Code and GitHub: reach for a pattern
people already read fluently rather than invent one. The grouping only,
though. Paseo's
+/-stats, New/Deleted badges and end-ellipsis truncationstay as they are, not GitLab's status glyphs and middle-truncated paths.
Trade-off: it's taller. Every group adds a label row — the diff in the
screenshots goes from 5 rows to 8. The label is cheap (smaller text, top
padding only) but not free, and it narrows the density gap with tree mode,
which renders the same diff in 9 rows and can at least collapse. What it
buys is width for the filename.
That diff is close to the worst case for this, by the way: three groups over
five files, two of them single-file. The label amortizes where more files
share a directory — the three
commands/files here pay one label betweenthem.
I considered keeping the directory inline for single-file groups to save
the label (the screenshots have two), and dropped it: GitLab doesn't do it
either — its flat list is mostly single-file groups and they all get labels
— and rows would change shape live as an agent adds a second file to a
directory.
Client-only, built from the existing
ParsedDiffFilepaths — no protocolor server change, tree mode untouched, icons reuse the existing
getFileIconSvg. The one non-obvious bit: labels get their own measuredheight feeding
getItemLayout, and sticky indices stay file-headers-onlyso a label can't enter the sticky stack.
How did you verify it
npm run typecheck,npm run lint,npm run formatclean. All 224tests across the 21
git/test files pass. Three cases indiff-flat-items.test.tscover the new grouping: one shared label forsame-directory files, no label at root, and the sticky index still
landing on the expanded file header. Existing tree-mode tests unchanged
and passing — that's what pins "tree mode is unaffected".
the three groups label correctly,
commands/collects its three filesunder one label, icons resolve, and
+/-stats stay right-aligned.Toggled to tree and back — unchanged.
Before:
After:
Tree, for comparison (unchanged):
Checklist
npm run typecheckpassesnpm run lintpassesnpm run formatran (Biome)