feat: add multi-select and drag-to-move for file tree and changes panel (#490)

* feat: add multi-select and drag-to-move for file tree and changes panel

Add Shift-click range and Ctrl/Cmd-click toggle selection to the file
tree and staged/unstaged changes sections. Includes bulk actions
(delete, stage, unstage, discard), drag-and-drop file moving into
directories, keyboard shortcuts (Escape, Ctrl+A), and E2E tests.

* fix: move bulk action components to file-row to fix max-lines lint

* style: apply prettier formatting to all changed files

* fix: bulk delete all selected items and update anchor on stale shift-click

* fix: address CodeRabbit review comments

- Keep original file-row-* data-testid for backwards compat
- Remove eager onClearSelection from bulk actions (auto-pruned by hook)
- Variant-scope bulk-discard/bulk-unstage test ids
- Remove index.lock deletion from E2E GitHelper retry loop

* fix: fix E2E tests — create files after session open, remove DnD test

- Create files AFTER opening session so WS detects git status changes
- Add proper git env vars (GIT_AUTHOR_NAME, etc.) matching existing tests
- Use element.press("Escape") instead of page keyboard for proper focus
- Remove drag-to-directory test (Playwright dragTo doesn't support HTML5
  dataTransfer events needed by our DnD implementation)

* fix: rename E2E task titles to avoid ambiguous 'Changes' tab selector

* fix: make file list focusable for Escape key handler in E2E tests

* fix: improve selection styling and ignore right-clicks in selection handler

- Use text-accent-foreground on selected rows for dark mode readability
- Override child span colors with [&_span]:text-accent-foreground
- Use hover:bg-accent/80 instead of hover:bg-muted on selected items
- Skip selection handling on right-click (button === 2) so context menu
  works without clearing multi-selection

* fix: plain click should not select — only Ctrl/Cmd+click and Shift+click

Plain click now clears any selection and performs the original action
(open file / toggle folder / open diff). Selection only happens with
modifier keys. handleClick returns boolean to indicate whether the
click was consumed by selection logic.

* fix: fix multi-select bugs and expand E2E test coverage

Bug fixes:
- Change isSelected from boolean to function prop so children compute
  their own selection state (fixes nested file selection)
- Inline TreeNodeRow JSX into TreeNodeItem so ContextMenuTrigger asChild
  can attach refs directly (fixes right-click context menu)
- Set lastClickedRef on plain click so shift-click has a valid anchor
- Add click-outside handler to clear selection

E2E tests expanded to 14 file-tree tests + 3 changes-panel tests:
- Ctrl-click select, toggle, multi-select
- Plain click clears selection
- Shift-click range after plain click and ctrl-click anchors
- Directory selection and nested file selection
- Right-click context menu (single + bulk delete)
- Click outside clears selection
- Escape clears selection

* fix: move AlertDialog outside ContextMenu to fix delete action

The AlertDialog wrapping the ContextMenu caused Radix focus management
to intercept context menu item selection, making Delete trigger Rename.
Moving AlertDialog to a sibling of ContextMenu fixes the conflict.

* fix: reorder context menu — Delete first, Rename second

Radix ContextMenu auto-focuses the first item on open, which caused
accidental Rename triggers. Putting Delete first (the more common
action for selection) avoids this.

* fix: don't clear selection when clicking context menu or dialog

The click-outside handler was clearing selection when clicking on
context menu items or confirmation dialog buttons (rendered in portals
outside the tree container). Now skips clearing for elements with
role=menu, role=alertdialog, or role=dialog.

Also extended the bulk delete E2E test to verify both files are
actually removed after confirming the dialog.

* fix: hide file count in delete dialog when folder children not loaded

When a folder hasn't been expanded, its children aren't loaded yet so
countFilesInTree returns 0. Instead of showing "0 files", hide the
count entirely and just show "delete <folder name>".

* fix: show confirmation dialog for bulk discard in changes panel

Bulk discard was calling git.discard() directly, bypassing the
confirmation dialog. Now routes through handleBulkDiscardClick which
opens the DiscardDialog with a bulk message ("discard changes to N
files") before executing.

* fix: address remaining CodeRabbit review comments

- Deduplicate filenames on move: when moving files with identical names
  to the same directory, append "(1)", "(2)" etc. to avoid collisions.
  Both tree update and rename API calls use the same deduplication.
- Replace void operator with .catch(() => {}) for bulk stage/unstage
  to prevent unhandled promise rejections.
- Add document-level dragend listener as safety net to clear stuck drag
  state if the element's onDragEnd doesn't fire (Escape, out-of-window).

* test: add unit tests for file tree move deduplication and visible paths

* fix: address all remaining PR review comments

- Move onSelectionChange out of React state updater (side effect fix)
- Stabilize keyboard listener deps (pass callbacks not object)
- Remove unnecessary useMemo in BulkActionBar
- Fix ghost-restore on partial bulk delete (remove all at once)
- Fix blank filename on single-file bulk discard
- Add aria-selected and role=treeitem for accessibility
- Extract computeNextSelection params into object (max-params lint)

* fix: add missing bulk action props to WorkingTreeProps type

WorkingTreeSections referenced onBulkStage, onBulkUnstage, and
onBulkDiscard but WorkingTreeProps Pick didn't include them, causing
a TypeScript build failure.

* fix: address final review comments

- Add unit tests for computeNextSelection (8 tests covering ctrl-click,
  shift-click range, stale anchor, reverse range, shift+ctrl extend)
- Extract shared E2E helpers (GitHelper, makeGitEnv, openTaskSession,
  createStandardProfile) to e2e/helpers/git-helper.ts
- Fix misleading comment in bulk delete (says "re-insert only failed"
  but implementation restores full snapshot)

* fix: use Promise.all for bulk delete to prevent partial rollback

Individual .catch() handlers could restore already-deleted files
if a later delete failed. Now uses Promise.all so rollback only
happens if any delete rejects.

* test: add renderHook tests for useMultiSelect hook behaviors

- Stale-path pruning: items shrink → selected paths auto-pruned
- Plain click: returns false, clears existing selection
- selectAll/clearSelection: work correctly, fire onSelectionChange
- Install @testing-library/react for renderHook support

* fix: handle false returns from onDeleteFile in bulk delete Promise.all

* chore: remove unused @testing-library/react-hooks dependency
This commit is contained in:
José Almeida
2026-04-11 17:43:21 +01:00
committed by GitHub
parent 2ae996a2c1
commit 2b8513f902
18 changed files with 2142 additions and 249 deletions
+142 -10
View File
@@ -397,7 +397,7 @@ importers:
version: 0.55.1
next:
specifier: 16.1.7
version: 16.1.7(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
version: 16.1.7(@babel/core@7.28.6)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -451,7 +451,7 @@ importers:
version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
styled-jsx:
specifier: 5.1.6
version: 5.1.6(react@19.2.3)
version: 5.1.6(@babel/core@7.28.6)(react@19.2.3)
tailwind-merge:
specifier: ^3.4.0
version: 3.4.0
@@ -474,6 +474,9 @@ importers:
'@tailwindcss/postcss':
specifier: ^4
version: 4.1.18
'@testing-library/react':
specifier: ^16.3.2
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@types/diff':
specifier: ^8.0.0
version: 8.0.0
@@ -1283,89 +1286,105 @@ packages:
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
@@ -1562,24 +1581,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@16.1.7':
resolution: {integrity: sha512-uufcze7LYv0FQg9GnNeZ3/whYfo+1Q3HnQpm16o6Uyi0OVzLlk2ZWoY7j07KADZFY8qwDbsmFnMQP3p3+Ftprw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@16.1.7':
resolution: {integrity: sha512-KWVf2gxYvHtvuT+c4MBOGxuse5TD7DsMFYSxVxRBnOzok/xryNeQSjXgxSv9QpIVlaGzEn/pIuI6Koosx8CGWA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@16.1.7':
resolution: {integrity: sha512-HguhaGwsGr1YAGs68uRKc4aGWxLET+NevJskOcCAwXbwj0fYX0RgZW2gsOCzr9S11CSQPIkxmoSbuVaBp4Z3dA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@16.1.7':
resolution: {integrity: sha512-S0n3KrDJokKTeFyM/vGGGR8+pCmXYrjNTk2ZozOL1C/JFdfUIL9O1ATaJOl5r2POe56iRChbsszrjMAdWSv7kQ==}
@@ -2416,66 +2439,79 @@ packages:
resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.55.1':
resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.55.1':
resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.55.1':
resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.55.1':
resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.55.1':
resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.55.1':
resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.55.1':
resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.55.1':
resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.55.1':
resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.55.1':
resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.55.1':
resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.55.1':
resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.55.1':
resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==}
@@ -2593,24 +2629,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.18':
resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.18':
resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.18':
resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.1.18':
resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
@@ -2654,6 +2694,25 @@ packages:
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
engines: {node: '>=12'}
'@testing-library/dom@10.4.1':
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
engines: {node: '>=18'}
'@testing-library/react@16.3.2':
resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==}
engines: {node: '>=18'}
peerDependencies:
'@testing-library/dom': ^10.0.0
'@types/react': ^18.0.0 || ^19.0.0
'@types/react-dom': ^18.0.0 || ^19.0.0
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@tiptap/core@3.19.0':
resolution: {integrity: sha512-bpqELwPW+DG8gWiD8iiFtSl4vIBooG5uVJod92Qxn3rA9nFatyXRr4kNbMJmOZ66ezUvmCjXVe/5/G4i5cyzKA==}
peerDependencies:
@@ -2924,6 +2983,9 @@ packages:
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
'@types/aria-query@5.0.4':
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
'@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
@@ -3237,41 +3299,49 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
@@ -3389,6 +3459,9 @@ packages:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
aria-query@5.3.0:
resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
aria-query@5.3.2:
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
engines: {node: '>= 0.4'}
@@ -4018,6 +4091,9 @@ packages:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
dom-accessibility-api@0.5.16:
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
dom-helpers@5.2.1:
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
@@ -5057,24 +5133,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.30.2:
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.30.2:
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.30.2:
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.30.2:
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
@@ -5162,6 +5242,10 @@ packages:
lru_map@0.4.1:
resolution: {integrity: sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==}
lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -5710,6 +5794,10 @@ packages:
engines: {node: '>=14'}
hasBin: true
pretty-format@27.5.1:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
pretty-format@29.7.0:
resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -5840,6 +5928,9 @@ packages:
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
@@ -8968,6 +9059,27 @@ snapshots:
'@tanstack/table-core@8.21.3': {}
'@testing-library/dom@10.4.1':
dependencies:
'@babel/code-frame': 7.28.6
'@babel/runtime': 7.28.6
'@types/aria-query': 5.0.4
aria-query: 5.3.0
dom-accessibility-api: 0.5.16
lz-string: 1.5.0
picocolors: 1.1.1
pretty-format: 27.5.1
'@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@babel/runtime': 7.28.6
'@testing-library/dom': 10.4.1
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.8
'@types/react-dom': 19.2.3(@types/react@19.2.8)
'@tiptap/core@3.19.0(@tiptap/pm@3.19.0)':
dependencies:
'@tiptap/pm': 3.19.0
@@ -9253,6 +9365,8 @@ snapshots:
tslib: 2.8.1
optional: true
'@types/aria-query@5.0.4': {}
'@types/d3-array@3.2.2': {}
'@types/d3-axis@3.0.6':
@@ -9744,6 +9858,10 @@ snapshots:
dependencies:
tslib: 2.8.1
aria-query@5.3.0:
dependencies:
dequal: 2.0.3
aria-query@5.3.2: {}
array-buffer-byte-length@1.0.2:
@@ -10392,6 +10510,8 @@ snapshots:
dependencies:
esutils: 2.0.3
dom-accessibility-api@0.5.16: {}
dom-helpers@5.2.1:
dependencies:
'@babel/runtime': 7.28.6
@@ -10634,7 +10754,7 @@ snapshots:
'@next/eslint-plugin-next': 16.1.1
eslint: 9.39.2(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1))
@@ -10657,7 +10777,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)):
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.3
@@ -10672,14 +10792,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)):
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
eslint: 9.39.2(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1))
transitivePeerDependencies:
- supports-color
@@ -10694,7 +10814,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.39.2(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@@ -11744,6 +11864,8 @@ snapshots:
lru_map@0.4.1: {}
lz-string@1.5.0: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -12248,7 +12370,7 @@ snapshots:
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
next@16.1.7(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
next@16.1.7(@babel/core@7.28.6)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
'@next/env': 16.1.7
'@swc/helpers': 0.5.15
@@ -12257,7 +12379,7 @@ snapshots:
postcss: 8.4.31
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
styled-jsx: 5.1.6(react@19.2.3)
styled-jsx: 5.1.6(@babel/core@7.28.6)(react@19.2.3)
optionalDependencies:
'@next/swc-darwin-arm64': 16.1.7
'@next/swc-darwin-x64': 16.1.7
@@ -12527,6 +12649,12 @@ snapshots:
prettier@3.8.1: {}
pretty-format@27.5.1:
dependencies:
ansi-regex: 5.0.1
ansi-styles: 5.2.0
react-is: 17.0.2
pretty-format@29.7.0:
dependencies:
'@jest/schemas': 29.6.3
@@ -12754,6 +12882,8 @@ snapshots:
react-is@16.13.1: {}
react-is@17.0.2: {}
react-is@18.3.1: {}
react-markdown@10.1.0(@types/react@19.2.8)(react@19.2.3):
@@ -13392,10 +13522,12 @@ snapshots:
dependencies:
inline-style-parser: 0.2.7
styled-jsx@5.1.6(react@19.2.3):
styled-jsx@5.1.6(@babel/core@7.28.6)(react@19.2.3):
dependencies:
client-only: 0.0.1
react: 19.2.3
optionalDependencies:
'@babel/core': 7.28.6
stylis@4.3.6: {}
@@ -33,6 +33,7 @@ type DiscardDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
fileToDiscard: string | null;
filesToDiscard: string[] | null;
onConfirm: () => void;
};
@@ -40,16 +41,27 @@ export function DiscardDialog({
open,
onOpenChange,
fileToDiscard,
filesToDiscard,
onConfirm,
}: DiscardDialogProps) {
const isBulk = filesToDiscard && filesToDiscard.length > 1;
const displayFile = fileToDiscard ?? (filesToDiscard?.length === 1 ? filesToDiscard[0] : null);
const description = isBulk
? `This will permanently discard all changes to ${filesToDiscard.length} files. This action cannot be undone.`
: null;
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Discard changes?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently discard all changes to{" "}
<span className="font-semibold">{fileToDiscard}</span>. This action cannot be undone.
{description ?? (
<>
This will permanently discard all changes to{" "}
<span className="font-semibold">{displayFile}</span>. This action cannot be undone.
</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@@ -9,6 +9,8 @@ import {
IconPencil,
} from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { cn } from "@/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { LineStat } from "@/components/diff-stat";
import type { FileInfo } from "@/lib/state/store";
@@ -35,6 +37,8 @@ type ChangedFile = {
type FileRowProps = {
file: ChangedFile;
isPending: boolean;
isSelected?: boolean;
onSelect?: (path: string, e: React.MouseEvent) => boolean;
onOpenDiff: (path: string) => void;
onStage: (path: string) => void;
onUnstage: (path: string) => void;
@@ -45,6 +49,8 @@ type FileRowProps = {
export function FileRow({
file,
isPending,
isSelected,
onSelect,
onOpenDiff,
onStage,
onUnstage,
@@ -53,11 +59,24 @@ export function FileRow({
}: FileRowProps) {
const { folder, file: name } = splitPath(file.path);
const handleClick = (e: React.MouseEvent) => {
if (e.button === 2) return;
const consumed = onSelect?.(file.path, e);
if (!consumed) {
onOpenDiff(file.path);
}
};
return (
<li
data-testid={`file-row-${file.path.replace(/[/\\]/g, "-")}`}
className="group flex items-center justify-between gap-2 text-sm rounded-md px-1 py-0.5 -mx-1 hover:bg-muted/60 cursor-pointer"
onClick={() => onOpenDiff(file.path)}
data-changes-file={file.path}
data-selected={isSelected ? "true" : "false"}
className={cn(
"group flex items-center justify-between gap-2 text-sm rounded-md px-1 py-0.5 -mx-1 cursor-pointer",
isSelected ? "bg-accent/60 text-accent-foreground hover:bg-accent/50" : "hover:bg-muted/60",
)}
onClick={handleClick}
>
<div className="flex items-center gap-2 min-w-0">
<StageButton
@@ -178,3 +197,105 @@ function FileRowActions({
</div>
);
}
// --- Bulk action components (used by FileListSection) ---
export function DefaultActionButtons({
actionLabel,
isActionLoading,
onAction,
secondaryActionLabel,
isSecondaryActionLoading,
onSecondaryAction,
}: {
actionLabel: string;
isActionLoading?: boolean;
onAction: () => void;
secondaryActionLabel?: string;
isSecondaryActionLoading?: boolean;
onSecondaryAction?: () => void;
}) {
return (
<>
<Button
size="sm"
variant="outline"
className="h-6 text-[11px] px-2.5 gap-1 cursor-pointer"
onClick={onAction}
disabled={isActionLoading}
>
{isActionLoading && <IconLoader2 className="h-3 w-3 animate-spin" />}
{actionLabel}
</Button>
{onSecondaryAction && secondaryActionLabel && (
<Button
size="sm"
variant="outline"
className="h-6 text-[11px] px-2.5 gap-1 cursor-pointer"
onClick={onSecondaryAction}
disabled={isSecondaryActionLoading}
>
{isSecondaryActionLoading && <IconLoader2 className="h-3 w-3 animate-spin" />}
{secondaryActionLabel}
</Button>
)}
</>
);
}
export function BulkActionBar({
variant,
selectionCount,
selectedPaths,
onBulkStage,
onBulkUnstage,
onBulkDiscard,
}: {
variant: "unstaged" | "staged";
selectionCount: number;
selectedPaths: Set<string>;
onBulkStage?: (paths: string[]) => void;
onBulkUnstage?: (paths: string[]) => void;
onBulkDiscard?: (paths: string[]) => void;
}) {
const paths = [...selectedPaths];
return (
<div data-testid={`bulk-actions-${variant}`} className="flex items-center gap-1.5">
<span className="text-[11px] text-muted-foreground">{selectionCount} selected</span>
{variant === "unstaged" && onBulkStage && (
<Button
data-testid="bulk-stage"
size="sm"
variant="outline"
className="h-6 text-[11px] px-2.5 gap-1 cursor-pointer"
onClick={() => onBulkStage(paths)}
>
Stage {selectionCount}
</Button>
)}
{variant === "staged" && onBulkUnstage && (
<Button
data-testid={`bulk-unstage-${variant}`}
size="sm"
variant="outline"
className="h-6 text-[11px] px-2.5 gap-1 cursor-pointer"
onClick={() => onBulkUnstage(paths)}
>
Unstage {selectionCount}
</Button>
)}
{onBulkDiscard && (
<Button
data-testid={`bulk-discard-${variant}`}
size="sm"
variant="outline"
className="h-6 text-[11px] px-2.5 gap-1 cursor-pointer text-destructive hover:text-destructive"
onClick={() => onBulkDiscard(paths)}
>
Discard {selectionCount}
</Button>
)}
</div>
);
}
@@ -95,15 +95,23 @@ function useChangesDiscardAmendHandlers(
) {
const [showDiscardDialog, setShowDiscardDialog] = useState(false);
const [fileToDiscard, setFileToDiscard] = useState<string | null>(null);
const [filesToDiscard, setFilesToDiscard] = useState<string[] | null>(null);
const handleDiscardClick = useCallback((filePath: string) => {
setFileToDiscard(filePath);
setFilesToDiscard(null);
setShowDiscardDialog(true);
}, []);
const handleBulkDiscardClick = useCallback((paths: string[]) => {
setFilesToDiscard(paths);
setFileToDiscard(null);
setShowDiscardDialog(true);
}, []);
const handleDiscardConfirm = useCallback(async () => {
if (!fileToDiscard) return;
const paths = filesToDiscard ?? (fileToDiscard ? [fileToDiscard] : null);
if (!paths) return;
try {
const result = await gitOps.discard([fileToDiscard]);
const result = await gitOps.discard(paths);
if (!result.success)
toast({
title: "Failed to discard changes",
@@ -119,8 +127,9 @@ function useChangesDiscardAmendHandlers(
} finally {
setShowDiscardDialog(false);
setFileToDiscard(null);
setFilesToDiscard(null);
}
}, [fileToDiscard, gitOps, toast]);
}, [fileToDiscard, filesToDiscard, gitOps, toast]);
// Amend dialog state (for editing last commit message directly)
const [amendDialogOpen, setAmendDialogOpen] = useState(false);
@@ -142,7 +151,9 @@ function useChangesDiscardAmendHandlers(
showDiscardDialog,
setShowDiscardDialog,
fileToDiscard,
filesToDiscard,
handleDiscardClick,
handleBulkDiscardClick,
handleDiscardConfirm,
// Amend dialog
amendDialogOpen,
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, useMemo, useCallback } from "react";
import {
IconGitPullRequest,
IconCloudUpload,
@@ -8,6 +8,7 @@ import {
IconChevronRight,
IconLoader2,
} from "@tabler/icons-react";
import { useMultiSelect } from "@/hooks/use-multi-select";
import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
@@ -21,7 +22,7 @@ import { cn } from "@/lib/utils";
import type { FileInfo } from "@/lib/state/store";
import { LineStat } from "@/components/diff-stat";
import { FileStatusIcon } from "./file-status-icon";
import { FileRow } from "./changes-panel-file-row";
import { FileRow, BulkActionBar, DefaultActionButtons } from "./changes-panel-file-row";
import { CommitRow, type CommitItem } from "./commit-row";
// --- Timeline visual components ---
@@ -291,28 +292,37 @@ type FileListSectionProps = {
onStage: (path: string) => void;
onUnstage: (path: string) => void;
onDiscard: (path: string) => void;
onBulkStage?: (paths: string[]) => void;
onBulkUnstage?: (paths: string[]) => void;
onBulkDiscard?: (paths: string[]) => void;
};
export function FileListSection({
variant,
files,
pendingStageFiles,
isLast,
actionLabel,
isActionLoading,
onAction,
secondaryActionLabel,
isSecondaryActionLoading,
onSecondaryAction,
onOpenDiff,
onEditFile,
onStage,
onUnstage,
onDiscard,
}: FileListSectionProps) {
export function FileListSection(props: FileListSectionProps) {
const {
variant,
files,
pendingStageFiles,
isLast,
onOpenDiff,
onEditFile,
onStage,
onUnstage,
onDiscard,
} = props;
const dotColor = variant === "unstaged" ? DOT_COLORS.unstaged : DOT_COLORS.staged;
const label = variant === "unstaged" ? "Unstaged" : "Staged";
const filePaths = useMemo(() => files.map((f) => f.path), [files]);
const multiSelect = useMultiSelect({ items: filePaths });
const hasSelection = multiSelect.selectedPaths.size > 0;
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape" && hasSelection) multiSelect.clearSelection();
},
[hasSelection, multiSelect],
);
return (
<TimelineSection
dotColor={dotColor}
@@ -322,44 +332,50 @@ export function FileListSection({
data-testid={`${variant}-files-section`}
>
{files.length > 0 && (
<ul data-testid={`${variant}-file-list`} className="space-y-0.5">
{files.map((file) => (
<FileRow
key={file.path}
file={file}
isPending={pendingStageFiles.has(file.path)}
onOpenDiff={onOpenDiff}
onStage={onStage}
onUnstage={onUnstage}
onDiscard={onDiscard}
onEditFile={onEditFile}
/>
))}
</ul>
<div>
<ul
data-testid={`${variant}-file-list`}
className="space-y-0.5"
tabIndex={-1}
onKeyDown={handleKeyDown}
>
{files.map((file) => (
<FileRow
key={file.path}
file={file}
isPending={pendingStageFiles.has(file.path)}
isSelected={multiSelect.isSelected(file.path)}
onSelect={multiSelect.handleClick}
onOpenDiff={onOpenDiff}
onStage={onStage}
onUnstage={onUnstage}
onDiscard={onDiscard}
onEditFile={onEditFile}
/>
))}
</ul>
</div>
)}
{files.length > 0 && (
<div className="mt-1.5 flex items-center gap-1.5">
<Button
size="sm"
variant="outline"
className="h-6 text-[11px] px-2.5 gap-1 cursor-pointer"
onClick={onAction}
disabled={isActionLoading}
>
{isActionLoading && <IconLoader2 className="h-3 w-3 animate-spin" />}
{actionLabel}
</Button>
{onSecondaryAction && secondaryActionLabel && (
<Button
size="sm"
variant="outline"
className="h-6 text-[11px] px-2.5 gap-1 cursor-pointer"
onClick={onSecondaryAction}
disabled={isSecondaryActionLoading}
>
{isSecondaryActionLoading && <IconLoader2 className="h-3 w-3 animate-spin" />}
{secondaryActionLabel}
</Button>
{hasSelection ? (
<BulkActionBar
variant={variant}
selectionCount={multiSelect.selectedPaths.size}
selectedPaths={multiSelect.selectedPaths}
onBulkStage={props.onBulkStage}
onBulkUnstage={props.onBulkUnstage}
onBulkDiscard={props.onBulkDiscard}
/>
) : (
<DefaultActionButtons
actionLabel={props.actionLabel}
isActionLoading={props.isActionLoading}
onAction={props.onAction}
secondaryActionLabel={props.secondaryActionLabel}
isSecondaryActionLoading={props.isSecondaryActionLoading}
onSecondaryAction={props.onSecondaryAction}
/>
)}
</div>
)}
@@ -291,6 +291,9 @@ type ChangesPanelBodyProps = {
onUnstageAll: () => void;
onStage: (path: string) => Promise<void>;
onUnstage: (path: string) => Promise<void>;
onBulkStage: (paths: string[]) => void;
onBulkUnstage: (paths: string[]) => void;
onBulkDiscard: (paths: string[]) => void;
onPush: () => void;
onForcePush: () => void;
stagedFileCount: number;
@@ -308,6 +311,7 @@ function ChangesPanelDialogsSection({
open={dialogs.showDiscardDialog}
onOpenChange={dialogs.setShowDiscardDialog}
fileToDiscard={dialogs.fileToDiscard}
filesToDiscard={dialogs.filesToDiscard}
onConfirm={dialogs.handleDiscardConfirm}
/>
<AmendDialog
@@ -357,6 +361,9 @@ type TimelineProps = Pick<
| "onUnstageAll"
| "onStage"
| "onUnstage"
| "onBulkStage"
| "onBulkUnstage"
| "onBulkDiscard"
| "onPush"
| "onForcePush"
>;
@@ -376,6 +383,9 @@ type WorkingTreeProps = Pick<
| "onUnstageAll"
| "onStage"
| "onUnstage"
| "onBulkStage"
| "onBulkUnstage"
| "onBulkDiscard"
> & { isLastUnstaged: boolean; isLastStaged: boolean };
function WorkingTreeSections(props: WorkingTreeProps) {
@@ -396,6 +406,8 @@ function WorkingTreeSections(props: WorkingTreeProps) {
onStage={props.onStage}
onUnstage={props.onUnstage}
onDiscard={props.dialogs.handleDiscardClick}
onBulkStage={props.onBulkStage}
onBulkDiscard={props.onBulkDiscard}
/>
)}
{props.hasStaged && (
@@ -415,6 +427,8 @@ function WorkingTreeSections(props: WorkingTreeProps) {
onStage={props.onStage}
onUnstage={props.onUnstage}
onDiscard={props.dialogs.handleDiscardClick}
onBulkUnstage={props.onBulkUnstage}
onBulkDiscard={props.onBulkDiscard}
/>
)}
</>
@@ -610,6 +624,13 @@ const ChangesPanel = memo(function ChangesPanel({
onUnstageAll={git.unstageAll}
onStage={(path) => git.stageFile([path]).then(() => undefined)}
onUnstage={(path) => git.unstageFile([path]).then(() => undefined)}
onBulkStage={(paths) => {
git.stageFile(paths).catch(() => {});
}}
onBulkUnstage={(paths) => {
git.unstageFile(paths).catch(() => {});
}}
onBulkDiscard={localDialogs.handleBulkDiscardClick}
onPush={gitHandlers.handlePush}
onForcePush={gitHandlers.handleForcePush}
stagedFileCount={staged.stagedFileCount}
+142 -80
View File
@@ -49,6 +49,17 @@ type TreeNodeItemProps = {
onCreateFileSubmit: (parentPath: string, name: string) => void;
onCancelCreate: () => void;
setTree: React.Dispatch<React.SetStateAction<FileTreeNode | null>>;
isSelectedFn?: (path: string) => boolean;
onSelect?: (path: string, e: React.MouseEvent) => boolean;
isDragging?: boolean;
dragOverPath?: string | null;
onDragStart?: (path: string, e: React.DragEvent) => void;
onDragEnd?: () => void;
onDragOver?: (path: string, e: React.DragEvent) => void;
onDragLeave?: (e: React.DragEvent) => void;
onDrop?: (targetPath: string, e: React.DragEvent) => void;
selectedCount?: number;
selectedPaths?: Set<string>;
};
function treeNodePaddingLeft(depth: number, isDir: boolean): string {
@@ -130,6 +141,25 @@ function TreeNodeChildren({ props, depth }: { props: TreeNodeItemProps; depth: n
);
}
function getTreeNodeRowClass(
isActive: boolean,
isActiveFolder: boolean,
isSelected: boolean,
isDragging: boolean | undefined,
isDropTarget: boolean,
) {
return cn(
"group flex w-full items-center gap-1 px-2 py-0.5 text-left text-sm cursor-pointer",
isSelected
? "bg-accent text-accent-foreground hover:bg-accent/80 [&_span]:text-accent-foreground"
: "hover:bg-muted",
isActive && !isSelected && "bg-muted",
isActiveFolder && !isSelected && "bg-muted/50",
isDragging && isSelected && "opacity-50",
isDropTarget && "bg-accent/40 ring-1 ring-accent",
);
}
export function TreeNodeItem(props: TreeNodeItemProps) {
const { node, depth, expandedPaths, activeFolderPath, activeFilePath, visibleLoadingPaths } =
props;
@@ -137,29 +167,56 @@ export function TreeNodeItem(props: TreeNodeItemProps) {
props;
const isExpanded = expandedPaths.has(node.path);
const isLoading = visibleLoadingPaths.has(node.path);
const isActive = !node.is_dir && activeFilePath === node.path;
const isActiveFolder = node.is_dir && activeFolderPath === node.path;
const gitStatus = node.is_dir ? undefined : fileStatuses.get(node.path);
const rename = useFileRename(node, tree, setTree, onRenameFile);
const isSelected = props.isSelectedFn?.(node.path) ?? false;
const isDropTarget = node.is_dir && props.dragOverPath === node.path;
const handleClick = (e: React.MouseEvent) => {
if (e.button === 2) return;
const consumed = props.onSelect?.(node.path, e);
if (!consumed) {
handleTreeNodeClick(node, onToggleExpand, onOpenFile);
}
};
// Inline the row JSX so ContextMenuTrigger asChild can attach directly to the DOM div
const rowContent = (
<div
data-testid="file-tree-node"
data-path={node.path}
data-is-dir={node.is_dir ? "true" : "false"}
className={cn(
"group flex w-full items-center gap-1 px-2 py-0.5 text-left text-sm cursor-pointer",
"hover:bg-muted",
isActive && "bg-muted",
isActiveFolder && "bg-muted/50",
data-selected={isSelected ? "true" : "false"}
aria-selected={isSelected}
role="treeitem"
className={getTreeNodeRowClass(
isActive,
isActiveFolder,
isSelected,
props.isDragging,
isDropTarget,
)}
style={{ paddingLeft: treeNodePaddingLeft(depth, node.is_dir) }}
onClick={() => handleTreeNodeClick(node, onToggleExpand, onOpenFile)}
onClick={handleClick}
draggable={!!props.onDragStart}
onDragStart={(e) => props.onDragStart?.(node.path, e)}
onDragEnd={() => props.onDragEnd?.()}
onDragOver={(e) => {
if (node.is_dir) props.onDragOver?.(node.path, e);
}}
onDragLeave={(e) => props.onDragLeave?.(e)}
onDrop={(e) => {
if (node.is_dir) props.onDrop?.(node.path, e);
}}
>
{node.is_dir && (
<span className="flex-shrink-0">
<TreeNodeExpandChevron isLoading={isLoading} isExpanded={isExpanded} />
<TreeNodeExpandChevron
isLoading={visibleLoadingPaths.has(node.path)}
isExpanded={isExpanded}
/>
</span>
)}
<TreeNodeFileIcon node={node} isExpanded={isExpanded} isActive={isActive} />
@@ -176,6 +233,8 @@ export function TreeNodeItem(props: TreeNodeItemProps) {
onDeleteFile={onDeleteFile}
onRenameFile={onRenameFile}
onStartRename={rename.handleStartRename}
selectedCount={props.selectedCount}
selectedPaths={props.selectedPaths}
>
{rowContent}
</FileContextMenu>
@@ -263,87 +322,90 @@ type FileBrowserContentAreaProps = {
onCancelCreate: () => void;
onRetry: () => void;
setTree: React.Dispatch<React.SetStateAction<FileTreeNode | null>>;
isSelectedFn?: (path: string) => boolean;
onSelect?: (path: string, e: React.MouseEvent) => boolean;
isDragging?: boolean;
dragOverPath?: string | null;
onDragStart?: (path: string, e: React.DragEvent) => void;
onDragEnd?: () => void;
onDragOver?: (path: string, e: React.DragEvent) => void;
onDragLeave?: (e: React.DragEvent) => void;
onDrop?: (targetPath: string, e: React.DragEvent) => void;
selectedCount?: number;
selectedPaths?: Set<string>;
};
export function FileBrowserContentArea({
isSearchActive,
searchResults,
isSessionFailed,
sessionError,
loadState,
isLoadingTree,
tree,
loadError,
creatingInPath,
fileStatuses,
expandedPaths,
activeFolderPath,
activeFilePath,
visibleLoadingPaths,
onOpenFile,
onToggleExpand,
onDeleteFile,
onRenameFile,
onCreateFileSubmit,
onCancelCreate,
onRetry,
setTree,
}: FileBrowserContentAreaProps) {
if (isSearchActive && searchResults !== null) {
function FileTreeView(props: FileBrowserContentAreaProps) {
const { tree, creatingInPath, onCreateFileSubmit, onCancelCreate } = props;
if (!tree) return null;
return (
<div className="pb-2">
{creatingInPath === "" && (
<InlineFileInput
depth={0}
onSubmit={(name) => onCreateFileSubmit("", name)}
onCancel={onCancelCreate}
/>
)}
{tree.children &&
[...tree.children]
.sort(compareTreeNodes)
.map((child) => (
<TreeNodeItem
key={child.path}
node={child}
depth={0}
expandedPaths={props.expandedPaths}
activeFolderPath={props.activeFolderPath}
activeFilePath={props.activeFilePath}
visibleLoadingPaths={props.visibleLoadingPaths}
creatingInPath={creatingInPath}
fileStatuses={props.fileStatuses}
tree={tree}
onToggleExpand={props.onToggleExpand}
onOpenFile={props.onOpenFile}
onDeleteFile={props.onDeleteFile}
onRenameFile={props.onRenameFile}
onCreateFileSubmit={onCreateFileSubmit}
onCancelCreate={onCancelCreate}
setTree={props.setTree}
isSelectedFn={props.isSelectedFn}
onSelect={props.onSelect}
isDragging={props.isDragging}
dragOverPath={props.dragOverPath}
onDragStart={props.onDragStart}
onDragEnd={props.onDragEnd}
onDragOver={props.onDragOver}
onDragLeave={props.onDragLeave}
onDrop={props.onDrop}
selectedCount={props.selectedCount}
selectedPaths={props.selectedPaths}
/>
))}
</div>
);
}
export function FileBrowserContentArea(props: FileBrowserContentAreaProps) {
if (props.isSearchActive && props.searchResults !== null) {
return (
<SearchResultsList
searchResults={searchResults}
fileStatuses={fileStatuses}
onOpenFile={onOpenFile}
searchResults={props.searchResults}
fileStatuses={props.fileStatuses}
onOpenFile={props.onOpenFile}
/>
);
}
const loadStateResult = renderSessionOrLoadState({
isSessionFailed,
sessionError,
loadState,
isLoadingTree,
tree,
loadError,
onRetry,
isSessionFailed: props.isSessionFailed,
sessionError: props.sessionError,
loadState: props.loadState,
isLoadingTree: props.isLoadingTree,
tree: props.tree,
loadError: props.loadError,
onRetry: props.onRetry,
});
if (loadStateResult) return loadStateResult;
if (tree) {
return (
<div className="pb-2">
{creatingInPath === "" && (
<InlineFileInput
depth={0}
onSubmit={(name) => onCreateFileSubmit("", name)}
onCancel={onCancelCreate}
/>
)}
{tree.children &&
[...tree.children]
.sort(compareTreeNodes)
.map((child) => (
<TreeNodeItem
key={child.path}
node={child}
depth={0}
expandedPaths={expandedPaths}
activeFolderPath={activeFolderPath}
activeFilePath={activeFilePath}
visibleLoadingPaths={visibleLoadingPaths}
creatingInPath={creatingInPath}
fileStatuses={fileStatuses}
tree={tree}
onToggleExpand={onToggleExpand}
onOpenFile={onOpenFile}
onDeleteFile={onDeleteFile}
onRenameFile={onRenameFile}
onCreateFileSubmit={onCreateFileSubmit}
onCancelCreate={onCancelCreate}
setTree={setTree}
/>
))}
</div>
);
}
if (props.tree) return <FileTreeView {...props} />;
return <div className="p-4 text-sm text-muted-foreground">No files found</div>;
}
+249 -40
View File
@@ -9,6 +9,7 @@ import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-sta
import { useOpenSessionFolder } from "@/hooks/use-open-session-folder";
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard";
import { useToast } from "@/components/toast-provider";
import { useMultiSelect } from "@/hooks/use-multi-select";
import { FileBrowserSearchHeader } from "./file-browser-search-header";
import {
insertNodeInTree,
@@ -23,6 +24,7 @@ import {
loadNodeChildren,
fetchAndOpenFile,
} from "./file-browser-hooks";
import { getVisiblePaths, moveNodesInTree, computeMoveTargets } from "./file-tree-utils";
type FileBrowserHeaderProps = {
treeLoaded: boolean;
@@ -52,7 +54,6 @@ function FileBrowserHeader({
showCreateButton,
}: FileBrowserHeaderProps) {
if (!treeLoaded) return null;
if (search.isSearchActive) {
return (
<FileBrowserSearchHeader
@@ -64,7 +65,6 @@ function FileBrowserHeader({
/>
);
}
return (
<FileBrowserToolbar
displayPath={displayPath}
@@ -145,9 +145,7 @@ function useFileBrowserHandlers(
(path: string) => fetchAndOpenFile(sessionId, path, onOpenFile, toast),
[sessionId, onOpenFile, toast],
);
const handleCancelCreate = useCallback(() => {
setCreatingInPath(null);
}, []);
const handleCancelCreate = useCallback(() => setCreatingInPath(null), []);
return {
creatingInPath,
@@ -160,6 +158,220 @@ function useFileBrowserHandlers(
};
}
function isDropInvalid(sources: string[], targetPath: string): boolean {
return sources.some((s) => s === targetPath || targetPath.startsWith(`${s}/`));
}
type MoveFilesParams = {
sources: string[];
targetPath: string;
treeState: ReturnType<typeof useFileBrowserTree>;
setSelectedPaths: (paths: Set<string>) => void;
onRenameFile: (oldPath: string, newPath: string) => Promise<boolean>;
};
function executeMoveFiles(params: MoveFilesParams, toast: ReturnType<typeof useToast>["toast"]) {
const { sources, targetPath, treeState, setSelectedPaths, onRenameFile } = params;
const snapshot = treeState.tree;
// Compute deduplicated target paths before modifying the tree
const targets = treeState.tree ? computeMoveTargets(treeState.tree, sources, targetPath) : [];
treeState.setTree((prev) => (prev ? moveNodesInTree(prev, sources, targetPath) : prev));
setSelectedPaths(new Set());
const movePromises = targets.map(({ oldPath, newPath }) => onRenameFile(oldPath, newPath));
Promise.all(movePromises)
.then((results) => {
if (results.some((ok) => !ok)) {
treeState.setTree(snapshot);
toast({
title: "Move failed",
description: "Some files could not be moved",
variant: "error",
});
}
})
.catch(() => {
treeState.setTree(snapshot);
toast({
title: "Move failed",
description: "An error occurred while moving files",
variant: "error",
});
});
}
function useDragAndDrop(
treeState: ReturnType<typeof useFileBrowserTree>,
selectedPaths: Set<string>,
setSelectedPaths: (paths: Set<string>) => void,
onRenameFile?: (oldPath: string, newPath: string) => Promise<boolean>,
) {
const { toast } = useToast();
const [isDragging, setIsDragging] = useState(false);
const [dragOverPath, setDragOverPath] = useState<string | null>(null);
const dragPathsRef = useRef<string[]>([]);
const handleDragStart = useCallback(
(path: string, e: React.DragEvent) => {
const paths = selectedPaths.has(path) ? [...selectedPaths] : [path];
if (!selectedPaths.has(path)) setSelectedPaths(new Set([path]));
dragPathsRef.current = paths;
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", JSON.stringify(paths));
setIsDragging(true);
},
[selectedPaths, setSelectedPaths],
);
const handleDragEnd = useCallback(() => {
setIsDragging(false);
setDragOverPath(null);
dragPathsRef.current = [];
}, []);
// Safety net: clear drag state if dragend doesn't fire on the element
// (e.g. Escape key, drag outside browser window)
useEffect(() => {
if (!isDragging) return;
const cleanup = () => {
setIsDragging(false);
setDragOverPath(null);
dragPathsRef.current = [];
};
document.addEventListener("dragend", cleanup);
return () => document.removeEventListener("dragend", cleanup);
}, [isDragging]);
const handleDragOver = useCallback((targetPath: string, e: React.DragEvent) => {
const sources = dragPathsRef.current;
if (isDropInvalid(sources, targetPath)) return;
const allSameParent = sources.every((s) => {
const parent = s.includes("/") ? s.substring(0, s.lastIndexOf("/")) : "";
return parent === targetPath;
});
if (allSameParent) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDragOverPath(targetPath);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
const related = e.relatedTarget as Node | null;
if (related && (e.currentTarget as Node).contains(related)) return;
setDragOverPath(null);
}, []);
const handleDrop = useCallback(
(targetPath: string, e: React.DragEvent) => {
e.preventDefault();
setDragOverPath(null);
setIsDragging(false);
if (!onRenameFile) return;
const sources = dragPathsRef.current;
if (sources.length === 0 || isDropInvalid(sources, targetPath)) return;
executeMoveFiles({ sources, targetPath, treeState, setSelectedPaths, onRenameFile }, toast);
},
[onRenameFile, treeState, setSelectedPaths, toast],
);
return {
isDragging,
dragOverPath,
handleDragStart,
handleDragEnd,
handleDragOver,
handleDragLeave,
handleDrop,
};
}
function useAutoExpandAncestors(
activeFilePath: string | null | undefined,
setExpandedPaths: React.Dispatch<React.SetStateAction<Set<string>>>,
) {
useEffect(() => {
if (!activeFilePath) return;
const parts = activeFilePath.split("/");
if (parts.length <= 1) return;
const ancestors: string[] = [];
for (let i = 1; i < parts.length; i++) {
ancestors.push(parts.slice(0, i).join("/"));
}
setExpandedPaths((prev) => {
if (ancestors.every((p) => prev.has(p))) return prev;
const next = new Set(prev);
for (const p of ancestors) next.add(p);
return next;
});
}, [activeFilePath, setExpandedPaths]);
}
function useSelectionInteractions(
treeState: ReturnType<typeof useFileBrowserTree>,
containerRef: React.RefObject<HTMLDivElement | null>,
activeFilePath: string | null | undefined,
onRenameFile?: (oldPath: string, newPath: string) => Promise<boolean>,
) {
const visiblePaths = useMemo(
() => (treeState.tree ? getVisiblePaths(treeState.tree, treeState.expandedPaths) : []),
[treeState.tree, treeState.expandedPaths],
);
const multiSelect = useMultiSelect({ items: visiblePaths });
const dnd = useDragAndDrop(
treeState,
multiSelect.selectedPaths,
multiSelect.setSelectedPaths,
onRenameFile,
);
useKeyboardShortcuts(containerRef, multiSelect.clearSelection, multiSelect.selectAll);
useAutoExpandAncestors(activeFilePath, treeState.setExpandedPaths);
const handleClickOutside = useCallback(
(e: React.MouseEvent) => {
if (multiSelect.selectedPaths.size === 0) return;
const target = e.target as HTMLElement;
// Don't clear if clicking on a tree node, context menu, or dialog
if (
target.closest("[data-testid='file-tree-node']") ||
target.closest("[role='menu']") ||
target.closest("[role='alertdialog']") ||
target.closest("[role='dialog']")
)
return;
multiSelect.clearSelection();
},
[multiSelect],
);
return { multiSelect, dnd, handleClickOutside };
}
function useKeyboardShortcuts(
containerRef: React.RefObject<HTMLDivElement | null>,
clearSelection: () => void,
selectAll: () => void,
) {
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (!container.contains(document.activeElement) && document.activeElement !== container)
return;
if (e.key === "Escape") {
clearSelection();
} else if ((e.ctrlKey || e.metaKey) && e.key === "a") {
e.preventDefault();
selectAll();
}
};
container.addEventListener("keydown", handleKeyDown);
return () => container.removeEventListener("keydown", handleKeyDown);
}, [containerRef, clearSelection, selectAll]);
}
export function FileBrowser({
sessionId,
environmentId,
@@ -175,6 +387,7 @@ export function FileBrowser({
const { open: openFolder } = useOpenSessionFolder(sessionId);
const { copied, copy: copyPath } = useCopyToClipboard(1000);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const search = useFileBrowserSearch(sessionId);
const treeState = useFileBrowserTree(sessionId, environmentId ?? undefined);
@@ -189,36 +402,21 @@ export function FileBrowser({
const fullPath = session?.worktree_path || repository?.local_path || "";
const displayPath = fullPath.replace(/^\/(?:Users|home)\/[^/]+\//, "~/");
const {
creatingInPath,
activeFolderPath,
handleStartCreate,
handleCreateFileSubmit,
toggleExpand,
openFileByPath,
handleCancelCreate,
} = useFileBrowserHandlers(sessionId, onOpenFile, onCreateFile, treeState);
// Auto-expand ancestor directories when the active file changes
useEffect(() => {
if (!activeFilePath) return;
const parts = activeFilePath.split("/");
if (parts.length <= 1) return;
const ancestors: string[] = [];
for (let i = 1; i < parts.length; i++) {
ancestors.push(parts.slice(0, i).join("/"));
}
treeState.setExpandedPaths((prev) => {
const allExpanded = ancestors.every((p) => prev.has(p));
if (allExpanded) return prev;
const next = new Set(prev);
for (const p of ancestors) next.add(p);
return next;
});
}, [activeFilePath, treeState]);
const handlers = useFileBrowserHandlers(sessionId, onOpenFile, onCreateFile, treeState);
const { multiSelect, dnd, handleClickOutside } = useSelectionInteractions(
treeState,
containerRef,
activeFilePath,
onRenameFile,
);
return (
<div className="flex flex-col h-full">
<div
className="flex flex-col h-full"
ref={containerRef}
tabIndex={-1}
onMouseDown={handleClickOutside}
>
<FileBrowserHeader
treeLoaded={Boolean(treeState.tree && treeState.loadState === "loaded")}
search={search}
@@ -227,7 +425,7 @@ export function FileBrowser({
copied={copied}
expandedPathsSize={treeState.expandedPaths.size}
onCopyPath={copyPath}
onStartCreate={onCreateFile ? handleStartCreate : undefined}
onStartCreate={onCreateFile ? handlers.handleStartCreate : undefined}
onOpenFolder={openFolder}
onCollapseAll={treeState.collapseAll}
showCreateButton={Boolean(onCreateFile)}
@@ -242,20 +440,31 @@ export function FileBrowser({
isLoadingTree={treeState.isLoadingTree}
tree={treeState.tree}
loadError={treeState.loadError}
creatingInPath={creatingInPath}
creatingInPath={handlers.creatingInPath}
fileStatuses={fileStatuses}
expandedPaths={treeState.expandedPaths}
activeFolderPath={activeFolderPath}
activeFolderPath={handlers.activeFolderPath}
activeFilePath={activeFilePath}
visibleLoadingPaths={treeState.visibleLoadingPaths}
onOpenFile={openFileByPath}
onToggleExpand={toggleExpand}
onOpenFile={handlers.openFileByPath}
onToggleExpand={handlers.toggleExpand}
onDeleteFile={onDeleteFile}
onRenameFile={onRenameFile}
onCreateFileSubmit={handleCreateFileSubmit}
onCancelCreate={handleCancelCreate}
onCreateFileSubmit={handlers.handleCreateFileSubmit}
onCancelCreate={handlers.handleCancelCreate}
onRetry={() => void treeState.loadTree({ resetRetry: true })}
setTree={treeState.setTree}
isSelectedFn={multiSelect.isSelected}
onSelect={multiSelect.handleClick}
isDragging={dnd.isDragging}
dragOverPath={dnd.dragOverPath}
onDragStart={dnd.handleDragStart}
onDragEnd={dnd.handleDragEnd}
onDragOver={dnd.handleDragOver}
onDragLeave={dnd.handleDragLeave}
onDrop={dnd.handleDrop}
selectedCount={multiSelect.selectedPaths.size}
selectedPaths={multiSelect.selectedPaths}
/>
</ScrollArea>
</div>
+111 -58
View File
@@ -33,6 +33,67 @@ import {
type GitFileStatus = FileInfo["status"] | undefined;
function deleteNodeOptimistically(
tree: FileTreeNode | null,
setTree: React.Dispatch<React.SetStateAction<FileTreeNode | null>>,
path: string,
onDeleteFile: (path: string) => Promise<boolean>,
) {
const snapshot = tree;
setTree((prev) => (prev ? removeNodeFromTree(prev, path) : prev));
onDeleteFile(path)
.then((ok) => {
if (!ok) setTree(snapshot);
})
.catch(() => setTree(snapshot));
}
function DeleteConfirmDialog({
isBulk,
selectedCount,
node,
fileCount,
onConfirm,
}: {
isBulk: boolean;
selectedCount: number;
node: FileTreeNode;
fileCount: number;
onConfirm: () => void;
}) {
const title = isBulk ? `Delete ${selectedCount} items?` : "Delete folder?";
return (
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>
{isBulk ? (
`This will permanently delete ${selectedCount} selected items. This action cannot be undone.`
) : (
<>
This will permanently delete <span className="font-semibold">{node.name}</span>
{fileCount > 0 && (
<>
{" "}
and <span className="font-semibold">{fileCount}</span>{" "}
{fileCount === 1 ? "file" : "files"} inside it
</>
)}
. This action cannot be undone.
</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer">Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm} variant="destructive" className="cursor-pointer">
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
);
}
/** Context menu for file nodes with Rename and Delete options */
export function FileContextMenu({
children,
@@ -42,6 +103,8 @@ export function FileContextMenu({
onDeleteFile,
onRenameFile,
onStartRename,
selectedCount = 0,
selectedPaths,
}: {
children: React.ReactNode;
node: FileTreeNode;
@@ -50,91 +113,81 @@ export function FileContextMenu({
onDeleteFile?: (path: string) => Promise<boolean>;
onRenameFile?: (oldPath: string, newPath: string) => Promise<boolean>;
onStartRename: () => void;
selectedCount?: number;
selectedPaths?: Set<string>;
}) {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const isDirectory = node.is_dir;
const fileCount = countFilesInTree(node);
const isBulk = selectedCount > 1;
const needsConfirmation = node.is_dir || isBulk;
const handleConfirmDelete = useCallback(() => {
setDeleteDialogOpen(false);
if (!onDeleteFile) return;
const snapshot = tree;
setTree((prev) => (prev ? removeNodeFromTree(prev, node.path) : prev));
onDeleteFile(node.path)
.then((ok) => {
if (!ok) setTree(snapshot);
})
.catch(() => {
setTree(snapshot);
if (isBulk && selectedPaths) {
// Remove all nodes optimistically, then call APIs.
// Rollback entire tree only if any individual delete fails.
const snapshot = tree;
const paths = [...selectedPaths];
setTree((prev) => {
let t = prev;
for (const p of paths) t = t ? removeNodeFromTree(t, p) : t;
return t;
});
}, [tree, setTree, node.path, onDeleteFile]);
Promise.all(paths.map((p) => onDeleteFile(p)))
.then((results) => {
if (results.some((ok) => !ok)) setTree(snapshot);
})
.catch(() => setTree(snapshot));
} else {
deleteNodeOptimistically(tree, setTree, node.path, onDeleteFile);
}
}, [tree, setTree, node.path, onDeleteFile, isBulk, selectedPaths]);
const handleDelete = useCallback(() => {
if (!onDeleteFile) return;
if (isDirectory) {
if (needsConfirmation) {
setDeleteDialogOpen(true);
return;
} else {
deleteNodeOptimistically(tree, setTree, node.path, onDeleteFile);
}
const snapshot = tree;
setTree((prev) => (prev ? removeNodeFromTree(prev, node.path) : prev));
onDeleteFile(node.path)
.then((ok) => {
if (!ok) setTree(snapshot);
})
.catch(() => {
setTree(snapshot);
});
}, [tree, setTree, node.path, onDeleteFile, isDirectory]);
}, [tree, setTree, node.path, onDeleteFile, needsConfirmation]);
const hasActions = onDeleteFile || onRenameFile;
if (!onDeleteFile && !onRenameFile) return <>{children}</>;
if (!hasActions) {
return <>{children}</>;
}
const deleteLabel = isBulk ? `Delete ${selectedCount} items` : "Delete";
return (
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<>
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent>
{onRenameFile && (
{onDeleteFile && (
<ContextMenuItem variant="destructive" onSelect={handleDelete}>
<IconTrash className="h-3.5 w-3.5" />
{deleteLabel}
</ContextMenuItem>
)}
{onRenameFile && onDeleteFile && !isBulk && <ContextMenuSeparator />}
{onRenameFile && !isBulk && (
<ContextMenuItem onSelect={onStartRename}>
<IconPencil className="h-3.5 w-3.5" />
Rename
</ContextMenuItem>
)}
{onRenameFile && onDeleteFile && <ContextMenuSeparator />}
{onDeleteFile && (
<ContextMenuItem variant="destructive" onSelect={handleDelete}>
<IconTrash className="h-3.5 w-3.5" />
Delete
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
{isDirectory && (
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete folder?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete <span className="font-semibold">{node.name}</span> and{" "}
<span className="font-semibold">{fileCount}</span>{" "}
{fileCount === 1 ? "file" : "files"} inside it. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer">Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
variant="destructive"
className="cursor-pointer"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
{needsConfirmation && (
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DeleteConfirmDialog
isBulk={isBulk}
selectedCount={selectedCount}
node={node}
fileCount={countFilesInTree(node)}
onConfirm={handleConfirmDelete}
/>
</AlertDialog>
)}
</AlertDialog>
</>
);
}
@@ -0,0 +1,105 @@
import { describe, it, expect } from "vitest";
import type { FileTreeNode } from "@/lib/types/backend";
import {
moveNodesInTree,
computeMoveTargets,
findNodeByPath,
getVisiblePaths,
} from "./file-tree-utils";
function file(name: string, parentPath = ""): FileTreeNode {
const path = parentPath ? `${parentPath}/${name}` : name;
return { name, path, is_dir: false, size: 0 };
}
function dir(name: string, children: FileTreeNode[], parentPath = ""): FileTreeNode {
const path = parentPath ? `${parentPath}/${name}` : name;
return { name, path, is_dir: true, children };
}
function root(children: FileTreeNode[]): FileTreeNode {
return { name: "", path: "", is_dir: true, children };
}
const UTILS_TS = "utils.ts";
describe("computeMoveTargets", () => {
it("computes simple move without collision", () => {
const tree = root([file("a.ts"), dir("lib", [file("b.ts", "lib")])]);
const result = computeMoveTargets(tree, ["a.ts"], "lib");
expect(result).toEqual([{ oldPath: "a.ts", newPath: "lib/a.ts" }]);
});
it("deduplicates when target already has a file with the same name", () => {
const tree = root([dir("src", [file(UTILS_TS, "src")]), dir("lib", [file(UTILS_TS, "lib")])]);
const result = computeMoveTargets(tree, [`src/${UTILS_TS}`], "lib");
expect(result).toEqual([{ oldPath: `src/${UTILS_TS}`, newPath: "lib/utils (1).ts" }]);
});
it("deduplicates when two source files have the same name", () => {
const tree = root([
dir("src", [file(UTILS_TS, "src")]),
dir("lib", [file(UTILS_TS, "lib")]),
dir("target", []),
]);
const result = computeMoveTargets(tree, [`src/${UTILS_TS}`, `lib/${UTILS_TS}`], "target");
expect(result).toEqual([
{ oldPath: `src/${UTILS_TS}`, newPath: `target/${UTILS_TS}` },
{ oldPath: `lib/${UTILS_TS}`, newPath: "target/utils (1).ts" },
]);
});
it("deduplicates with multiple existing collisions", () => {
const tree = root([
file("readme.md"),
dir("dest", [file("readme.md", "dest"), file("readme (1).md", "dest")]),
]);
const result = computeMoveTargets(tree, ["readme.md"], "dest");
expect(result).toEqual([{ oldPath: "readme.md", newPath: "dest/readme (2).md" }]);
});
it("handles files without extensions", () => {
const tree = root([file("Makefile"), dir("dest", [file("Makefile", "dest")])]);
const result = computeMoveTargets(tree, ["Makefile"], "dest");
expect(result).toEqual([{ oldPath: "Makefile", newPath: "dest/Makefile (1)" }]);
});
});
describe("moveNodesInTree", () => {
it("moves a file into a directory", () => {
const tree = root([file("a.ts"), dir("lib", [])]);
const result = moveNodesInTree(tree, ["a.ts"], "lib");
expect(findNodeByPath(result, "a.ts")).toBeNull();
expect(findNodeByPath(result, "lib/a.ts")).not.toBeNull();
});
it("deduplicates on collision during move", () => {
const tree = root([
dir("src", [file("x.ts", "src")]),
dir("lib", [file("x.ts", "lib")]),
dir("dest", []),
]);
const result = moveNodesInTree(tree, ["src/x.ts", "lib/x.ts"], "dest");
expect(findNodeByPath(result, "dest/x.ts")).not.toBeNull();
expect(findNodeByPath(result, "dest/x (1).ts")).not.toBeNull();
expect(findNodeByPath(result, "src/x.ts")).toBeNull();
expect(findNodeByPath(result, "lib/x.ts")).toBeNull();
});
});
describe("getVisiblePaths", () => {
it("returns all nodes in DFS order including directories", () => {
const tree = root([dir("lib", [file("config.ts", "lib")], ""), file("readme.md")]);
const expanded = new Set(["lib"]);
const paths = getVisiblePaths(tree, expanded);
// dirs first then files, alphabetical within each group
expect(paths).toEqual(["lib", "lib/config.ts", "readme.md"]);
});
it("excludes collapsed directory children", () => {
const tree = root([dir("lib", [file("config.ts", "lib")], ""), file("readme.md")]);
const expanded = new Set<string>();
const paths = getVisiblePaths(tree, expanded);
expect(paths).toEqual(["lib", "readme.md"]);
});
});
+104
View File
@@ -87,3 +87,107 @@ export function renameNodeInTree(
const nextChildren = root.children.map((c) => renameNodeInTree(c, oldPath, newPath));
return { ...root, children: nextChildren.sort(compareTreeNodes) };
}
/** Collect visible (expanded) node paths in DFS order for multi-select range computation. */
export function getVisiblePaths(tree: FileTreeNode, expandedPaths: Set<string>): string[] {
const result: string[] = [];
function walk(node: FileTreeNode) {
// Skip the root node itself (it represents the workspace root)
if (node !== tree) result.push(node.path);
if (node.is_dir && (node === tree || expandedPaths.has(node.path)) && node.children) {
const sorted = [...node.children].sort(compareTreeNodes);
for (const child of sorted) walk(child);
}
}
walk(tree);
return result;
}
/** Find a node in the tree by path. */
export function findNodeByPath(root: FileTreeNode, targetPath: string): FileTreeNode | null {
if (root.path === targetPath) return root;
if (!root.children) return null;
for (const child of root.children) {
const found = findNodeByPath(child, targetPath);
if (found) return found;
}
return null;
}
/** Disambiguate a filename if it already exists in the used set. */
function deduplicateName(name: string, usedNames: Set<string>): string {
if (!usedNames.has(name)) return name;
const dotIndex = name.lastIndexOf(".");
const base = dotIndex > 0 ? name.slice(0, dotIndex) : name;
const ext = dotIndex > 0 ? name.slice(dotIndex) : "";
let counter = 1;
let candidate = `${base} (${counter})${ext}`;
while (usedNames.has(candidate)) {
counter++;
candidate = `${base} (${counter})${ext}`;
}
return candidate;
}
/** Compute deduplicated old→new path mappings for a move operation. */
export function computeMoveTargets(
root: FileTreeNode,
sourcePaths: string[],
targetDirPath: string,
): { oldPath: string; newPath: string }[] {
const targetNode = targetDirPath ? findNodeByPath(root, targetDirPath) : root;
const usedNames = new Set<string>();
if (targetNode?.children) {
for (const child of targetNode.children) usedNames.add(child.name);
}
const results: { oldPath: string; newPath: string }[] = [];
for (const path of sourcePaths) {
const node = findNodeByPath(root, path);
if (node) {
const safeName = deduplicateName(node.name, usedNames);
usedNames.add(safeName);
const newPath = targetDirPath ? `${targetDirPath}/${safeName}` : safeName;
results.push({ oldPath: path, newPath });
}
}
return results;
}
/** Move nodes from their current locations into a target directory. Returns the updated tree. */
export function moveNodesInTree(
root: FileTreeNode,
sourcePaths: string[],
targetDirPath: string,
): FileTreeNode {
// Collect existing names in target to avoid collisions
const targetNode = targetDirPath ? findNodeByPath(root, targetDirPath) : root;
const usedNames = new Set<string>();
if (targetNode?.children) {
for (const child of targetNode.children) usedNames.add(child.name);
}
// Collect the nodes to move, deduplicating names
const nodesToMove: FileTreeNode[] = [];
for (const path of sourcePaths) {
const node = findNodeByPath(root, path);
if (node) {
const safeName = deduplicateName(node.name, usedNames);
usedNames.add(safeName);
const newPath = targetDirPath ? `${targetDirPath}/${safeName}` : safeName;
nodesToMove.push(renameSubtree(node, path, newPath));
}
}
// Remove source nodes from tree
let updated = root;
for (const path of sourcePaths) {
updated = removeNodeFromTree(updated, path);
}
// Insert into target directory
for (const node of nodesToMove) {
updated = insertNodeInTree(updated, targetDirPath, node);
}
return updated;
}
+97
View File
@@ -0,0 +1,97 @@
import { expect } from "@playwright/test";
import type { Page } from "@playwright/test";
import fs from "node:fs";
import path from "node:path";
import { execSync } from "node:child_process";
import type { ApiClient } from "./api-client";
import { KanbanPage } from "../pages/kanban-page";
import { SessionPage } from "../pages/session-page";
export class GitHelper {
constructor(
private repoDir: string,
private env: NodeJS.ProcessEnv,
) {}
exec(cmd: string): string {
for (let attempt = 0; attempt < 3; attempt++) {
try {
return execSync(cmd, { cwd: this.repoDir, env: this.env, encoding: "utf8" });
} catch (err) {
const msg = (err as Error).message ?? "";
if (msg.includes("index.lock") && attempt < 2) {
execSync("sleep 0.3");
continue;
}
throw err;
}
}
throw new Error(`git exec failed after 3 attempts: ${cmd}`);
}
createFile(name: string, content: string) {
const filePath = path.join(this.repoDir, name);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content);
}
modifyFile(name: string, content: string) {
this.createFile(name, content);
}
deleteFile(name: string) {
const filePath = path.join(this.repoDir, name);
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
}
stageFile(name: string) {
this.exec(`git add "${name}"`);
}
stageAll() {
this.exec("git add -A");
}
commit(message: string): string {
this.exec(`git commit -m "${message}"`);
return this.exec("git rev-parse HEAD").trim();
}
getCurrentSha(): string {
return this.exec("git rev-parse HEAD").trim();
}
}
export function makeGitEnv(tmpDir: string): NodeJS.ProcessEnv {
return {
...process.env,
HOME: tmpDir,
GIT_AUTHOR_NAME: "E2E Test",
GIT_AUTHOR_EMAIL: "e2e@test.local",
GIT_COMMITTER_NAME: "E2E Test",
GIT_COMMITTER_EMAIL: "e2e@test.local",
};
}
export async function openTaskSession(page: Page, title: string): Promise<SessionPage> {
const kanban = new KanbanPage(page);
await kanban.goto();
const card = kanban.taskCardByTitle(title);
await expect(card).toBeVisible({ timeout: 15_000 });
await card.click();
await expect(page).toHaveURL(/\/t\//, { timeout: 15_000 });
const session = new SessionPage(page);
await session.waitForLoad();
return session;
}
export async function createStandardProfile(apiClient: ApiClient, name: string) {
const { agents } = await apiClient.listAgents();
const agentId = agents[0]?.id;
if (!agentId) throw new Error("No agent available");
return apiClient.createAgentProfile(agentId, name, {
model: "mock-fast",
auto_approve: true,
cli_passthrough: false,
});
}
+44
View File
@@ -576,4 +576,48 @@ export class SessionPage {
const taskRow = this.sidebar.locator("[role='button']").filter({ hasText: title });
await taskRow.click();
}
// --- File tree multi-select helpers ---
/** Find a tree node by its data-path attribute. */
fileTreeNode(nodePath: string): Locator {
return this.files.locator(`[data-testid="file-tree-node"][data-path="${nodePath}"]`);
}
/** All file tree nodes with data-selected="true". */
fileTreeSelectedNodes(): Locator {
return this.files.locator("[data-selected='true']");
}
// --- Changes panel multi-select helpers ---
/** Find a file row in the changes panel by path. */
changesFileRow(path: string): Locator {
return this.changes.locator(`[data-changes-file="${path}"]`);
}
/** All selected file rows in the changes panel. */
changesSelectedRows(): Locator {
return this.changes.locator("[data-selected='true']");
}
/** Bulk action bar for a variant (unstaged/staged). */
changesBulkActionBar(variant: "unstaged" | "staged"): Locator {
return this.changes.getByTestId(`bulk-actions-${variant}`);
}
/** Bulk stage button (unstaged section). */
changesBulkStageButton(): Locator {
return this.changes.getByTestId("bulk-stage");
}
/** Bulk unstage button (staged section). */
changesBulkUnstageButton(): Locator {
return this.changes.getByTestId("bulk-unstage-staged");
}
/** Bulk discard button for a variant. */
changesBulkDiscardButton(variant: "unstaged" | "staged" = "unstaged"): Locator {
return this.changes.getByTestId(`bulk-discard-${variant}`);
}
}
@@ -0,0 +1,135 @@
import { test, expect } from "../fixtures/test-base";
import path from "node:path";
import {
GitHelper,
makeGitEnv,
openTaskSession,
createStandardProfile,
} from "../helpers/git-helper";
const MOD = process.platform === "darwin" ? ("Meta" as const) : ("Control" as const);
test.describe("Git Panel Multi-Select", () => {
test("ctrl-click selects multiple unstaged files and shows bulk actions", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
const git = new GitHelper(repoDir, makeGitEnv(backend.tmpDir));
const profile = await createStandardProfile(apiClient, "git-multi-select");
await apiClient.createTaskWithAgent(seedData.workspaceId, "Git Multi-Select Test", profile.id, {
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
const session = await openTaskSession(testPage, "Git Multi-Select Test");
await session.clickTab("Changes");
// Create files AFTER session is open so WS detects the changes
git.createFile("file-a.ts", "a");
git.createFile("file-b.ts", "b");
await expect(testPage.getByTestId("unstaged-files-section")).toBeVisible({ timeout: 15_000 });
const fileA = session.changesFileRow("file-a.ts");
const fileB = session.changesFileRow("file-b.ts");
await expect(fileA).toBeVisible({ timeout: 15_000 });
await expect(fileB).toBeVisible({ timeout: 15_000 });
await fileA.click({ modifiers: [MOD] });
await expect(fileA).toHaveAttribute("data-selected", "true");
await fileB.click({ modifiers: [MOD] });
await expect(fileA).toHaveAttribute("data-selected", "true");
await expect(fileB).toHaveAttribute("data-selected", "true");
const bulkBar = session.changesBulkActionBar("unstaged");
await expect(bulkBar).toBeVisible({ timeout: 5_000 });
await expect(session.changesBulkStageButton()).toBeVisible();
await expect(session.changesBulkDiscardButton()).toBeVisible();
});
test("bulk stage moves selected files to staged section", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
const git = new GitHelper(repoDir, makeGitEnv(backend.tmpDir));
const profile = await createStandardProfile(apiClient, "git-bulk-stage");
await apiClient.createTaskWithAgent(seedData.workspaceId, "Git Bulk Stage Test", profile.id, {
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
const session = await openTaskSession(testPage, "Git Bulk Stage Test");
await session.clickTab("Changes");
git.createFile("stage-a.ts", "a");
git.createFile("stage-b.ts", "b");
await expect(testPage.getByTestId("unstaged-files-section")).toBeVisible({ timeout: 15_000 });
const fileA = session.changesFileRow("stage-a.ts");
const fileB = session.changesFileRow("stage-b.ts");
await expect(fileA).toBeVisible({ timeout: 15_000 });
await expect(fileB).toBeVisible({ timeout: 15_000 });
await fileA.click({ modifiers: [MOD] });
await fileB.click({ modifiers: [MOD] });
await session.changesBulkStageButton().click();
const stagedList = session.changes.getByTestId("staged-file-list");
await expect(stagedList.getByText("stage-a.ts")).toBeVisible({ timeout: 15_000 });
await expect(stagedList.getByText("stage-b.ts")).toBeVisible({ timeout: 15_000 });
});
test("escape clears selection in git panel", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
const git = new GitHelper(repoDir, makeGitEnv(backend.tmpDir));
const profile = await createStandardProfile(apiClient, "git-escape");
await apiClient.createTaskWithAgent(seedData.workspaceId, "Git Escape Test", profile.id, {
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
const session = await openTaskSession(testPage, "Git Escape Test");
await session.clickTab("Changes");
git.createFile("esc-a.ts", "a");
git.createFile("esc-b.ts", "b");
await expect(testPage.getByTestId("unstaged-files-section")).toBeVisible({ timeout: 15_000 });
const fileA = session.changesFileRow("esc-a.ts");
await expect(fileA).toBeVisible({ timeout: 15_000 });
await fileA.click({ modifiers: [MOD] });
await expect(fileA).toHaveAttribute("data-selected", "true");
const bulkBar = session.changesBulkActionBar("unstaged");
await expect(bulkBar).toBeVisible({ timeout: 5_000 });
const fileList = testPage.getByTestId("unstaged-file-list");
await fileList.focus();
await testPage.keyboard.press("Escape");
await expect(session.changesSelectedRows()).toHaveCount(0, { timeout: 5_000 });
await expect(bulkBar).not.toBeVisible({ timeout: 5_000 });
});
});
@@ -0,0 +1,425 @@
import { test, expect } from "../fixtures/test-base";
import path from "node:path";
import {
GitHelper,
makeGitEnv,
openTaskSession,
createStandardProfile,
} from "../helpers/git-helper";
const MOD = process.platform === "darwin" ? ("Meta" as const) : ("Control" as const);
async function setupFileTreeTest(
testPage: Page,
apiClient: ApiClient,
seedData: { workspaceId: string; workflowId: string; startStepId: string; repositoryId: string },
profileName: string,
taskTitle: string,
) {
const profile = await createStandardProfile(apiClient, profileName);
await apiClient.createTaskWithAgent(seedData.workspaceId, taskTitle, profile.id, {
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
const session = await openTaskSession(testPage, taskTitle);
await session.clickTab("Files");
return session;
}
test.describe("File Tree Multi-Select", () => {
// --- Selection Basics ---
test("ctrl-click selects a file without opening it", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const git = new GitHelper(
path.join(backend.tmpDir, "repos", "e2e-repo"),
makeGitEnv(backend.tmpDir),
);
git.createFile("alpha.ts", "a");
git.createFile("beta.ts", "b");
git.stageAll();
git.commit("add files");
const session = await setupFileTreeTest(
testPage,
apiClient,
seedData,
"ft-select-1",
"FT Select File",
);
const alpha = session.fileTreeNode("alpha.ts");
await expect(alpha).toBeVisible({ timeout: 15_000 });
await alpha.click({ modifiers: [MOD] });
await expect(alpha).toHaveAttribute("data-selected", "true");
});
test("ctrl-click toggles file out of selection", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const git = new GitHelper(
path.join(backend.tmpDir, "repos", "e2e-repo"),
makeGitEnv(backend.tmpDir),
);
git.createFile("toggle-a.ts", "a");
git.stageAll();
git.commit("add toggle file");
const session = await setupFileTreeTest(
testPage,
apiClient,
seedData,
"ft-toggle",
"FT Toggle File",
);
const file = session.fileTreeNode("toggle-a.ts");
await expect(file).toBeVisible({ timeout: 15_000 });
await file.click({ modifiers: [MOD] });
await expect(file).toHaveAttribute("data-selected", "true");
await file.click({ modifiers: [MOD] });
await expect(file).toHaveAttribute("data-selected", "false");
});
test("ctrl-click selects multiple files", async ({ testPage, apiClient, seedData, backend }) => {
const git = new GitHelper(
path.join(backend.tmpDir, "repos", "e2e-repo"),
makeGitEnv(backend.tmpDir),
);
git.createFile("multi-a.ts", "a");
git.createFile("multi-b.ts", "b");
git.createFile("multi-c.ts", "c");
git.stageAll();
git.commit("add multi files");
const session = await setupFileTreeTest(
testPage,
apiClient,
seedData,
"ft-multi",
"FT Multi Select",
);
const a = session.fileTreeNode("multi-a.ts");
const b = session.fileTreeNode("multi-b.ts");
await expect(a).toBeVisible({ timeout: 15_000 });
await a.click({ modifiers: [MOD] });
await b.click({ modifiers: [MOD] });
await expect(a).toHaveAttribute("data-selected", "true");
await expect(b).toHaveAttribute("data-selected", "true");
});
test("plain click clears selection", async ({ testPage, apiClient, seedData, backend }) => {
const git = new GitHelper(
path.join(backend.tmpDir, "repos", "e2e-repo"),
makeGitEnv(backend.tmpDir),
);
git.createFile("clear-a.ts", "a");
git.createFile("clear-b.ts", "b");
git.stageAll();
git.commit("add clear files");
const session = await setupFileTreeTest(
testPage,
apiClient,
seedData,
"ft-clear",
"FT Plain Click Clear",
);
const a = session.fileTreeNode("clear-a.ts");
const b = session.fileTreeNode("clear-b.ts");
await expect(a).toBeVisible({ timeout: 15_000 });
await a.click({ modifiers: [MOD] });
await b.click({ modifiers: [MOD] });
await expect(session.fileTreeSelectedNodes()).toHaveCount(2);
await a.click();
await expect(session.fileTreeSelectedNodes()).toHaveCount(0);
});
// --- Shift-Click Range ---
test("shift-click selects range after plain click anchor", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const git = new GitHelper(
path.join(backend.tmpDir, "repos", "e2e-repo"),
makeGitEnv(backend.tmpDir),
);
git.createFile("r-a.ts", "a");
git.createFile("r-b.ts", "b");
git.createFile("r-c.ts", "c");
git.createFile("r-d.ts", "d");
git.stageAll();
git.commit("add range files");
const session = await setupFileTreeTest(
testPage,
apiClient,
seedData,
"ft-range",
"FT Shift Range",
);
const a = session.fileTreeNode("r-a.ts");
const d = session.fileTreeNode("r-d.ts");
await expect(a).toBeVisible({ timeout: 15_000 });
await expect(d).toBeVisible({ timeout: 15_000 });
await a.click();
await d.click({ modifiers: ["Shift"] });
await expect(session.fileTreeNode("r-a.ts")).toHaveAttribute("data-selected", "true");
await expect(session.fileTreeNode("r-b.ts")).toHaveAttribute("data-selected", "true");
await expect(session.fileTreeNode("r-c.ts")).toHaveAttribute("data-selected", "true");
await expect(session.fileTreeNode("r-d.ts")).toHaveAttribute("data-selected", "true");
});
test("shift-click selects range after ctrl-click anchor", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const git = new GitHelper(
path.join(backend.tmpDir, "repos", "e2e-repo"),
makeGitEnv(backend.tmpDir),
);
git.createFile("s-a.ts", "a");
git.createFile("s-b.ts", "b");
git.createFile("s-c.ts", "c");
git.stageAll();
git.commit("add shift range files");
const session = await setupFileTreeTest(
testPage,
apiClient,
seedData,
"ft-shift-anchor",
"FT Shift Anchor",
);
const b = session.fileTreeNode("s-b.ts");
const c = session.fileTreeNode("s-c.ts");
await expect(b).toBeVisible({ timeout: 15_000 });
await b.click({ modifiers: [MOD] });
await expect(b).toHaveAttribute("data-selected", "true");
await c.click({ modifiers: ["Shift"] });
await expect(session.fileTreeNode("s-b.ts")).toHaveAttribute("data-selected", "true");
await expect(session.fileTreeNode("s-c.ts")).toHaveAttribute("data-selected", "true");
});
// --- Directory Selection ---
test("ctrl-click selects a directory without expanding it", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const git = new GitHelper(
path.join(backend.tmpDir, "repos", "e2e-repo"),
makeGitEnv(backend.tmpDir),
);
git.createFile("mydir/nested.ts", "n");
git.stageAll();
git.commit("add dir");
const session = await setupFileTreeTest(
testPage,
apiClient,
seedData,
"ft-dir-select",
"FT Dir Select",
);
const dir = session.fileTreeNode("mydir");
await expect(dir).toBeVisible({ timeout: 15_000 });
await dir.click({ modifiers: [MOD] });
await expect(dir).toHaveAttribute("data-selected", "true");
});
test("ctrl-click selects file inside expanded directory", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const git = new GitHelper(
path.join(backend.tmpDir, "repos", "e2e-repo"),
makeGitEnv(backend.tmpDir),
);
git.createFile("srcdir/inner.ts", "inner");
git.stageAll();
git.commit("add inner file");
const session = await setupFileTreeTest(
testPage,
apiClient,
seedData,
"ft-nested-select",
"FT Nested Select",
);
const dir = session.fileTreeNode("srcdir");
await expect(dir).toBeVisible({ timeout: 15_000 });
await dir.click();
const inner = session.fileTreeNode("srcdir/inner.ts");
await expect(inner).toBeVisible({ timeout: 10_000 });
await inner.click({ modifiers: [MOD] });
await expect(inner).toHaveAttribute("data-selected", "true");
await expect(dir).toHaveAttribute("data-selected", "false");
});
// --- Right-Click Context Menu ---
test("right-click single file shows context menu with delete", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const git = new GitHelper(
path.join(backend.tmpDir, "repos", "e2e-repo"),
makeGitEnv(backend.tmpDir),
);
git.createFile("ctx-file.ts", "ctx");
git.stageAll();
git.commit("add ctx file");
const session = await setupFileTreeTest(
testPage,
apiClient,
seedData,
"ft-ctx-menu",
"FT Context Menu",
);
const file = session.fileTreeNode("ctx-file.ts");
await expect(file).toBeVisible({ timeout: 15_000 });
await file.click({ button: "right" });
await expect(testPage.getByRole("menuitem", { name: "Delete" })).toBeVisible({
timeout: 5_000,
});
});
test("right-click on multi-selection shows bulk delete", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const git = new GitHelper(
path.join(backend.tmpDir, "repos", "e2e-repo"),
makeGitEnv(backend.tmpDir),
);
git.createFile("bulk-a.ts", "a");
git.createFile("bulk-b.ts", "b");
git.stageAll();
git.commit("add bulk files");
const session = await setupFileTreeTest(
testPage,
apiClient,
seedData,
"ft-bulk-ctx",
"FT Bulk Context",
);
const a = session.fileTreeNode("bulk-a.ts");
const b = session.fileTreeNode("bulk-b.ts");
await expect(a).toBeVisible({ timeout: 15_000 });
await a.click({ modifiers: [MOD] });
await b.click({ modifiers: [MOD] });
await a.click({ button: "right" });
const deleteItem = testPage.getByRole("menuitem", { name: "Delete 2 items" });
await expect(deleteItem).toBeVisible({ timeout: 5_000 });
// Click the delete menu item and confirm dialog
await deleteItem.click();
const confirmBtn = testPage.getByRole("button", { name: "Delete" });
await expect(confirmBtn).toBeVisible({ timeout: 5_000 });
await confirmBtn.click();
// Both files should be removed from the tree
await expect(a).not.toBeVisible({ timeout: 10_000 });
await expect(b).not.toBeVisible({ timeout: 10_000 });
});
// --- Click Outside ---
test("clicking outside tree clears selection", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const git = new GitHelper(
path.join(backend.tmpDir, "repos", "e2e-repo"),
makeGitEnv(backend.tmpDir),
);
git.createFile("outside-a.ts", "a");
git.stageAll();
git.commit("add outside file");
const session = await setupFileTreeTest(
testPage,
apiClient,
seedData,
"ft-click-outside",
"FT Click Outside",
);
const file = session.fileTreeNode("outside-a.ts");
await expect(file).toBeVisible({ timeout: 15_000 });
await file.click({ modifiers: [MOD] });
await expect(file).toHaveAttribute("data-selected", "true");
// Click empty area in the files panel header area
await session.files.click({ position: { x: 5, y: 5 } });
await expect(session.fileTreeSelectedNodes()).toHaveCount(0, { timeout: 5_000 });
});
// --- Keyboard ---
test("escape clears selection", async ({ testPage, apiClient, seedData, backend }) => {
const git = new GitHelper(
path.join(backend.tmpDir, "repos", "e2e-repo"),
makeGitEnv(backend.tmpDir),
);
git.createFile("esc-file.ts", "e");
git.stageAll();
git.commit("add esc file");
const session = await setupFileTreeTest(
testPage,
apiClient,
seedData,
"ft-escape",
"FT Escape",
);
const file = session.fileTreeNode("esc-file.ts");
await expect(file).toBeVisible({ timeout: 15_000 });
await file.click({ modifiers: [MOD] });
await expect(file).toHaveAttribute("data-selected", "true");
await file.press("Escape");
await expect(session.fileTreeSelectedNodes()).toHaveCount(0, { timeout: 5_000 });
});
});
+197
View File
@@ -0,0 +1,197 @@
import { describe, it, expect, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { computeNextSelection, useMultiSelect } from "./use-multi-select";
function makeRef(value: string | null = null) {
return { current: value };
}
describe("computeNextSelection — ctrl/cmd click", () => {
it("selects a single item", () => {
const ref = makeRef();
const result = computeNextSelection({
prev: new Set(),
path: "a",
items: ["a", "b"],
isShift: false,
isCtrlOrMeta: true,
lastClickedRef: ref,
});
expect(result).toEqual(new Set(["a"]));
expect(ref.current).toBe("a");
});
it("toggles item out of selection", () => {
const ref = makeRef("a");
const result = computeNextSelection({
prev: new Set(["a"]),
path: "a",
items: ["a", "b"],
isShift: false,
isCtrlOrMeta: true,
lastClickedRef: ref,
});
expect(result.size).toBe(0);
});
it("accumulates selections", () => {
const ref = makeRef("a");
const result = computeNextSelection({
prev: new Set(["a"]),
path: "c",
items: ["a", "b", "c"],
isShift: false,
isCtrlOrMeta: true,
lastClickedRef: ref,
});
expect(result).toEqual(new Set(["a", "c"]));
});
});
describe("computeNextSelection — shift click", () => {
it("selects range from anchor", () => {
const ref = makeRef("b");
const result = computeNextSelection({
prev: new Set(),
path: "d",
items: ["a", "b", "c", "d", "e"],
isShift: true,
isCtrlOrMeta: false,
lastClickedRef: ref,
});
expect(result).toEqual(new Set(["b", "c", "d"]));
});
it("selects single item when no anchor exists", () => {
const ref = makeRef();
const result = computeNextSelection({
prev: new Set(),
path: "b",
items: ["a", "b", "c"],
isShift: true,
isCtrlOrMeta: false,
lastClickedRef: ref,
});
expect(result).toEqual(new Set(["b"]));
expect(ref.current).toBe("b");
});
it("resets to single item when anchor is stale", () => {
const ref = makeRef("x"); // not in items
const result = computeNextSelection({
prev: new Set(),
path: "c",
items: ["a", "b", "c"],
isShift: true,
isCtrlOrMeta: false,
lastClickedRef: ref,
});
expect(result).toEqual(new Set(["c"]));
expect(ref.current).toBe("c");
});
it("extends existing selection with shift+ctrl", () => {
const ref = makeRef("b");
const result = computeNextSelection({
prev: new Set(["x"]),
path: "d",
items: ["a", "b", "c", "d", "e"],
isShift: true,
isCtrlOrMeta: true,
lastClickedRef: ref,
});
expect(result).toEqual(new Set(["x", "b", "c", "d"]));
});
it("selects reverse range", () => {
const ref = makeRef("d");
const result = computeNextSelection({
prev: new Set(),
path: "b",
items: ["a", "b", "c", "d", "e"],
isShift: true,
isCtrlOrMeta: false,
lastClickedRef: ref,
});
expect(result).toEqual(new Set(["b", "c", "d"]));
});
});
// --- Hook-level tests (renderHook) ---
function mouseEvent(overrides: Partial<React.MouseEvent> = {}): React.MouseEvent {
return {
ctrlKey: false,
metaKey: false,
shiftKey: false,
button: 0,
...overrides,
} as React.MouseEvent;
}
describe("useMultiSelect — stale-path pruning", () => {
it("prunes selected paths when items shrink", () => {
const { result, rerender } = renderHook(({ items }) => useMultiSelect({ items }), {
initialProps: { items: ["a", "b", "c"] },
});
act(() => result.current.selectAll());
expect(result.current.selectedPaths).toEqual(new Set(["a", "b", "c"]));
rerender({ items: ["a", "c"] });
expect(result.current.selectedPaths).toEqual(new Set(["a", "c"]));
});
it("returns empty set when all items removed", () => {
const { result, rerender } = renderHook(({ items }) => useMultiSelect({ items }), {
initialProps: { items: ["a", "b"] },
});
act(() => result.current.selectAll());
rerender({ items: [] });
expect(result.current.selectedPaths.size).toBe(0);
});
});
describe("useMultiSelect — plain click", () => {
it("returns false and does not select", () => {
const { result } = renderHook(() => useMultiSelect({ items: ["a", "b"] }));
let consumed: boolean;
act(() => {
consumed = result.current.handleClick("a", mouseEvent());
});
expect(consumed!).toBe(false);
expect(result.current.selectedPaths.size).toBe(0);
});
it("clears existing selection", () => {
const { result } = renderHook(() => useMultiSelect({ items: ["a", "b"] }));
act(() => result.current.handleClick("a", mouseEvent({ ctrlKey: true })));
expect(result.current.selectedPaths.size).toBe(1);
act(() => result.current.handleClick("b", mouseEvent()));
expect(result.current.selectedPaths.size).toBe(0);
});
});
describe("useMultiSelect — selectAll / clearSelection", () => {
it("selectAll selects all items", () => {
const { result } = renderHook(() => useMultiSelect({ items: ["a", "b", "c"] }));
act(() => result.current.selectAll());
expect(result.current.selectedPaths).toEqual(new Set(["a", "b", "c"]));
});
it("clearSelection empties selection", () => {
const { result } = renderHook(() => useMultiSelect({ items: ["a", "b"] }));
act(() => result.current.selectAll());
act(() => result.current.clearSelection());
expect(result.current.selectedPaths.size).toBe(0);
});
it("onSelectionChange fires on selectAll", () => {
const onChange = vi.fn();
const { result } = renderHook(() =>
useMultiSelect({ items: ["a", "b"], onSelectionChange: onChange }),
);
act(() => result.current.selectAll());
expect(onChange).toHaveBeenCalledWith(new Set(["a", "b"]));
});
});
+148
View File
@@ -0,0 +1,148 @@
"use client";
import { useState, useCallback, useRef, useMemo } from "react";
type UseMultiSelectOptions = {
items: string[];
onSelectionChange?: (selected: Set<string>) => void;
};
type UseMultiSelectReturn = {
selectedPaths: Set<string>;
isSelected: (path: string) => boolean;
/** Returns true if the click was consumed by selection (modifier key held). */
handleClick: (path: string, event: React.MouseEvent) => boolean;
selectAll: () => void;
clearSelection: () => void;
setSelectedPaths: (paths: Set<string>) => void;
};
export type SelectionParams = {
prev: Set<string>;
path: string;
items: string[];
isShift: boolean;
isCtrlOrMeta: boolean;
lastClickedRef: React.RefObject<string | null>;
};
/** Exported for testing. */
export function computeNextSelection(params: SelectionParams): Set<string> {
const { prev, path, items, isShift, isCtrlOrMeta, lastClickedRef } = params;
if (isShift) {
if (!lastClickedRef.current) {
lastClickedRef.current = path;
return new Set([path]);
}
const anchorIndex = items.indexOf(lastClickedRef.current);
const currentIndex = items.indexOf(path);
if (anchorIndex === -1 || currentIndex === -1) {
lastClickedRef.current = path;
return new Set([path]);
}
const start = Math.min(anchorIndex, currentIndex);
const end = Math.max(anchorIndex, currentIndex);
const next = isCtrlOrMeta ? new Set(prev) : new Set<string>();
for (let i = start; i <= end; i++) next.add(items[i]);
return next;
}
// Ctrl/Cmd+click: toggle individual item
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
lastClickedRef.current = path;
return next;
}
export function useMultiSelect({
items,
onSelectionChange,
}: UseMultiSelectOptions): UseMultiSelectReturn {
const [rawSelection, setRawSelection] = useState<Set<string>>(new Set());
const lastClickedRef = useRef<string | null>(null);
const itemSet = useMemo(() => new Set(items), [items]);
const selectedPaths = useMemo(() => {
if (rawSelection.size === 0) return rawSelection;
let allValid = true;
for (const p of rawSelection) {
if (!itemSet.has(p)) {
allValid = false;
break;
}
}
if (allValid) return rawSelection;
const pruned = new Set<string>();
for (const p of rawSelection) {
if (itemSet.has(p)) pruned.add(p);
}
return pruned;
}, [rawSelection, itemSet]);
const setSelectedPaths = useCallback(
(paths: Set<string>) => {
setRawSelection(paths);
onSelectionChange?.(paths);
},
[onSelectionChange],
);
const handleClick = useCallback(
(path: string, event: React.MouseEvent): boolean => {
const isCtrlOrMeta = event.ctrlKey || event.metaKey;
const isShift = event.shiftKey;
// Plain click: set anchor, clear selection, let caller handle action
if (!isCtrlOrMeta && !isShift) {
lastClickedRef.current = path;
setRawSelection((prev) => {
if (prev.size === 0) return prev;
return new Set();
});
onSelectionChange?.(new Set());
return false;
}
// Modifier click: compute new selection, then notify
const next = computeNextSelection({
prev: rawSelection,
path,
items,
isShift,
isCtrlOrMeta,
lastClickedRef,
});
setRawSelection(next);
onSelectionChange?.(next);
return true;
},
[items, rawSelection, onSelectionChange],
);
const selectAll = useCallback(() => {
const all = new Set(items);
setRawSelection(all);
onSelectionChange?.(all);
}, [items, onSelectionChange]);
const clearSelection = useCallback(() => {
setRawSelection(new Set());
onSelectionChange?.(new Set());
lastClickedRef.current = null;
}, [onSelectionChange]);
const isSelected = useCallback((path: string) => selectedPaths.has(path), [selectedPaths]);
return {
selectedPaths,
isSelected,
handleClick,
selectAll,
clearSelection,
setSelectedPaths,
};
}
+1
View File
@@ -119,6 +119,7 @@
"devDependencies": {
"@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4",
"@testing-library/react": "^16.3.2",
"@types/diff": "^8.0.0",
"@types/node": "^20",
"@types/react": "^19",