Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
afd295b
feat(desktop): folders + tree view modes for v2 changes sidebar
Kitenite May 12, 2026
2ba7291
fix(desktop): give changes tree view an explicit height so it renders
Kitenite May 12, 2026
453e205
feat(desktop): changes tree — hover actions, folder menus, count badg…
Kitenite May 12, 2026
b8a4a7d
feat(desktop): add collapse-all/expand-all toolbar under each changes…
Kitenite May 12, 2026
d4a23b4
refactor(desktop): single changes toolbar row; drop header background…
Kitenite May 12, 2026
5b380ad
refactor(desktop): single collapse/expand-all toggle in changes toolbar
Kitenite May 12, 2026
6e84e46
fix(desktop): size changes tree to Pierre's real content height
Kitenite May 12, 2026
9584c28
style(desktop): right-align view toggle next to collapse button; drop…
Kitenite May 12, 2026
60b56bf
style(desktop): add bottom padding to changes toolbar row
Kitenite May 12, 2026
9ea3e94
style(desktop): move changes filter dropdown into the toolbar row
Kitenite May 12, 2026
6284525
style(desktop): merge changes stats into the toolbar row next to the …
Kitenite May 12, 2026
759abbd
style(desktop): drop bottom border from changes toolbar row
Kitenite May 12, 2026
8f1bfc2
Revert "style(desktop): drop bottom border from changes toolbar row"
Kitenite May 12, 2026
7348cd2
chore(desktop): trim a restating comment in ViewModeToggle
Kitenite May 12, 2026
0407afa
Merge remote-tracking branch 'origin' into changes-sidebar-diffs-tre
Kitenite May 12, 2026
64898cd
refactor(desktop): share loadFallthroughIcons util across sidebar tabs
Kitenite May 12, 2026
5f26e8b
refactor(desktop): consolidate file-icon system into renderer/lib/fil…
Kitenite May 12, 2026
005983f
fix(desktop): give changes folder headers a working path tooltip
Kitenite May 12, 2026
93e5ab7
refactor(desktop): extract shared Pierre-tree module + decompose File…
Kitenite May 12, 2026
13dbfb6
refactor(desktop): extract useFallthroughIcons hook
Kitenite May 12, 2026
9b064ea
refactor(desktop): share stripTrailingSlash via renderer/lib/pierreTree
Kitenite May 12, 2026
2b2eeba
docs(desktop): move architecture docs into apps/desktop/docs/
Kitenite May 12, 2026
8219bd3
fix(desktop): use folderPath directly as the changes folder-group key
Kitenite May 12, 2026
6c9d5ad
fix(desktop): repair ChangesFoldersView JSX broken by bad comment pla…
Kitenite May 12, 2026
fe701b4
fix(desktop): drop changes tree hover overlay on scroll
Kitenite May 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
238 changes: 238 additions & 0 deletions apps/desktop/plans/20260510-changes-sidebar-diffs-tree.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
# Changes sidebar → PierreFileTree (folders + tree view modes)

**Status:** Draft (v2 — addresses self-review pushback)
**Owner:** @kietho
**Created:** 2026-05-10
**Branch:** `changes-sidebar-diffs-tre`

## Premise to confirm before coding

The user said "use diffs tree which we use for file tree." This plan assumes
that means `PierreFileTree` from `@pierre/trees` — the component already
powering `FilesTab` (the v2 files explorer). v2's diff *viewer* (`DiffPane`)
uses `@pierre/diffs` and renders a flat list, not a tree, so it can't be what's
meant. **Confirm before step 1.** If you meant something different, the
component contract below is wrong.

## Problem

The v2 changes sidebar (`ChangesFileList`) renders each changed file as a
single flat row inside category sections (unstaged / staged / against-base /
committed). v1 supported two display modes for navigating large changesets:

- **Folders** — files grouped by their parent folder (one level deep).
- **Tree** — full recursive directory hierarchy.

v2 reimplements neither. v1's `FileListGrouped`, `FileListGroupedVirtualized`,
`FileListTree`, `FileListTreeVirtualized` are four files reinventing
virtualization, expand/collapse, icons, and selection that `@pierre/trees`
already gives `FilesTab` for free.

## Goal

