Skip to content

feat(app): group the Changes flat file list by directory#2153

Open
cleiter wants to merge 1 commit into
getpaseo:mainfrom
cleiter:changes-file-icons
Open

feat(app): group the Changes flat file list by directory#2153
cleiter wants to merge 1 commit into
getpaseo:mainfrom
cleiter:changes-file-icons

Conversation

@cleiter

@cleiter cleiter commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

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

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Docs

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 — filename plus 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 truncation
stay 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 between
them.

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 ParsedDiffFile paths — no protocol
or server change, tree mode untouched, icons reuse the existing
getFileIconSvg. The one non-obvious bit: labels get their own measured
height feeding getItemLayout, and sticky indices stay file-headers-only
so a label can't enter the sticky stack.

How did you verify it

  • npm run typecheck, npm run lint, npm run format clean. All 224
    tests across the 21 git/ test files pass. Three cases in
    diff-flat-items.test.ts cover the new grouping: one shared label for
    same-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".
  • Live in the Electron desktop app on this branch, against a real diff:
    the three groups label correctly, commands/ collects its three files
    under one label, icons resolve, and +/- stats stay right-aligned.
    Toggled to tree and back — unchanged.

Before:

image

After:

image

Tree, for comparison (unchanged):

image

Checklist

  • One focused change. Unrelated cleanups split out.
  • npm run typecheck passes
  • npm run lint passes
  • npm run format ran (Biome)
  • UI changes include screenshots or video for every affected platform
  • Tests added or updated where it made sense

@greptile-apps

greptile-apps Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR groups the flat-mode file list by directory, emitting a dirLabel item above each run of same-directory files rather than repeating an inline directory suffix on every row, and promotes file-type icons from tree-only to both view modes.

  • diff-flat-items.ts adds a dirLabel union variant to DiffFlatItem, a new underDirLabel: boolean field on header items, and a single-pass loop that tracks the current directory and emits one label per contiguous run of files sharing a path prefix. Root-level files receive no label and no indent.
  • diff-pane.tsx adds DiffDirLabelRow and DiffFileIcon memo components, wires height measurement for dir-label rows into getItemLayout, removes the now-redundant showDir prop from DiffFileHeader, and ensures viewMode is no longer needed directly in renderItem (it flows through underDirLabel from the item model instead).
  • Tests cover: shared label for same-directory files, no label at root, indent only under an emitted label, tree mode unaffected (underDirLabel always false), and the sticky index landing on the file header not the label.

Confidence Score: 5/5

Safe to merge — changes are client-only, tree mode is untouched, and the new flat grouping logic is well-tested.

The flat-mode grouping loop is straightforward and correct: root files get no label and no indent, the underDirLabel flag is set at the same site as label emission so they can't disagree, and viewMode correctly flows through the item model rather than into renderItem directly. Height measurement for dir-label rows follows the same pattern as the existing folder-row height tracking. Tests cross the module interface and cover the key behavioral variants. The previous-thread concern about root-file indentation is explicitly addressed by the underDirLabel = false path and verified by a dedicated test.

No files require special attention.

Important Files Changed

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/'"]
Loading
%%{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/'"]
Loading

Reviews (2): Last reviewed commit: "feat(app): group the Changes flat file l..." | Re-trigger Greptile

Comment thread packages/app/src/git/diff-pane.tsx Outdated
Comment thread packages/app/src/git/diff-flat-items.ts
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>
@cleiter
cleiter force-pushed the changes-file-icons branch from a79c35e to ebaf6cc Compare July 17, 2026 06:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant