From afd295b6940d505e78bcaeb2956a602503d134ed Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 11 May 2026 21:34:38 -0700 Subject: [PATCH 01/24] feat(desktop): folders + tree view modes for v2 changes sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds v1's two grouping modes to the v2 changes sidebar: - Folders mode (new default): files grouped one level deep by parent folder, reusing FileRow; root files under a "Root Path" header. - Tree mode: one PierreFileTree per category section — hierarchy, virtualization, status tints, icons from Pierre; right-click parity with FileRow. View mode persists per workspace via sidebarState.changesViewMode; toggled from ChangesHeader. Extracts the Pierre row click-policy into a shared usePierreRowClickPolicy hook and lifts RowContextMenu up to WorkspaceSidebar/components/PierreRowContextMenu (used by both tabs). --- .../20260510-changes-sidebar-diffs-tree.md | 238 ++++++++++++++++++ .../src/renderer/lib/clickPolicy/index.ts | 1 + .../clickPolicy/usePierreRowClickPolicy.ts | 94 +++++++ .../components/FilesTab/FilesTab.tsx | 76 +----- .../components/RowContextMenu/index.ts | 1 - .../PierreRowContextMenu.tsx} | 6 +- .../components/PierreRowContextMenu/index.ts | 1 + .../ChangesFileList/ChangesFileList.tsx | 27 +- .../ChangesFoldersView/ChangesFoldersView.tsx | 118 +++++++++ .../components/FolderHeader/FolderHeader.tsx | 37 +++ .../components/FolderHeader/index.ts | 1 + .../components/ChangesFoldersView/index.ts | 1 + .../ChangesTreeView/ChangesTreeView.tsx | 228 +++++++++++++++++ .../FileRowContextMenuItems.tsx | 153 +++++++++++ .../FileRowContextMenuItems/index.ts | 1 + .../components/ChangesTreeView/index.ts | 1 + .../components/FileRow/FileRow.tsx | 6 +- .../ChangesHeader/ChangesHeader.tsx | 11 +- .../ViewModeToggle/ViewModeToggle.tsx | 69 +++++ .../components/ViewModeToggle/index.ts | 1 + .../ChangesTabContent/ChangesTabContent.tsx | 12 +- .../hooks/useChangesTab/useChangesTab.tsx | 21 +- .../dashboardSidebarLocal/schema.ts | 4 + 23 files changed, 1029 insertions(+), 79 deletions(-) create mode 100644 apps/desktop/plans/20260510-changes-sidebar-diffs-tree.md create mode 100644 apps/desktop/src/renderer/lib/clickPolicy/usePierreRowClickPolicy.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/RowContextMenu/index.ts rename apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/{FilesTab/components/RowContextMenu/RowContextMenu.tsx => PierreRowContextMenu/PierreRowContextMenu.tsx} (89%) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PierreRowContextMenu/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/components/FolderHeader/FolderHeader.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/components/FolderHeader/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/components/FileRowContextMenuItems/FileRowContextMenuItems.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/components/FileRowContextMenuItems/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/components/ViewModeToggle/ViewModeToggle.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/components/ViewModeToggle/index.ts diff --git a/apps/desktop/plans/20260510-changes-sidebar-diffs-tree.md b/apps/desktop/plans/20260510-changes-sidebar-diffs-tree.md new file mode 100644 index 00000000000..ea2854d9964 --- /dev/null +++ b/apps/desktop/plans/20260510-changes-sidebar-diffs-tree.md @@ -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 # 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)). diff --git a/apps/desktop/src/renderer/lib/clickPolicy/index.ts b/apps/desktop/src/renderer/lib/clickPolicy/index.ts index 309d85c4b9b..e93d92451b2 100644 --- a/apps/desktop/src/renderer/lib/clickPolicy/index.ts +++ b/apps/desktop/src/renderer/lib/clickPolicy/index.ts @@ -29,3 +29,4 @@ export type { Surface, TierMode, } from "./types"; +export { usePierreRowClickPolicy } from "./usePierreRowClickPolicy"; diff --git a/apps/desktop/src/renderer/lib/clickPolicy/usePierreRowClickPolicy.ts b/apps/desktop/src/renderer/lib/clickPolicy/usePierreRowClickPolicy.ts new file mode 100644 index 00000000000..ecac2382b8b --- /dev/null +++ b/apps/desktop/src/renderer/lib/clickPolicy/usePierreRowClickPolicy.ts @@ -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) => 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) => { + 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 }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx index 9a9c4ef2d02..18e4f16438b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx @@ -27,8 +27,8 @@ import { useCallback, useEffect, useRef } from "react"; import type { FileStatus } from "renderer/hooks/host-service/useGitStatusMap"; import { useGitStatusMap } from "renderer/hooks/host-service/useGitStatusMap"; import { - folderIntentFor, ShadowClickHint, + usePierreRowClickPolicy, useSidebarFilePolicy, } from "renderer/lib/clickPolicy"; import { useOpenInExternalEditor } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor"; @@ -37,9 +37,9 @@ import { ROW_HEIGHT, TREE_INDENT, } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/constants"; +import { PierreRowContextMenu } from "../PierreRowContextMenu"; import { FileMenuItems } from "./components/FileMenuItems"; import { FolderMenuItems } from "./components/FolderMenuItems"; -import { RowContextMenu } from "./components/RowContextMenu"; import { useFilesTabBridge } from "./hooks/useFilesTabBridge"; import { loadFallthroughIcons } from "./utils/loadFallthroughIcons"; import { @@ -491,69 +491,19 @@ export function FilesTab({ [workspaceId, deletePath], ); - // Pierre mounts its tree inside an open shadow root on a custom element - // (``). Events bubbling out retarget to that host, - // so `e.target.closest(...)` from our wrapper finds nothing — walk - // `composedPath()` to cross the shadow boundary and find the row by its - // `data-item-path` attribute (stamped by render/rowAttributes.ts in - // @pierre/trees — pin coverage with the version in package.json). - 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; - }, []); - - // Capture-phase click intercept routes every row click through clickPolicy. - // File rows: settings-driven via `useSidebarFilePolicy`. Folders: fixed - // rule via `folderIntentFor` (meta=reveal, metaShift=external) — they're - // not user-configurable because the action vocabulary doesn't fit. - // Unbound tiers and plain "pane" defer to Pierre's onSelectionChange so - // the visual selection stays in sync; intercepting would swallow the click. - const handleClickCapture = useCallback( - (e: React.MouseEvent) => { - if (!rootPath) return; - const treePath = findRow(e)?.getAttribute("data-item-path"); - if (!treePath) return; - const abs = toAbs(rootPath, treePath); - - if (treePath.endsWith("/")) { - const intent = folderIntentFor(e); - if (intent === null) return; - e.preventDefault(); - e.stopPropagation(); - if (intent === "external") openInExternalEditor(abs); - // "reveal" is a no-op — folder 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(abs); - else if (action === "newTab") onSelectFile(abs, true); - else if (action === "pane") onSelectFile(abs, false); - }, - [rootPath, openInExternalEditor, onSelectFile, findRow, filePolicy], - ); - // Hint tooltip uses ShadowClickHint to anchor a single shadcn Tooltip // over the hovered row's bounding rect — Pierre owns the row DOM inside // an open shadow root, so per-row Tooltip wrappers aren't possible. // Folders are excluded since folder intents are hardcoded. - 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], - ); + // The hook fires Pierre's relative path; this surface's external + // contract is absolute, so wrap each callback to join with `rootPath`. + const { onClickCapture: handleClickCapture, findFileRow } = + usePierreRowClickPolicy({ + filePolicy, + onSelectFile: (rel, openInNewTab) => + onSelectFile(toAbs(rootPath, rel), openInNewTab), + openInExternalEditor: (rel) => openInExternalEditor(toAbs(rootPath, rel)), + }); const renderContextMenu = useCallback( (item: PierreContextMenuItem, ctx: PierreContextMenuOpenContext) => { @@ -564,7 +514,7 @@ export function FilesTab({ const abs = toAbs(rootPath, item.path); const rel = stripTrailingSlash(item.path); return ( - handleDelete(abs, item.name, false)} /> )} - + ); }, [ diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/RowContextMenu/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/RowContextMenu/index.ts deleted file mode 100644 index ba2432855c5..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/RowContextMenu/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { RowContextMenu } from "./RowContextMenu"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/RowContextMenu/RowContextMenu.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PierreRowContextMenu/PierreRowContextMenu.tsx similarity index 89% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/RowContextMenu/RowContextMenu.tsx rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PierreRowContextMenu/PierreRowContextMenu.tsx index e0aa9cb6629..851ff73d24b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/RowContextMenu/RowContextMenu.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PierreRowContextMenu/PierreRowContextMenu.tsx @@ -5,7 +5,7 @@ import { DropdownMenuTrigger, } from "@superset/ui/dropdown-menu"; -interface RowContextMenuProps extends Record { +interface PierreRowContextMenuProps extends Record { anchorRect: ContextMenuOpenContext["anchorRect"]; onClose: () => void; children: React.ReactNode; @@ -18,12 +18,12 @@ interface RowContextMenuProps extends Record { * The data-file-tree-context-menu-root attr (passed via {...attrs}) tells * Pierre that portaled clicks inside the menu are not "outside" clicks. */ -export function RowContextMenu({ +export function PierreRowContextMenu({ anchorRect, onClose, children, ...attrs -}: RowContextMenuProps) { +}: PierreRowContextMenuProps) { return ( !open && onClose()}> diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PierreRowContextMenu/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PierreRowContextMenu/index.ts new file mode 100644 index 00000000000..aa0bdf56f6b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PierreRowContextMenu/index.ts @@ -0,0 +1 @@ +export { PierreRowContextMenu } from "./PierreRowContextMenu"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx index 9b7ed740512..196d2661098 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx @@ -1,12 +1,15 @@ import { memo, useMemo } from "react"; import type { ChangesetFile } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset"; +import type { ChangesViewMode } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; +import { ChangesFoldersView } from "./components/ChangesFoldersView"; import { ChangesSection } from "./components/ChangesSection"; -import { FileRow } from "./components/FileRow"; +import { ChangesTreeView } from "./components/ChangesTreeView"; interface ChangesFileListProps { files: ChangesetFile[]; workspaceId: string; isLoading?: boolean; + viewMode: ChangesViewMode; worktreePath?: string; onSelectFile?: (path: string, openInNewTab?: boolean) => void; onOpenFile?: (absolutePath: string, openInNewTab?: boolean) => void; @@ -33,6 +36,7 @@ export const ChangesFileList = memo(function ChangesFileList({ files, workspaceId, isLoading, + viewMode, worktreePath, onSelectFile, onOpenFile, @@ -84,17 +88,26 @@ export const ChangesFileList = memo(function ChangesFileList({ : undefined } > - {groupFiles.map((file) => ( - - ))} + ) : ( + + )} ); })} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx new file mode 100644 index 00000000000..e5e6574b45c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx @@ -0,0 +1,118 @@ +import { memo, useCallback, useMemo, useState } from "react"; +import type { ChangesetFile } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset"; +import { FileRow } from "../FileRow"; +import { FolderHeader } from "./components/FolderHeader"; + +const ROOT_FOLDER_KEY = ""; +const ROOT_FOLDER_LABEL = "Root Path"; + +interface ChangesFoldersViewProps { + files: ChangesetFile[]; + workspaceId: string; + worktreePath?: string; + onSelectFile?: (path: string, openInNewTab?: boolean) => void; + onOpenFile?: (absolutePath: string, openInNewTab?: boolean) => void; + onOpenInEditor?: (path: string) => void; +} + +interface FolderGroup { + folderPath: string; + files: ChangesetFile[]; +} + +/** + * Render a flat list of changed files grouped by their immediate parent + * folder (one level deep — v1's "grouped" mode, not the full tree). + * + * Differences from v1's `FileListGrouped`: + * - Collapse state tracked as a *closed* set, so folders that newly appear + * in the changeset default to open (v1 tracked an *expanded* set keyed by + * folder path, so a folder that didn't exist on first render stayed + * collapsed when it appeared later). + * - Per-folder bulk Stage/Unstage/Discard intentionally not ported — + * section-level bulk actions already cover the common case, and the + * per-folder buttons crowd the header. + */ +export const ChangesFoldersView = memo(function ChangesFoldersView({ + files, + workspaceId, + worktreePath, + onSelectFile, + onOpenFile, + onOpenInEditor, +}: ChangesFoldersViewProps) { + const groups = useMemo(() => groupFilesByFolder(files), [files]); + const [closedFolders, setClosedFolders] = useState>(new Set()); + + const toggleFolder = useCallback((folderPath: string) => { + setClosedFolders((prev) => { + const next = new Set(prev); + if (next.has(folderPath)) next.delete(folderPath); + else next.add(folderPath); + return next; + }); + }, []); + + return ( +
+ {groups.map((group) => { + const isRoot = group.folderPath === ROOT_FOLDER_KEY; + const isOpen = !closedFolders.has(group.folderPath); + return ( +
+ toggleFolder(group.folderPath)} + /> + {isOpen && + group.files.map((file) => ( + + ))} +
+ ); + })} +
+ ); +}); + +function groupFilesByFolder(files: ChangesetFile[]): FolderGroup[] { + const map = new Map(); + for (const file of files) { + const lastSlash = file.path.lastIndexOf("/"); + const folderPath = + lastSlash >= 0 ? file.path.slice(0, lastSlash) : ROOT_FOLDER_KEY; + const group = map.get(folderPath); + if (group) group.push(file); + else map.set(folderPath, [file]); + } + return Array.from(map.entries()) + .map(([folderPath, groupFiles]) => ({ + folderPath, + files: groupFiles.sort((a, b) => + basenameOf(a.path).localeCompare(basenameOf(b.path)), + ), + })) + .sort((a, b) => { + // Root-level files come first so they read like the top of a tree. + if (a.folderPath === ROOT_FOLDER_KEY) + return b.folderPath === ROOT_FOLDER_KEY ? 0 : -1; + if (b.folderPath === ROOT_FOLDER_KEY) return 1; + return a.folderPath.localeCompare(b.folderPath); + }); +} + +function basenameOf(path: string): string { + const i = path.lastIndexOf("/"); + return i < 0 ? path : path.slice(i + 1); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/components/FolderHeader/FolderHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/components/FolderHeader/FolderHeader.tsx new file mode 100644 index 00000000000..5f890845a5a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/components/FolderHeader/FolderHeader.tsx @@ -0,0 +1,37 @@ +interface FolderHeaderProps { + /** Display label — a folder path like "src/components", or "Root Path". */ + label: string; + fileCount: number; + isOpen: boolean; + onToggle: () => void; +} + +/** + * Collapsible header for a folder group in the changes sidebar. Shows the + * folder path right-truncated (so the deepest segment stays visible) and the + * file count. The whole row toggles collapse — no chevron, matching v1's + * "grouped" variant. + */ +export function FolderHeader({ + label, + fileCount, + isOpen, + onToggle, +}: FolderHeaderProps) { + return ( + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/components/FolderHeader/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/components/FolderHeader/index.ts new file mode 100644 index 00000000000..11b007479cb --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/components/FolderHeader/index.ts @@ -0,0 +1 @@ +export { FolderHeader } from "./FolderHeader"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/index.ts new file mode 100644 index 00000000000..43c2d81ae42 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/index.ts @@ -0,0 +1 @@ +export { ChangesFoldersView } from "./ChangesFoldersView"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx new file mode 100644 index 00000000000..b24954fe7e6 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx @@ -0,0 +1,228 @@ +import type { + FileTreeRowDecoration, + FileTreeRowDecorationContext, + ContextMenuItem as PierreContextMenuItem, + ContextMenuOpenContext as PierreContextMenuOpenContext, +} from "@pierre/trees"; +import { + FileTree as PierreFileTree, + useFileTree as usePierreFileTree, +} from "@pierre/trees/react"; +import { memo, useEffect, useMemo, useRef } from "react"; +import { + ShadowClickHint, + usePierreRowClickPolicy, + useSidebarFilePolicy, +} from "renderer/lib/clickPolicy"; +import type { FileStatus } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/StatusIndicator"; +import { PierreRowContextMenu } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PierreRowContextMenu"; +import type { ChangesetFile } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset"; +import { FileRowContextMenuItems } from "./components/FileRowContextMenuItems"; + +const TREE_STYLE: React.CSSProperties = { + "--trees-row-height-override": "24px", + "--trees-level-gap-override": "8px", + "--trees-padding-inline-override": "0", + "--trees-item-margin-x-override": "0", + "--trees-item-padding-x-override": "calc(var(--spacing) * 3)", + "--trees-item-row-gap-override": "calc(var(--spacing) * 1.5)", + "--trees-icon-width-override": "calc(var(--spacing) * 3.5)", + "--trees-border-radius-override": "0", + + "--trees-bg-override": "var(--background)", + "--trees-fg-override": "var(--foreground)", + "--trees-fg-muted-override": "var(--muted-foreground)", + "--trees-bg-muted-override": + "color-mix(in oklab, var(--accent) 50%, transparent)", + "--trees-accent-override": "var(--accent)", + "--trees-border-color-override": "var(--border)", + + "--trees-selected-bg-override": "var(--accent)", + "--trees-selected-fg-override": "var(--accent-foreground)", + "--trees-selected-focused-border-color-override": "var(--ring)", + + "--trees-focus-ring-color-override": "var(--ring)", + "--trees-focus-ring-offset-override": "0px", + + "--trees-status-added-override": "oklch(0.627 0.194 149.214)", + "--trees-status-untracked-override": "oklch(0.627 0.194 149.214)", + "--trees-status-modified-override": "oklch(0.681 0.162 75.834)", + "--trees-status-deleted-override": "oklch(0.577 0.245 27.325)", + "--trees-status-renamed-override": "oklch(0.6 0.118 244.557)", + "--trees-status-ignored-override": "var(--muted-foreground)", + + "--trees-font-size-override": "var(--text-xs)", +} as React.CSSProperties; + +const PIERRE_GIT_STATUS: Record< + FileStatus, + "added" | "deleted" | "modified" | "renamed" | "untracked" +> = { + added: "added", + changed: "modified", + copied: "added", + deleted: "deleted", + modified: "modified", + renamed: "renamed", + untracked: "untracked", +}; + +interface ChangesTreeViewProps { + /** Files for a single section — caller has already pre-grouped by `source.kind`. */ + files: ChangesetFile[]; + /** Section the files came from; used to scope context-menu Discard. */ + sectionKind: "unstaged" | "staged" | "against-base" | "commit"; + workspaceId: string; + worktreePath?: string; + onSelectFile?: (path: string, openInNewTab?: boolean) => void; + onOpenFile?: (absolutePath: string, openInNewTab?: boolean) => void; + onOpenInEditor?: (path: string) => void; +} + +/** + * Tree view of a single changes section, powered by `@pierre/trees`. Pierre + * builds the directory hierarchy from the flat path list, handles + * virtualization + status tints + icons, and we layer on top: + * + * - `renderRowDecoration` for `+N/−N` and the rename arrow + * - `renderContextMenu` for the same actions as `FileRow` (Open Diff, Open + * in New Tab, Open File, Open in Editor, Discard on unstaged) + * - `usePierreRowClickPolicy` for settings-driven click routing + * + * Selection sync (an external `selectedFilePath` echoed back to Pierre via + * `model.focusPath`) is intentionally not plumbed yet — clicks still fire + * `onSelectFile`, and the diff pane stays the source of truth. + */ +export const ChangesTreeView = memo(function ChangesTreeView({ + files, + sectionKind, + workspaceId, + worktreePath, + onSelectFile, + onOpenFile, + onOpenInEditor, +}: ChangesTreeViewProps) { + const paths = useMemo(() => files.map((f) => f.path), [files]); + const fileByPath = useMemo(() => { + const map = new Map(); + for (const file of files) map.set(file.path, file); + return map; + }, [files]); + + const initialGitStatusEntriesRef = useRef(buildPierreGitStatus(files)); + + // Callbacks routed through a ref so Pierre's stable handler closures + // (resolved once at `useFileTree` time) always see the latest props. + const handlersRef = useRef({ + onSelect(_path: string) {}, + renderRowDecoration( + _ctx: FileTreeRowDecorationContext, + ): FileTreeRowDecoration | null { + return null; + }, + }); + + const { model } = usePierreFileTree({ + paths, + initialExpansion: "open", + search: false, + gitStatus: initialGitStatusEntriesRef.current, + icons: { set: "complete", colored: true }, + itemHeight: 24, + overscan: 20, + stickyFolders: true, + onSelectionChange: (selected) => { + const last = selected[selected.length - 1]; + if (!last || last.endsWith("/")) return; + handlersRef.current.onSelect(last); + }, + renderRowDecoration: (ctx) => handlersRef.current.renderRowDecoration(ctx), + }); + + // Keep Pierre's path set in sync as files churn (stage/unstage, new edits). + useEffect(() => { + model.resetPaths(paths); + }, [model, paths]); + + useEffect(() => { + model.setGitStatus(buildPierreGitStatus(files)); + }, [model, files]); + + handlersRef.current.onSelect = (treePath) => { + onSelectFile?.(treePath, false); + }; + // Pierre's row decoration accepts text or icon, not arbitrary JSX. The + // status indicator is already painted by `setGitStatus` (row tint + icon), + // so we only contribute the `+N/−N` summary as text. Color distinction + // between additions and deletions is dropped here — trade-off for Pierre's + // shadow-DOM ownership of the row. + handlersRef.current.renderRowDecoration = (ctx) => { + if (ctx.item.kind === "directory") return null; + const file = fileByPath.get(ctx.item.path); + if (!file) return null; + const text = formatDiffStats(file.additions, file.deletions); + return text ? { text } : null; + }; + + const filePolicy = useSidebarFilePolicy(); + const { onClickCapture, findFileRow } = usePierreRowClickPolicy({ + filePolicy, + onSelectFile: (rel, openInNewTab) => onSelectFile?.(rel, openInNewTab), + openInExternalEditor: (rel) => onOpenInEditor?.(rel), + }); + + const renderContextMenu = ( + item: PierreContextMenuItem, + ctx: PierreContextMenuOpenContext, + ) => { + if (item.kind === "directory") return null; + const file = fileByPath.get(item.path); + if (!file) return null; + return ( + + + + ); + }; + + return ( +
+ + + +
+ ); +}); + +function buildPierreGitStatus(files: ChangesetFile[]): { + path: string; + status: "added" | "deleted" | "modified" | "renamed" | "untracked"; +}[] { + return files.map((file) => ({ + path: file.path, + status: PIERRE_GIT_STATUS[file.status], + })); +} + +function formatDiffStats(additions: number, deletions: number): string { + if (additions === 0 && deletions === 0) return ""; + if (additions === 0) return `−${deletions}`; + if (deletions === 0) return `+${additions}`; + return `+${additions} −${deletions}`; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/components/FileRowContextMenuItems/FileRowContextMenuItems.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/components/FileRowContextMenuItems/FileRowContextMenuItems.tsx new file mode 100644 index 00000000000..3174bb50537 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/components/FileRowContextMenuItems/FileRowContextMenuItems.tsx @@ -0,0 +1,153 @@ +import { + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuShortcut, +} from "@superset/ui/dropdown-menu"; +import { toast } from "@superset/ui/sonner"; +import { workspaceTrpc } from "@superset/workspace-client"; +import { + ExternalLink, + FileText, + GitCompare, + SquarePlus, + Trash2, + Undo2, +} from "lucide-react"; +import { useState } from "react"; +import { modifierLabel, useSidebarFilePolicy } from "renderer/lib/clickPolicy"; +import { DiscardConfirmDialog } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/DiscardConfirmDialog"; +import { PathActionsMenuItems } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PathActionsMenuItems"; +import type { ChangesetFile } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset"; +import { toAbsoluteWorkspacePath } from "shared/absolute-paths"; + +interface FileRowContextMenuItemsProps { + file: ChangesetFile; + workspaceId: string; + worktreePath?: string; + sectionKind: "unstaged" | "staged" | "against-base" | "commit"; + onSelectFile?: (path: string, openInNewTab?: boolean) => void; + onOpenFile?: (absolutePath: string, openInNewTab?: boolean) => void; + onOpenInEditor?: (path: string) => void; +} + +/** + * Right-click menu items for a Pierre row in the changes tree. Mirrors the + * `FileRow` right-click menu so users get the same vocabulary regardless of + * view mode. + */ +export function FileRowContextMenuItems({ + file, + workspaceId, + worktreePath, + sectionKind, + onSelectFile, + onOpenFile, + onOpenInEditor, +}: FileRowContextMenuItemsProps) { + const absolutePath = worktreePath + ? toAbsoluteWorkspacePath(worktreePath, file.path) + : undefined; + const canDiscard = sectionKind === "unstaged"; + const isDeleteAction = file.status === "untracked" || file.status === "added"; + const basename = file.path.split("/").pop() ?? file.path; + + const [showDiscardConfirm, setShowDiscardConfirm] = useState(false); + const utils = workspaceTrpc.useUtils(); + const discardMutation = workspaceTrpc.git.discardChanges.useMutation({ + onSuccess: () => { + void utils.git.getStatus.invalidate({ workspaceId }); + void utils.git.getDiff.invalidate({ workspaceId }); + }, + onError: (err) => { + toast.error("Couldn't discard changes", { description: err.message }); + }, + }); + + const policy = useSidebarFilePolicy(); + const newTabTier = policy.tierForAction("newTab"); + const externalTier = policy.tierForAction("external"); + + return ( + <> + onSelectFile?.(file.path)}> + + Open Diff + + onSelectFile?.(file.path, true)}> + + Open Diff in New Tab + {newTabTier && ( + + {modifierLabel(newTabTier)} + + )} + + absolutePath && onOpenFile?.(absolutePath)} + disabled={!onOpenFile || !absolutePath} + > + + Open File + + absolutePath && onOpenFile?.(absolutePath, true)} + disabled={!onOpenFile || !absolutePath} + > + + Open File in New Tab + + onOpenInEditor?.(file.path)} + disabled={!onOpenInEditor} + > + + Open in Editor + {externalTier && ( + + {modifierLabel(externalTier)} + + )} + + {absolutePath && ( + <> + + + + )} + {canDiscard && ( + <> + + setShowDiscardConfirm(true)} + > + {isDeleteAction ? : } + {isDeleteAction ? "Delete" : "Discard changes"} + + + )} + { + setShowDiscardConfirm(false); + discardMutation.mutate({ workspaceId, filePath: file.path }); + }} + /> + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/components/FileRowContextMenuItems/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/components/FileRowContextMenuItems/index.ts new file mode 100644 index 00000000000..4b4b97e37d6 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/components/FileRowContextMenuItems/index.ts @@ -0,0 +1 @@ +export { FileRowContextMenuItems } from "./FileRowContextMenuItems"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/index.ts new file mode 100644 index 00000000000..6f4dd715013 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/index.ts @@ -0,0 +1 @@ +export { ChangesTreeView } from "./ChangesTreeView"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/FileRow/FileRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/FileRow/FileRow.tsx index 5214a5241af..8c30722b4e2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/FileRow/FileRow.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/FileRow/FileRow.tsx @@ -47,6 +47,8 @@ interface FileRowProps { file: ChangesetFile; workspaceId: string; worktreePath?: string; + /** Hide the directory prefix — used when the row sits under a folder group. */ + hideDir?: boolean; onSelect?: (path: string, openInNewTab?: boolean) => void; onOpenFile?: (absolutePath: string, openInNewTab?: boolean) => void; onOpenInEditor?: (path: string) => void; @@ -56,11 +58,13 @@ export const FileRow = memo(function FileRow({ file, workspaceId, worktreePath, + hideDir, onSelect, onOpenFile, onOpenInEditor, }: FileRowProps) { - const { dir, basename } = splitPath(file.path); + const { dir: fullDir, basename } = splitPath(file.path); + const dir = hideDir ? "" : fullDir; const oldBasename = file.oldPath && (file.status === "renamed" || file.status === "copied") ? splitPath(file.oldPath).basename diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/ChangesHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/ChangesHeader.tsx index ae7dc18e485..6e6080f9bb7 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/ChangesHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/ChangesHeader.tsx @@ -1,9 +1,13 @@ import { GitBranch, Pencil } from "lucide-react"; import { useRef, useState } from "react"; -import type { ChangesFilter } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; +import type { + ChangesFilter, + ChangesViewMode, +} from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; import type { Branch, Commit } from "../../types"; import { BaseBranchSelector } from "../BaseBranchSelector"; import { CommitFilterDropdown } from "../CommitFilterDropdown"; +import { ViewModeToggle } from "./components/ViewModeToggle"; interface ChangesHeaderProps { currentBranch: { name: string; aheadCount: number; behindCount: number }; @@ -14,6 +18,8 @@ interface ChangesHeaderProps { totalDeletions: number; filter: ChangesFilter; onFilterChange: (filter: ChangesFilter) => void; + viewMode: ChangesViewMode; + onViewModeChange: (viewMode: ChangesViewMode) => void; commits: Commit[]; uncommittedCount: number; branches: Branch[]; @@ -33,6 +39,8 @@ export function ChangesHeader({ canRename, filter, onFilterChange, + viewMode, + onViewModeChange, commits, uncommittedCount, branches, @@ -115,6 +123,7 @@ export function ChangesHeader({ uncommittedCount={uncommittedCount} />
+ {totalFiles} {totalFiles === 1 ? "file" : "files"} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/components/ViewModeToggle/ViewModeToggle.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/components/ViewModeToggle/ViewModeToggle.tsx new file mode 100644 index 00000000000..c950b0cbe46 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/components/ViewModeToggle/ViewModeToggle.tsx @@ -0,0 +1,69 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { cn } from "@superset/ui/utils"; +import { Folder, ListTree } from "lucide-react"; +import type { ChangesViewMode } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; + +interface ViewModeToggleProps { + viewMode: ChangesViewMode; + onChange: (next: ChangesViewMode) => void; +} + +/** + * Two-button segmented toggle: folders (flat by parent folder) vs tree + * (full directory hierarchy). The active button gets `bg-accent`; the other + * stays `text-muted-foreground` so the current mode reads at a glance. + */ +export function ViewModeToggle({ viewMode, onChange }: ViewModeToggleProps) { + return ( +
+ onChange("folders")} + /> + onChange("tree")} + /> +
+ ); +} + +interface ToggleButtonProps { + icon: React.ComponentType<{ className?: string }>; + label: string; + active: boolean; + onClick: () => void; +} + +function ToggleButton({ + icon: Icon, + label, + active, + onClick, +}: ToggleButtonProps) { + return ( + + + + + {label} + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/components/ViewModeToggle/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/components/ViewModeToggle/index.ts new file mode 100644 index 00000000000..5e69ac17ef8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/components/ViewModeToggle/index.ts @@ -0,0 +1 @@ +export { ViewModeToggle } from "./ViewModeToggle"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/ChangesTabContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/ChangesTabContent.tsx index a61cf8a0897..22fc82290ff 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/ChangesTabContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/ChangesTabContent.tsx @@ -1,7 +1,10 @@ import type { AppRouter } from "@superset/host-service"; import type { inferRouterOutputs } from "@trpc/server"; import { memo } from "react"; -import type { ChangesFilter } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; +import type { + ChangesFilter, + ChangesViewMode, +} from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; import type { ChangesetFile } from "../../../../../../hooks/useChangeset"; import { ChangesFileList } from "../ChangesFileList"; import { ChangesHeader } from "../ChangesHeader"; @@ -17,6 +20,7 @@ interface ChangesTabContentProps { commits: { data: RouterOutputs["git"]["listCommits"] | undefined }; branches: { data: RouterOutputs["git"]["listBranches"] | undefined }; filter: ChangesFilter; + viewMode: ChangesViewMode; baseBranch: string | null; files: ChangesetFile[]; isLoading: boolean; @@ -28,6 +32,7 @@ interface ChangesTabContentProps { onOpenFile?: (absolutePath: string, openInNewTab?: boolean) => void; onOpenInEditor?: (path: string) => void; onFilterChange: (filter: ChangesFilter) => void; + onViewModeChange: (viewMode: ChangesViewMode) => void; onBaseBranchChange: (branchName: string) => void; onRenameBranch: (newName: string) => void; canRenameBranch: boolean; @@ -39,6 +44,7 @@ export const ChangesTabContent = memo(function ChangesTabContent({ commits, branches, filter, + viewMode, baseBranch, files, isLoading, @@ -50,6 +56,7 @@ export const ChangesTabContent = memo(function ChangesTabContent({ onOpenFile, onOpenInEditor, onFilterChange, + onViewModeChange, onBaseBranchChange, onRenameBranch, canRenameBranch, @@ -81,6 +88,8 @@ export const ChangesTabContent = memo(function ChangesTabContent({ totalDeletions={totalDeletions} filter={filter} onFilterChange={onFilterChange} + viewMode={viewMode} + onViewModeChange={onViewModeChange} commits={commits.data?.commits ?? []} uncommittedCount={ status.data.staged.length + status.data.unstaged.length @@ -95,6 +104,7 @@ export const ChangesTabContent = memo(function ChangesTabContent({ files={files} workspaceId={workspaceId} isLoading={isLoading} + viewMode={viewMode} worktreePath={worktreePath} onSelectFile={onSelectFile} onOpenFile={onOpenFile} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx index d730032fca4..b5b7dfd4d16 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx @@ -10,12 +10,15 @@ import { useChangeset } from "renderer/routes/_authenticated/_dashboard/v2-works import { useOpenInExternalEditor } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor"; import { useSidebarDiffRef } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSidebarDiffRef"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; -import type { ChangesFilter } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; +import type { + ChangesFilter, + ChangesViewMode, +} from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; import { toAbsoluteWorkspacePath } from "shared/absolute-paths"; import type { SidebarTabDefinition } from "../../types"; import { ChangesTabContent } from "./components/ChangesTabContent"; -export type { ChangesFilter }; +export type { ChangesFilter, ChangesViewMode }; interface UseChangesTabParams { workspaceId: string; @@ -36,6 +39,8 @@ export function useChangesTab({ const filter: ChangesFilter = localState?.sidebarState?.changesFilter ?? { kind: "all", }; + const viewMode: ChangesViewMode = + localState?.sidebarState?.changesViewMode ?? "folders"; const baseBranchQuery = workspaceTrpc.git.getBaseBranch.useQuery( { workspaceId }, @@ -70,6 +75,16 @@ export function useChangesTab({ [collections, workspaceId], ); + const setViewMode = useCallback( + (next: ChangesViewMode) => { + if (!collections.v2WorkspaceLocalState.get(workspaceId)) return; + collections.v2WorkspaceLocalState.update(workspaceId, (draft) => { + draft.sidebarState.changesViewMode = next; + }); + }, + [collections, workspaceId], + ); + const setBaseBranchMutation = workspaceTrpc.git.setBaseBranch.useMutation({ onSuccess: () => { void utils.git.getBaseBranch.invalidate({ workspaceId }); @@ -173,6 +188,7 @@ export function useChangesTab({ commits={commits} branches={branches} filter={filter} + viewMode={viewMode} baseBranch={baseBranch} files={files} isLoading={isLoading} @@ -184,6 +200,7 @@ export function useChangesTab({ onOpenFile={onOpenFile} onOpenInEditor={handleOpenInEditor} onFilterChange={setFilter} + onViewModeChange={setViewMode} onBaseBranchChange={setBaseBranch} onRenameBranch={handleRenameBranch} canRenameBranch={canRenameBranch} diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts index 0382cf92872..fc4a641ce15 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts @@ -28,6 +28,8 @@ const changesFilterSchema = z.discriminatedUnion("kind", [ export type ChangesFilter = z.infer; +export type ChangesViewMode = "folders" | "tree"; + const workspaceRunStateSchema = z.enum([ "running", "stopped-by-user", @@ -56,6 +58,7 @@ export const workspaceLocalStateSchema = z.object({ tabOrder: z.number().int().default(0), sectionId: z.string().uuid().nullable().default(null), changesFilter: changesFilterSchema.default({ kind: "all" }), + changesViewMode: z.enum(["folders", "tree"]).default("folders"), activeTab: z.enum(["changes", "files", "review"]).default("changes"), isHidden: z.boolean().default(false), }), @@ -82,6 +85,7 @@ const SIDEBAR_STATE_DEFAULTS = { tabOrder: 0, sectionId: null, changesFilter: { kind: "all" }, + changesViewMode: "folders", activeTab: "changes", isHidden: false, } as const; From 2ba729162d4f4f9f4dc071aad03b54f952fe222e Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 11 May 2026 21:39:59 -0700 Subject: [PATCH 02/24] fix(desktop): give changes tree view an explicit height so it renders Pierre's FileTree host is `height: 100%; overflow: hidden` when virtualized. Inside a changes section's auto-height container that collapses to 0, so the tree was invisible. Size each tree explicitly to its content: derive the expanded row count from the path list and recompute via model.subscribe when folders collapse/expand. --- .../ChangesTreeView/ChangesTreeView.tsx | 99 ++++++++++++++++++- 1 file changed, 95 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx index b24954fe7e6..8c0a768e722 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx @@ -1,4 +1,6 @@ import type { + FileTree, + FileTreeDirectoryHandle, FileTreeRowDecoration, FileTreeRowDecorationContext, ContextMenuItem as PierreContextMenuItem, @@ -8,7 +10,7 @@ import { FileTree as PierreFileTree, useFileTree as usePierreFileTree, } from "@pierre/trees/react"; -import { memo, useEffect, useMemo, useRef } from "react"; +import { memo, useEffect, useMemo, useRef, useState } from "react"; import { ShadowClickHint, usePierreRowClickPolicy, @@ -19,8 +21,14 @@ import { PierreRowContextMenu } from "renderer/routes/_authenticated/_dashboard/ import type { ChangesetFile } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset"; import { FileRowContextMenuItems } from "./components/FileRowContextMenuItems"; +const ITEM_HEIGHT = 24; +// Pierre rows carry `margin-block: 1px`, so each row occupies ITEM_HEIGHT + 2px. +const ROW_BOX = ITEM_HEIGHT + 2; +// Small cushion so the last row never clips against the host's `overflow: hidden`. +const HEIGHT_CUSHION = 8; + const TREE_STYLE: React.CSSProperties = { - "--trees-row-height-override": "24px", + "--trees-row-height-override": `${ITEM_HEIGHT}px`, "--trees-level-gap-override": "8px", "--trees-padding-inline-override": "0", "--trees-item-margin-x-override": "0", @@ -109,6 +117,12 @@ export const ChangesTreeView = memo(function ChangesTreeView({ return map; }, [files]); + // Pierre's host element is `height: 100%` when virtualized — inside this + // section's auto-height container that collapses to 0, so the tree would be + // invisible. We size the tree explicitly to its content. `dirs` is sorted + // shallow→deep so `countVisibleRows` can resolve each dir's ancestors first. + const { dirs, fileParents } = useMemo(() => buildTreeShape(paths), [paths]); + const initialGitStatusEntriesRef = useRef(buildPierreGitStatus(files)); // Callbacks routed through a ref so Pierre's stable handler closures @@ -128,7 +142,7 @@ export const ChangesTreeView = memo(function ChangesTreeView({ search: false, gitStatus: initialGitStatusEntriesRef.current, icons: { set: "complete", colored: true }, - itemHeight: 24, + itemHeight: ITEM_HEIGHT, overscan: 20, stickyFolders: true, onSelectionChange: (selected) => { @@ -148,6 +162,19 @@ export const ChangesTreeView = memo(function ChangesTreeView({ model.setGitStatus(buildPierreGitStatus(files)); }, [model, files]); + // Track the visible row count (shrinks when the user collapses a folder) so + // the explicit tree height tracks the actual content height. + const [visibleRowCount, setVisibleRowCount] = useState( + () => dirs.length + paths.length, + ); + useEffect(() => { + const recompute = () => + setVisibleRowCount(countVisibleRows(model, dirs, fileParents)); + recompute(); + return model.subscribe(recompute); + }, [model, dirs, fileParents]); + const treeHeight = visibleRowCount * ROW_BOX + HEIGHT_CUSHION; + handlersRef.current.onSelect = (treePath) => { onSelectFile?.(treePath, false); }; @@ -202,7 +229,7 @@ export const ChangesTreeView = memo(function ChangesTreeView({ @@ -210,6 +237,70 @@ export const ChangesTreeView = memo(function ChangesTreeView({ ); }); +/** + * From a flat list of file paths, return every directory path implied by them + * (sorted shallow→deep) and the parent directory of each file. Root-level + * files report `""` as their parent. + */ +function buildTreeShape(paths: string[]): { + dirs: string[]; + fileParents: string[]; +} { + const dirs: string[] = []; + const seen = new Set(); + const fileParents: string[] = []; + for (const path of paths) { + const segments = path.split("/"); + fileParents.push( + segments.length > 1 ? segments.slice(0, -1).join("/") : "", + ); + let acc = ""; + for (let i = 0; i < segments.length - 1; i++) { + acc = acc ? `${acc}/${segments[i]}` : segments[i]; + if (!seen.has(acc)) { + seen.add(acc); + dirs.push(acc); + } + } + } + dirs.sort( + (a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b), + ); + return { dirs, fileParents }; +} + +/** + * Count the rows Pierre currently renders: every directory whose ancestors are + * all expanded, plus every file under such a directory. `dirs` must be sorted + * shallow→deep. Pierre defaults directories to expanded (`initialExpansion`), + * so a missing/unknown handle counts as expanded. + */ +function countVisibleRows( + model: FileTree, + dirs: string[], + fileParents: string[], +): number { + const renderedDirs = new Set(); + const expandedAndVisible = new Set(); + for (const dir of dirs) { + const lastSlash = dir.lastIndexOf("/"); + const parent = lastSlash < 0 ? "" : dir.slice(0, lastSlash); + if (parent !== "" && !expandedAndVisible.has(parent)) continue; + renderedDirs.add(dir); + const handle = model.getItem(`${dir}/`); + const expanded = + handle?.isDirectory() === true + ? (handle as FileTreeDirectoryHandle).isExpanded() + : true; + if (expanded) expandedAndVisible.add(dir); + } + let count = renderedDirs.size; + for (const parent of fileParents) { + if (parent === "" || expandedAndVisible.has(parent)) count += 1; + } + return count; +} + function buildPierreGitStatus(files: ChangesetFile[]): { path: string; status: "added" | "deleted" | "modified" | "renamed" | "untracked"; From 453e205789864077b4ea03855c5e55769104bf93 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 11 May 2026 22:27:05 -0700 Subject: [PATCH 03/24] =?UTF-8?q?feat(desktop):=20changes=20tree=20?= =?UTF-8?q?=E2=80=94=20hover=20actions,=20folder=20menus,=20count=20badge,?= =?UTF-8?q?=20selection=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Hover overlay on file rows: Discard (unstaged) + more-actions ⌄ dropdown, matching FileRow. Anchors a light-DOM overlay over Pierre's shadow-root rows. - Right-click on directory rows: Open in Editor + copy-path actions. - Directory rows show a file-count decoration. - Echo the diff pane's open file into the tree's selection when it belongs to the section (with a feedback-loop guard). - Hoist the discard confirm dialog to ChangesTreeView — Pierre tears down renderContextMenu output on close, which would unmount a dialog inside it. Not done (out of reach in v2): per-file Stage/Unstage (host-service git API has no path-scoped staging), per-folder bulk actions, drag-to-copy path (Pierre owns row DOM), colored +N/−N (Pierre row decoration is text/icon only). --- .../WorkspaceSidebar/WorkspaceSidebar.tsx | 1 + .../ChangesFileList/ChangesFileList.tsx | 3 + .../ChangesTreeView/ChangesTreeView.tsx | 210 +++++++++++++++--- .../FileRowContextMenuItems.tsx | 56 ++--- .../FolderContextMenuItems.tsx | 50 +++++ .../FolderContextMenuItems/index.ts | 1 + .../ShadowRowHoverActions.tsx | 121 ++++++++++ .../components/ShadowRowHoverActions/index.ts | 1 + .../ChangesTabContent/ChangesTabContent.tsx | 3 + .../hooks/useChangesTab/useChangesTab.tsx | 4 + 10 files changed, 380 insertions(+), 70 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/components/FolderContextMenuItems/FolderContextMenuItems.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/components/FolderContextMenuItems/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/components/ShadowRowHoverActions/ShadowRowHoverActions.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/components/ShadowRowHoverActions/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx index 109ef38798e..d7972188da9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx @@ -120,6 +120,7 @@ export function WorkspaceSidebar({ const changesTabDef = useChangesTab({ workspaceId, gitStatus, + selectedFilePath, onSelectFile: onSelectDiffFile, onOpenFile: onSelectFile, }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx index 196d2661098..0d5f17b45ce 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx @@ -11,6 +11,7 @@ interface ChangesFileListProps { isLoading?: boolean; viewMode: ChangesViewMode; worktreePath?: string; + selectedFilePath?: string; onSelectFile?: (path: string, openInNewTab?: boolean) => void; onOpenFile?: (absolutePath: string, openInNewTab?: boolean) => void; onOpenInEditor?: (path: string) => void; @@ -38,6 +39,7 @@ export const ChangesFileList = memo(function ChangesFileList({ isLoading, viewMode, worktreePath, + selectedFilePath, onSelectFile, onOpenFile, onOpenInEditor, @@ -94,6 +96,7 @@ export const ChangesFileList = memo(function ChangesFileList({ sectionKind={key} workspaceId={workspaceId} worktreePath={worktreePath} + selectedFilePath={selectedFilePath} onSelectFile={onSelectFile} onOpenFile={onOpenFile} onOpenInEditor={onOpenInEditor} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx index 8c0a768e722..9bc650eb8a5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx @@ -10,16 +10,24 @@ import { FileTree as PierreFileTree, useFileTree as usePierreFileTree, } from "@pierre/trees/react"; +import { toast } from "@superset/ui/sonner"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { workspaceTrpc } from "@superset/workspace-client"; +import { Undo2 } from "lucide-react"; import { memo, useEffect, useMemo, useRef, useState } from "react"; import { ShadowClickHint, usePierreRowClickPolicy, useSidebarFilePolicy, } from "renderer/lib/clickPolicy"; +import { DiscardConfirmDialog } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/DiscardConfirmDialog"; import type { FileStatus } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/StatusIndicator"; import { PierreRowContextMenu } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PierreRowContextMenu"; import type { ChangesetFile } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset"; +import { toRelativeWorkspacePath } from "shared/absolute-paths"; import { FileRowContextMenuItems } from "./components/FileRowContextMenuItems"; +import { FolderContextMenuItems } from "./components/FolderContextMenuItems"; +import { ShadowRowHoverActions } from "./components/ShadowRowHoverActions"; const ITEM_HEIGHT = 24; // Pierre rows carry `margin-block: 1px`, so each row occupies ITEM_HEIGHT + 2px. @@ -75,13 +83,17 @@ const PIERRE_GIT_STATUS: Record< untracked: "untracked", }; +type SectionKind = "unstaged" | "staged" | "against-base" | "commit"; + interface ChangesTreeViewProps { /** Files for a single section — caller has already pre-grouped by `source.kind`. */ files: ChangesetFile[]; - /** Section the files came from; used to scope context-menu Discard. */ - sectionKind: "unstaged" | "staged" | "against-base" | "commit"; + /** Section the files came from; used to scope context-menu/hover Discard. */ + sectionKind: SectionKind; workspaceId: string; worktreePath?: string; + /** Absolute path of the file whose diff is currently open, if any. */ + selectedFilePath?: string; onSelectFile?: (path: string, openInNewTab?: boolean) => void; onOpenFile?: (absolutePath: string, openInNewTab?: boolean) => void; onOpenInEditor?: (path: string) => void; @@ -89,23 +101,26 @@ interface ChangesTreeViewProps { /** * Tree view of a single changes section, powered by `@pierre/trees`. Pierre - * builds the directory hierarchy from the flat path list, handles - * virtualization + status tints + icons, and we layer on top: + * builds the directory hierarchy from the flat path list and handles + * virtualization + status tints + icons; we layer on: * - * - `renderRowDecoration` for `+N/−N` and the rename arrow - * - `renderContextMenu` for the same actions as `FileRow` (Open Diff, Open - * in New Tab, Open File, Open in Editor, Discard on unstaged) + * - `renderRowDecoration`: `+N/−N` on files, file count on directories + * - `renderContextMenu`: file-row actions matching `FileRow`; folder-row + * actions (open in editor, copy path) + * - hover actions overlay (Discard on unstaged + more-actions ⌄ dropdown) * - `usePierreRowClickPolicy` for settings-driven click routing + * - selection echo: when the diff pane's file is in this section, focus it * - * Selection sync (an external `selectedFilePath` echoed back to Pierre via - * `model.focusPath`) is intentionally not plumbed yet — clicks still fire - * `onSelectFile`, and the diff pane stays the source of truth. + * The discard confirm dialog lives here, not in the per-row menus: Pierre + * tears down `renderContextMenu` output when the menu closes, which would + * unmount a dialog rendered inside it before the user could confirm. */ export const ChangesTreeView = memo(function ChangesTreeView({ files, sectionKind, workspaceId, worktreePath, + selectedFilePath, onSelectFile, onOpenFile, onOpenInEditor, @@ -121,7 +136,10 @@ export const ChangesTreeView = memo(function ChangesTreeView({ // section's auto-height container that collapses to 0, so the tree would be // invisible. We size the tree explicitly to its content. `dirs` is sorted // shallow→deep so `countVisibleRows` can resolve each dir's ancestors first. - const { dirs, fileParents } = useMemo(() => buildTreeShape(paths), [paths]); + const { dirs, fileParents, dirFileCount } = useMemo( + () => buildTreeShape(paths), + [paths], + ); const initialGitStatusEntriesRef = useRef(buildPierreGitStatus(files)); @@ -175,16 +193,37 @@ export const ChangesTreeView = memo(function ChangesTreeView({ }, [model, dirs, fileParents]); const treeHeight = visibleRowCount * ROW_BOX + HEIGHT_CUSHION; + // Echo the diff pane's open file back into the tree's selection — but only + // when it belongs to this section. `lastUserSelectRef` guards the loop: + // after the user clicks a row, the parent's selectedFilePath comes back to + // us and we must not re-focus (which would re-fire onSelectionChange). + const lastUserSelectRef = useRef(null); + const selectedRelPath = + selectedFilePath && worktreePath + ? toRelativeWorkspacePath(worktreePath, selectedFilePath) + : selectedFilePath; + useEffect(() => { + if (!selectedRelPath || !fileByPath.has(selectedRelPath)) return; + if (lastUserSelectRef.current === selectedRelPath) { + lastUserSelectRef.current = null; + return; + } + model.focusPath(selectedRelPath); + }, [model, selectedRelPath, fileByPath]); + handlersRef.current.onSelect = (treePath) => { + lastUserSelectRef.current = treePath; onSelectFile?.(treePath, false); }; // Pierre's row decoration accepts text or icon, not arbitrary JSX. The // status indicator is already painted by `setGitStatus` (row tint + icon), - // so we only contribute the `+N/−N` summary as text. Color distinction - // between additions and deletions is dropped here — trade-off for Pierre's - // shadow-DOM ownership of the row. + // so we contribute the `+N/−N` summary on files (uncolored — a library + // limitation) and the file count on directories. handlersRef.current.renderRowDecoration = (ctx) => { - if (ctx.item.kind === "directory") return null; + if (ctx.item.kind === "directory") { + const count = dirFileCount.get(stripTrailingSlash(ctx.item.path)); + return count ? { text: String(count) } : null; + } const file = fileByPath.get(ctx.item.path); if (!file) return null; const text = formatDiffStats(file.additions, file.deletions); @@ -194,15 +233,47 @@ export const ChangesTreeView = memo(function ChangesTreeView({ const filePolicy = useSidebarFilePolicy(); const { onClickCapture, findFileRow } = usePierreRowClickPolicy({ filePolicy, - onSelectFile: (rel, openInNewTab) => onSelectFile?.(rel, openInNewTab), + onSelectFile: (rel, openInNewTab) => { + lastUserSelectRef.current = rel; + onSelectFile?.(rel, openInNewTab); + }, openInExternalEditor: (rel) => onOpenInEditor?.(rel), }); + // Hoisted so the dialog outlives the menu/hover overlay that triggers it. + const [discardTarget, setDiscardTarget] = useState( + null, + ); + const utils = workspaceTrpc.useUtils(); + const discardMutation = workspaceTrpc.git.discardChanges.useMutation({ + onSuccess: () => { + void utils.git.getStatus.invalidate({ workspaceId }); + void utils.git.getDiff.invalidate({ workspaceId }); + }, + onError: (err) => { + toast.error("Couldn't discard changes", { description: err.message }); + }, + }); + const renderContextMenu = ( item: PierreContextMenuItem, ctx: PierreContextMenuOpenContext, ) => { - if (item.kind === "directory") return null; + if (item.kind === "directory") { + return ( + + + + ); + } const file = fileByPath.get(item.path); if (!file) return null; return ( @@ -213,42 +284,122 @@ export const ChangesTreeView = memo(function ChangesTreeView({ > ); }; + const renderHoverInlineActions = (treePath: string) => { + if (sectionKind !== "unstaged") return null; + const file = fileByPath.get(treePath); + if (!file) return null; + return ( + + + + + Discard changes + + ); + }; + + const renderHoverMenuContent = (treePath: string) => { + const file = fileByPath.get(treePath); + if (!file) return null; + return ( + + ); + }; + + const discardIsDelete = + discardTarget?.status === "untracked" || discardTarget?.status === "added"; + const discardBasename = discardTarget + ? (discardTarget.path.split("/").pop() ?? discardTarget.path) + : ""; + return (
- + + + + {discardTarget && ( + !open && setDiscardTarget(null)} + title={ + discardIsDelete + ? `Delete "${discardBasename}"?` + : `Discard changes to "${discardBasename}"?` + } + description={ + discardIsDelete + ? "This will permanently delete this file. This action cannot be undone." + : "This will revert all changes to this file. This action cannot be undone." + } + confirmLabel={discardIsDelete ? "Delete" : "Discard"} + onConfirm={() => { + const target = discardTarget; + setDiscardTarget(null); + discardMutation.mutate({ + workspaceId, + filePath: target.path, + }); + }} + /> + )}
); }); /** - * From a flat list of file paths, return every directory path implied by them - * (sorted shallow→deep) and the parent directory of each file. Root-level - * files report `""` as their parent. + * From a flat list of file paths, return: every directory path implied by them + * (sorted shallow→deep), the parent directory of each file, and a map of + * directory → count of files anywhere beneath it. Root-level files report `""` + * as their parent and don't contribute to any directory's count. */ function buildTreeShape(paths: string[]): { dirs: string[]; fileParents: string[]; + dirFileCount: Map; } { const dirs: string[] = []; const seen = new Set(); const fileParents: string[] = []; + const dirFileCount = new Map(); for (const path of paths) { const segments = path.split("/"); fileParents.push( @@ -261,12 +412,13 @@ function buildTreeShape(paths: string[]): { seen.add(acc); dirs.push(acc); } + dirFileCount.set(acc, (dirFileCount.get(acc) ?? 0) + 1); } } dirs.sort( (a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b), ); - return { dirs, fileParents }; + return { dirs, fileParents, dirFileCount }; } /** @@ -317,3 +469,7 @@ function formatDiffStats(additions: number, deletions: number): string { if (deletions === 0) return `+${additions}`; return `+${additions} −${deletions}`; } + +function stripTrailingSlash(path: string): string { + return path.endsWith("/") ? path.slice(0, -1) : path; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/components/FileRowContextMenuItems/FileRowContextMenuItems.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/components/FileRowContextMenuItems/FileRowContextMenuItems.tsx index 3174bb50537..52c8f8ab62f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/components/FileRowContextMenuItems/FileRowContextMenuItems.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/components/FileRowContextMenuItems/FileRowContextMenuItems.tsx @@ -3,8 +3,6 @@ import { DropdownMenuSeparator, DropdownMenuShortcut, } from "@superset/ui/dropdown-menu"; -import { toast } from "@superset/ui/sonner"; -import { workspaceTrpc } from "@superset/workspace-client"; import { ExternalLink, FileText, @@ -13,55 +11,46 @@ import { Trash2, Undo2, } from "lucide-react"; -import { useState } from "react"; import { modifierLabel, useSidebarFilePolicy } from "renderer/lib/clickPolicy"; -import { DiscardConfirmDialog } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/DiscardConfirmDialog"; import { PathActionsMenuItems } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PathActionsMenuItems"; import type { ChangesetFile } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset"; import { toAbsoluteWorkspacePath } from "shared/absolute-paths"; interface FileRowContextMenuItemsProps { file: ChangesetFile; - workspaceId: string; worktreePath?: string; sectionKind: "unstaged" | "staged" | "against-base" | "commit"; onSelectFile?: (path: string, openInNewTab?: boolean) => void; onOpenFile?: (absolutePath: string, openInNewTab?: boolean) => void; onOpenInEditor?: (path: string) => void; + /** + * Ask the parent to run the discard flow for this file. The confirm dialog + * lives on `ChangesTreeView`, not here — Pierre unmounts `renderContextMenu` + * output when the menu closes, which would tear down a dialog rendered + * inside it before the user could confirm. + */ + onRequestDiscard?: (file: ChangesetFile) => void; } /** - * Right-click menu items for a Pierre row in the changes tree. Mirrors the - * `FileRow` right-click menu so users get the same vocabulary regardless of - * view mode. + * Menu items for a file row in the changes tree — used both by the right-click + * context menu and the hover more-actions dropdown. Mirrors the `FileRow` + * menus so the action vocabulary is the same in folders and tree view. */ export function FileRowContextMenuItems({ file, - workspaceId, worktreePath, sectionKind, onSelectFile, onOpenFile, onOpenInEditor, + onRequestDiscard, }: FileRowContextMenuItemsProps) { const absolutePath = worktreePath ? toAbsoluteWorkspacePath(worktreePath, file.path) : undefined; const canDiscard = sectionKind === "unstaged"; const isDeleteAction = file.status === "untracked" || file.status === "added"; - const basename = file.path.split("/").pop() ?? file.path; - - const [showDiscardConfirm, setShowDiscardConfirm] = useState(false); - const utils = workspaceTrpc.useUtils(); - const discardMutation = workspaceTrpc.git.discardChanges.useMutation({ - onSuccess: () => { - void utils.git.getStatus.invalidate({ workspaceId }); - void utils.git.getDiff.invalidate({ workspaceId }); - }, - onError: (err) => { - toast.error("Couldn't discard changes", { description: err.message }); - }, - }); const policy = useSidebarFilePolicy(); const newTabTier = policy.tierForAction("newTab"); @@ -117,37 +106,18 @@ export function FileRowContextMenuItems({ /> )} - {canDiscard && ( + {canDiscard && onRequestDiscard && ( <> setShowDiscardConfirm(true)} + onSelect={() => onRequestDiscard(file)} > {isDeleteAction ? : } {isDeleteAction ? "Delete" : "Discard changes"} )} - { - setShowDiscardConfirm(false); - discardMutation.mutate({ workspaceId, filePath: file.path }); - }} - /> ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/components/FolderContextMenuItems/FolderContextMenuItems.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/components/FolderContextMenuItems/FolderContextMenuItems.tsx new file mode 100644 index 00000000000..74c64fcf490 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/components/FolderContextMenuItems/FolderContextMenuItems.tsx @@ -0,0 +1,50 @@ +import { + DropdownMenuItem, + DropdownMenuSeparator, +} from "@superset/ui/dropdown-menu"; +import { ExternalLink } from "lucide-react"; +import { PathActionsMenuItems } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PathActionsMenuItems"; +import { toAbsoluteWorkspacePath } from "shared/absolute-paths"; + +interface FolderContextMenuItemsProps { + /** Folder path relative to the workspace root. */ + relativePath: string; + worktreePath?: string; + onOpenInEditor?: (path: string) => void; +} + +/** + * Right-click menu items for a directory row in the changes tree. Bulk + * Stage/Unstage/Discard-for-folder aren't offered: the host-service git API + * has no path-scoped staging, and section-level bulk actions already cover the + * common case. + */ +export function FolderContextMenuItems({ + relativePath, + worktreePath, + onOpenInEditor, +}: FolderContextMenuItemsProps) { + const absolutePath = worktreePath + ? toAbsoluteWorkspacePath(worktreePath, relativePath) + : undefined; + return ( + <> + onOpenInEditor?.(relativePath)} + disabled={!onOpenInEditor} + > + + Open in Editor + + {absolutePath && ( + <> + + + + )} + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/components/FolderContextMenuItems/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/components/FolderContextMenuItems/index.ts new file mode 100644 index 00000000000..b1012140a97 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/components/FolderContextMenuItems/index.ts @@ -0,0 +1 @@ +export { FolderContextMenuItems } from "./FolderContextMenuItems"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/components/ShadowRowHoverActions/ShadowRowHoverActions.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/components/ShadowRowHoverActions/ShadowRowHoverActions.tsx new file mode 100644 index 00000000000..d97f995532c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/components/ShadowRowHoverActions/ShadowRowHoverActions.tsx @@ -0,0 +1,121 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { ChevronDown } from "lucide-react"; +import { type ReactNode, useCallback, useRef, useState } from "react"; + +interface ShadowRowHoverActionsProps { + /** + * Walk a mouse event's composed path to find the hovered file row, or null + * for folder rows / empty space. Pierre owns the row DOM inside an open + * shadow root, so per-row hover containers aren't possible — we anchor a + * single light-DOM overlay over the hovered row's bounding rect instead. + */ + findFileRow: (e: React.MouseEvent) => HTMLElement | null; + /** Inline action buttons (e.g. Discard) for the row at `treePath`. */ + renderInlineActions?: (treePath: string) => ReactNode; + /** Items for the more-actions ⌄ dropdown for the row at `treePath`. */ + renderMenuContent: (treePath: string) => ReactNode; + children: ReactNode; +} + +/** + * Anchors a hover-actions overlay over the file row currently under the mouse + * inside `children`. Owns the more-actions dropdown so the overlay stays + * mounted while that dropdown is open (closing the overlay mid-open would tear + * the dropdown down). + */ +export function ShadowRowHoverActions({ + findFileRow, + renderInlineActions, + renderMenuContent, + children, +}: ShadowRowHoverActionsProps) { + const [hover, setHover] = useState<{ + rect: DOMRect; + treePath: string; + } | null>(null); + const [menuOpen, setMenuOpen] = useState(false); + const hoverRowRef = useRef(null); + + const handleMouseOver = useCallback( + (e: React.MouseEvent) => { + if (menuOpen) return; + const row = findFileRow(e); + if (!row) { + if (hoverRowRef.current) { + hoverRowRef.current = null; + setHover(null); + } + return; + } + if (hoverRowRef.current === row) return; + const treePath = row.getAttribute("data-item-path"); + if (!treePath) return; + hoverRowRef.current = row; + setHover({ rect: row.getBoundingClientRect(), treePath }); + }, + [findFileRow, menuOpen], + ); + + const handleMouseLeave = useCallback(() => { + if (menuOpen) return; + hoverRowRef.current = null; + setHover(null); + }, [menuOpen]); + + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: wraps a custom-element host with its own keyboard nav + // biome-ignore lint/a11y/useKeyWithMouseEvents: hover-action anchoring is mouse-only by nature +
+ {children} + {hover && ( +
+
+ {renderInlineActions?.(hover.treePath)} + { + setMenuOpen(open); + if (!open) { + hoverRowRef.current = null; + setHover(null); + } + }} + > + + + + + {renderMenuContent(hover.treePath)} + + +
+
+ )} +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/components/ShadowRowHoverActions/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/components/ShadowRowHoverActions/index.ts new file mode 100644 index 00000000000..6c1acd3858b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/components/ShadowRowHoverActions/index.ts @@ -0,0 +1 @@ +export { ShadowRowHoverActions } from "./ShadowRowHoverActions"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/ChangesTabContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/ChangesTabContent.tsx index 22fc82290ff..2e0ec9a99c6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/ChangesTabContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/ChangesTabContent.tsx @@ -28,6 +28,7 @@ interface ChangesTabContentProps { totalAdditions: number; totalDeletions: number; worktreePath?: string; + selectedFilePath?: string; onSelectFile?: (path: string, openInNewTab?: boolean) => void; onOpenFile?: (absolutePath: string, openInNewTab?: boolean) => void; onOpenInEditor?: (path: string) => void; @@ -52,6 +53,7 @@ export const ChangesTabContent = memo(function ChangesTabContent({ totalAdditions, totalDeletions, worktreePath, + selectedFilePath, onSelectFile, onOpenFile, onOpenInEditor, @@ -106,6 +108,7 @@ export const ChangesTabContent = memo(function ChangesTabContent({ isLoading={isLoading} viewMode={viewMode} worktreePath={worktreePath} + selectedFilePath={selectedFilePath} onSelectFile={onSelectFile} onOpenFile={onOpenFile} onOpenInEditor={onOpenInEditor} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx index b5b7dfd4d16..54c63d00187 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx @@ -23,6 +23,8 @@ export type { ChangesFilter, ChangesViewMode }; interface UseChangesTabParams { workspaceId: string; gitStatus: ReturnType; + /** Absolute path of the file whose diff/preview is currently open. */ + selectedFilePath?: string; onSelectFile?: (path: string, openInNewTab?: boolean) => void; onOpenFile?: (absolutePath: string, openInNewTab?: boolean) => void; } @@ -30,6 +32,7 @@ interface UseChangesTabParams { export function useChangesTab({ workspaceId, gitStatus: status, + selectedFilePath, onSelectFile, onOpenFile, }: UseChangesTabParams): SidebarTabDefinition { @@ -196,6 +199,7 @@ export function useChangesTab({ totalAdditions={totalAdditions} totalDeletions={totalDeletions} worktreePath={worktreePath} + selectedFilePath={selectedFilePath} onSelectFile={onSelectFile} onOpenFile={onOpenFile} onOpenInEditor={handleOpenInEditor} From b8a4a7db9610f1f6dfdafc42b6bf2b033c085d54 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 12 May 2026 00:03:45 -0700 Subject: [PATCH 04/24] feat(desktop): add collapse-all/expand-all toolbar under each changes section A thin action row below each section header (both folders and tree modes), mirroring FilesTab's header button strip. Folders mode toggles the folder groups; tree mode collapses/expands the Pierre tree's directories (which resizes the section via the existing row-count subscription). --- .../ChangesFoldersView/ChangesFoldersView.tsx | 8 +++ .../ChangesTreeView/ChangesTreeView.tsx | 21 ++++++- .../SectionToolbar/SectionToolbar.tsx | 58 +++++++++++++++++++ .../components/SectionToolbar/index.ts | 1 + 4 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/SectionToolbar/SectionToolbar.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/SectionToolbar/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx index e5e6574b45c..68790c0ded6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx @@ -1,6 +1,7 @@ import { memo, useCallback, useMemo, useState } from "react"; import type { ChangesetFile } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset"; import { FileRow } from "../FileRow"; +import { SectionToolbar } from "../SectionToolbar"; import { FolderHeader } from "./components/FolderHeader"; const ROOT_FOLDER_KEY = ""; @@ -53,8 +54,15 @@ export const ChangesFoldersView = memo(function ChangesFoldersView({ }); }, []); + const collapseAll = useCallback( + () => setClosedFolders(new Set(groups.map((g) => g.folderPath))), + [groups], + ); + const expandAll = useCallback(() => setClosedFolders(new Set()), []); + return (
+ {groups.map((group) => { const isRoot = group.folderPath === ROOT_FOLDER_KEY; const isOpen = !closedFolders.has(group.folderPath); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx index 9bc650eb8a5..6a2840bc921 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx @@ -14,7 +14,7 @@ import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { workspaceTrpc } from "@superset/workspace-client"; import { Undo2 } from "lucide-react"; -import { memo, useEffect, useMemo, useRef, useState } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ShadowClickHint, usePierreRowClickPolicy, @@ -25,6 +25,7 @@ import type { FileStatus } from "renderer/routes/_authenticated/_dashboard/v2-wo import { PierreRowContextMenu } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PierreRowContextMenu"; import type { ChangesetFile } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset"; import { toRelativeWorkspacePath } from "shared/absolute-paths"; +import { SectionToolbar } from "../SectionToolbar"; import { FileRowContextMenuItems } from "./components/FileRowContextMenuItems"; import { FolderContextMenuItems } from "./components/FolderContextMenuItems"; import { ShadowRowHoverActions } from "./components/ShadowRowHoverActions"; @@ -193,6 +194,23 @@ export const ChangesTreeView = memo(function ChangesTreeView({ }, [model, dirs, fileParents]); const treeHeight = visibleRowCount * ROW_BOX + HEIGHT_CUSHION; + const collapseAll = useCallback(() => { + for (const dir of dirs) { + const handle = model.getItem(`${dir}/`); + if (handle?.isDirectory() !== true) continue; + const dirHandle = handle as FileTreeDirectoryHandle; + if (dirHandle.isExpanded()) dirHandle.collapse(); + } + }, [model, dirs]); + const expandAll = useCallback(() => { + for (const dir of dirs) { + const handle = model.getItem(`${dir}/`); + if (handle?.isDirectory() !== true) continue; + const dirHandle = handle as FileTreeDirectoryHandle; + if (!dirHandle.isExpanded()) dirHandle.expand(); + } + }, [model, dirs]); + // Echo the diff pane's open file back into the tree's selection — but only // when it belongs to this section. `lastUserSelectRef` guards the loop: // after the user clicks a row, the parent's selectedFilePath comes back to @@ -343,6 +361,7 @@ export const ChangesTreeView = memo(function ChangesTreeView({ return (
+ void; + onExpandAll: () => void; +} + +/** + * Thin action row that sits under a changes section header (below the + * title/count, above the file list/tree) — the changes-sidebar analog of + * `FilesTab`'s header button strip. Currently just collapse-all / expand-all. + */ +export function SectionToolbar({ + onCollapseAll, + onExpandAll, +}: SectionToolbarProps) { + return ( +
+ + +
+ ); +} + +interface ToolbarButtonProps { + icon: React.ComponentType<{ className?: string }>; + label: string; + onClick: () => void; +} + +function ToolbarButton({ icon: Icon, label, onClick }: ToolbarButtonProps) { + return ( + + + + + {label} + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/SectionToolbar/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/SectionToolbar/index.ts new file mode 100644 index 00000000000..677ee83ecb2 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/SectionToolbar/index.ts @@ -0,0 +1 @@ +export { SectionToolbar } from "./SectionToolbar"; From d4a23b4c93dc85ef21484eacaeaa7630570bdcde Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 12 May 2026 00:17:45 -0700 Subject: [PATCH 05/24] refactor(desktop): single changes toolbar row; drop header background + per-section toolbars - New ChangesToolbar row beneath the changes header: folders/tree toggle on the left (moved out of the header), expand-all / collapse-all on the right. - Removed the per-section collapse/expand toolbars; expand/collapse-all now fans out to every section via a fold-signal prop (folder groups in folders mode, Pierre tree directories in tree mode). - Dropped the muted background tint from the changes header. --- .../ChangesFileList/ChangesFileList.tsx | 10 +++ .../ChangesFoldersView/ChangesFoldersView.tsx | 27 +++++--- .../ChangesTreeView/ChangesTreeView.tsx | 16 ++++- .../SectionToolbar/SectionToolbar.tsx | 58 ---------------- .../components/SectionToolbar/index.ts | 1 - .../components/ChangesFileList/index.ts | 1 + .../ChangesHeader/ChangesHeader.tsx | 13 +--- .../ChangesTabContent/ChangesTabContent.tsx | 26 ++++++- .../ChangesToolbar/ChangesToolbar.tsx | 68 +++++++++++++++++++ .../components/ChangesToolbar/index.ts | 1 + 10 files changed, 138 insertions(+), 83 deletions(-) delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/SectionToolbar/SectionToolbar.tsx delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/SectionToolbar/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesToolbar/ChangesToolbar.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesToolbar/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx index 0d5f17b45ce..3fcfc241181 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx @@ -5,6 +5,12 @@ import { ChangesFoldersView } from "./components/ChangesFoldersView"; import { ChangesSection } from "./components/ChangesSection"; import { ChangesTreeView } from "./components/ChangesTreeView"; +/** Pulse from the toolbar's expand-all / collapse-all buttons. `epoch` is 0 until the first press. */ +export interface FoldSignal { + epoch: number; + action: "collapse" | "expand"; +} + interface ChangesFileListProps { files: ChangesetFile[]; workspaceId: string; @@ -12,6 +18,7 @@ interface ChangesFileListProps { viewMode: ChangesViewMode; worktreePath?: string; selectedFilePath?: string; + foldSignal: FoldSignal; onSelectFile?: (path: string, openInNewTab?: boolean) => void; onOpenFile?: (absolutePath: string, openInNewTab?: boolean) => void; onOpenInEditor?: (path: string) => void; @@ -40,6 +47,7 @@ export const ChangesFileList = memo(function ChangesFileList({ viewMode, worktreePath, selectedFilePath, + foldSignal, onSelectFile, onOpenFile, onOpenInEditor, @@ -97,6 +105,7 @@ export const ChangesFileList = memo(function ChangesFileList({ workspaceId={workspaceId} worktreePath={worktreePath} selectedFilePath={selectedFilePath} + foldSignal={foldSignal} onSelectFile={onSelectFile} onOpenFile={onOpenFile} onOpenInEditor={onOpenInEditor} @@ -106,6 +115,7 @@ export const ChangesFileList = memo(function ChangesFileList({ files={groupFiles} workspaceId={workspaceId} worktreePath={worktreePath} + foldSignal={foldSignal} onSelectFile={onSelectFile} onOpenFile={onOpenFile} onOpenInEditor={onOpenInEditor} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx index 68790c0ded6..025d01ac48f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx @@ -1,7 +1,7 @@ -import { memo, useCallback, useMemo, useState } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ChangesetFile } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset"; +import type { FoldSignal } from "../../ChangesFileList"; import { FileRow } from "../FileRow"; -import { SectionToolbar } from "../SectionToolbar"; import { FolderHeader } from "./components/FolderHeader"; const ROOT_FOLDER_KEY = ""; @@ -11,6 +11,8 @@ interface ChangesFoldersViewProps { files: ChangesetFile[]; workspaceId: string; worktreePath?: string; + /** Bumped by the toolbar's expand-all / collapse-all buttons. */ + foldSignal: FoldSignal; onSelectFile?: (path: string, openInNewTab?: boolean) => void; onOpenFile?: (absolutePath: string, openInNewTab?: boolean) => void; onOpenInEditor?: (path: string) => void; @@ -38,6 +40,7 @@ export const ChangesFoldersView = memo(function ChangesFoldersView({ files, workspaceId, worktreePath, + foldSignal, onSelectFile, onOpenFile, onOpenInEditor, @@ -54,15 +57,23 @@ export const ChangesFoldersView = memo(function ChangesFoldersView({ }); }, []); - const collapseAll = useCallback( - () => setClosedFolders(new Set(groups.map((g) => g.folderPath))), - [groups], - ); - const expandAll = useCallback(() => setClosedFolders(new Set()), []); + // React to expand-all / collapse-all from the toolbar — but only on a new + // signal, not when `groups` changes (which would re-apply the last action + // and stomp any folder the user re-toggled in between). + const lastFoldEpochRef = useRef(0); + useEffect(() => { + if (foldSignal.epoch === 0 || foldSignal.epoch === lastFoldEpochRef.current) + return; + lastFoldEpochRef.current = foldSignal.epoch; + setClosedFolders( + foldSignal.action === "collapse" + ? new Set(groups.map((g) => g.folderPath)) + : new Set(), + ); + }, [foldSignal, groups]); return (
- {groups.map((group) => { const isRoot = group.folderPath === ROOT_FOLDER_KEY; const isOpen = !closedFolders.has(group.folderPath); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx index 6a2840bc921..55e52c8b26e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx @@ -25,7 +25,7 @@ import type { FileStatus } from "renderer/routes/_authenticated/_dashboard/v2-wo import { PierreRowContextMenu } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PierreRowContextMenu"; import type { ChangesetFile } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset"; import { toRelativeWorkspacePath } from "shared/absolute-paths"; -import { SectionToolbar } from "../SectionToolbar"; +import type { FoldSignal } from "../../ChangesFileList"; import { FileRowContextMenuItems } from "./components/FileRowContextMenuItems"; import { FolderContextMenuItems } from "./components/FolderContextMenuItems"; import { ShadowRowHoverActions } from "./components/ShadowRowHoverActions"; @@ -95,6 +95,8 @@ interface ChangesTreeViewProps { worktreePath?: string; /** Absolute path of the file whose diff is currently open, if any. */ selectedFilePath?: string; + /** Bumped by the toolbar's expand-all / collapse-all buttons. */ + foldSignal: FoldSignal; onSelectFile?: (path: string, openInNewTab?: boolean) => void; onOpenFile?: (absolutePath: string, openInNewTab?: boolean) => void; onOpenInEditor?: (path: string) => void; @@ -122,6 +124,7 @@ export const ChangesTreeView = memo(function ChangesTreeView({ workspaceId, worktreePath, selectedFilePath, + foldSignal, onSelectFile, onOpenFile, onOpenInEditor, @@ -211,6 +214,16 @@ export const ChangesTreeView = memo(function ChangesTreeView({ } }, [model, dirs]); + // React to expand-all / collapse-all from the toolbar (new signal only). + const lastFoldEpochRef = useRef(0); + useEffect(() => { + if (foldSignal.epoch === 0 || foldSignal.epoch === lastFoldEpochRef.current) + return; + lastFoldEpochRef.current = foldSignal.epoch; + if (foldSignal.action === "collapse") collapseAll(); + else expandAll(); + }, [foldSignal, collapseAll, expandAll]); + // Echo the diff pane's open file back into the tree's selection — but only // when it belongs to this section. `lastUserSelectRef` guards the loop: // after the user clicks a row, the parent's selectedFilePath comes back to @@ -361,7 +374,6 @@ export const ChangesTreeView = memo(function ChangesTreeView({ return (
- void; - onExpandAll: () => void; -} - -/** - * Thin action row that sits under a changes section header (below the - * title/count, above the file list/tree) — the changes-sidebar analog of - * `FilesTab`'s header button strip. Currently just collapse-all / expand-all. - */ -export function SectionToolbar({ - onCollapseAll, - onExpandAll, -}: SectionToolbarProps) { - return ( -
- - -
- ); -} - -interface ToolbarButtonProps { - icon: React.ComponentType<{ className?: string }>; - label: string; - onClick: () => void; -} - -function ToolbarButton({ icon: Icon, label, onClick }: ToolbarButtonProps) { - return ( - - - - - {label} - - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/SectionToolbar/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/SectionToolbar/index.ts deleted file mode 100644 index 677ee83ecb2..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/SectionToolbar/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SectionToolbar } from "./SectionToolbar"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/index.ts index 937e4423214..3b79824f0b2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/index.ts @@ -1 +1,2 @@ +export type { FoldSignal } from "./ChangesFileList"; export { ChangesFileList } from "./ChangesFileList"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/ChangesHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/ChangesHeader.tsx index 6e6080f9bb7..39ead047a9d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/ChangesHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/ChangesHeader.tsx @@ -1,13 +1,9 @@ import { GitBranch, Pencil } from "lucide-react"; import { useRef, useState } from "react"; -import type { - ChangesFilter, - ChangesViewMode, -} from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; +import type { ChangesFilter } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; import type { Branch, Commit } from "../../types"; import { BaseBranchSelector } from "../BaseBranchSelector"; import { CommitFilterDropdown } from "../CommitFilterDropdown"; -import { ViewModeToggle } from "./components/ViewModeToggle"; interface ChangesHeaderProps { currentBranch: { name: string; aheadCount: number; behindCount: number }; @@ -18,8 +14,6 @@ interface ChangesHeaderProps { totalDeletions: number; filter: ChangesFilter; onFilterChange: (filter: ChangesFilter) => void; - viewMode: ChangesViewMode; - onViewModeChange: (viewMode: ChangesViewMode) => void; commits: Commit[]; uncommittedCount: number; branches: Branch[]; @@ -39,8 +33,6 @@ export function ChangesHeader({ canRename, filter, onFilterChange, - viewMode, - onViewModeChange, commits, uncommittedCount, branches, @@ -67,7 +59,7 @@ export function ChangesHeader({ }; return ( -
+
{isEditing ? ( @@ -123,7 +115,6 @@ export function ChangesHeader({ uncommittedCount={uncommittedCount} />
- {totalFiles} {totalFiles === 1 ? "file" : "files"} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/ChangesTabContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/ChangesTabContent.tsx index 2e0ec9a99c6..238dac53354 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/ChangesTabContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/ChangesTabContent.tsx @@ -1,13 +1,15 @@ import type { AppRouter } from "@superset/host-service"; import type { inferRouterOutputs } from "@trpc/server"; -import { memo } from "react"; +import { memo, useCallback, useState } from "react"; import type { ChangesFilter, ChangesViewMode, } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; import type { ChangesetFile } from "../../../../../../hooks/useChangeset"; +import type { FoldSignal } from "../ChangesFileList"; import { ChangesFileList } from "../ChangesFileList"; import { ChangesHeader } from "../ChangesHeader"; +import { ChangesToolbar } from "../ChangesToolbar"; type RouterOutputs = inferRouterOutputs; @@ -63,6 +65,19 @@ export const ChangesTabContent = memo(function ChangesTabContent({ onRenameBranch, canRenameBranch, }: ChangesTabContentProps) { + const [foldSignal, setFoldSignal] = useState({ + epoch: 0, + action: "expand", + }); + const collapseAll = useCallback( + () => setFoldSignal((s) => ({ epoch: s.epoch + 1, action: "collapse" })), + [], + ); + const expandAll = useCallback( + () => setFoldSignal((s) => ({ epoch: s.epoch + 1, action: "expand" })), + [], + ); + if (status.isLoading) { return (
@@ -90,8 +105,6 @@ export const ChangesTabContent = memo(function ChangesTabContent({ totalDeletions={totalDeletions} filter={filter} onFilterChange={onFilterChange} - viewMode={viewMode} - onViewModeChange={onViewModeChange} commits={commits.data?.commits ?? []} uncommittedCount={ status.data.staged.length + status.data.unstaged.length @@ -101,6 +114,12 @@ export const ChangesTabContent = memo(function ChangesTabContent({ onRenameBranch={onRenameBranch} canRename={canRenameBranch} /> +
void; + onCollapseAll: () => void; + onExpandAll: () => void; +} + +/** + * Single action row beneath the changes header (above the section list): the + * folders/tree view-mode toggle on the left, expand-all / collapse-all on the + * right. The fold actions apply to every section's folder groups (folders + * mode) or tree directories (tree mode). + */ +export function ChangesToolbar({ + viewMode, + onViewModeChange, + onCollapseAll, + onExpandAll, +}: ChangesToolbarProps) { + return ( +
+ +
+ + +
+
+ ); +} + +interface ToolbarButtonProps { + icon: React.ComponentType<{ className?: string }>; + label: string; + onClick: () => void; +} + +function ToolbarButton({ icon: Icon, label, onClick }: ToolbarButtonProps) { + return ( + + + + + {label} + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesToolbar/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesToolbar/index.ts new file mode 100644 index 00000000000..ca6b4656110 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesToolbar/index.ts @@ -0,0 +1 @@ +export { ChangesToolbar } from "./ChangesToolbar"; From 5b380ad8ea07704745350ff98dd385e8e93662e8 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 12 May 2026 00:19:53 -0700 Subject: [PATCH 06/24] refactor(desktop): single collapse/expand-all toggle in changes toolbar --- .../ChangesTabContent/ChangesTabContent.tsx | 21 +++--- .../ChangesToolbar/ChangesToolbar.tsx | 67 +++++++------------ 2 files changed, 37 insertions(+), 51 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/ChangesTabContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/ChangesTabContent.tsx index 238dac53354..7814ed26b7e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/ChangesTabContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/ChangesTabContent.tsx @@ -69,12 +69,17 @@ export const ChangesTabContent = memo(function ChangesTabContent({ epoch: 0, action: "expand", }); - const collapseAll = useCallback( - () => setFoldSignal((s) => ({ epoch: s.epoch + 1, action: "collapse" })), - [], - ); - const expandAll = useCallback( - () => setFoldSignal((s) => ({ epoch: s.epoch + 1, action: "expand" })), + const foldCollapsed = + foldSignal.epoch > 0 && foldSignal.action === "collapse"; + const toggleFold = useCallback( + () => + setFoldSignal((s) => { + const wasCollapsed = s.epoch > 0 && s.action === "collapse"; + return { + epoch: s.epoch + 1, + action: wasCollapsed ? "expand" : "collapse", + }; + }), [], ); @@ -117,8 +122,8 @@ export const ChangesTabContent = memo(function ChangesTabContent({
void; - onCollapseAll: () => void; - onExpandAll: () => void; + /** Whether the last fold action was "collapse all". */ + collapsed: boolean; + /** Toggle between collapse-all and expand-all across every section. */ + onToggleFold: () => void; } /** * Single action row beneath the changes header (above the section list): the - * folders/tree view-mode toggle on the left, expand-all / collapse-all on the - * right. The fold actions apply to every section's folder groups (folders + * folders/tree view-mode toggle on the left, and one collapse/expand-all + * toggle on the right that applies to every section's folder groups (folders * mode) or tree directories (tree mode). */ export function ChangesToolbar({ viewMode, onViewModeChange, - onCollapseAll, - onExpandAll, + collapsed, + onToggleFold, }: ChangesToolbarProps) { + const label = collapsed ? "Expand all" : "Collapse all"; + const Icon = collapsed ? UnfoldVertical : FoldVertical; return (
-
- - -
+ + + + + {label} +
); } - -interface ToolbarButtonProps { - icon: React.ComponentType<{ className?: string }>; - label: string; - onClick: () => void; -} - -function ToolbarButton({ icon: Icon, label, onClick }: ToolbarButtonProps) { - return ( - - - - - {label} - - ); -} From 6e84e466a3fbded2051eff5130697cee63bd482b Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 12 May 2026 00:23:59 -0700 Subject: [PATCH 07/24] fix(desktop): size changes tree to Pierre's real content height The previous estimate (dirs + files) over-counted badly for deep monorepo paths because Pierre flattens single-child directory chains into one row, so the tree host ended up far taller than its content (big gap below the rows). Read the content height Pierre writes to the virtualized list's inline style instead, re-reading after each model mutation. --- .../ChangesTreeView/ChangesTreeView.tsx | 109 ++++++++---------- 1 file changed, 47 insertions(+), 62 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx index 55e52c8b26e..eaf2a6f4635 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx @@ -1,5 +1,4 @@ import type { - FileTree, FileTreeDirectoryHandle, FileTreeRowDecoration, FileTreeRowDecorationContext, @@ -136,14 +135,7 @@ export const ChangesTreeView = memo(function ChangesTreeView({ return map; }, [files]); - // Pierre's host element is `height: 100%` when virtualized — inside this - // section's auto-height container that collapses to 0, so the tree would be - // invisible. We size the tree explicitly to its content. `dirs` is sorted - // shallow→deep so `countVisibleRows` can resolve each dir's ancestors first. - const { dirs, fileParents, dirFileCount } = useMemo( - () => buildTreeShape(paths), - [paths], - ); + const { dirs, dirFileCount } = useMemo(() => buildTreeShape(paths), [paths]); const initialGitStatusEntriesRef = useRef(buildPierreGitStatus(files)); @@ -184,18 +176,49 @@ export const ChangesTreeView = memo(function ChangesTreeView({ model.setGitStatus(buildPierreGitStatus(files)); }, [model, files]); - // Track the visible row count (shrinks when the user collapses a folder) so - // the explicit tree height tracks the actual content height. - const [visibleRowCount, setVisibleRowCount] = useState( - () => dirs.length + paths.length, - ); + // Pierre's host is `height: 100%` when virtualized — inside this section's + // auto-height container that collapses to 0, so the tree would be + // invisible. Size it to the content. Pierre already computes that height + // (rendered rows × itemHeight, *after* it flattens single-child directory + // chains into one row) and writes it to the virtualized list's inline + // `style.height` — mirror that. A naive `dirs + files` count would + // massively over-estimate because it doesn't know about flattening. + const [contentHeight, setContentHeight] = useState(null); useEffect(() => { - const recompute = () => - setVisibleRowCount(countVisibleRows(model, dirs, fileParents)); - recompute(); - return model.subscribe(recompute); - }, [model, dirs, fileParents]); - const treeHeight = visibleRowCount * ROW_BOX + HEIGHT_CUSHION; + const readHeight = (): boolean => { + const list = model + .getFileTreeContainer() + ?.shadowRoot?.querySelector( + "[data-file-tree-virtualized-list]", + ); + const h = list ? Number.parseFloat(list.style.height) : Number.NaN; + if (Number.isFinite(h) && h > 0) { + setContentHeight(h); + return true; + } + return false; + }; + let raf = 0; + let attempts = 0; + const retryUntilReady = () => { + if (readHeight() || attempts++ > 30) return; + raf = requestAnimationFrame(retryUntilReady); + }; + retryUntilReady(); + // Pierre rewrites `style.height` when the rendered row count changes + // (resetPaths, expand/collapse); re-read on the next frame after each. + const unsubscribe = model.subscribe(() => { + raf = requestAnimationFrame(readHeight); + }); + return () => { + cancelAnimationFrame(raf); + unsubscribe(); + }; + }, [model]); + const treeHeight = + contentHeight != null + ? contentHeight + HEIGHT_CUSHION + : (dirs.length + paths.length) * ROW_BOX + HEIGHT_CUSHION; const collapseAll = useCallback(() => { for (const dir of dirs) { @@ -417,25 +440,19 @@ export const ChangesTreeView = memo(function ChangesTreeView({ }); /** - * From a flat list of file paths, return: every directory path implied by them - * (sorted shallow→deep), the parent directory of each file, and a map of - * directory → count of files anywhere beneath it. Root-level files report `""` - * as their parent and don't contribute to any directory's count. + * From a flat list of file paths, return every directory path implied by them + * (sorted shallow→deep, so a directory's ancestors precede it) and a map of + * directory → count of files anywhere beneath it. */ function buildTreeShape(paths: string[]): { dirs: string[]; - fileParents: string[]; dirFileCount: Map; } { const dirs: string[] = []; const seen = new Set(); - const fileParents: string[] = []; const dirFileCount = new Map(); for (const path of paths) { const segments = path.split("/"); - fileParents.push( - segments.length > 1 ? segments.slice(0, -1).join("/") : "", - ); let acc = ""; for (let i = 0; i < segments.length - 1; i++) { acc = acc ? `${acc}/${segments[i]}` : segments[i]; @@ -449,39 +466,7 @@ function buildTreeShape(paths: string[]): { dirs.sort( (a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b), ); - return { dirs, fileParents, dirFileCount }; -} - -/** - * Count the rows Pierre currently renders: every directory whose ancestors are - * all expanded, plus every file under such a directory. `dirs` must be sorted - * shallow→deep. Pierre defaults directories to expanded (`initialExpansion`), - * so a missing/unknown handle counts as expanded. - */ -function countVisibleRows( - model: FileTree, - dirs: string[], - fileParents: string[], -): number { - const renderedDirs = new Set(); - const expandedAndVisible = new Set(); - for (const dir of dirs) { - const lastSlash = dir.lastIndexOf("/"); - const parent = lastSlash < 0 ? "" : dir.slice(0, lastSlash); - if (parent !== "" && !expandedAndVisible.has(parent)) continue; - renderedDirs.add(dir); - const handle = model.getItem(`${dir}/`); - const expanded = - handle?.isDirectory() === true - ? (handle as FileTreeDirectoryHandle).isExpanded() - : true; - if (expanded) expandedAndVisible.add(dir); - } - let count = renderedDirs.size; - for (const parent of fileParents) { - if (parent === "" || expandedAndVisible.has(parent)) count += 1; - } - return count; + return { dirs, dirFileCount }; } function buildPierreGitStatus(files: ChangesetFile[]): { From 9584c28f73beb56c0d0547da5a1ab7ab2edb6d35 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 12 May 2026 00:25:31 -0700 Subject: [PATCH 08/24] style(desktop): right-align view toggle next to collapse button; drop header/toolbar divider --- .../useChangesTab/components/ChangesHeader/ChangesHeader.tsx | 2 +- .../useChangesTab/components/ChangesToolbar/ChangesToolbar.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/ChangesHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/ChangesHeader.tsx index 39ead047a9d..3fbf7bdb7dc 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/ChangesHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/ChangesHeader.tsx @@ -59,7 +59,7 @@ export function ChangesHeader({ }; return ( -
+
{isEditing ? ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesToolbar/ChangesToolbar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesToolbar/ChangesToolbar.tsx index 6b86d5a336b..dc3631a5905 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesToolbar/ChangesToolbar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesToolbar/ChangesToolbar.tsx @@ -28,7 +28,7 @@ export function ChangesToolbar({ const label = collapsed ? "Expand all" : "Collapse all"; const Icon = collapsed ? UnfoldVertical : FoldVertical; return ( -
+
From 60b56bf94a4202cd2f0be60e1673b572ce8f21ba Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 12 May 2026 00:27:34 -0700 Subject: [PATCH 09/24] style(desktop): add bottom padding to changes toolbar row --- .../useChangesTab/components/ChangesToolbar/ChangesToolbar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesToolbar/ChangesToolbar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesToolbar/ChangesToolbar.tsx index dc3631a5905..f2f908647c8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesToolbar/ChangesToolbar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesToolbar/ChangesToolbar.tsx @@ -28,7 +28,7 @@ export function ChangesToolbar({ const label = collapsed ? "Expand all" : "Collapse all"; const Icon = collapsed ? UnfoldVertical : FoldVertical; return ( -
+
From 9ea3e94e8ccda0733cd9cbf2463cf82c53a6d255 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 12 May 2026 00:30:11 -0700 Subject: [PATCH 10/24] style(desktop): move changes filter dropdown into the toolbar row --- .../ChangesHeader/ChangesHeader.tsx | 46 +++++--------- .../ChangesTabContent/ChangesTabContent.tsx | 12 ++-- .../ChangesToolbar/ChangesToolbar.tsx | 62 +++++++++++++------ 3 files changed, 62 insertions(+), 58 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/ChangesHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/ChangesHeader.tsx index 3fbf7bdb7dc..c65e8d856ae 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/ChangesHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/ChangesHeader.tsx @@ -1,9 +1,7 @@ import { GitBranch, Pencil } from "lucide-react"; import { useRef, useState } from "react"; -import type { ChangesFilter } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; -import type { Branch, Commit } from "../../types"; +import type { Branch } from "../../types"; import { BaseBranchSelector } from "../BaseBranchSelector"; -import { CommitFilterDropdown } from "../CommitFilterDropdown"; interface ChangesHeaderProps { currentBranch: { name: string; aheadCount: number; behindCount: number }; @@ -12,10 +10,6 @@ interface ChangesHeaderProps { totalFiles: number; totalAdditions: number; totalDeletions: number; - filter: ChangesFilter; - onFilterChange: (filter: ChangesFilter) => void; - commits: Commit[]; - uncommittedCount: number; branches: Branch[]; onBaseBranchChange: (branchName: string) => void; onRenameBranch: (newName: string) => void; @@ -31,10 +25,6 @@ export function ChangesHeader({ totalDeletions, onRenameBranch, canRename, - filter, - onFilterChange, - commits, - uncommittedCount, branches, onBaseBranchChange, }: ChangesHeaderProps) { @@ -107,29 +97,21 @@ export function ChangesHeader({ )}
-
- -
+
+ + {totalFiles} {totalFiles === 1 ? "file" : "files"} + + {(totalAdditions > 0 || totalDeletions > 0) && ( - {totalFiles} {totalFiles === 1 ? "file" : "files"} + {totalAdditions > 0 && ( + +{totalAdditions} + )} + {totalAdditions > 0 && totalDeletions > 0 && " "} + {totalDeletions > 0 && ( + -{totalDeletions} + )} - {(totalAdditions > 0 || totalDeletions > 0) && ( - - {totalAdditions > 0 && ( - +{totalAdditions} - )} - {totalAdditions > 0 && totalDeletions > 0 && " "} - {totalDeletions > 0 && ( - -{totalDeletions} - )} - - )} -
+ )}
); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/ChangesTabContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/ChangesTabContent.tsx index 7814ed26b7e..0f8fd85ae06 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/ChangesTabContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/ChangesTabContent.tsx @@ -108,18 +108,18 @@ export const ChangesTabContent = memo(function ChangesTabContent({ totalFiles={totalChanges} totalAdditions={totalAdditions} totalDeletions={totalDeletions} - filter={filter} - onFilterChange={onFilterChange} - commits={commits.data?.commits ?? []} - uncommittedCount={ - status.data.staged.length + status.data.unstaged.length - } branches={branches.data?.branches ?? []} onBaseBranchChange={onBaseBranchChange} onRenameBranch={onRenameBranch} canRename={canRenameBranch} /> void; + commits: Commit[]; + uncommittedCount: number; viewMode: ChangesViewMode; onViewModeChange: (next: ChangesViewMode) => void; /** Whether the last fold action was "collapse all". */ @@ -15,11 +24,16 @@ interface ChangesToolbarProps { /** * Single action row beneath the changes header (above the section list): the - * folders/tree view-mode toggle on the left, and one collapse/expand-all - * toggle on the right that applies to every section's folder groups (folders - * mode) or tree directories (tree mode). + * commit/uncommitted filter on the left, then the folders/tree view-mode + * toggle and a collapse/expand-all toggle on the right. The fold action + * applies to every section's folder groups (folders mode) or tree directories + * (tree mode). */ export function ChangesToolbar({ + filter, + onFilterChange, + commits, + uncommittedCount, viewMode, onViewModeChange, collapsed, @@ -28,22 +42,30 @@ export function ChangesToolbar({ const label = collapsed ? "Expand all" : "Collapse all"; const Icon = collapsed ? UnfoldVertical : FoldVertical; return ( -
- - - - - - {label} - +
+ +
+ + + + + + {label} + +
); } From 6284525bbd96c862e4e60840e0caa31951c49873 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 12 May 2026 00:34:24 -0700 Subject: [PATCH 11/24] style(desktop): merge changes stats into the toolbar row next to the filter --- .../ChangesHeader/ChangesHeader.tsx | 111 +++++++----------- .../ChangesTabContent/ChangesTabContent.tsx | 6 +- .../ChangesToolbar/ChangesToolbar.tsx | 43 +++++-- 3 files changed, 78 insertions(+), 82 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/ChangesHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/ChangesHeader.tsx index c65e8d856ae..3494a59de51 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/ChangesHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/ChangesHeader.tsx @@ -7,9 +7,6 @@ interface ChangesHeaderProps { currentBranch: { name: string; aheadCount: number; behindCount: number }; defaultBranchName: string; baseBranch: string | null; - totalFiles: number; - totalAdditions: number; - totalDeletions: number; branches: Branch[]; onBaseBranchChange: (branchName: string) => void; onRenameBranch: (newName: string) => void; @@ -20,9 +17,6 @@ export function ChangesHeader({ currentBranch, defaultBranchName, baseBranch, - totalFiles, - totalAdditions, - totalDeletions, onRenameBranch, canRename, branches, @@ -49,70 +43,51 @@ export function ChangesHeader({ }; return ( -
-
- - {isEditing ? ( - setEditValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - skipBlurRef.current = true; - handleSubmit(); - } - if (e.key === "Escape") { - skipBlurRef.current = true; - setIsEditing(false); - } - }} - onBlur={() => { - if (skipBlurRef.current) return; +
+ + {isEditing ? ( + setEditValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + skipBlurRef.current = true; handleSubmit(); - }} - className="min-w-0 flex-1 truncate rounded-sm bg-transparent px-1 font-medium outline-none ring-1 ring-ring" - /> - ) : ( - <> - - {currentBranch.name} - - {canRename && ( - - )} - from - - - )} -
- -
- - {totalFiles} {totalFiles === 1 ? "file" : "files"} - - {(totalAdditions > 0 || totalDeletions > 0) && ( - - {totalAdditions > 0 && ( - +{totalAdditions} - )} - {totalAdditions > 0 && totalDeletions > 0 && " "} - {totalDeletions > 0 && ( - -{totalDeletions} - )} + } + if (e.key === "Escape") { + skipBlurRef.current = true; + setIsEditing(false); + } + }} + onBlur={() => { + if (skipBlurRef.current) return; + handleSubmit(); + }} + className="min-w-0 flex-1 truncate rounded-sm bg-transparent px-1 font-medium outline-none ring-1 ring-ring" + /> + ) : ( + <> + + {currentBranch.name} - )} -
+ {canRename && ( + + )} + from + + + )}
); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/ChangesTabContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/ChangesTabContent.tsx index 0f8fd85ae06..db09b0d10c8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/ChangesTabContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/ChangesTabContent.tsx @@ -105,9 +105,6 @@ export const ChangesTabContent = memo(function ChangesTabContent({ currentBranch={status.data.currentBranch} defaultBranchName={status.data.defaultBranch.name} baseBranch={baseBranch} - totalFiles={totalChanges} - totalAdditions={totalAdditions} - totalDeletions={totalDeletions} branches={branches.data?.branches ?? []} onBaseBranchChange={onBaseBranchChange} onRenameBranch={onRenameBranch} @@ -120,6 +117,9 @@ export const ChangesTabContent = memo(function ChangesTabContent({ uncommittedCount={ status.data.staged.length + status.data.unstaged.length } + totalFiles={totalChanges} + totalAdditions={totalAdditions} + totalDeletions={totalDeletions} viewMode={viewMode} onViewModeChange={onViewModeChange} collapsed={foldCollapsed} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesToolbar/ChangesToolbar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesToolbar/ChangesToolbar.tsx index afd7800edbc..d2d71ebb2b0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesToolbar/ChangesToolbar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesToolbar/ChangesToolbar.tsx @@ -14,6 +14,9 @@ interface ChangesToolbarProps { onFilterChange: (filter: ChangesFilter) => void; commits: Commit[]; uncommittedCount: number; + totalFiles: number; + totalAdditions: number; + totalDeletions: number; viewMode: ChangesViewMode; onViewModeChange: (next: ChangesViewMode) => void; /** Whether the last fold action was "collapse all". */ @@ -23,17 +26,19 @@ interface ChangesToolbarProps { } /** - * Single action row beneath the changes header (above the section list): the - * commit/uncommitted filter on the left, then the folders/tree view-mode - * toggle and a collapse/expand-all toggle on the right. The fold action - * applies to every section's folder groups (folders mode) or tree directories - * (tree mode). + * Single action row beneath the changes header: the commit/uncommitted filter + * and the changeset totals on the left, then the folders/tree view-mode toggle + * and a collapse/expand-all toggle on the right. The fold action applies to + * every section's folder groups (folders mode) or tree directories (tree mode). */ export function ChangesToolbar({ filter, onFilterChange, commits, uncommittedCount, + totalFiles, + totalAdditions, + totalDeletions, viewMode, onViewModeChange, collapsed, @@ -43,12 +48,28 @@ export function ChangesToolbar({ const Icon = collapsed ? UnfoldVertical : FoldVertical; return (
- +
+ + + {totalFiles} {totalFiles === 1 ? "file" : "files"} + + {(totalAdditions > 0 || totalDeletions > 0) && ( + + {totalAdditions > 0 && ( + +{totalAdditions} + )} + {totalAdditions > 0 && totalDeletions > 0 && " "} + {totalDeletions > 0 && ( + -{totalDeletions} + )} + + )} +
From 759abbd5f2800d59b32b1d8f2594a7965cebc9f3 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 12 May 2026 00:41:40 -0700 Subject: [PATCH 12/24] style(desktop): drop bottom border from changes toolbar row --- .../useChangesTab/components/ChangesToolbar/ChangesToolbar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesToolbar/ChangesToolbar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesToolbar/ChangesToolbar.tsx index d2d71ebb2b0..cb6622de17b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesToolbar/ChangesToolbar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesToolbar/ChangesToolbar.tsx @@ -47,7 +47,7 @@ export function ChangesToolbar({ const label = collapsed ? "Expand all" : "Collapse all"; const Icon = collapsed ? UnfoldVertical : FoldVertical; return ( -
+
Date: Tue, 12 May 2026 00:45:12 -0700 Subject: [PATCH 13/24] Revert "style(desktop): drop bottom border from changes toolbar row" --- .../useChangesTab/components/ChangesToolbar/ChangesToolbar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesToolbar/ChangesToolbar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesToolbar/ChangesToolbar.tsx index cb6622de17b..d2d71ebb2b0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesToolbar/ChangesToolbar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesToolbar/ChangesToolbar.tsx @@ -47,7 +47,7 @@ export function ChangesToolbar({ const label = collapsed ? "Expand all" : "Collapse all"; const Icon = collapsed ? UnfoldVertical : FoldVertical; return ( -
+
Date: Tue, 12 May 2026 00:46:33 -0700 Subject: [PATCH 14/24] chore(desktop): trim a restating comment in ViewModeToggle --- .../ChangesHeader/components/ViewModeToggle/ViewModeToggle.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/components/ViewModeToggle/ViewModeToggle.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/components/ViewModeToggle/ViewModeToggle.tsx index c950b0cbe46..0ca41b1022b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/components/ViewModeToggle/ViewModeToggle.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/components/ViewModeToggle/ViewModeToggle.tsx @@ -10,8 +10,7 @@ interface ViewModeToggleProps { /** * Two-button segmented toggle: folders (flat by parent folder) vs tree - * (full directory hierarchy). The active button gets `bg-accent`; the other - * stays `text-muted-foreground` so the current mode reads at a glance. + * (full directory hierarchy). */ export function ViewModeToggle({ viewMode, onChange }: ViewModeToggleProps) { return ( From 64898cd1abaa985c9303e940d168ab105f261bc4 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 12 May 2026 10:33:14 -0700 Subject: [PATCH 15/24] refactor(desktop): share loadFallthroughIcons util across sidebar tabs --- .../components/FilesTab/FilesTab.tsx | 2 +- .../ChangesFileList/ChangesFileList.tsx | 4 +- .../components/FolderHeader/FolderHeader.tsx | 1 + .../ChangesTreeView/ChangesTreeView.tsx | 115 ++++++++++-------- .../ChangesTabContent/ChangesTabContent.tsx | 26 ++-- .../ChangesToolbar/ChangesToolbar.tsx | 6 +- .../CommitFilterDropdown.tsx | 8 +- .../utils/loadFallthroughIcons/index.ts | 0 .../loadFallthroughIcons.ts | 0 9 files changed, 84 insertions(+), 78 deletions(-) rename apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/{components/FilesTab => }/utils/loadFallthroughIcons/index.ts (100%) rename apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/{components/FilesTab => }/utils/loadFallthroughIcons/loadFallthroughIcons.ts (100%) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx index 18e4f16438b..d941f02883f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx @@ -37,11 +37,11 @@ import { ROW_HEIGHT, TREE_INDENT, } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/constants"; +import { loadFallthroughIcons } from "../../utils/loadFallthroughIcons"; import { PierreRowContextMenu } from "../PierreRowContextMenu"; import { FileMenuItems } from "./components/FileMenuItems"; import { FolderMenuItems } from "./components/FolderMenuItems"; import { useFilesTabBridge } from "./hooks/useFilesTabBridge"; -import { loadFallthroughIcons } from "./utils/loadFallthroughIcons"; import { asDirectoryHandle, basename, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx index 3fcfc241181..cba18a18de1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx @@ -24,7 +24,7 @@ interface ChangesFileListProps { onOpenInEditor?: (path: string) => void; } -type GroupKey = "unstaged" | "staged" | "against-base" | "commit"; +type GroupKey = ChangesetFile["source"]["kind"]; const GROUP_ORDER: GroupKey[] = [ "unstaged", @@ -82,7 +82,7 @@ export const ChangesFileList = memo(function ChangesFileList({ } return ( -
+
{GROUP_ORDER.map((key) => { const groupFiles = grouped[key]; if (groupFiles.length === 0) return null; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/components/FolderHeader/FolderHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/components/FolderHeader/FolderHeader.tsx index 5f890845a5a..9be451896b5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/components/FolderHeader/FolderHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/components/FolderHeader/FolderHeader.tsx @@ -23,6 +23,7 @@ export function FolderHeader({ type="button" onClick={onToggle} aria-expanded={isOpen} + title={label} className="flex w-full items-center gap-1.5 py-1 pr-3 pl-3 text-left text-xs text-muted-foreground hover:bg-accent/30" > {/* `dir="rtl"` right-truncates long paths so the deepest segment stays visible. */} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx index eaf2a6f4635..92f103c025e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx @@ -22,6 +22,7 @@ import { import { DiscardConfirmDialog } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/DiscardConfirmDialog"; import type { FileStatus } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/StatusIndicator"; import { PierreRowContextMenu } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PierreRowContextMenu"; +import { loadFallthroughIcons } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/utils/loadFallthroughIcons"; import type { ChangesetFile } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset"; import { toRelativeWorkspacePath } from "shared/absolute-paths"; import type { FoldSignal } from "../../ChangesFileList"; @@ -83,7 +84,7 @@ const PIERRE_GIT_STATUS: Record< untracked: "untracked", }; -type SectionKind = "unstaged" | "staged" | "against-base" | "commit"; +type SectionKind = ChangesetFile["source"]["kind"]; interface ChangesTreeViewProps { /** Files for a single section — caller has already pre-grouped by `source.kind`. */ @@ -176,6 +177,28 @@ export const ChangesTreeView = memo(function ChangesTreeView({ model.setGitStatus(buildPierreGitStatus(files)); }, [model, files]); + // Fill in Material icons for file types Pierre's built-in set doesn't cover + // (matches the Files tab). Initial render uses Pierre's defaults; the + // sprite-loading cache makes repeat mounts a no-op. + useEffect(() => { + let cancelled = false; + void loadFallthroughIcons().then( + ({ spriteSheet, byFileName, byFileExtension }) => { + if (cancelled) return; + model.setIcons({ + set: "complete", + colored: true, + spriteSheet, + byFileName, + byFileExtension, + }); + }, + ); + return () => { + cancelled = true; + }; + }, [model]); + // Pierre's host is `height: 100%` when virtualized — inside this section's // auto-height container that collapses to 0, so the tree would be // invisible. Size it to the content. Pierre already computes that height @@ -220,22 +243,19 @@ export const ChangesTreeView = memo(function ChangesTreeView({ ? contentHeight + HEIGHT_CUSHION : (dirs.length + paths.length) * ROW_BOX + HEIGHT_CUSHION; - const collapseAll = useCallback(() => { - for (const dir of dirs) { - const handle = model.getItem(`${dir}/`); - if (handle?.isDirectory() !== true) continue; - const dirHandle = handle as FileTreeDirectoryHandle; - if (dirHandle.isExpanded()) dirHandle.collapse(); - } - }, [model, dirs]); - const expandAll = useCallback(() => { - for (const dir of dirs) { - const handle = model.getItem(`${dir}/`); - if (handle?.isDirectory() !== true) continue; - const dirHandle = handle as FileTreeDirectoryHandle; - if (!dirHandle.isExpanded()) dirHandle.expand(); - } - }, [model, dirs]); + const setAllDirsExpanded = useCallback( + (expanded: boolean) => { + for (const dir of dirs) { + const handle = model.getItem(`${dir}/`); + if (handle?.isDirectory() !== true) continue; + const dirHandle = handle as FileTreeDirectoryHandle; + if (dirHandle.isExpanded() === expanded) continue; + if (expanded) dirHandle.expand(); + else dirHandle.collapse(); + } + }, + [model, dirs], + ); // React to expand-all / collapse-all from the toolbar (new signal only). const lastFoldEpochRef = useRef(0); @@ -243,9 +263,8 @@ export const ChangesTreeView = memo(function ChangesTreeView({ if (foldSignal.epoch === 0 || foldSignal.epoch === lastFoldEpochRef.current) return; lastFoldEpochRef.current = foldSignal.epoch; - if (foldSignal.action === "collapse") collapseAll(); - else expandAll(); - }, [foldSignal, collapseAll, expandAll]); + setAllDirsExpanded(foldSignal.action === "expand"); + }, [foldSignal, setAllDirsExpanded]); // Echo the diff pane's open file back into the tree's selection — but only // when it belongs to this section. `lastUserSelectRef` guards the loop: @@ -309,42 +328,43 @@ export const ChangesTreeView = memo(function ChangesTreeView({ }, }); + const fileMenuItems = (file: ChangesetFile) => ( + + ); + const renderContextMenu = ( item: PierreContextMenuItem, ctx: PierreContextMenuOpenContext, ) => { - if (item.kind === "directory") { - return ( - + const menuItems = (() => { + if (item.kind === "directory") { + return ( - - ); - } - const file = fileByPath.get(item.path); - if (!file) return null; + ); + } + const file = fileByPath.get(item.path); + return file ? fileMenuItems(file) : null; + })(); + if (!menuItems) return null; return ( - + {menuItems} ); }; @@ -375,18 +395,7 @@ export const ChangesTreeView = memo(function ChangesTreeView({ const renderHoverMenuContent = (treePath: string) => { const file = fileByPath.get(treePath); - if (!file) return null; - return ( - - ); + return file ? fileMenuItems(file) : null; }; const discardIsDelete = diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/ChangesTabContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/ChangesTabContent.tsx index db09b0d10c8..f108c110c2b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/ChangesTabContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/ChangesTabContent.tsx @@ -125,20 +125,18 @@ export const ChangesTabContent = memo(function ChangesTabContent({ collapsed={foldCollapsed} onToggleFold={toggleFold} /> -
- -
+
); }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesToolbar/ChangesToolbar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesToolbar/ChangesToolbar.tsx index d2d71ebb2b0..696fc9f6385 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesToolbar/ChangesToolbar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesToolbar/ChangesToolbar.tsx @@ -48,18 +48,18 @@ export function ChangesToolbar({ const Icon = collapsed ? UnfoldVertical : FoldVertical; return (
-
+
- + {totalFiles} {totalFiles === 1 ? "file" : "files"} {(totalAdditions > 0 || totalDeletions > 0) && ( - + {totalAdditions > 0 && ( +{totalAdditions} )} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/CommitFilterDropdown.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/CommitFilterDropdown.tsx index aa08e9f0280..1d1a056b090 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/CommitFilterDropdown.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/CommitFilterDropdown.tsx @@ -49,12 +49,10 @@ export function CommitFilterDropdown({ diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/utils/loadFallthroughIcons/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/utils/loadFallthroughIcons/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/utils/loadFallthroughIcons/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/utils/loadFallthroughIcons/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/utils/loadFallthroughIcons/loadFallthroughIcons.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/utils/loadFallthroughIcons/loadFallthroughIcons.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/utils/loadFallthroughIcons/loadFallthroughIcons.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/utils/loadFallthroughIcons/loadFallthroughIcons.ts From 5f26e8b93109d27567975d445bb8c2f793e1d33b Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 12 May 2026 10:47:41 -0700 Subject: [PATCH 16/24] refactor(desktop): consolidate file-icon system into renderer/lib/fileIcons Move FileIcon / getFileIcon / resolveFileIconAssetUrl / loadFallthroughIcons and the manifest typing into one shared module so the v2 sidebar trees, the diff/pane chrome, and the chat/mention surfaces all draw from the same place (v1 FilesView/utils now just re-exports it). Also give unrecognized file types a real fallback icon in the Pierre trees: loadFallthroughIcons now adds the Material default-file icon to the sprite and remaps Pierre's generic `file` slot to it, matching what the non-tree FileIcon surfaces already do via manifest.defaultIcon. --- .../MentionPopover/MentionPopover.tsx | 2 +- .../TiptapPromptEditor/TiptapPromptEditor.tsx | 2 +- .../FileMention/FileMentionNode.tsx | 2 +- .../FileMentionList/FileMentionList.tsx | 2 +- .../utils => lib/fileIcons}/FileIcon.tsx | 2 +- .../fileIcons/getFileIcon.ts} | 19 +++------- .../src/renderer/lib/fileIcons/index.ts | 6 +++ .../fileIcons}/loadFallthroughIcons.ts | 37 ++++++++++++------- .../src/renderer/lib/fileIcons/manifest.ts | 19 ++++++++++ .../resolveFileIconAssetUrl.test.ts} | 0 .../fileIcons}/resolveFileIconAssetUrl.ts | 0 .../components/FilesTab/FilesTab.tsx | 28 +++++--------- .../ChangesTreeView/ChangesTreeView.tsx | 25 +++++-------- .../components/FileRow/FileRow.tsx | 2 +- .../utils/loadFallthroughIcons/index.ts | 1 - .../MentionPopover/MentionPopover.tsx | 2 +- .../DiffFileHeader/DiffFileHeader.tsx | 2 +- .../hooks/usePaneRegistry/usePaneRegistry.tsx | 2 +- .../FileResultItem/FileResultItem.tsx | 2 +- .../RightSidebar/FilesView/utils/index.ts | 10 +++-- 20 files changed, 90 insertions(+), 75 deletions(-) rename apps/desktop/src/renderer/{screens/main/components/WorkspaceView/RightSidebar/FilesView/utils => lib/fileIcons}/FileIcon.tsx (88%) rename apps/desktop/src/renderer/{screens/main/components/WorkspaceView/RightSidebar/FilesView/utils/file-icons.ts => lib/fileIcons/getFileIcon.ts} (81%) create mode 100644 apps/desktop/src/renderer/lib/fileIcons/index.ts rename apps/desktop/src/renderer/{routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/utils/loadFallthroughIcons => lib/fileIcons}/loadFallthroughIcons.ts (82%) create mode 100644 apps/desktop/src/renderer/lib/fileIcons/manifest.ts rename apps/desktop/src/renderer/{screens/main/components/WorkspaceView/RightSidebar/FilesView/utils/file-icons.test.ts => lib/fileIcons/resolveFileIconAssetUrl.test.ts} (100%) rename apps/desktop/src/renderer/{screens/main/components/WorkspaceView/RightSidebar/FilesView/utils => lib/fileIcons}/resolveFileIconAssetUrl.ts (100%) delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/utils/loadFallthroughIcons/index.ts diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/MentionPopover/MentionPopover.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/MentionPopover/MentionPopover.tsx index 129a7f5ec6f..6033316f669 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/MentionPopover/MentionPopover.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/MentionPopover/MentionPopover.tsx @@ -27,7 +27,7 @@ import { } from "react"; import { HiMiniAtSymbol } from "react-icons/hi2"; import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; -import { FileIcon } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils"; +import { FileIcon } from "renderer/lib/fileIcons"; const MAX_RESULTS = 20; diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/TiptapPromptEditor/TiptapPromptEditor.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/TiptapPromptEditor/TiptapPromptEditor.tsx index bde280ae7df..bbc4bac8117 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/TiptapPromptEditor/TiptapPromptEditor.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/TiptapPromptEditor/TiptapPromptEditor.tsx @@ -28,7 +28,7 @@ const mentionSuggestionKey = new PluginKey("fileMentionSuggestion"); import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; -import { FileIcon } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils"; +import { FileIcon } from "renderer/lib/fileIcons"; import { getCommandMatchRank, type SlashCommand, diff --git a/apps/desktop/src/renderer/components/MarkdownEditor/components/FileMention/FileMentionNode.tsx b/apps/desktop/src/renderer/components/MarkdownEditor/components/FileMention/FileMentionNode.tsx index b7cb5fa50dd..78ae3cdd974 100644 --- a/apps/desktop/src/renderer/components/MarkdownEditor/components/FileMention/FileMentionNode.tsx +++ b/apps/desktop/src/renderer/components/MarkdownEditor/components/FileMention/FileMentionNode.tsx @@ -7,7 +7,7 @@ import { ReactNodeViewRenderer, } from "@tiptap/react"; import { LuX } from "react-icons/lu"; -import { FileIcon } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils"; +import { FileIcon } from "renderer/lib/fileIcons"; function FileMentionChip({ node, selected, deleteNode }: NodeViewProps) { const path = (node.attrs.path as string | null | undefined) ?? ""; diff --git a/apps/desktop/src/renderer/components/MarkdownEditor/components/FileMention/components/FileMentionList/FileMentionList.tsx b/apps/desktop/src/renderer/components/MarkdownEditor/components/FileMention/components/FileMentionList/FileMentionList.tsx index af88946416b..1bfeba0abec 100644 --- a/apps/desktop/src/renderer/components/MarkdownEditor/components/FileMention/components/FileMentionList/FileMentionList.tsx +++ b/apps/desktop/src/renderer/components/MarkdownEditor/components/FileMention/components/FileMentionList/FileMentionList.tsx @@ -9,7 +9,7 @@ import { useRef, useState, } from "react"; -import { FileIcon } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils"; +import { FileIcon } from "renderer/lib/fileIcons"; import type { FileMentionResult } from "../../types"; function getDirectory(relativePath: string): string { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils/FileIcon.tsx b/apps/desktop/src/renderer/lib/fileIcons/FileIcon.tsx similarity index 88% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils/FileIcon.tsx rename to apps/desktop/src/renderer/lib/fileIcons/FileIcon.tsx index 6e4342f5646..d5f4c189069 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils/FileIcon.tsx +++ b/apps/desktop/src/renderer/lib/fileIcons/FileIcon.tsx @@ -1,4 +1,4 @@ -import { getFileIcon } from "./file-icons"; +import { getFileIcon } from "./getFileIcon"; interface FileIconProps { fileName: string; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils/file-icons.ts b/apps/desktop/src/renderer/lib/fileIcons/getFileIcon.ts similarity index 81% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils/file-icons.ts rename to apps/desktop/src/renderer/lib/fileIcons/getFileIcon.ts index f60963fec54..aa5107d827d 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils/file-icons.ts +++ b/apps/desktop/src/renderer/lib/fileIcons/getFileIcon.ts @@ -1,22 +1,15 @@ -import rawManifest from "resources/public/file-icons/manifest.json"; +import { fileIconManifest as manifest } from "./manifest"; import { resolveFileIconAssetUrl } from "./resolveFileIconAssetUrl"; -interface FileIconManifest { - fileNames: Record; - fileExtensions: Record; - folderNames: Record; - folderNamesExpanded: Record; - defaultIcon: string; - defaultFolderIcon: string; - defaultFolderOpenIcon: string; -} - -const manifest = rawManifest as FileIconManifest; - interface FileIconResult { src: string; } +/** + * Resolve the asset URL for a file/folder's icon from the Material-icon + * manifest. Always returns a result — when nothing matches, falls back to + * `manifest.defaultIcon` (files) or `manifest.defaultFolder*Icon` (folders). + */ export function getFileIcon( fileName: string, isDirectory: boolean, diff --git a/apps/desktop/src/renderer/lib/fileIcons/index.ts b/apps/desktop/src/renderer/lib/fileIcons/index.ts new file mode 100644 index 00000000000..3e9d438c090 --- /dev/null +++ b/apps/desktop/src/renderer/lib/fileIcons/index.ts @@ -0,0 +1,6 @@ +export { FileIcon } from "./FileIcon"; +export { getFileIcon } from "./getFileIcon"; +export { loadFallthroughIcons } from "./loadFallthroughIcons"; +export type { FileIconManifest } from "./manifest"; +export { fileIconManifest } from "./manifest"; +export { resolveFileIconAssetUrl } from "./resolveFileIconAssetUrl"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/utils/loadFallthroughIcons/loadFallthroughIcons.ts b/apps/desktop/src/renderer/lib/fileIcons/loadFallthroughIcons.ts similarity index 82% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/utils/loadFallthroughIcons/loadFallthroughIcons.ts rename to apps/desktop/src/renderer/lib/fileIcons/loadFallthroughIcons.ts index dc88eef9506..3cf80d0f9d3 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/utils/loadFallthroughIcons/loadFallthroughIcons.ts +++ b/apps/desktop/src/renderer/lib/fileIcons/loadFallthroughIcons.ts @@ -1,16 +1,6 @@ import type { FileTreeIconConfig } from "@pierre/trees"; -import { resolveFileIconAssetUrl } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils/resolveFileIconAssetUrl"; -import rawManifest from "resources/public/file-icons/manifest.json"; - -interface FileIconManifest { - fileNames: Record; - fileExtensions: Record; - folderNames: Record; - folderNamesExpanded: Record; - defaultIcon: string; - defaultFolderIcon: string; - defaultFolderOpenIcon: string; -} +import { fileIconManifest as manifest } from "./manifest"; +import { resolveFileIconAssetUrl } from "./resolveFileIconAssetUrl"; // Pierre's built-in coverage @ 1.0.0-beta.3. These are the lowercased keys of // BUILT_IN_FILE_NAME_TOKENS / BUILT_IN_FILE_EXTENSION_TOKENS / the complete-tier @@ -154,16 +144,30 @@ const PIERRE_FILE_EXTENSIONS = new Set([ ]); const SYMBOL_PREFIX = "material-"; -const manifest = rawManifest as FileIconManifest; interface FallthroughIconConfig { spriteSheet: string; byFileName: NonNullable; byFileExtension: NonNullable; + /** + * Remaps Pierre's built-in slots — here, the generic `file` slot, so an + * unrecognized file type falls back to the Material default file icon + * instead of Pierre's plainer built-in one (and reads the same as the + * non-tree `FileIcon` surfaces, which use `manifest.defaultIcon`). + */ + remap: NonNullable; } let cached: Promise | null = null; +/** + * Layer our richer Material-icon coverage on top of `@pierre/trees`' built-in + * icon set: file types Pierre doesn't recognize (`.toml`, `.lock`, framework + * dirs, etc) and a saner generic-file fallback. Result is memoized — the first + * tree mount pays the sprite-fetch cost, later mounts are a no-op. + * + * Apply the result via `model.setIcons({ set, colored, ...result })`. + */ export function loadFallthroughIcons(): Promise { if (cached) return cached; cached = doLoad().catch((error) => { @@ -187,6 +191,7 @@ async function doLoad(): Promise { } const uniqueIcons = new Set([ + manifest.defaultIcon, ...Object.values(byFileNameRaw), ...Object.values(byFileExtensionRaw), ]); @@ -216,6 +221,10 @@ async function doLoad(): Promise { if (usableIcons.has(icon)) byFileExtension[extension] = SYMBOL_PREFIX + icon; } + const remap: NonNullable = {}; + if (usableIcons.has(manifest.defaultIcon)) { + remap.file = SYMBOL_PREFIX + manifest.defaultIcon; + } // Pierre injects spriteSheet into light DOM as a slotted child of the // host. Without explicit dimensions an SVG defaults to ~300×150, which @@ -223,7 +232,7 @@ async function doLoad(): Promise { // own built-in sprite (`width="0" height="0" aria-hidden`) so it renders // the symbols but takes no space. const spriteSheet = ``; - return { spriteSheet, byFileName, byFileExtension }; + return { spriteSheet, byFileName, byFileExtension, remap }; } async function fetchSymbolBody( diff --git a/apps/desktop/src/renderer/lib/fileIcons/manifest.ts b/apps/desktop/src/renderer/lib/fileIcons/manifest.ts new file mode 100644 index 00000000000..fe6169aae9f --- /dev/null +++ b/apps/desktop/src/renderer/lib/fileIcons/manifest.ts @@ -0,0 +1,19 @@ +import rawManifest from "resources/public/file-icons/manifest.json"; + +/** + * Shape of `resources/public/file-icons/manifest.json` — the Material-icon-theme + * mapping we ship alongside the renderer. `defaultIcon` / `defaultFolder*` are + * the catch-all icons used whenever a file/folder name or extension isn't + * recognized, so every entity gets *some* icon. + */ +export interface FileIconManifest { + fileNames: Record; + fileExtensions: Record; + folderNames: Record; + folderNamesExpanded: Record; + defaultIcon: string; + defaultFolderIcon: string; + defaultFolderOpenIcon: string; +} + +export const fileIconManifest = rawManifest as FileIconManifest; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils/file-icons.test.ts b/apps/desktop/src/renderer/lib/fileIcons/resolveFileIconAssetUrl.test.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils/file-icons.test.ts rename to apps/desktop/src/renderer/lib/fileIcons/resolveFileIconAssetUrl.test.ts diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils/resolveFileIconAssetUrl.ts b/apps/desktop/src/renderer/lib/fileIcons/resolveFileIconAssetUrl.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils/resolveFileIconAssetUrl.ts rename to apps/desktop/src/renderer/lib/fileIcons/resolveFileIconAssetUrl.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx index d941f02883f..559d271f7fd 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx @@ -31,13 +31,13 @@ import { usePierreRowClickPolicy, useSidebarFilePolicy, } from "renderer/lib/clickPolicy"; +import { loadFallthroughIcons } from "renderer/lib/fileIcons"; import { useOpenInExternalEditor } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor"; import { OVERSCAN_COUNT, ROW_HEIGHT, TREE_INDENT, } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/constants"; -import { loadFallthroughIcons } from "../../utils/loadFallthroughIcons"; import { PierreRowContextMenu } from "../PierreRowContextMenu"; import { FileMenuItems } from "./components/FileMenuItems"; import { FolderMenuItems } from "./components/FolderMenuItems"; @@ -218,25 +218,17 @@ export function FilesTab({ ); }, [model, fileStatusByPath, folderStatusByPath, ignoredPaths]); - // Layer our Material-icon coverage on top of Pierre's built-ins for file - // types Pierre doesn't recognize (`.toml`, `.lock`, framework dirs, etc). - // Initial render uses Pierre's defaults; ours fill in once the sprite - // finishes loading. The cache inside loadFallthroughIcons makes subsequent - // mounts a no-op. + // Layer our Material-icon coverage on top of Pierre's built-ins: file types + // Pierre doesn't recognize (`.toml`, `.lock`, framework dirs, etc) plus a + // Material default-file icon for anything still unmatched. Initial render + // uses Pierre's defaults; ours fill in once the sprite finishes loading. + // The cache inside loadFallthroughIcons makes subsequent mounts a no-op. useEffect(() => { let cancelled = false; - void loadFallthroughIcons().then( - ({ spriteSheet, byFileName, byFileExtension }) => { - if (cancelled) return; - model.setIcons({ - set: "complete", - colored: true, - spriteSheet, - byFileName, - byFileExtension, - }); - }, - ); + void loadFallthroughIcons().then((config) => { + if (cancelled) return; + model.setIcons({ set: "complete", colored: true, ...config }); + }); return () => { cancelled = true; }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx index 92f103c025e..ecc369b6ddb 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx @@ -19,10 +19,10 @@ import { usePierreRowClickPolicy, useSidebarFilePolicy, } from "renderer/lib/clickPolicy"; +import { loadFallthroughIcons } from "renderer/lib/fileIcons"; import { DiscardConfirmDialog } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/DiscardConfirmDialog"; import type { FileStatus } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/StatusIndicator"; import { PierreRowContextMenu } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PierreRowContextMenu"; -import { loadFallthroughIcons } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/utils/loadFallthroughIcons"; import type { ChangesetFile } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset"; import { toRelativeWorkspacePath } from "shared/absolute-paths"; import type { FoldSignal } from "../../ChangesFileList"; @@ -177,23 +177,16 @@ export const ChangesTreeView = memo(function ChangesTreeView({ model.setGitStatus(buildPierreGitStatus(files)); }, [model, files]); - // Fill in Material icons for file types Pierre's built-in set doesn't cover - // (matches the Files tab). Initial render uses Pierre's defaults; the - // sprite-loading cache makes repeat mounts a no-op. + // Fill in Material icons for file types Pierre's built-in set doesn't cover, + // plus a Material default-file icon for anything still unmatched (matches + // the Files tab). Initial render uses Pierre's defaults; the sprite-loading + // cache makes repeat mounts a no-op. useEffect(() => { let cancelled = false; - void loadFallthroughIcons().then( - ({ spriteSheet, byFileName, byFileExtension }) => { - if (cancelled) return; - model.setIcons({ - set: "complete", - colored: true, - spriteSheet, - byFileName, - byFileExtension, - }); - }, - ); + void loadFallthroughIcons().then((config) => { + if (cancelled) return; + model.setIcons({ set: "complete", colored: true, ...config }); + }); return () => { cancelled = true; }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/FileRow/FileRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/FileRow/FileRow.tsx index 8c30722b4e2..d35092c147b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/FileRow/FileRow.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/FileRow/FileRow.tsx @@ -27,11 +27,11 @@ import { } from "lucide-react"; import { memo, useState } from "react"; import { modifierLabel, useSidebarFilePolicy } from "renderer/lib/clickPolicy"; +import { FileIcon } from "renderer/lib/fileIcons"; import { DiscardConfirmDialog } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/DiscardConfirmDialog"; import { StatusIndicator } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/StatusIndicator"; import { PathActionsMenuItems } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PathActionsMenuItems"; import type { ChangesetFile } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset"; -import { FileIcon } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils"; import { toAbsoluteWorkspacePath } from "shared/absolute-paths"; function splitPath(path: string): { dir: string; basename: string } { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/utils/loadFallthroughIcons/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/utils/loadFallthroughIcons/index.ts deleted file mode 100644 index 5506f48c253..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/utils/loadFallthroughIcons/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { loadFallthroughIcons } from "./loadFallthroughIcons"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/MentionPopover/MentionPopover.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/MentionPopover/MentionPopover.tsx index c8cf8a42d08..f10b5f1997e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/MentionPopover/MentionPopover.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/MentionPopover/MentionPopover.tsx @@ -26,7 +26,7 @@ import { } from "react"; import { HiMiniAtSymbol } from "react-icons/hi2"; import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; -import { FileIcon } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils"; +import { FileIcon } from "renderer/lib/fileIcons"; function findAtTriggerIndex(value: string, prevValue: string): number { if (value.length !== prevValue.length + 1) return -1; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffFileHeader/DiffFileHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffFileHeader/DiffFileHeader.tsx index 9de1938eb1c..d5a60fa0826 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffFileHeader/DiffFileHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffFileHeader/DiffFileHeader.tsx @@ -5,8 +5,8 @@ import { useId } from "react"; import { LuCheck, LuCopy, LuUndo2 } from "react-icons/lu"; import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; import { useSidebarFilePolicy } from "renderer/lib/clickPolicy"; +import { FileIcon } from "renderer/lib/fileIcons"; import { StatusIndicator } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/StatusIndicator"; -import { FileIcon } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils"; import { GIT_STAT_TEXT_CLASSES } from "../../utils/gitDecorationColors"; interface DiffFileHeaderProps { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx index 4a133491632..4f8fa8c5c83 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx @@ -17,12 +17,12 @@ import { LuPower, } from "react-icons/lu"; import { useHotkeyDisplay } from "renderer/hotkeys"; +import { FileIcon } from "renderer/lib/fileIcons"; import { getBaseName } from "renderer/lib/pathBasename"; import { consumeTerminalBackgroundIntent } from "renderer/lib/terminal/terminal-background-intents"; import { terminalRuntimeRegistry } from "renderer/lib/terminal/terminal-runtime-registry"; import { useWorkspace } from "renderer/routes/_authenticated/_dashboard/v2-workspace/providers/WorkspaceProvider"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; -import { FileIcon } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils"; import { clearV2TerminalRunStatus, getV2NotificationSourcesForPane, diff --git a/apps/desktop/src/renderer/screens/main/components/CommandPalette/components/FileResultItem/FileResultItem.tsx b/apps/desktop/src/renderer/screens/main/components/CommandPalette/components/FileResultItem/FileResultItem.tsx index 24958da6200..9521ce81ad4 100644 --- a/apps/desktop/src/renderer/screens/main/components/CommandPalette/components/FileResultItem/FileResultItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/CommandPalette/components/FileResultItem/FileResultItem.tsx @@ -1,5 +1,5 @@ import { CommandPrimitive } from "@superset/ui/command"; -import { FileIcon } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils"; +import { FileIcon } from "renderer/lib/fileIcons"; interface FileResultItemProps { value: string; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils/index.ts index 9bb7ad50235..8dcf5030b26 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils/index.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils/index.ts @@ -1,3 +1,7 @@ -export { FileIcon } from "./FileIcon"; -export { getFileIcon } from "./file-icons"; -export { resolveFileIconAssetUrl } from "./resolveFileIconAssetUrl"; +// File-icon helpers now live in the shared `renderer/lib/fileIcons` module. +// Re-exported here so existing v1 FilesView imports keep working. +export { + FileIcon, + getFileIcon, + resolveFileIconAssetUrl, +} from "renderer/lib/fileIcons"; From 005983fc6e614c1e9f8b9c10b6f8d78ef48ac869 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 12 May 2026 10:55:11 -0700 Subject: [PATCH 17/24] fix(desktop): give changes folder headers a working path tooltip The native `title` attribute doesn't render reliably in the renderer; use the shadcn Tooltip (same component FileRow uses for its hover hint) so the full folder path shows on hover. --- .../components/FolderHeader/FolderHeader.tsx | 40 +++++++++++-------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/components/FolderHeader/FolderHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/components/FolderHeader/FolderHeader.tsx index 9be451896b5..a2adb025541 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/components/FolderHeader/FolderHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/components/FolderHeader/FolderHeader.tsx @@ -1,3 +1,5 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; + interface FolderHeaderProps { /** Display label — a folder path like "src/components", or "Root Path". */ label: string; @@ -10,7 +12,9 @@ interface FolderHeaderProps { * Collapsible header for a folder group in the changes sidebar. Shows the * folder path right-truncated (so the deepest segment stays visible) and the * file count. The whole row toggles collapse — no chevron, matching v1's - * "grouped" variant. + * "grouped" variant. The full path is surfaced via the shadcn `Tooltip` (the + * native `title` attribute doesn't render reliably in our Electron renderer — + * `FileRow` uses the same component for its hover hint). */ export function FolderHeader({ label, @@ -19,20 +23,24 @@ export function FolderHeader({ onToggle, }: FolderHeaderProps) { return ( - + + + + + {label} + ); } From 93e5ab7aa0a49ebb11d684c4594c7c969aa5ffa0 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 12 May 2026 11:09:32 -0700 Subject: [PATCH 18/24] refactor(desktop): extract shared Pierre-tree module + decompose FilesTab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New renderer/lib/pierreTree: createPierreTreeStyle (the ~55-line --trees-* CSS-var map, parameterized by row height / indent / search chrome) and the FILE_STATUS_TO_PIERRE git-status mapping — both were duplicated verbatim in FilesTab and ChangesTreeView. - FilesTab (722 → ~290 lines): pull the filesystem actions (create / rename / delete / reveal / collapse-all + the bridge bookkeeping) into a useFilesTabActions hook; move buildPierreGitStatus and the creation-path helpers into utils/; lift the header icon button into its own component; add a local constants.ts (FILE_EXPLORER_ROW_HEIGHT/INDENT/OVERSCAN), which also drops FilesTab's last import from the sunsetting v1 FilesView code. - ChangesTreeView: use the shared style + status map; extract the content- height measurement into a useMeasuredTreeHeight hook and buildTreeShape into utils/. No behavior change. --- .../lib/pierreTree/createPierreTreeStyle.ts | 84 ++++ .../src/renderer/lib/pierreTree/index.ts | 7 + .../lib/pierreTree/pierreGitStatus.ts | 46 ++ .../components/FilesTab/FilesTab.tsx | 465 ++---------------- .../FilesTabHeaderButton.tsx | 39 ++ .../components/FilesTabHeaderButton/index.ts | 1 + .../components/FilesTab/constants.ts | 6 + .../hooks/useFilesTabActions/index.ts | 1 + .../useFilesTabActions/useFilesTabActions.ts | 288 +++++++++++ .../buildPierreGitStatus.ts | 30 ++ .../utils/buildPierreGitStatus/index.ts | 1 + .../utils/creationPaths/creationPaths.ts | 40 ++ .../FilesTab/utils/creationPaths/index.ts | 1 + .../ChangesTreeView/ChangesTreeView.tsx | 139 +----- .../hooks/useMeasuredTreeHeight/index.ts | 1 + .../useMeasuredTreeHeight.ts | 48 ++ .../utils/buildTreeShape/buildTreeShape.ts | 29 ++ .../utils/buildTreeShape/index.ts | 1 + 18 files changed, 670 insertions(+), 557 deletions(-) create mode 100644 apps/desktop/src/renderer/lib/pierreTree/createPierreTreeStyle.ts create mode 100644 apps/desktop/src/renderer/lib/pierreTree/index.ts create mode 100644 apps/desktop/src/renderer/lib/pierreTree/pierreGitStatus.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/FilesTabHeaderButton/FilesTabHeaderButton.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/FilesTabHeaderButton/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/constants.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/hooks/useFilesTabActions/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/hooks/useFilesTabActions/useFilesTabActions.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/utils/buildPierreGitStatus/buildPierreGitStatus.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/utils/buildPierreGitStatus/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/utils/creationPaths/creationPaths.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/utils/creationPaths/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/hooks/useMeasuredTreeHeight/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/hooks/useMeasuredTreeHeight/useMeasuredTreeHeight.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/utils/buildTreeShape/buildTreeShape.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/utils/buildTreeShape/index.ts diff --git a/apps/desktop/src/renderer/lib/pierreTree/createPierreTreeStyle.ts b/apps/desktop/src/renderer/lib/pierreTree/createPierreTreeStyle.ts new file mode 100644 index 00000000000..34a2a1f760d --- /dev/null +++ b/apps/desktop/src/renderer/lib/pierreTree/createPierreTreeStyle.ts @@ -0,0 +1,84 @@ +import type { CSSProperties } from "react"; + +interface PierreTreeStyleOptions { + /** Row height in px — keep in sync with the model's `itemHeight`. */ + rowHeight: number; + /** Per-level indent in px. */ + levelIndent: number; + /** + * Include the search-bar chrome overrides. Only the searchable Files-tab + * explorer shows a search box; the changes-tab section trees don't. + */ + withSearchChrome?: boolean; +} + +/** + * Builds the inline `style` object that maps `@pierre/trees`' `--trees-*` CSS + * variables onto our shadcn theme tokens, so the file tree picks up light/dark + * automatically. Pierre resolves `*-override → theme tokens → defaults`, so the + * overrides alone are enough — we never touch the theme tier. Custom properties + * cascade through Pierre's shadow DOM, so setting them on the host element is + * sufficient. + * + * Shared by the Files-tab explorer tree and the changes-tab section trees so + * they read consistently; the row height / indent vary per surface. + */ +export function createPierreTreeStyle({ + rowHeight, + levelIndent, + withSearchChrome = false, +}: PierreTreeStyleOptions): CSSProperties { + return { + // Layout. Hover/selected backgrounds paint on the row element, which sits + // inside the scroll container's `padding-inline`; zero the outer padding + // so highlights bleed edge-to-edge. Padding/gap/icon size match the v2 + // ChangesFileList FileRow chrome (pl-3 pr-3, gap-1.5, size-3.5). + "--trees-row-height-override": `${rowHeight}px`, + "--trees-level-gap-override": `${levelIndent}px`, + "--trees-padding-inline-override": "0", + "--trees-item-margin-x-override": "0", + "--trees-item-padding-x-override": "calc(var(--spacing) * 3)", // pl-3 / pr-3 + "--trees-item-row-gap-override": "calc(var(--spacing) * 1.5)", // gap-1.5 + "--trees-icon-width-override": "calc(var(--spacing) * 3.5)", // size-3.5 + "--trees-border-radius-override": "0", + + // Surface + "--trees-bg-override": "var(--background)", + "--trees-fg-override": "var(--foreground)", + "--trees-fg-muted-override": "var(--muted-foreground)", + // Match v2 FileRow's `hover:bg-accent/50` — translucent accent, not solid muted. + "--trees-bg-muted-override": + "color-mix(in oklab, var(--accent) 50%, transparent)", + "--trees-accent-override": "var(--accent)", + "--trees-border-color-override": "var(--border)", + + // Selected row matches v2's `bg-accent` / `text-accent-foreground` rows + "--trees-selected-bg-override": "var(--accent)", + "--trees-selected-fg-override": "var(--accent-foreground)", + "--trees-selected-focused-border-color-override": "var(--ring)", + + // Focus ring + "--trees-focus-ring-color-override": "var(--ring)", + "--trees-focus-ring-offset-override": "0px", + + // Git status row tint — the green / yellow / red / blue Tailwind palette, + // so a 'modified' file in the tree reads the same color as a 'modified' + // badge elsewhere in the v2 chrome. + "--trees-status-added-override": "oklch(0.627 0.194 149.214)", + "--trees-status-untracked-override": "oklch(0.627 0.194 149.214)", + "--trees-status-modified-override": "oklch(0.681 0.162 75.834)", + "--trees-status-deleted-override": "oklch(0.577 0.245 27.325)", + "--trees-status-renamed-override": "oklch(0.6 0.118 244.557)", + "--trees-status-ignored-override": "var(--muted-foreground)", + + "--trees-font-size-override": "var(--text-xs)", + + // Search-bar chrome (Files tab only) — matches our text-input tokens. + ...(withSearchChrome + ? { + "--trees-search-bg-override": "var(--input, var(--background))", + "--trees-search-fg-override": "var(--foreground)", + } + : {}), + } as CSSProperties; +} diff --git a/apps/desktop/src/renderer/lib/pierreTree/index.ts b/apps/desktop/src/renderer/lib/pierreTree/index.ts new file mode 100644 index 00000000000..de63611772f --- /dev/null +++ b/apps/desktop/src/renderer/lib/pierreTree/index.ts @@ -0,0 +1,7 @@ +export { createPierreTreeStyle } from "./createPierreTreeStyle"; +export { + FILE_STATUS_TO_PIERRE, + type FileStatus, + type PierreGitStatus, + type PierreGitStatusEntry, +} from "./pierreGitStatus"; diff --git a/apps/desktop/src/renderer/lib/pierreTree/pierreGitStatus.ts b/apps/desktop/src/renderer/lib/pierreTree/pierreGitStatus.ts new file mode 100644 index 00000000000..d49ffbb83d0 --- /dev/null +++ b/apps/desktop/src/renderer/lib/pierreTree/pierreGitStatus.ts @@ -0,0 +1,46 @@ +/** + * Our richer git-status enum — shared by `useGitStatusMap` (Files tab) and + * `StatusIndicator` (changes tab). `@pierre/trees`' own enum is narrower; see + * `FILE_STATUS_TO_PIERRE`. + */ +export type FileStatus = + | "added" + | "changed" + | "copied" + | "deleted" + | "modified" + | "renamed" + | "untracked"; + +/** The status values `@pierre/trees` understands for row tint / indicators. */ +export type PierreGitStatus = + | "added" + | "deleted" + | "ignored" + | "modified" + | "renamed" + | "untracked"; + +/** One `setGitStatus` entry: a tree path plus its Pierre status. */ +export interface PierreGitStatusEntry { + path: string; + status: PierreGitStatus; +} + +/** + * Maps our status enum onto Pierre's. `changed` (binary modify) → `modified`; + * `copied` → `added` (Pierre has no native equivalent). `ignored` has no + * per-file source status, so it isn't in this map — callers set it directly. + */ +export const FILE_STATUS_TO_PIERRE: Record< + FileStatus, + Exclude +> = { + added: "added", + changed: "modified", + copied: "added", + deleted: "deleted", + modified: "modified", + renamed: "renamed", + untracked: "untracked", +}; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx index 559d271f7fd..947ff92b001 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx @@ -10,10 +10,7 @@ import { useFileTree as usePierreFileTree, } from "@pierre/trees/react"; import type { AppRouter } from "@superset/host-service"; -import { alert } from "@superset/ui/atoms/Alert"; -import { Button } from "@superset/ui/button"; import { toast } from "@superset/ui/sonner"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { workspaceTrpc } from "@superset/workspace-client"; import type { inferRouterOutputs } from "@trpc/server"; import { @@ -24,7 +21,6 @@ import { RefreshCw, } from "lucide-react"; import { useCallback, useEffect, useRef } from "react"; -import type { FileStatus } from "renderer/hooks/host-service/useGitStatusMap"; import { useGitStatusMap } from "renderer/hooks/host-service/useGitStatusMap"; import { ShadowClickHint, @@ -32,82 +28,27 @@ import { useSidebarFilePolicy, } from "renderer/lib/clickPolicy"; import { loadFallthroughIcons } from "renderer/lib/fileIcons"; +import { createPierreTreeStyle } from "renderer/lib/pierreTree"; import { useOpenInExternalEditor } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor"; -import { - OVERSCAN_COUNT, - ROW_HEIGHT, - TREE_INDENT, -} from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/constants"; import { PierreRowContextMenu } from "../PierreRowContextMenu"; import { FileMenuItems } from "./components/FileMenuItems"; +import { FilesTabHeaderButton } from "./components/FilesTabHeaderButton"; import { FolderMenuItems } from "./components/FolderMenuItems"; -import { useFilesTabBridge } from "./hooks/useFilesTabBridge"; import { - asDirectoryHandle, - basename, - parentRel, - stripTrailingSlash, - toAbs, - toRel, -} from "./utils/treePath"; - -// Map Pierre's --trees-* CSS variables to our shadcn tokens so the file tree -// inherits app theme (light/dark) automatically. Pierre falls back through -// `*-override → theme tokens → defaults`, so providing overrides is enough — -// no need to touch the theme tier. Custom properties cascade through Pierre's -// shadow DOM, so setting them on the host element is sufficient. -const TREE_STYLE: React.CSSProperties = { - // Layout. Hover/selected backgrounds paint on the row element, which sits - // inside the scroll container's `padding-inline`. Set the outer padding to - // 0 so highlights bleed edge-to-edge (matching v1's full-width row look). - // Padding/gap/icon size match the v2 ChangesFileList FileRow chrome - // (pl-3 pr-3, gap-1.5, size-3.5) so this tree reads consistently with the - // changes-tab file list. - "--trees-row-height-override": `${ROW_HEIGHT}px`, - "--trees-level-gap-override": `${TREE_INDENT}px`, - "--trees-padding-inline-override": "0", - "--trees-item-margin-x-override": "0", - "--trees-item-padding-x-override": "calc(var(--spacing) * 3)", // pl-3 / pr-3 - "--trees-item-row-gap-override": "calc(var(--spacing) * 1.5)", // gap-1.5 - "--trees-icon-width-override": "calc(var(--spacing) * 3.5)", // size-3.5 - "--trees-border-radius-override": "0", - - // Surface - "--trees-bg-override": "var(--background)", - "--trees-fg-override": "var(--foreground)", - "--trees-fg-muted-override": "var(--muted-foreground)", - // Match v2 FileRow's `hover:bg-accent/50` — translucent accent over the - // row background, not solid muted. - "--trees-bg-muted-override": - "color-mix(in oklab, var(--accent) 50%, transparent)", - "--trees-accent-override": "var(--accent)", - "--trees-border-color-override": "var(--border)", - - // Selected row matches v2's `bg-accent`/`text-accent-foreground` rows - "--trees-selected-bg-override": "var(--accent)", - "--trees-selected-fg-override": "var(--accent-foreground)", - "--trees-selected-focused-border-color-override": "var(--ring)", - - // Search bar matches our text input chrome - "--trees-search-bg-override": "var(--input, var(--background))", - "--trees-search-fg-override": "var(--foreground)", - - // Focus ring - "--trees-focus-ring-color-override": "var(--ring)", - "--trees-focus-ring-offset-override": "0px", - - // Git status row tint — matches the Tailwind palette v1 used (green / yellow - // / red / blue) so a 'modified' file in the tree reads the same color as a - // 'modified' badge elsewhere in the v2 chrome. - "--trees-status-added-override": "oklch(0.627 0.194 149.214)", - "--trees-status-untracked-override": "oklch(0.627 0.194 149.214)", - "--trees-status-modified-override": "oklch(0.681 0.162 75.834)", - "--trees-status-deleted-override": "oklch(0.577 0.245 27.325)", - "--trees-status-renamed-override": "oklch(0.6 0.118 244.557)", - "--trees-status-ignored-override": "var(--muted-foreground)", + FILE_EXPLORER_INDENT, + FILE_EXPLORER_OVERSCAN, + FILE_EXPLORER_ROW_HEIGHT, +} from "./constants"; +import { useFilesTabActions } from "./hooks/useFilesTabActions"; +import { useFilesTabBridge } from "./hooks/useFilesTabBridge"; +import { buildPierreGitStatus } from "./utils/buildPierreGitStatus"; +import { stripTrailingSlash, toAbs, toRel } from "./utils/treePath"; - "--trees-font-size-override": "var(--text-xs)", // text-xs -} as React.CSSProperties; +const TREE_STYLE = createPierreTreeStyle({ + rowHeight: FILE_EXPLORER_ROW_HEIGHT, + levelIndent: FILE_EXPLORER_INDENT, + withSearchChrome: true, +}); type GitStatusData = inferRouterOutputs["git"]["getStatus"]; @@ -122,21 +63,6 @@ interface FilesTabProps { gitStatus: GitStatusData | undefined; } -// Map our richer FileStatus into Pierre's narrower GitStatus enum. -// 'changed' (binary modify) → modified; 'copied' → added (no native equivalent). -const PIERRE_GIT_STATUS: Record< - FileStatus, - "added" | "deleted" | "modified" | "renamed" | "untracked" -> = { - added: "added", - changed: "modified", - copied: "added", - deleted: "deleted", - modified: "modified", - renamed: "renamed", - untracked: "untracked", -}; - export function FilesTab({ onSelectFile, selectedFilePath, @@ -151,11 +77,6 @@ export function FilesTab({ const openInExternalEditor = useOpenInExternalEditor(workspaceId); const filePolicy = useSidebarFilePolicy(); - const writeFile = workspaceTrpc.filesystem.writeFile.useMutation(); - const createDirectory = - workspaceTrpc.filesystem.createDirectory.useMutation(); - const movePath = workspaceTrpc.filesystem.movePath.useMutation(); - const deletePath = workspaceTrpc.filesystem.deletePath.useMutation(); const { fileStatusByPath, folderStatusByPath, ignoredPaths } = useGitStatusMap(gitStatus); @@ -195,8 +116,8 @@ export function FilesTab({ }, gitStatus: initialGitStatusEntriesRef.current, icons: { set: "complete", colored: true }, - itemHeight: ROW_HEIGHT, - overscan: OVERSCAN_COUNT, + itemHeight: FILE_EXPLORER_ROW_HEIGHT, + overscan: FILE_EXPLORER_OVERSCAN, stickyFolders: true, onSelectionChange: (paths) => { const last = paths[paths.length - 1]; @@ -210,6 +131,15 @@ export function FilesTab({ }); const bridge = useFilesTabBridge({ model, workspaceId, rootPath }); + const { reveal, startCreating, handleRename, handleDelete, collapseAll } = + useFilesTabActions({ + model, + bridge, + rootPath, + workspaceId, + selectedFilePath, + onSelectFile, + }); // Push live git status updates into Pierre. useEffect(() => { @@ -246,198 +176,11 @@ export function FilesTab({ model.focusPath(rel); }, [model, selectedFilePath, rootPath, bridge.knownPaths]); - // Reveal a path: ensure all ancestor directories are expanded so the row - // is visible, then scroll it into view. - const reveal = useCallback( - async (absolutePath: string, isDirectory: boolean): Promise => { - if (!rootPath || !absolutePath.startsWith(rootPath)) return; - const rel = toRel(rootPath, absolutePath); - if (!rel) return; - - // Always wait on the root listing before focusPath. For root-level - // files the ancestor loop runs zero iterations, so without this - // we'd race the initial fetch and the reveal silently no-ops. - // fetchDir is idempotent + cached, so this is free after first call. - await bridge.fetchDir(""); - - const segments = rel.split("/"); - let acc = ""; - for (let i = 0; i < segments.length - 1; i++) { - acc = acc ? `${acc}/${segments[i]}` : segments[i]; - const dirKey = `${acc}/`; - if (!bridge.knownPaths.has(dirKey)) { - // Ancestor not loaded yet — load its parent then expand. - await bridge.fetchDir(parentRel(acc)); - } - const handle = asDirectoryHandle(model.getItem(dirKey)); - if (handle && !handle.isExpanded()) { - handle.expand(); - await bridge.fetchDir(acc); - } - } - if (isDirectory) { - const dirKey = `${rel}/`; - const handle = asDirectoryHandle(model.getItem(dirKey)); - if (handle && !handle.isExpanded()) { - handle.expand(); - await bridge.fetchDir(rel); - } - } - - requestAnimationFrame(() => { - model.focusPath(rel); - }); - }, - [model, rootPath, bridge.fetchDir, bridge.knownPaths], - ); - useEffect(() => { if (!pendingReveal || !rootPath) return; void reveal(pendingReveal.path, pendingReveal.isDirectory); }, [pendingReveal, rootPath, reveal]); - const startCreating = useCallback( - async (mode: "file" | "folder", parentAbs?: string): Promise => { - if (!rootPath) return; - const parentAbsPath = - parentAbs ?? - deriveCreationParent(selectedFilePath, bridge.knownPaths, rootPath); - const parentRelPath = toRel(rootPath, parentAbsPath); - const parentDirKey = parentRelPath ? `${parentRelPath}/` : ""; - - // Make sure Pierre has the parent's children loaded + expanded so - // the placeholder row appears in the right place. - if (parentRelPath) { - await bridge.fetchDir(parentRelPath); - const handle = asDirectoryHandle(model.getItem(parentDirKey)); - if (handle && !handle.isExpanded()) { - handle.expand(); - } - } - - const placeholderName = pickPlaceholderName( - parentRelPath, - mode, - bridge.knownPaths, - ); - const placeholderPath = - (parentRelPath ? `${parentRelPath}/` : "") + - placeholderName + - (mode === "folder" ? "/" : ""); - - bridge.pendingCreates.set(placeholderPath, mode); - bridge.knownPaths.add(placeholderPath); - model.add(placeholderPath); - // removeIfCanceled cleans up the placeholder if user hits Esc. - model.startRenaming(placeholderPath, { removeIfCanceled: true }); - }, - [model, rootPath, selectedFilePath, bridge], - ); - - const handleRename = useCallback( - async (event: FileTreeRenameEvent): Promise => { - if (!rootPath) return; - const { sourcePath, destinationPath, isFolder } = event; - const pendingMode = bridge.pendingCreates.get(sourcePath); - // Snapshot before any await so post-mutation cleanup against a - // stale workspace (user switched mid-flight) bails out instead of - // leaking source/destination paths into the new workspace's - // knownPaths / model. - const versionToken = bridge.getVersion(); - - if (pendingMode) { - bridge.pendingCreates.delete(sourcePath); - // Pierre has already moved placeholder → destinationPath in - // its tree; sync our knownPaths so we don't double-account. - bridge.knownPaths.delete(sourcePath); - bridge.knownPaths.add(destinationPath); - const absPath = toAbs(rootPath, destinationPath); - try { - if (pendingMode === "folder") { - await createDirectory.mutateAsync({ - workspaceId, - absolutePath: absPath, - recursive: true, - }); - } else { - const segments = stripTrailingSlash( - basename(destinationPath), - ).split("/"); - if (segments.length === 0) return; - await writeFile.mutateAsync({ - workspaceId, - absolutePath: absPath, - content: "", - options: { create: true, overwrite: false }, - }); - if (bridge.isCurrent(versionToken)) onSelectFile(absPath); - } - } catch (error) { - if (!bridge.isCurrent(versionToken)) return; - bridge.knownPaths.delete(destinationPath); - try { - model.remove(destinationPath, { recursive: true }); - } catch { - // ignore - } - toast.error("Failed to create item", { - description: error instanceof Error ? error.message : undefined, - }); - } - return; - } - - // Genuine rename. Pierre has already moved the entry on its side. - // For folders, also rekey every cached descendant (knownPaths + - // loadedDirs) under the new prefix so later fs reconciliation / - // reveals don't target stale paths. - bridge.knownPaths.delete(sourcePath); - bridge.knownPaths.add(destinationPath); - if (isFolder) { - bridge.rekeyDescendants( - stripTrailingSlash(sourcePath), - stripTrailingSlash(destinationPath), - ); - } - try { - await movePath.mutateAsync({ - workspaceId, - sourceAbsolutePath: toAbs(rootPath, sourcePath), - destinationAbsolutePath: toAbs(rootPath, destinationPath), - }); - } catch (error) { - if (!bridge.isCurrent(versionToken)) return; - // Revert Pierre's optimistic rename. - try { - model.move(destinationPath, sourcePath); - bridge.knownPaths.delete(destinationPath); - bridge.knownPaths.add(sourcePath); - if (isFolder) { - bridge.rekeyDescendants( - stripTrailingSlash(destinationPath), - stripTrailingSlash(sourcePath), - ); - } - } catch { - // ignore — fs:events will reconcile - } - toast.error("Failed to rename", { - description: error instanceof Error ? error.message : undefined, - }); - } - }, - [ - model, - rootPath, - workspaceId, - createDirectory, - writeFile, - movePath, - onSelectFile, - bridge, - ], - ); - // Wire the ref-based handlers so Pierre's stable callbacks always reach // the latest closures. Updated on every render — no diffing needed. handlersRef.current.onRename = (event) => void handleRename(event); @@ -452,37 +195,6 @@ export function FilesTab({ // something Pierre doesn't show (e.g. lock icons, debug markers). handlersRef.current.renderRowDecoration = () => null; - const handleDelete = useCallback( - (absolutePath: string, name: string, isDirectory: boolean): void => { - const itemType = isDirectory ? "folder" : "file"; - alert({ - title: `Delete ${name}?`, - description: `Are you sure you want to delete this ${itemType}? This action cannot be undone.`, - actions: [ - { - label: "Delete", - variant: "destructive", - onClick: () => { - toast.promise( - deletePath.mutateAsync({ - workspaceId, - absolutePath, - }), - { - loading: `Deleting ${name}...`, - success: `Deleted ${name}`, - error: `Failed to delete ${name}`, - }, - ); - }, - }, - { label: "Cancel", variant: "ghost" }, - ], - }); - }, - [workspaceId, deletePath], - ); - // Hint tooltip uses ShadowClickHint to anchor a single shadcn Tooltip // over the hovered row's bounding rect — Pierre owns the row DOM inside // an open shadow root, so per-row Tooltip wrappers aren't possible. @@ -544,16 +256,6 @@ export function FilesTab({ ], ); - const collapseAll = useCallback(() => { - for (const path of bridge.knownPaths) { - if (!path.endsWith("/")) continue; - const handle = asDirectoryHandle(model.getItem(path)); - if (handle?.isExpanded()) { - handle.collapse(); - } - } - }, [model, bridge.knownPaths]); - if (!rootPath) { return (
@@ -583,23 +285,23 @@ export function FilesTab({
Explorer
- void startCreating("file")} /> - void startCreating("folder")} /> - void bridge.doRefresh()} /> - ); } - -interface HeaderButtonProps { - icon: React.ComponentType<{ className?: string }>; - label: string; - loading?: boolean; - onClick: () => void; -} - -function HeaderButton({ - icon: Icon, - label, - loading, - onClick, -}: HeaderButtonProps) { - return ( - - - - - {label} - - ); -} - -function buildPierreGitStatus( - fileStatusByPath: Map, - folderStatusByPath: Map, - ignoredPaths: Set, -): { - path: string; - status: - | "added" - | "deleted" - | "ignored" - | "modified" - | "renamed" - | "untracked"; -}[] { - const entries: { - path: string; - status: - | "added" - | "deleted" - | "ignored" - | "modified" - | "renamed" - | "untracked"; - }[] = []; - for (const [path, status] of fileStatusByPath) { - entries.push({ path, status: PIERRE_GIT_STATUS[status] }); - } - // Feed folder rollup entries with a trailing slash so Pierre matches them - // against directory rows (its canonical directory path form). Tinting the - // folder row text uses the same `--trees-status-*` color as files, which - // then cascades to our renderRowDecoration bullet. - for (const [path, status] of folderStatusByPath) { - entries.push({ path: `${path}/`, status: PIERRE_GIT_STATUS[status] }); - } - for (const path of ignoredPaths) { - entries.push({ path, status: "ignored" }); - } - return entries; -} - -function deriveCreationParent( - selectedFilePath: string | undefined, - knownPaths: Set, - rootPath: string, -): string { - if (!selectedFilePath) return rootPath; - // If the selected path is itself a known directory, target it. - const selectedRel = toRel(rootPath, selectedFilePath); - if (knownPaths.has(`${selectedRel}/`)) return selectedFilePath; - // Otherwise, target the selected file's parent dir. - const lastSlash = selectedFilePath.lastIndexOf("/"); - return lastSlash > rootPath.length - ? selectedFilePath.slice(0, lastSlash) - : rootPath; -} - -function pickPlaceholderName( - parentRel: string, - mode: "file" | "folder", - knownPaths: Set, -): string { - const base = mode === "folder" ? "Untitled" : "untitled"; - const suffix = mode === "folder" ? "/" : ""; - const prefix = parentRel ? `${parentRel}/` : ""; - if (!knownPaths.has(`${prefix}${base}${suffix}`)) return base; - for (let i = 2; i < 100; i++) { - const name = `${base}-${i}`; - if (!knownPaths.has(`${prefix}${name}${suffix}`)) return name; - } - return `${base}-${Date.now()}`; -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/FilesTabHeaderButton/FilesTabHeaderButton.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/FilesTabHeaderButton/FilesTabHeaderButton.tsx new file mode 100644 index 00000000000..bed36c7f1c5 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/FilesTabHeaderButton/FilesTabHeaderButton.tsx @@ -0,0 +1,39 @@ +import { Button } from "@superset/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { Loader2 } from "lucide-react"; + +interface FilesTabHeaderButtonProps { + icon: React.ComponentType<{ className?: string }>; + label: string; + loading?: boolean; + onClick: () => void; +} + +/** Icon-only action button in the Files-tab "Explorer" header (New File/Folder, Refresh, Collapse All). */ +export function FilesTabHeaderButton({ + icon: Icon, + label, + loading, + onClick, +}: FilesTabHeaderButtonProps) { + return ( + + + + + {label} + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/FilesTabHeaderButton/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/FilesTabHeaderButton/index.ts new file mode 100644 index 00000000000..e0ad38367d6 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/FilesTabHeaderButton/index.ts @@ -0,0 +1 @@ +export { FilesTabHeaderButton } from "./FilesTabHeaderButton"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/constants.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/constants.ts new file mode 100644 index 00000000000..ca0dc22aca6 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/constants.ts @@ -0,0 +1,6 @@ +/** Row height (px) for the Files-tab explorer tree — drives the Pierre model's `itemHeight` and the `--trees-row-height-override`. */ +export const FILE_EXPLORER_ROW_HEIGHT = 28; +/** Per-level indent (px) for the Files-tab explorer tree. */ +export const FILE_EXPLORER_INDENT = 10; +/** Rows rendered beyond the viewport in the virtualized explorer tree. */ +export const FILE_EXPLORER_OVERSCAN = 10; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/hooks/useFilesTabActions/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/hooks/useFilesTabActions/index.ts new file mode 100644 index 00000000000..ddb64c951ff --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/hooks/useFilesTabActions/index.ts @@ -0,0 +1 @@ +export { type FilesTabActions, useFilesTabActions } from "./useFilesTabActions"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/hooks/useFilesTabActions/useFilesTabActions.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/hooks/useFilesTabActions/useFilesTabActions.ts new file mode 100644 index 00000000000..7c465de9220 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/hooks/useFilesTabActions/useFilesTabActions.ts @@ -0,0 +1,288 @@ +import type { FileTree, FileTreeRenameEvent } from "@pierre/trees"; +import { alert } from "@superset/ui/atoms/Alert"; +import { toast } from "@superset/ui/sonner"; +import { workspaceTrpc } from "@superset/workspace-client"; +import { useCallback } from "react"; +import { + deriveCreationParent, + pickPlaceholderName, +} from "../../utils/creationPaths"; +import { + asDirectoryHandle, + basename, + parentRel, + stripTrailingSlash, + toAbs, + toRel, +} from "../../utils/treePath"; +import type { FilesTabBridge } from "../useFilesTabBridge"; + +interface UseFilesTabActionsOptions { + model: FileTree; + bridge: FilesTabBridge; + /** Workspace worktree root (absolute). */ + rootPath: string; + workspaceId: string; + /** Absolute path of the file currently open in the diff/editor pane, if any. */ + selectedFilePath: string | undefined; + onSelectFile: (absolutePath: string, openInNewTab?: boolean) => void; +} + +export interface FilesTabActions { + /** Expand every ancestor directory of `absolutePath` then scroll the row into view. */ + reveal(absolutePath: string, isDirectory: boolean): Promise; + /** Start the inline "New file/folder" flow under `parentAbs` (or near the selection). */ + startCreating(mode: "file" | "folder", parentAbs?: string): Promise; + /** Commit a Pierre rename event — either finalizing a pending create or moving an existing path. */ + handleRename(event: FileTreeRenameEvent): Promise; + /** Confirm + delete a file/folder. */ + handleDelete(absolutePath: string, name: string, isDirectory: boolean): void; + /** Collapse every expanded directory in the tree. */ + collapseAll(): void; +} + +/** + * Filesystem-mutating actions for the Files tab: create / rename / delete / + * reveal / collapse-all. Owns the tRPC mutations and the bridge bookkeeping + * dance (optimistic Pierre updates + workspace-switch race guards) so + * `FilesTab` itself stays focused on wiring the tree. + */ +export function useFilesTabActions({ + model, + bridge, + rootPath, + workspaceId, + selectedFilePath, + onSelectFile, +}: UseFilesTabActionsOptions): FilesTabActions { + const writeFile = workspaceTrpc.filesystem.writeFile.useMutation(); + const createDirectory = + workspaceTrpc.filesystem.createDirectory.useMutation(); + const movePath = workspaceTrpc.filesystem.movePath.useMutation(); + const deletePath = workspaceTrpc.filesystem.deletePath.useMutation(); + + const reveal = useCallback( + async (absolutePath: string, isDirectory: boolean): Promise => { + if (!rootPath || !absolutePath.startsWith(rootPath)) return; + const rel = toRel(rootPath, absolutePath); + if (!rel) return; + + // Always wait on the root listing before focusPath. For root-level + // files the ancestor loop runs zero iterations, so without this + // we'd race the initial fetch and the reveal silently no-ops. + // fetchDir is idempotent + cached, so this is free after first call. + await bridge.fetchDir(""); + + const segments = rel.split("/"); + let acc = ""; + for (let i = 0; i < segments.length - 1; i++) { + acc = acc ? `${acc}/${segments[i]}` : segments[i]; + const dirKey = `${acc}/`; + if (!bridge.knownPaths.has(dirKey)) { + // Ancestor not loaded yet — load its parent then expand. + await bridge.fetchDir(parentRel(acc)); + } + const handle = asDirectoryHandle(model.getItem(dirKey)); + if (handle && !handle.isExpanded()) { + handle.expand(); + await bridge.fetchDir(acc); + } + } + if (isDirectory) { + const dirKey = `${rel}/`; + const handle = asDirectoryHandle(model.getItem(dirKey)); + if (handle && !handle.isExpanded()) { + handle.expand(); + await bridge.fetchDir(rel); + } + } + + requestAnimationFrame(() => { + model.focusPath(rel); + }); + }, + [model, rootPath, bridge.fetchDir, bridge.knownPaths], + ); + + const startCreating = useCallback( + async (mode: "file" | "folder", parentAbs?: string): Promise => { + if (!rootPath) return; + const parentAbsPath = + parentAbs ?? + deriveCreationParent(selectedFilePath, bridge.knownPaths, rootPath); + const parentRelPath = toRel(rootPath, parentAbsPath); + const parentDirKey = parentRelPath ? `${parentRelPath}/` : ""; + + // Make sure Pierre has the parent's children loaded + expanded so + // the placeholder row appears in the right place. + if (parentRelPath) { + await bridge.fetchDir(parentRelPath); + const handle = asDirectoryHandle(model.getItem(parentDirKey)); + if (handle && !handle.isExpanded()) { + handle.expand(); + } + } + + const placeholderName = pickPlaceholderName( + parentRelPath, + mode, + bridge.knownPaths, + ); + const placeholderPath = + (parentRelPath ? `${parentRelPath}/` : "") + + placeholderName + + (mode === "folder" ? "/" : ""); + + bridge.pendingCreates.set(placeholderPath, mode); + bridge.knownPaths.add(placeholderPath); + model.add(placeholderPath); + // removeIfCanceled cleans up the placeholder if user hits Esc. + model.startRenaming(placeholderPath, { removeIfCanceled: true }); + }, + [model, rootPath, selectedFilePath, bridge], + ); + + const handleRename = useCallback( + async (event: FileTreeRenameEvent): Promise => { + if (!rootPath) return; + const { sourcePath, destinationPath, isFolder } = event; + const pendingMode = bridge.pendingCreates.get(sourcePath); + // Snapshot before any await so post-mutation cleanup against a + // stale workspace (user switched mid-flight) bails out instead of + // leaking source/destination paths into the new workspace's + // knownPaths / model. + const versionToken = bridge.getVersion(); + + if (pendingMode) { + bridge.pendingCreates.delete(sourcePath); + // Pierre has already moved placeholder → destinationPath in + // its tree; sync our knownPaths so we don't double-account. + bridge.knownPaths.delete(sourcePath); + bridge.knownPaths.add(destinationPath); + const absPath = toAbs(rootPath, destinationPath); + try { + if (pendingMode === "folder") { + await createDirectory.mutateAsync({ + workspaceId, + absolutePath: absPath, + recursive: true, + }); + } else { + const segments = stripTrailingSlash( + basename(destinationPath), + ).split("/"); + if (segments.length === 0) return; + await writeFile.mutateAsync({ + workspaceId, + absolutePath: absPath, + content: "", + options: { create: true, overwrite: false }, + }); + if (bridge.isCurrent(versionToken)) onSelectFile(absPath); + } + } catch (error) { + if (!bridge.isCurrent(versionToken)) return; + bridge.knownPaths.delete(destinationPath); + try { + model.remove(destinationPath, { recursive: true }); + } catch { + // ignore + } + toast.error("Failed to create item", { + description: error instanceof Error ? error.message : undefined, + }); + } + return; + } + + // Genuine rename. Pierre has already moved the entry on its side. + // For folders, also rekey every cached descendant (knownPaths + + // loadedDirs) under the new prefix so later fs reconciliation / + // reveals don't target stale paths. + bridge.knownPaths.delete(sourcePath); + bridge.knownPaths.add(destinationPath); + if (isFolder) { + bridge.rekeyDescendants( + stripTrailingSlash(sourcePath), + stripTrailingSlash(destinationPath), + ); + } + try { + await movePath.mutateAsync({ + workspaceId, + sourceAbsolutePath: toAbs(rootPath, sourcePath), + destinationAbsolutePath: toAbs(rootPath, destinationPath), + }); + } catch (error) { + if (!bridge.isCurrent(versionToken)) return; + // Revert Pierre's optimistic rename. + try { + model.move(destinationPath, sourcePath); + bridge.knownPaths.delete(destinationPath); + bridge.knownPaths.add(sourcePath); + if (isFolder) { + bridge.rekeyDescendants( + stripTrailingSlash(destinationPath), + stripTrailingSlash(sourcePath), + ); + } + } catch { + // ignore — fs:events will reconcile + } + toast.error("Failed to rename", { + description: error instanceof Error ? error.message : undefined, + }); + } + }, + [ + model, + rootPath, + workspaceId, + createDirectory, + writeFile, + movePath, + onSelectFile, + bridge, + ], + ); + + const handleDelete = useCallback( + (absolutePath: string, name: string, isDirectory: boolean): void => { + const itemType = isDirectory ? "folder" : "file"; + alert({ + title: `Delete ${name}?`, + description: `Are you sure you want to delete this ${itemType}? This action cannot be undone.`, + actions: [ + { + label: "Delete", + variant: "destructive", + onClick: () => { + toast.promise( + deletePath.mutateAsync({ workspaceId, absolutePath }), + { + loading: `Deleting ${name}...`, + success: `Deleted ${name}`, + error: `Failed to delete ${name}`, + }, + ); + }, + }, + { label: "Cancel", variant: "ghost" }, + ], + }); + }, + [workspaceId, deletePath], + ); + + const collapseAll = useCallback(() => { + for (const path of bridge.knownPaths) { + if (!path.endsWith("/")) continue; + const handle = asDirectoryHandle(model.getItem(path)); + if (handle?.isExpanded()) { + handle.collapse(); + } + } + }, [model, bridge.knownPaths]); + + return { reveal, startCreating, handleRename, handleDelete, collapseAll }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/utils/buildPierreGitStatus/buildPierreGitStatus.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/utils/buildPierreGitStatus/buildPierreGitStatus.ts new file mode 100644 index 00000000000..8519bfef732 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/utils/buildPierreGitStatus/buildPierreGitStatus.ts @@ -0,0 +1,30 @@ +import type { FileStatus } from "renderer/hooks/host-service/useGitStatusMap"; +import { + FILE_STATUS_TO_PIERRE, + type PierreGitStatusEntry, +} from "renderer/lib/pierreTree"; + +/** + * Flatten the per-path git-status maps into the entry list `@pierre/trees` + * consumes. Folder rollups get a trailing slash so Pierre matches them against + * directory rows (its canonical directory path form); tinting the folder row + * text uses the same `--trees-status-*` color as files. Ignored paths have no + * per-file status of their own, so they're tagged `ignored` directly. + */ +export function buildPierreGitStatus( + fileStatusByPath: Map, + folderStatusByPath: Map, + ignoredPaths: Set, +): PierreGitStatusEntry[] { + const entries: PierreGitStatusEntry[] = []; + for (const [path, status] of fileStatusByPath) { + entries.push({ path, status: FILE_STATUS_TO_PIERRE[status] }); + } + for (const [path, status] of folderStatusByPath) { + entries.push({ path: `${path}/`, status: FILE_STATUS_TO_PIERRE[status] }); + } + for (const path of ignoredPaths) { + entries.push({ path, status: "ignored" }); + } + return entries; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/utils/buildPierreGitStatus/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/utils/buildPierreGitStatus/index.ts new file mode 100644 index 00000000000..309138f563b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/utils/buildPierreGitStatus/index.ts @@ -0,0 +1 @@ +export { buildPierreGitStatus } from "./buildPierreGitStatus"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/utils/creationPaths/creationPaths.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/utils/creationPaths/creationPaths.ts new file mode 100644 index 00000000000..40ee2ff9026 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/utils/creationPaths/creationPaths.ts @@ -0,0 +1,40 @@ +import { toRel } from "../treePath"; + +/** + * Pick the directory a new file/folder should be created in, based on the + * current selection: the selected path if it's itself a known directory, + * otherwise the selected file's parent, otherwise the workspace root. + */ +export function deriveCreationParent( + selectedFilePath: string | undefined, + knownPaths: Set, + rootPath: string, +): string { + if (!selectedFilePath) return rootPath; + const selectedRel = toRel(rootPath, selectedFilePath); + if (knownPaths.has(`${selectedRel}/`)) return selectedFilePath; + const lastSlash = selectedFilePath.lastIndexOf("/"); + return lastSlash > rootPath.length + ? selectedFilePath.slice(0, lastSlash) + : rootPath; +} + +/** + * First non-colliding placeholder name for an inline "New file/folder" row + * under `parentRel` — `untitled` / `Untitled`, then `-2`, `-3`, … + */ +export function pickPlaceholderName( + parentRel: string, + mode: "file" | "folder", + knownPaths: Set, +): string { + const base = mode === "folder" ? "Untitled" : "untitled"; + const suffix = mode === "folder" ? "/" : ""; + const prefix = parentRel ? `${parentRel}/` : ""; + if (!knownPaths.has(`${prefix}${base}${suffix}`)) return base; + for (let i = 2; i < 100; i++) { + const name = `${base}-${i}`; + if (!knownPaths.has(`${prefix}${name}${suffix}`)) return name; + } + return `${base}-${Date.now()}`; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/utils/creationPaths/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/utils/creationPaths/index.ts new file mode 100644 index 00000000000..504a8487808 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/utils/creationPaths/index.ts @@ -0,0 +1 @@ +export { deriveCreationParent, pickPlaceholderName } from "./creationPaths"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx index ecc369b6ddb..77170c14789 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx @@ -20,8 +20,12 @@ import { useSidebarFilePolicy, } from "renderer/lib/clickPolicy"; import { loadFallthroughIcons } from "renderer/lib/fileIcons"; +import { + createPierreTreeStyle, + FILE_STATUS_TO_PIERRE, + type PierreGitStatusEntry, +} from "renderer/lib/pierreTree"; import { DiscardConfirmDialog } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/DiscardConfirmDialog"; -import type { FileStatus } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/StatusIndicator"; import { PierreRowContextMenu } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PierreRowContextMenu"; import type { ChangesetFile } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset"; import { toRelativeWorkspacePath } from "shared/absolute-paths"; @@ -29,6 +33,8 @@ import type { FoldSignal } from "../../ChangesFileList"; import { FileRowContextMenuItems } from "./components/FileRowContextMenuItems"; import { FolderContextMenuItems } from "./components/FolderContextMenuItems"; import { ShadowRowHoverActions } from "./components/ShadowRowHoverActions"; +import { useMeasuredTreeHeight } from "./hooks/useMeasuredTreeHeight"; +import { buildTreeShape } from "./utils/buildTreeShape"; const ITEM_HEIGHT = 24; // Pierre rows carry `margin-block: 1px`, so each row occupies ITEM_HEIGHT + 2px. @@ -36,53 +42,10 @@ const ROW_BOX = ITEM_HEIGHT + 2; // Small cushion so the last row never clips against the host's `overflow: hidden`. const HEIGHT_CUSHION = 8; -const TREE_STYLE: React.CSSProperties = { - "--trees-row-height-override": `${ITEM_HEIGHT}px`, - "--trees-level-gap-override": "8px", - "--trees-padding-inline-override": "0", - "--trees-item-margin-x-override": "0", - "--trees-item-padding-x-override": "calc(var(--spacing) * 3)", - "--trees-item-row-gap-override": "calc(var(--spacing) * 1.5)", - "--trees-icon-width-override": "calc(var(--spacing) * 3.5)", - "--trees-border-radius-override": "0", - - "--trees-bg-override": "var(--background)", - "--trees-fg-override": "var(--foreground)", - "--trees-fg-muted-override": "var(--muted-foreground)", - "--trees-bg-muted-override": - "color-mix(in oklab, var(--accent) 50%, transparent)", - "--trees-accent-override": "var(--accent)", - "--trees-border-color-override": "var(--border)", - - "--trees-selected-bg-override": "var(--accent)", - "--trees-selected-fg-override": "var(--accent-foreground)", - "--trees-selected-focused-border-color-override": "var(--ring)", - - "--trees-focus-ring-color-override": "var(--ring)", - "--trees-focus-ring-offset-override": "0px", - - "--trees-status-added-override": "oklch(0.627 0.194 149.214)", - "--trees-status-untracked-override": "oklch(0.627 0.194 149.214)", - "--trees-status-modified-override": "oklch(0.681 0.162 75.834)", - "--trees-status-deleted-override": "oklch(0.577 0.245 27.325)", - "--trees-status-renamed-override": "oklch(0.6 0.118 244.557)", - "--trees-status-ignored-override": "var(--muted-foreground)", - - "--trees-font-size-override": "var(--text-xs)", -} as React.CSSProperties; - -const PIERRE_GIT_STATUS: Record< - FileStatus, - "added" | "deleted" | "modified" | "renamed" | "untracked" -> = { - added: "added", - changed: "modified", - copied: "added", - deleted: "deleted", - modified: "modified", - renamed: "renamed", - untracked: "untracked", -}; +const TREE_STYLE = createPierreTreeStyle({ + rowHeight: ITEM_HEIGHT, + levelIndent: 8, +}); type SectionKind = ChangesetFile["source"]["kind"]; @@ -192,45 +155,10 @@ export const ChangesTreeView = memo(function ChangesTreeView({ }; }, [model]); - // Pierre's host is `height: 100%` when virtualized — inside this section's - // auto-height container that collapses to 0, so the tree would be - // invisible. Size it to the content. Pierre already computes that height - // (rendered rows × itemHeight, *after* it flattens single-child directory - // chains into one row) and writes it to the virtualized list's inline - // `style.height` — mirror that. A naive `dirs + files` count would - // massively over-estimate because it doesn't know about flattening. - const [contentHeight, setContentHeight] = useState(null); - useEffect(() => { - const readHeight = (): boolean => { - const list = model - .getFileTreeContainer() - ?.shadowRoot?.querySelector( - "[data-file-tree-virtualized-list]", - ); - const h = list ? Number.parseFloat(list.style.height) : Number.NaN; - if (Number.isFinite(h) && h > 0) { - setContentHeight(h); - return true; - } - return false; - }; - let raf = 0; - let attempts = 0; - const retryUntilReady = () => { - if (readHeight() || attempts++ > 30) return; - raf = requestAnimationFrame(retryUntilReady); - }; - retryUntilReady(); - // Pierre rewrites `style.height` when the rendered row count changes - // (resetPaths, expand/collapse); re-read on the next frame after each. - const unsubscribe = model.subscribe(() => { - raf = requestAnimationFrame(readHeight); - }); - return () => { - cancelAnimationFrame(raf); - unsubscribe(); - }; - }, [model]); + // Size the host to Pierre's measured content height (it renders `height: + // 100%`, which collapses to 0 inside this section's auto-height container); + // fall back to a row-count estimate until that first measurement lands. + const contentHeight = useMeasuredTreeHeight(model); const treeHeight = contentHeight != null ? contentHeight + HEIGHT_CUSHION @@ -441,43 +369,10 @@ export const ChangesTreeView = memo(function ChangesTreeView({ ); }); -/** - * From a flat list of file paths, return every directory path implied by them - * (sorted shallow→deep, so a directory's ancestors precede it) and a map of - * directory → count of files anywhere beneath it. - */ -function buildTreeShape(paths: string[]): { - dirs: string[]; - dirFileCount: Map; -} { - const dirs: string[] = []; - const seen = new Set(); - const dirFileCount = new Map(); - for (const path of paths) { - const segments = path.split("/"); - let acc = ""; - for (let i = 0; i < segments.length - 1; i++) { - acc = acc ? `${acc}/${segments[i]}` : segments[i]; - if (!seen.has(acc)) { - seen.add(acc); - dirs.push(acc); - } - dirFileCount.set(acc, (dirFileCount.get(acc) ?? 0) + 1); - } - } - dirs.sort( - (a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b), - ); - return { dirs, dirFileCount }; -} - -function buildPierreGitStatus(files: ChangesetFile[]): { - path: string; - status: "added" | "deleted" | "modified" | "renamed" | "untracked"; -}[] { +function buildPierreGitStatus(files: ChangesetFile[]): PierreGitStatusEntry[] { return files.map((file) => ({ path: file.path, - status: PIERRE_GIT_STATUS[file.status], + status: FILE_STATUS_TO_PIERRE[file.status], })); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/hooks/useMeasuredTreeHeight/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/hooks/useMeasuredTreeHeight/index.ts new file mode 100644 index 00000000000..aba0a0baa3d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/hooks/useMeasuredTreeHeight/index.ts @@ -0,0 +1 @@ +export { useMeasuredTreeHeight } from "./useMeasuredTreeHeight"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/hooks/useMeasuredTreeHeight/useMeasuredTreeHeight.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/hooks/useMeasuredTreeHeight/useMeasuredTreeHeight.ts new file mode 100644 index 00000000000..21cc151f396 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/hooks/useMeasuredTreeHeight/useMeasuredTreeHeight.ts @@ -0,0 +1,48 @@ +import type { FileTree } from "@pierre/trees"; +import { useEffect, useState } from "react"; + +/** + * Pierre's virtualized host renders `height: 100%`, which collapses to 0 inside + * an auto-height container — so the tree would be invisible. Pierre also writes + * the true content height (rendered rows × itemHeight, *after* it flattens + * single-child directory chains into one row) to the virtualized list's inline + * `style.height`; this hook mirrors that value so the caller can size the host + * explicitly. A naive `dirs + files` count would massively over-estimate + * because it doesn't know about flattening. Returns `null` until Pierre has + * rendered (caller should fall back to an estimate meanwhile). + */ +export function useMeasuredTreeHeight(model: FileTree): number | null { + const [height, setHeight] = useState(null); + useEffect(() => { + const readHeight = (): boolean => { + const list = model + .getFileTreeContainer() + ?.shadowRoot?.querySelector( + "[data-file-tree-virtualized-list]", + ); + const h = list ? Number.parseFloat(list.style.height) : Number.NaN; + if (Number.isFinite(h) && h > 0) { + setHeight(h); + return true; + } + return false; + }; + let raf = 0; + let attempts = 0; + const retryUntilReady = () => { + if (readHeight() || attempts++ > 30) return; + raf = requestAnimationFrame(retryUntilReady); + }; + retryUntilReady(); + // Pierre rewrites `style.height` when the rendered row count changes + // (resetPaths, expand/collapse); re-read on the next frame after each. + const unsubscribe = model.subscribe(() => { + raf = requestAnimationFrame(readHeight); + }); + return () => { + cancelAnimationFrame(raf); + unsubscribe(); + }; + }, [model]); + return height; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/utils/buildTreeShape/buildTreeShape.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/utils/buildTreeShape/buildTreeShape.ts new file mode 100644 index 00000000000..c05e7858df4 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/utils/buildTreeShape/buildTreeShape.ts @@ -0,0 +1,29 @@ +export interface TreeShape { + /** Every directory path implied by the files, sorted shallow→deep (ancestors first). */ + dirs: string[]; + /** Directory path → count of files anywhere beneath it. */ + dirFileCount: Map; +} + +/** Derive the directory hierarchy + per-directory file counts from a flat list of file paths. */ +export function buildTreeShape(paths: string[]): TreeShape { + const dirs: string[] = []; + const seen = new Set(); + const dirFileCount = new Map(); + for (const path of paths) { + const segments = path.split("/"); + let acc = ""; + for (let i = 0; i < segments.length - 1; i++) { + acc = acc ? `${acc}/${segments[i]}` : segments[i]; + if (!seen.has(acc)) { + seen.add(acc); + dirs.push(acc); + } + dirFileCount.set(acc, (dirFileCount.get(acc) ?? 0) + 1); + } + } + dirs.sort( + (a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b), + ); + return { dirs, dirFileCount }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/utils/buildTreeShape/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/utils/buildTreeShape/index.ts new file mode 100644 index 00000000000..b365232a299 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/utils/buildTreeShape/index.ts @@ -0,0 +1 @@ +export { buildTreeShape, type TreeShape } from "./buildTreeShape"; From 13dbfb6ed76c02596a363c1aa3358820f743e738 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 12 May 2026 11:14:08 -0700 Subject: [PATCH 19/24] refactor(desktop): extract useFallthroughIcons hook The setIcons-after-load effect was copy-pasted in FilesTab and ChangesTreeView; move it into renderer/lib/fileIcons as useFallthroughIcons(model). --- .../src/renderer/lib/fileIcons/index.ts | 1 + .../lib/fileIcons/useFallthroughIcons.ts | 24 +++++++++++++++++++ .../components/FilesTab/FilesTab.tsx | 18 ++------------ .../ChangesTreeView/ChangesTreeView.tsx | 17 ++----------- 4 files changed, 29 insertions(+), 31 deletions(-) create mode 100644 apps/desktop/src/renderer/lib/fileIcons/useFallthroughIcons.ts diff --git a/apps/desktop/src/renderer/lib/fileIcons/index.ts b/apps/desktop/src/renderer/lib/fileIcons/index.ts index 3e9d438c090..e665c1ca98e 100644 --- a/apps/desktop/src/renderer/lib/fileIcons/index.ts +++ b/apps/desktop/src/renderer/lib/fileIcons/index.ts @@ -4,3 +4,4 @@ export { loadFallthroughIcons } from "./loadFallthroughIcons"; export type { FileIconManifest } from "./manifest"; export { fileIconManifest } from "./manifest"; export { resolveFileIconAssetUrl } from "./resolveFileIconAssetUrl"; +export { useFallthroughIcons } from "./useFallthroughIcons"; diff --git a/apps/desktop/src/renderer/lib/fileIcons/useFallthroughIcons.ts b/apps/desktop/src/renderer/lib/fileIcons/useFallthroughIcons.ts new file mode 100644 index 00000000000..6dea979ad85 --- /dev/null +++ b/apps/desktop/src/renderer/lib/fileIcons/useFallthroughIcons.ts @@ -0,0 +1,24 @@ +import type { FileTree } from "@pierre/trees"; +import { useEffect } from "react"; +import { loadFallthroughIcons } from "./loadFallthroughIcons"; + +/** + * Layers our Material-icon fallthrough coverage onto a `@pierre/trees` model + * once the icon sprite has loaded: file types Pierre's built-in `complete` set + * doesn't recognize (`.toml`, `.lock`, framework dirs, …) plus a Material + * default-file icon for anything still unmatched. The model renders with + * Pierre's defaults first; ours fill in async. The cache inside + * `loadFallthroughIcons` makes repeat mounts a no-op. + */ +export function useFallthroughIcons(model: FileTree): void { + useEffect(() => { + let cancelled = false; + void loadFallthroughIcons().then((config) => { + if (cancelled) return; + model.setIcons({ set: "complete", colored: true, ...config }); + }); + return () => { + cancelled = true; + }; + }, [model]); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx index 947ff92b001..f7c83a51103 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx @@ -27,7 +27,7 @@ import { usePierreRowClickPolicy, useSidebarFilePolicy, } from "renderer/lib/clickPolicy"; -import { loadFallthroughIcons } from "renderer/lib/fileIcons"; +import { useFallthroughIcons } from "renderer/lib/fileIcons"; import { createPierreTreeStyle } from "renderer/lib/pierreTree"; import { useOpenInExternalEditor } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor"; import { PierreRowContextMenu } from "../PierreRowContextMenu"; @@ -148,21 +148,7 @@ export function FilesTab({ ); }, [model, fileStatusByPath, folderStatusByPath, ignoredPaths]); - // Layer our Material-icon coverage on top of Pierre's built-ins: file types - // Pierre doesn't recognize (`.toml`, `.lock`, framework dirs, etc) plus a - // Material default-file icon for anything still unmatched. Initial render - // uses Pierre's defaults; ours fill in once the sprite finishes loading. - // The cache inside loadFallthroughIcons makes subsequent mounts a no-op. - useEffect(() => { - let cancelled = false; - void loadFallthroughIcons().then((config) => { - if (cancelled) return; - model.setIcons({ set: "complete", colored: true, ...config }); - }); - return () => { - cancelled = true; - }; - }, [model]); + useFallthroughIcons(model); // Reflect external selection changes (e.g. tab switch) back into the model. useEffect(() => { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx index 77170c14789..44100a2e9d1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx @@ -19,7 +19,7 @@ import { usePierreRowClickPolicy, useSidebarFilePolicy, } from "renderer/lib/clickPolicy"; -import { loadFallthroughIcons } from "renderer/lib/fileIcons"; +import { useFallthroughIcons } from "renderer/lib/fileIcons"; import { createPierreTreeStyle, FILE_STATUS_TO_PIERRE, @@ -140,20 +140,7 @@ export const ChangesTreeView = memo(function ChangesTreeView({ model.setGitStatus(buildPierreGitStatus(files)); }, [model, files]); - // Fill in Material icons for file types Pierre's built-in set doesn't cover, - // plus a Material default-file icon for anything still unmatched (matches - // the Files tab). Initial render uses Pierre's defaults; the sprite-loading - // cache makes repeat mounts a no-op. - useEffect(() => { - let cancelled = false; - void loadFallthroughIcons().then((config) => { - if (cancelled) return; - model.setIcons({ set: "complete", colored: true, ...config }); - }); - return () => { - cancelled = true; - }; - }, [model]); + useFallthroughIcons(model); // Size the host to Pierre's measured content height (it renders `height: // 100%`, which collapses to 0 inside this section's auto-height container); From 9b064ea0a0ee3291a3df2337a02a1a8baa641c1c Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 12 May 2026 11:16:32 -0700 Subject: [PATCH 20/24] refactor(desktop): share stripTrailingSlash via renderer/lib/pierreTree It was defined in FilesTab's treePath utils and duplicated inline in ChangesTreeView; move it to renderer/lib/pierreTree (the Pierre tree's directory-marker convention) and re-export it from treePath. --- apps/desktop/src/renderer/lib/pierreTree/index.ts | 1 + apps/desktop/src/renderer/lib/pierreTree/treePaths.ts | 8 ++++++++ .../components/FilesTab/utils/treePath/treePath.ts | 7 +++---- .../components/ChangesTreeView/ChangesTreeView.tsx | 5 +---- 4 files changed, 13 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/src/renderer/lib/pierreTree/treePaths.ts diff --git a/apps/desktop/src/renderer/lib/pierreTree/index.ts b/apps/desktop/src/renderer/lib/pierreTree/index.ts index de63611772f..25165aa3a11 100644 --- a/apps/desktop/src/renderer/lib/pierreTree/index.ts +++ b/apps/desktop/src/renderer/lib/pierreTree/index.ts @@ -5,3 +5,4 @@ export { type PierreGitStatus, type PierreGitStatusEntry, } from "./pierreGitStatus"; +export { stripTrailingSlash } from "./treePaths"; diff --git a/apps/desktop/src/renderer/lib/pierreTree/treePaths.ts b/apps/desktop/src/renderer/lib/pierreTree/treePaths.ts new file mode 100644 index 00000000000..9bd89c2f96f --- /dev/null +++ b/apps/desktop/src/renderer/lib/pierreTree/treePaths.ts @@ -0,0 +1,8 @@ +/** + * `@pierre/trees` denotes directory rows with a trailing `/` (its canonical + * directory path form). Drop it to get the bare path. Safe to call on file + * paths (no-op). + */ +export function stripTrailingSlash(path: string): string { + return path.endsWith("/") ? path.slice(0, -1) : path; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/utils/treePath/treePath.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/utils/treePath/treePath.ts index b7f4132e86f..c7a563dba75 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/utils/treePath/treePath.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/utils/treePath/treePath.ts @@ -2,15 +2,14 @@ import type { FileTreeDirectoryHandle, FileTreeItemHandle, } from "@pierre/trees"; +import { stripTrailingSlash } from "renderer/lib/pierreTree"; + +export { stripTrailingSlash }; export function toPosix(p: string): string { return p.replace(/\\/g, "/"); } -export function stripTrailingSlash(p: string): string { - return p.endsWith("/") ? p.slice(0, -1) : p; -} - export function toRel(rootPath: string, abs: string): string { const a = toPosix(abs); const r = toPosix(rootPath); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx index 44100a2e9d1..3fa716f7ebb 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx @@ -24,6 +24,7 @@ import { createPierreTreeStyle, FILE_STATUS_TO_PIERRE, type PierreGitStatusEntry, + stripTrailingSlash, } from "renderer/lib/pierreTree"; import { DiscardConfirmDialog } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/DiscardConfirmDialog"; import { PierreRowContextMenu } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PierreRowContextMenu"; @@ -369,7 +370,3 @@ function formatDiffStats(additions: number, deletions: number): string { if (deletions === 0) return `+${additions}`; return `+${additions} −${deletions}`; } - -function stripTrailingSlash(path: string): string { - return path.endsWith("/") ? path.slice(0, -1) : path; -} From 2b2eeba16d6f78a2ee1c4aecd06cdcc3908ae0b6 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 12 May 2026 11:27:42 -0700 Subject: [PATCH 21/24] docs(desktop): move architecture docs into apps/desktop/docs/ Per AGENTS rule #6 (reference docs live in /docs/). Moves the HOST_SERVICE_* and V2_WORKSPACE_* docs; fixes the few path references in plans/ and a code comment, and the GIT_REFS.md relative link inside V2_WORKSPACE_CREATION.md. --- apps/desktop/{ => docs}/HOST_SERVICE_ARCHITECTURE.md | 0 apps/desktop/{ => docs}/HOST_SERVICE_BOUNDARIES.md | 0 apps/desktop/{ => docs}/HOST_SERVICE_LIFECYCLE.md | 0 apps/desktop/{ => docs}/V2_WORKSPACE_CREATION.md | 2 +- apps/desktop/{ => docs}/V2_WORKSPACE_MODAL_GAPS.md | 0 apps/desktop/plans/20260416-v2-pr-checkout-endpoint.md | 2 +- apps/desktop/src/main/lib/host-service-coordinator.ts | 2 +- plans/20260502-bun-dev-server-cleanup.md | 2 +- plans/done/v2-workspace-context-composition.md | 2 +- 9 files changed, 5 insertions(+), 5 deletions(-) rename apps/desktop/{ => docs}/HOST_SERVICE_ARCHITECTURE.md (100%) rename apps/desktop/{ => docs}/HOST_SERVICE_BOUNDARIES.md (100%) rename apps/desktop/{ => docs}/HOST_SERVICE_LIFECYCLE.md (100%) rename apps/desktop/{ => docs}/V2_WORKSPACE_CREATION.md (99%) rename apps/desktop/{ => docs}/V2_WORKSPACE_MODAL_GAPS.md (100%) diff --git a/apps/desktop/HOST_SERVICE_ARCHITECTURE.md b/apps/desktop/docs/HOST_SERVICE_ARCHITECTURE.md similarity index 100% rename from apps/desktop/HOST_SERVICE_ARCHITECTURE.md rename to apps/desktop/docs/HOST_SERVICE_ARCHITECTURE.md diff --git a/apps/desktop/HOST_SERVICE_BOUNDARIES.md b/apps/desktop/docs/HOST_SERVICE_BOUNDARIES.md similarity index 100% rename from apps/desktop/HOST_SERVICE_BOUNDARIES.md rename to apps/desktop/docs/HOST_SERVICE_BOUNDARIES.md diff --git a/apps/desktop/HOST_SERVICE_LIFECYCLE.md b/apps/desktop/docs/HOST_SERVICE_LIFECYCLE.md similarity index 100% rename from apps/desktop/HOST_SERVICE_LIFECYCLE.md rename to apps/desktop/docs/HOST_SERVICE_LIFECYCLE.md diff --git a/apps/desktop/V2_WORKSPACE_CREATION.md b/apps/desktop/docs/V2_WORKSPACE_CREATION.md similarity index 99% rename from apps/desktop/V2_WORKSPACE_CREATION.md rename to apps/desktop/docs/V2_WORKSPACE_CREATION.md index ad97547384a..1eaf87775d0 100644 --- a/apps/desktop/V2_WORKSPACE_CREATION.md +++ b/apps/desktop/docs/V2_WORKSPACE_CREATION.md @@ -328,7 +328,7 @@ See `packages/host-service/GIT_REFS.md` for the pattern. Key rules: ## Cross-reference -For the foundational git-ref handling pattern that underpins `create` / `checkout` / `resolveStartPoint`, see [`packages/host-service/GIT_REFS.md`](../../packages/host-service/GIT_REFS.md). +For the foundational git-ref handling pattern that underpins `create` / `checkout` / `resolveStartPoint`, see [`packages/host-service/GIT_REFS.md`](../../../packages/host-service/GIT_REFS.md). --- diff --git a/apps/desktop/V2_WORKSPACE_MODAL_GAPS.md b/apps/desktop/docs/V2_WORKSPACE_MODAL_GAPS.md similarity index 100% rename from apps/desktop/V2_WORKSPACE_MODAL_GAPS.md rename to apps/desktop/docs/V2_WORKSPACE_MODAL_GAPS.md diff --git a/apps/desktop/plans/20260416-v2-pr-checkout-endpoint.md b/apps/desktop/plans/20260416-v2-pr-checkout-endpoint.md index 4f0251d13ef..29b27f422b1 100644 --- a/apps/desktop/plans/20260416-v2-pr-checkout-endpoint.md +++ b/apps/desktop/plans/20260416-v2-pr-checkout-endpoint.md @@ -12,7 +12,7 @@ time — moved earlier in the pending-page sequence and shared between the mutation payload and the agent-launch resolver. **Zero net new fetches.** Cross-refs: -- `apps/desktop/V2_WORKSPACE_CREATION.md` — umbrella design this extends. +- `apps/desktop/docs/V2_WORKSPACE_CREATION.md` — umbrella design this extends. - `packages/host-service/GIT_REFS.md` — ref handling discipline. - V1 source: `apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts:752` (`createFromPr`) + `.../utils/git.ts:1630-1791`. diff --git a/apps/desktop/src/main/lib/host-service-coordinator.ts b/apps/desktop/src/main/lib/host-service-coordinator.ts index e32c75759f5..e10ae1f5d77 100644 --- a/apps/desktop/src/main/lib/host-service-coordinator.ts +++ b/apps/desktop/src/main/lib/host-service-coordinator.ts @@ -372,7 +372,7 @@ export class HostServiceCoordinator extends EventEmitter { let child: ReturnType; try { // Prod: detached so PTYs survive Electron restarts via manifest - // adoption (HOST_SERVICE_LIFECYCLE.md). Dev: attached so a `bun dev` + // adoption (docs/HOST_SERVICE_LIFECYCLE.md). Dev: attached so a `bun dev` // kill propagates and serve.ts's dev shutdown can stop pty-daemon. child = childProcess.spawn(process.execPath, [this.scriptPath], { detached: !isDev, diff --git a/plans/20260502-bun-dev-server-cleanup.md b/plans/20260502-bun-dev-server-cleanup.md index 3f3c8659031..7fcddd1200f 100644 --- a/plans/20260502-bun-dev-server-cleanup.md +++ b/plans/20260502-bun-dev-server-cleanup.md @@ -23,7 +23,7 @@ Killing `bun run dev` left orphan processes (PPID=1): dev servers bound to ports ## Prod safety -All daemon-spawn changes branch on `app.isPackaged` (or `NODE_ENV === "production"` for `DaemonSupervisor`). Packaged builds keep `detached: true` + `unref()` and the `releaseAll()` quit path unchanged, preserving manifest-based adoption and PTY survival across restarts (see `apps/desktop/HOST_SERVICE_LIFECYCLE.md`). The `exec` edits only touch `"dev"` scripts; cloud apps deploy via `next start` / `wrangler deploy`, which never run them. +All daemon-spawn changes branch on `app.isPackaged` (or `NODE_ENV === "production"` for `DaemonSupervisor`). Packaged builds keep `detached: true` + `unref()` and the `releaseAll()` quit path unchanged, preserving manifest-based adoption and PTY survival across restarts (see `apps/desktop/docs/HOST_SERVICE_LIFECYCLE.md`). The `exec` edits only touch `"dev"` scripts; cloud apps deploy via `next start` / `wrangler deploy`, which never run them. ## Verification diff --git a/plans/done/v2-workspace-context-composition.md b/plans/done/v2-workspace-context-composition.md index 007937f0ae2..198f1edda27 100644 --- a/plans/done/v2-workspace-context-composition.md +++ b/plans/done/v2-workspace-context-composition.md @@ -1,6 +1,6 @@ # V2 Workspace Launch Context — Composition -Closes Gaps 3, 4, 5 (unblocks 6) in `apps/desktop/V2_WORKSPACE_MODAL_GAPS.md`. +Closes Gaps 3, 4, 5 (unblocks 6) in `apps/desktop/docs/V2_WORKSPACE_MODAL_GAPS.md`. V2-only — V1 stays as-is. We rewrite where V1's shape is wrong; we duplicate where V1 is fine. From 8219bd30fe3509524b9026f147985f72cbb9be36 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 12 May 2026 12:00:39 -0700 Subject: [PATCH 22/24] fix(desktop): use folderPath directly as the changes folder-group key Drops the "__root__" sentinel key, which could collide with a real top-level folder named "__root__"; folderPath ("" for root) is already the unique per-group discriminator. --- .../components/ChangesFoldersView/ChangesFoldersView.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx index 025d01ac48f..806ec35d0a0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx @@ -78,7 +78,9 @@ export const ChangesFoldersView = memo(function ChangesFoldersView({ const isRoot = group.folderPath === ROOT_FOLDER_KEY; const isOpen = !closedFolders.has(group.folderPath); return ( -
+ {/* `folderPath` (`""` for the root group) is already the unique + discriminator — `groupFilesByFolder` keys a Map by it. */} +
Date: Tue, 12 May 2026 12:01:41 -0700 Subject: [PATCH 23/24] fix(desktop): repair ChangesFoldersView JSX broken by bad comment placement The previous commit put a JSX `{/* */}` comment directly after `return (`, which biome's formatter then mangled into invalid syntax. Move it to a plain `//` comment above the return. --- .../components/ChangesFoldersView/ChangesFoldersView.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx index 806ec35d0a0..e525781f33d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx @@ -77,9 +77,9 @@ export const ChangesFoldersView = memo(function ChangesFoldersView({ {groups.map((group) => { const isRoot = group.folderPath === ROOT_FOLDER_KEY; const isOpen = !closedFolders.has(group.folderPath); + // `folderPath` ("" for the root group) is already the unique + // per-group discriminator — `groupFilesByFolder` keys a Map by it. return ( - {/* `folderPath` (`""` for the root group) is already the unique - discriminator — `groupFilesByFolder` keys a Map by it. */}
Date: Tue, 12 May 2026 12:35:45 -0700 Subject: [PATCH 24/24] fix(desktop): drop changes tree hover overlay on scroll --- .../ShadowRowHoverActions/ShadowRowHoverActions.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/components/ShadowRowHoverActions/ShadowRowHoverActions.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/components/ShadowRowHoverActions/ShadowRowHoverActions.tsx index d97f995532c..3701cafa075 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/components/ShadowRowHoverActions/ShadowRowHoverActions.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/components/ShadowRowHoverActions/ShadowRowHoverActions.tsx @@ -66,6 +66,15 @@ export function ShadowRowHoverActions({ setHover(null); }, [menuOpen]); + // Scrolling the virtualized list moves rows out from under the captured + // rect, so drop the overlay (it re-anchors on the next mouseover). Skip + // while the dropdown is open — closing the overlay would tear it down. + const handleScrollCapture = useCallback(() => { + if (menuOpen || !hoverRowRef.current) return; + hoverRowRef.current = null; + setHover(null); + }, [menuOpen]); + return ( // biome-ignore lint/a11y/noStaticElementInteractions: wraps a custom-element host with its own keyboard nav // biome-ignore lint/a11y/useKeyWithMouseEvents: hover-action anchoring is mouse-only by nature @@ -73,6 +82,7 @@ export function ShadowRowHoverActions({ className="contents" onMouseOver={handleMouseOver} onMouseLeave={handleMouseLeave} + onScrollCapture={handleScrollCapture} > {children} {hover && (