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/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/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/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/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/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..e665c1ca98e --- /dev/null +++ b/apps/desktop/src/renderer/lib/fileIcons/index.ts @@ -0,0 +1,7 @@ +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"; +export { useFallthroughIcons } from "./useFallthroughIcons"; 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/lib/fileIcons/loadFallthroughIcons.ts similarity index 82% 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/lib/fileIcons/loadFallthroughIcons.ts index dc88eef9506..3cf80d0f9d3 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/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/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/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..25165aa3a11 --- /dev/null +++ b/apps/desktop/src/renderer/lib/pierreTree/index.ts @@ -0,0 +1,8 @@ +export { createPierreTreeStyle } from "./createPierreTreeStyle"; +export { + FILE_STATUS_TO_PIERRE, + type FileStatus, + type PierreGitStatus, + type PierreGitStatusEntry, +} from "./pierreGitStatus"; +export { stripTrailingSlash } from "./treePaths"; 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/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/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/components/FilesTab/FilesTab.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx index 9a9c4ef2d02..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 @@ -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,90 +21,34 @@ 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 { - folderIntentFor, ShadowClickHint, + usePierreRowClickPolicy, useSidebarFilePolicy, } from "renderer/lib/clickPolicy"; +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 { - 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 { RowContextMenu } from "./components/RowContextMenu"; -import { useFilesTabBridge } from "./hooks/useFilesTabBridge"; -import { loadFallthroughIcons } from "./utils/loadFallthroughIcons"; 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(() => { @@ -218,29 +148,7 @@ 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. - 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]); + useFallthroughIcons(model); // Reflect external selection changes (e.g. tab switch) back into the model. useEffect(() => { @@ -254,198 +162,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); @@ -460,100 +181,19 @@ 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], - ); - - // 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 +204,7 @@ export function FilesTab({ const abs = toAbs(rootPath, item.path); const rel = stripTrailingSlash(item.path); return ( - handleDelete(abs, item.name, false)} /> )} - + ); }, [ @@ -602,16 +242,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 (
@@ -641,23 +271,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/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/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/components/FilesTab/utils/loadFallthroughIcons/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/utils/loadFallthroughIcons/index.ts deleted file mode 100644 index 5506f48c253..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/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/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/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..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 @@ -1,19 +1,30 @@ 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"; + +/** 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; isLoading?: boolean; + viewMode: ChangesViewMode; worktreePath?: string; + selectedFilePath?: string; + foldSignal: FoldSignal; onSelectFile?: (path: string, openInNewTab?: boolean) => void; onOpenFile?: (absolutePath: string, openInNewTab?: boolean) => void; onOpenInEditor?: (path: string) => void; } -type GroupKey = "unstaged" | "staged" | "against-base" | "commit"; +type GroupKey = ChangesetFile["source"]["kind"]; const GROUP_ORDER: GroupKey[] = [ "unstaged", @@ -33,7 +44,10 @@ export const ChangesFileList = memo(function ChangesFileList({ files, workspaceId, isLoading, + viewMode, worktreePath, + selectedFilePath, + foldSignal, onSelectFile, onOpenFile, onOpenInEditor, @@ -68,7 +82,7 @@ export const ChangesFileList = memo(function ChangesFileList({ } return ( -
+
{GROUP_ORDER.map((key) => { const groupFiles = grouped[key]; if (groupFiles.length === 0) return null; @@ -84,17 +98,29 @@ 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..e525781f33d --- /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,139 @@ +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 { FolderHeader } from "./components/FolderHeader"; + +const ROOT_FOLDER_KEY = ""; +const ROOT_FOLDER_LABEL = "Root Path"; + +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; +} + +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, + foldSignal, + 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; + }); + }, []); + + // 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); + // `folderPath` ("" for the root group) is already the unique + // per-group discriminator — `groupFilesByFolder` keys a Map by it. + 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..a2adb025541 --- /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,46 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; + +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. 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, + fileCount, + isOpen, + onToggle, +}: FolderHeaderProps) { + return ( + + + + + {label} + + ); +} 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..3fa716f7ebb --- /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,372 @@ +import type { + FileTreeDirectoryHandle, + FileTreeRowDecoration, + FileTreeRowDecorationContext, + ContextMenuItem as PierreContextMenuItem, + ContextMenuOpenContext as PierreContextMenuOpenContext, +} from "@pierre/trees"; +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, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + ShadowClickHint, + usePierreRowClickPolicy, + useSidebarFilePolicy, +} from "renderer/lib/clickPolicy"; +import { useFallthroughIcons } from "renderer/lib/fileIcons"; +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"; +import type { ChangesetFile } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset"; +import { toRelativeWorkspacePath } from "shared/absolute-paths"; +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. +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 = createPierreTreeStyle({ + rowHeight: ITEM_HEIGHT, + levelIndent: 8, +}); + +type SectionKind = ChangesetFile["source"]["kind"]; + +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/hover Discard. */ + sectionKind: SectionKind; + workspaceId: string; + 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; +} + +/** + * Tree view of a single changes section, powered by `@pierre/trees`. Pierre + * builds the directory hierarchy from the flat path list and handles + * virtualization + status tints + icons; we layer on: + * + * - `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 + * + * 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, + foldSignal, + 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 { dirs, dirFileCount } = useMemo(() => buildTreeShape(paths), [paths]); + + 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: ITEM_HEIGHT, + 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]); + + 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); + // fall back to a row-count estimate until that first measurement lands. + const contentHeight = useMeasuredTreeHeight(model); + const treeHeight = + contentHeight != null + ? contentHeight + HEIGHT_CUSHION + : (dirs.length + paths.length) * ROW_BOX + HEIGHT_CUSHION; + + 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); + useEffect(() => { + if (foldSignal.epoch === 0 || foldSignal.epoch === lastFoldEpochRef.current) + return; + lastFoldEpochRef.current = foldSignal.epoch; + 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: + // 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 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") { + 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); + return text ? { text } : null; + }; + + const filePolicy = useSidebarFilePolicy(); + const { onClickCapture, findFileRow } = usePierreRowClickPolicy({ + filePolicy, + 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 fileMenuItems = (file: ChangesetFile) => ( + + ); + + const renderContextMenu = ( + item: PierreContextMenuItem, + ctx: PierreContextMenuOpenContext, + ) => { + const menuItems = (() => { + if (item.kind === "directory") { + return ( + + ); + } + const file = fileByPath.get(item.path); + return file ? fileMenuItems(file) : null; + })(); + if (!menuItems) return null; + return ( + + {menuItems} + + ); + }; + + 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); + return file ? fileMenuItems(file) : null; + }; + + 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, + }); + }} + /> + )} +
+ ); +}); + +function buildPierreGitStatus(files: ChangesetFile[]): PierreGitStatusEntry[] { + return files.map((file) => ({ + path: file.path, + status: FILE_STATUS_TO_PIERRE[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..52c8f8ab62f --- /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,123 @@ +import { + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuShortcut, +} from "@superset/ui/dropdown-menu"; +import { + ExternalLink, + FileText, + GitCompare, + SquarePlus, + Trash2, + Undo2, +} from "lucide-react"; +import { modifierLabel, useSidebarFilePolicy } from "renderer/lib/clickPolicy"; +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; + 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; +} + +/** + * 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, + 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 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 && onRequestDiscard && ( + <> + + onRequestDiscard(file)} + > + {isDeleteAction ? : } + {isDeleteAction ? "Delete" : "Discard changes"} + + + )} + + ); +} 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/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..3701cafa075 --- /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,131 @@ +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]); + + // 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 +
+ {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/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/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/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"; 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..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 } { @@ -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/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 ae7dc18e485..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 @@ -1,21 +1,12 @@ 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 }; defaultBranchName: string; baseBranch: string | null; - 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; @@ -26,15 +17,8 @@ export function ChangesHeader({ currentBranch, defaultBranchName, baseBranch, - totalFiles, - totalAdditions, - totalDeletions, onRenameBranch, canRename, - filter, - onFilterChange, - commits, - uncommittedCount, branches, onBaseBranchChange, }: ChangesHeaderProps) { @@ -59,78 +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 - - - )} -
- -
- { + 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" /> -
- - {totalFiles} {totalFiles === 1 ? "file" : "files"} + ) : ( + <> + + {currentBranch.name} - {(totalAdditions > 0 || totalDeletions > 0) && ( - - {totalAdditions > 0 && ( - +{totalAdditions} - )} - {totalAdditions > 0 && totalDeletions > 0 && " "} - {totalDeletions > 0 && ( - -{totalDeletions} - )} - + {canRename && ( + )} -
-
+ from + + + )}
); } 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..0ca41b1022b --- /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,68 @@ +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). + */ +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..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 @@ -1,10 +1,15 @@ 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 { 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; @@ -17,6 +22,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; @@ -24,10 +30,12 @@ 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; onFilterChange: (filter: ChangesFilter) => void; + onViewModeChange: (viewMode: ChangesViewMode) => void; onBaseBranchChange: (branchName: string) => void; onRenameBranch: (newName: string) => void; canRenameBranch: boolean; @@ -39,6 +47,7 @@ export const ChangesTabContent = memo(function ChangesTabContent({ commits, branches, filter, + viewMode, baseBranch, files, isLoading, @@ -46,14 +55,34 @@ export const ChangesTabContent = memo(function ChangesTabContent({ totalAdditions, totalDeletions, worktreePath, + selectedFilePath, onSelectFile, onOpenFile, onOpenInEditor, onFilterChange, + onViewModeChange, onBaseBranchChange, onRenameBranch, canRenameBranch, }: ChangesTabContentProps) { + const [foldSignal, setFoldSignal] = useState({ + epoch: 0, + 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", + }; + }), + [], + ); + if (status.isLoading) { return (
@@ -76,31 +105,38 @@ 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} + canRename={canRenameBranch} + /> + + -
- -
); }); 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 new file mode 100644 index 00000000000..696fc9f6385 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesToolbar/ChangesToolbar.tsx @@ -0,0 +1,92 @@ +import { Button } from "@superset/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { FoldVertical, UnfoldVertical } from "lucide-react"; +import type { + ChangesFilter, + ChangesViewMode, +} from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; +import type { Commit } from "../../types"; +import { ViewModeToggle } from "../ChangesHeader/components/ViewModeToggle"; +import { CommitFilterDropdown } from "../CommitFilterDropdown"; + +interface ChangesToolbarProps { + filter: ChangesFilter; + 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". */ + collapsed: boolean; + /** Toggle between collapse-all and expand-all across every section. */ + onToggleFold: () => void; +} + +/** + * 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, + onToggleFold, +}: ChangesToolbarProps) { + const label = collapsed ? "Expand all" : "Collapse all"; + 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} + )} + + )} +
+
+ + + + + + {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"; 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/hooks/useChangesTab/useChangesTab.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx index d730032fca4..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 @@ -10,16 +10,21 @@ 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; 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; } @@ -27,6 +32,7 @@ interface UseChangesTabParams { export function useChangesTab({ workspaceId, gitStatus: status, + selectedFilePath, onSelectFile, onOpenFile, }: UseChangesTabParams): SidebarTabDefinition { @@ -36,6 +42,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 +78,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 +191,7 @@ export function useChangesTab({ commits={commits} branches={branches} filter={filter} + viewMode={viewMode} baseBranch={baseBranch} files={files} isLoading={isLoading} @@ -180,10 +199,12 @@ export function useChangesTab({ totalAdditions={totalAdditions} totalDeletions={totalDeletions} worktreePath={worktreePath} + selectedFilePath={selectedFilePath} onSelectFile={onSelectFile} onOpenFile={onOpenFile} onOpenInEditor={handleOpenInEditor} onFilterChange={setFilter} + onViewModeChange={setViewMode} onBaseBranchChange={setBaseBranch} onRenameBranch={handleRenameBranch} canRenameBranch={canRenameBranch} 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/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; 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"; 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.