Add v1's two grouping modes (**Folders** and **Tree**) to the v2 changes
sidebar. Tree mode uses `PierreFileTree` (reusing what `FilesTab` does).
Folders mode keeps `FileRow` (see [hybrid approach](#hybrid-renderer-strategy)
for why). Folders is the new default; no flat mode.

## Non-goals

- Touching v1 (`apps/desktop/src/renderer/screens/main/...`). v1 is sunset.
- Changing the `ChangesetFile` data model or the `useChangeset` hook.
- Reworking the diff viewer (`DiffPane`).
- Adding new bulk actions or commit-flow features.

## Hybrid renderer strategy

**Folders mode** keeps the existing `FileRow` component, grouped under
lightweight folder headers (basically v1's `FileListGrouped` pattern, ported
to v2's data model). Why:

- v1's folders mode renders `src/components/Sidebar/` as a *single* row, even
though it has 3 path segments. `PierreFileTree` builds nested folders from
nested paths — it does not natively flatten intermediate dirs into one
segment. Workarounds are all weak: patching upstream, munging path strings
with a visual separator (and reversing on selection), or accepting Pierre's
auto-expand-single-child behavior (which may or may not exist in the version
we ship).
- `FileRow` already carries the per-row chrome we'd otherwise have to rebuild
as Pierre row decorations: `+N/−N` badges, hover Discard button, hover
more-actions dropdown, rename arrow, click-policy tooltip, context menu.
Rebuilding that on top of Pierre's shadow-DOM rendering is real work — the
`ShadowClickHint` precedent shows it's doable, but the cost is significant
and entirely avoided here.
- Tree mode still gets the win we actually care about: hierarchy, expand /
collapse, virtualization, status tints, icons — all from Pierre, no
reinvention.

**Tree mode** uses `PierreFileTree`, one instance per category section. We
accept the cost of N models (currently 4) because:

- The bulk staging actions on `ChangesSection` headers (unstaged/staged) stay
declarative — no reinventing them as Pierre header decorations.
- A single file path can appear in *multiple* sections simultaneously (partial
staging: same `path` in both unstaged and staged). One tree per section means
each tree's model has a unique key set, and visual selection in one section
doesn't ghost into another.
- Each section is small in practice; N small models has no perceptible cost.

## View mode toggle

Two modes, persisted to settings:

- **Folders** (default) — `FolderGroup → FileRow[]` per category section.
- **Tree** — one `PierreFileTree` per category section.

The toggle lives in `ChangesHeader`. Persistence: read what `useChangesTab`
already does for tab state (filter selection, base branch, etc.) and add
`changesViewMode: "folders" | "tree"` alongside. **Audit `useChangesTab` in
step 1 of implementation.** If it has no settings store, fall back to the
global desktop settings store used elsewhere.

## Component contract

```
ChangesFileList/
├── ChangesFileList.tsx # Orchestration: reads viewMode, picks renderer
├── components/
│ ├── ChangesSection/ (existing — no change)
│ ├── ChangesFoldersView/ (NEW — folders mode)
│ │ ├── ChangesFoldersView.tsx # FolderHeader + FileRow per section
│ │ ├── FolderHeader.tsx
│ │ └── index.ts
│ ├── ChangesTreeView/ (NEW — tree mode)
│ │ ├── ChangesTreeView.tsx # <PierreFileTree> per section
│ │ ├── RowDecorations.tsx # +N/-N badge, rename arrow (Pierre slot)
│ │ ├── RowContextMenu.tsx # Shadow-DOM right-click menu
│ │ ├── ShadowRowHover.tsx # Discard + more-actions overlay
│ │ └── index.ts
│ ├── FileRow/ (keep — used by ChangesFoldersView)
│ └── ViewModeToggle/ (NEW — Folders | Tree)
└── utils/
└── groupFilesByFolder.ts # Port v1's `groupFilesByFolder`, adapted to ChangesetFile
```

### Selection model

Selection is a `(sectionKind, path)` tuple, not just `path`. The active diff
pane carries `sectionKind` already (it's `file.source.kind`), so we just need
to pipe it through to each renderer. Each `ChangesTreeView` instance is told
"this section's kind is X" and renders `selectedFilePath` only if the active
diff's `sectionKind === X`; otherwise `undefined`. Same for
`ChangesFoldersView`.

### Click-policy extraction

`FilesTab`'s `handleClickCapture` (capture-phase click intercept,
`composedPath()` walk, tier resolution via `useSidebarFilePolicy`,
preventDefault on tier match) needs to run inside `ChangesTreeView` too.
Extract it into a shared hook:

```
apps/desktop/src/renderer/lib/clickPolicy/usePierreRowClickPolicy.ts
```

Returns `{ onClickCapture, findFileRow }` parameterised by `rootPath` and
`onSelectFile`. Then both `FilesTab` and `ChangesTreeView` consume it.
This refactor is a prereq for step 3.

## Risks (after pushback)

1. **Selection in Tree mode across multiple sections.** Solved by the
`(sectionKind, path)` tuple above — flagging here so the implementer
doesn't regress to a string.

2. **`PierreFileTree` empty state.** If a section has no files in tree mode,
bypass Pierre entirely and render nothing (or the existing empty-state
string). Don't trust Pierre's defaults.

3. **Rebuilding `FileRow` capabilities inside Pierre rows.** Tree mode still
pays this cost — `+N/−N`, rename arrow, hover Discard, hover more-actions
dropdown. Plan:
- `+N/−N` and rename arrow → `renderRowDecoration` (trailing slot).
- Hover Discard + more-actions → `ShadowRowHover` component patterned on
`ShadowClickHint` (anchors a light-DOM overlay over the hovered row's
bounding rect, since Pierre owns row DOM inside a shadow root).
- Right-click context menu → `renderContextMenu` (Pierre native, same
wiring `FilesTab` uses).
- Click policy → shared hook from prereq above.

4. **Status tints in Tree mode.** Feed Pierre a `gitStatus` array via its
prop — reuse `buildPierreGitStatus` (currently inlined in `FilesTab.tsx`;
extract to `lib/buildPierreGitStatus.ts` if needed across the two callers).

5. **Performance.** Tree mode is virtualized by Pierre. Folders mode renders
N `FileRow`s — same as today, plus folder headers. For 5000+ files in a
single section, folders mode may need virtualization later; not a blocker
for v1.

6. **Settings store audit.** Plan assumes `useChangesTab` has somewhere to
persist `changesViewMode`. If it doesn't, step 1 widens to add one.

## Implementation plan (reordered: highest-risk validation first)

1. **Prereq audit.** Read `useChangesTab` to confirm settings persistence
target. Confirm "diffs tree" = `PierreFileTree` with the user (see
[Premise](#premise-to-confirm-before-coding)).

2. **Extract `usePierreRowClickPolicy`.** Move `FilesTab`'s click-capture
logic into a shared hook. Verify `FilesTab` still works identically.

3. **`ChangesFoldersView` end-to-end.** This is the new default mode, low
risk, reuses `FileRow`. Port v1's `groupFilesByFolder` to v2's
`ChangesetFile`. Wire one section through it, verify visually, then wire
all four sections.

4. **`ViewModeToggle` + persistence.** Header toggle. Default Folders. Wire
`useChangesTab` to store the choice. At this point Folders is shipped
end-to-end; Tree mode is still placeholder.

5. **`ChangesTreeView` prototype (one section).** Single `PierreFileTree`
instance for the unstaged section. Pierre's built-ins only: status tints,
icons, expand/collapse, selection. No row decorations, no hover actions,
no context menu yet. Click opens the diff. Take a screenshot.

6. **Tree-mode row decorations.** `+N/−N` badge + rename arrow via
`renderRowDecoration`. Verify legibility at narrow sidebar widths.

7. **Tree-mode context menu.** Port menu items from `FileRow` into a
`RowContextMenu` returned by `renderContextMenu`. Discard item enabled
only when `sectionKind === "unstaged"`.

8. **Tree-mode hover actions.** Build `ShadowRowHover` overlay (Discard +
more-actions dropdown). Tooltip integration via `ShadowClickHint`.

9. **Wire all four sections through `ChangesTreeView`.** Test partial-staging
case (same file in unstaged + staged) — verify selection stays scoped to
the active section.

10. **Visual parity pass.** Compare against v1 + current v2 screenshots in
both modes.

11. **Cleanup.** Delete unused v2 code. (v1 stays.) Confirm no dead exports.

## Test plan

- Workspace with deep file paths; toggle Folders ↔ Tree; both render readable.
- Tree mode: expand/collapse, virtualization at 500+ files in one section,
status tints match `FilesTab`.
- Right-click in tree mode → all menu items fire correctly; Discard enabled
only on unstaged.
- Hover in tree mode → Discard works on unstaged only; more-actions dropdown
opens, all items fire.
- Cmd-click → opens diff in new tab. Cmd-shift-click → external editor. Same
behavior in both modes.
- Partial-staging case: stage a hunk, leave others unstaged. File appears in
both sections. Clicking in one section doesn't visually select the row in
the other.
- Tab switch out and back — view mode persists.
- Empty sections render correctly (no Pierre artifacts).

## Out of scope

- Flat mode (dropped per design call).
- Multi-select for batch stage/unstage.
- Drag-and-drop staging via tree.
- Cross-section keyboard navigation.
- A unified single-tree view (cost/benefit doesn't pencil out — see
[Hybrid strategy](#hybrid-renderer-strategy)).
1 change: 1 addition & 0 deletions apps/desktop/src/renderer/lib/clickPolicy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@ export type {
Surface,
TierMode,
} from "./types";
export { usePierreRowClickPolicy } from "./usePierreRowClickPolicy";
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { useCallback } from "react";
import { folderIntentFor } from "./policies/folderPolicy";
import type { ClickPolicy } from "./policies/policy";

interface UsePierreRowClickPolicyOptions {
/** Resolved file-row click policy (settings-driven, e.g. `useSidebarFilePolicy`). */
filePolicy: ClickPolicy;
/**
* Open a file row's path in the current pane / a new tab. Receives Pierre's
* relative path (no trailing slash). Callers needing an absolute path can
* join with their own `rootPath`.
*/
onSelectFile: (relativePath: string, openInNewTab?: boolean) => void;
/**
* Open the path (file or folder) in the user's external editor. Receives
* the row's relative path with any trailing slash stripped.
*/
openInExternalEditor: (relativePath: string) => void;
}

interface UsePierreRowClickPolicyResult {
/** Capture-phase handler — attach to the wrapper holding the `PierreFileTree`. */
onClickCapture: (e: React.MouseEvent<HTMLDivElement>) => void;
/** Find the file-row element under a mouse event (skips folder rows). */
findFileRow: (e: React.MouseEvent) => HTMLElement | null;
}

/**
* Capture-phase click intercept for `PierreFileTree`. Pierre mounts inside
* an open shadow root, so we walk `composedPath()` to find the row by its
* `data-item-path` attribute (stamped by render/rowAttributes.ts in
* `@pierre/trees`), then route through clickPolicy:
*
* - folder rows → `folderIntentFor` (meta=reveal/no-op, metaShift=external)
* - file rows → settings-driven via the injected `filePolicy`
*
* Unbound tiers and plain "pane" defer to Pierre's own `onSelectionChange`
* so the visual selection stays in sync; intercepting would swallow the
* click and leave Pierre out of date.
*/
export function usePierreRowClickPolicy({
filePolicy,
onSelectFile,
openInExternalEditor,
}: UsePierreRowClickPolicyOptions): UsePierreRowClickPolicyResult {
const findRow = useCallback((e: React.MouseEvent): HTMLElement | null => {
const path = e.nativeEvent.composedPath();
for (const node of path) {
if (!(node instanceof HTMLElement)) continue;
if (node.getAttribute("data-item-path")) return node;
}
return null;
}, []);

const findFileRow = useCallback(
(e: React.MouseEvent): HTMLElement | null => {
const row = findRow(e);
const itemPath = row?.getAttribute("data-item-path");
if (!row || !itemPath || itemPath.endsWith("/")) return null;
return row;
},
[findRow],
);

const onClickCapture = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const treePath = findRow(e)?.getAttribute("data-item-path");
if (!treePath) return;
const trimmed = treePath.endsWith("/") ? treePath.slice(0, -1) : treePath;

if (treePath.endsWith("/")) {
const intent = folderIntentFor(e);
if (intent === null) return;
e.preventDefault();
e.stopPropagation();
if (intent === "external") openInExternalEditor(trimmed);
// "reveal" is a no-op — the folder row is already in this sidebar.
return;
}

const { tier, action } = filePolicy.resolve(e);
if (action === null) return;
if (tier === "plain" && action === "pane") return;
e.preventDefault();
e.stopPropagation();
if (action === "external") openInExternalEditor(trimmed);
else if (action === "newTab") onSelectFile(trimmed, true);
else if (action === "pane") onSelectFile(trimmed, false);
},
[filePolicy, onSelectFile, openInExternalEditor, findRow],
);

return { onClickCapture, findFileRow };
}
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ export function WorkspaceSidebar({
const changesTabDef = useChangesTab({
workspaceId,
gitStatus,
selectedFilePath,
onSelectFile: onSelectDiffFile,
onOpenFile: onSelectFile,
});
Expand Down
Loading
Loading