From 2b8513f9027e68935553269a032bd1d5f070505f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Sat, 11 Apr 2026 17:43:21 +0100 Subject: [PATCH] feat: add multi-select and drag-to-move for file tree and changes panel (#490) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 ". * 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 --- apps/pnpm-lock.yaml | 152 ++++++- .../components/task/changes-panel-dialogs.tsx | 16 +- .../task/changes-panel-file-row.tsx | 125 +++++- .../components/task/changes-panel-hooks.ts | 17 +- .../task/changes-panel-timeline.tsx | 124 ++--- apps/web/components/task/changes-panel.tsx | 21 + .../components/task/file-browser-parts.tsx | 222 +++++---- apps/web/components/task/file-browser.tsx | 289 ++++++++++-- .../web/components/task/file-context-menu.tsx | 169 ++++--- .../components/task/file-tree-utils.test.ts | 105 +++++ apps/web/components/task/file-tree-utils.ts | 104 +++++ apps/web/e2e/helpers/git-helper.ts | 97 ++++ apps/web/e2e/pages/session-page.ts | 44 ++ .../tests/changes-panel-multi-select.spec.ts | 135 ++++++ .../e2e/tests/file-tree-multi-select.spec.ts | 425 ++++++++++++++++++ apps/web/hooks/use-multi-select.test.ts | 197 ++++++++ apps/web/hooks/use-multi-select.ts | 148 ++++++ apps/web/package.json | 1 + 18 files changed, 2142 insertions(+), 249 deletions(-) create mode 100644 apps/web/components/task/file-tree-utils.test.ts create mode 100644 apps/web/e2e/helpers/git-helper.ts create mode 100644 apps/web/e2e/tests/changes-panel-multi-select.spec.ts create mode 100644 apps/web/e2e/tests/file-tree-multi-select.spec.ts create mode 100644 apps/web/hooks/use-multi-select.test.ts create mode 100644 apps/web/hooks/use-multi-select.ts diff --git a/apps/pnpm-lock.yaml b/apps/pnpm-lock.yaml index 8331c1f7c8..2690591c04 100644 --- a/apps/pnpm-lock.yaml +++ b/apps/pnpm-lock.yaml @@ -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: {} diff --git a/apps/web/components/task/changes-panel-dialogs.tsx b/apps/web/components/task/changes-panel-dialogs.tsx index 4370f89a17..18120abd65 100644 --- a/apps/web/components/task/changes-panel-dialogs.tsx +++ b/apps/web/components/task/changes-panel-dialogs.tsx @@ -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 ( Discard changes? - This will permanently discard all changes to{" "} - {fileToDiscard}. This action cannot be undone. + {description ?? ( + <> + This will permanently discard all changes to{" "} + {displayFile}. This action cannot be undone. + + )} diff --git a/apps/web/components/task/changes-panel-file-row.tsx b/apps/web/components/task/changes-panel-file-row.tsx index 5d86f6521f..f50dcdaf48 100644 --- a/apps/web/components/task/changes-panel-file-row.tsx +++ b/apps/web/components/task/changes-panel-file-row.tsx @@ -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 (
  • 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} >
    ); } + +// --- 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 ( + <> + + {onSecondaryAction && secondaryActionLabel && ( + + )} + + ); +} + +export function BulkActionBar({ + variant, + selectionCount, + selectedPaths, + onBulkStage, + onBulkUnstage, + onBulkDiscard, +}: { + variant: "unstaged" | "staged"; + selectionCount: number; + selectedPaths: Set; + onBulkStage?: (paths: string[]) => void; + onBulkUnstage?: (paths: string[]) => void; + onBulkDiscard?: (paths: string[]) => void; +}) { + const paths = [...selectedPaths]; + + return ( +
    + {selectionCount} selected + {variant === "unstaged" && onBulkStage && ( + + )} + {variant === "staged" && onBulkUnstage && ( + + )} + {onBulkDiscard && ( + + )} +
    + ); +} diff --git a/apps/web/components/task/changes-panel-hooks.ts b/apps/web/components/task/changes-panel-hooks.ts index fe9373c394..8eb0ffd3cb 100644 --- a/apps/web/components/task/changes-panel-hooks.ts +++ b/apps/web/components/task/changes-panel-hooks.ts @@ -95,15 +95,23 @@ function useChangesDiscardAmendHandlers( ) { const [showDiscardDialog, setShowDiscardDialog] = useState(false); const [fileToDiscard, setFileToDiscard] = useState(null); + const [filesToDiscard, setFilesToDiscard] = useState(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, diff --git a/apps/web/components/task/changes-panel-timeline.tsx b/apps/web/components/task/changes-panel-timeline.tsx index 9c76c9f5c6..1414ed280f 100644 --- a/apps/web/components/task/changes-panel-timeline.tsx +++ b/apps/web/components/task/changes-panel-timeline.tsx @@ -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 ( {files.length > 0 && ( -
      - {files.map((file) => ( - - ))} -
    +
    +
      + {files.map((file) => ( + + ))} +
    +
    )} {files.length > 0 && (
    - - {onSecondaryAction && secondaryActionLabel && ( - + {hasSelection ? ( + + ) : ( + )}
    )} diff --git a/apps/web/components/task/changes-panel.tsx b/apps/web/components/task/changes-panel.tsx index 572cc056c5..c31344b422 100644 --- a/apps/web/components/task/changes-panel.tsx +++ b/apps/web/components/task/changes-panel.tsx @@ -291,6 +291,9 @@ type ChangesPanelBodyProps = { onUnstageAll: () => void; onStage: (path: string) => Promise; onUnstage: (path: string) => Promise; + 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} /> ; @@ -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} diff --git a/apps/web/components/task/file-browser-parts.tsx b/apps/web/components/task/file-browser-parts.tsx index 9746438c89..47bf031516 100644 --- a/apps/web/components/task/file-browser-parts.tsx +++ b/apps/web/components/task/file-browser-parts.tsx @@ -49,6 +49,17 @@ type TreeNodeItemProps = { onCreateFileSubmit: (parentPath: string, name: string) => void; onCancelCreate: () => void; setTree: React.Dispatch>; + 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; }; 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 = (
    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 && ( - + )} @@ -176,6 +233,8 @@ export function TreeNodeItem(props: TreeNodeItemProps) { onDeleteFile={onDeleteFile} onRenameFile={onRenameFile} onStartRename={rename.handleStartRename} + selectedCount={props.selectedCount} + selectedPaths={props.selectedPaths} > {rowContent} @@ -263,87 +322,90 @@ type FileBrowserContentAreaProps = { onCancelCreate: () => void; onRetry: () => void; setTree: React.Dispatch>; + 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; }; -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 ( +
    + {creatingInPath === "" && ( + onCreateFileSubmit("", name)} + onCancel={onCancelCreate} + /> + )} + {tree.children && + [...tree.children] + .sort(compareTreeNodes) + .map((child) => ( + + ))} +
    + ); +} + +export function FileBrowserContentArea(props: FileBrowserContentAreaProps) { + if (props.isSearchActive && props.searchResults !== null) { return ( ); } 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 ( -
    - {creatingInPath === "" && ( - onCreateFileSubmit("", name)} - onCancel={onCancelCreate} - /> - )} - {tree.children && - [...tree.children] - .sort(compareTreeNodes) - .map((child) => ( - - ))} -
    - ); - } + if (props.tree) return ; return
    No files found
    ; } diff --git a/apps/web/components/task/file-browser.tsx b/apps/web/components/task/file-browser.tsx index 3f706a7873..2c160e8749 100644 --- a/apps/web/components/task/file-browser.tsx +++ b/apps/web/components/task/file-browser.tsx @@ -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 ( ); } - return ( 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; + setSelectedPaths: (paths: Set) => void; + onRenameFile: (oldPath: string, newPath: string) => Promise; +}; + +function executeMoveFiles(params: MoveFilesParams, toast: ReturnType["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, + selectedPaths: Set, + setSelectedPaths: (paths: Set) => void, + onRenameFile?: (oldPath: string, newPath: string) => Promise, +) { + const { toast } = useToast(); + const [isDragging, setIsDragging] = useState(false); + const [dragOverPath, setDragOverPath] = useState(null); + const dragPathsRef = useRef([]); + + 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>>, +) { + 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, + containerRef: React.RefObject, + activeFilePath: string | null | undefined, + onRenameFile?: (oldPath: string, newPath: string) => Promise, +) { + 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, + 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(null); + const containerRef = useRef(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 ( -
    +
    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} />
    diff --git a/apps/web/components/task/file-context-menu.tsx b/apps/web/components/task/file-context-menu.tsx index 09d145bfa7..d779f1af26 100644 --- a/apps/web/components/task/file-context-menu.tsx +++ b/apps/web/components/task/file-context-menu.tsx @@ -33,6 +33,67 @@ import { type GitFileStatus = FileInfo["status"] | undefined; +function deleteNodeOptimistically( + tree: FileTreeNode | null, + setTree: React.Dispatch>, + path: string, + onDeleteFile: (path: string) => Promise, +) { + 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 ( + + + {title} + + {isBulk ? ( + `This will permanently delete ${selectedCount} selected items. This action cannot be undone.` + ) : ( + <> + This will permanently delete {node.name} + {fileCount > 0 && ( + <> + {" "} + and {fileCount}{" "} + {fileCount === 1 ? "file" : "files"} inside it + + )} + . This action cannot be undone. + + )} + + + + Cancel + + Delete + + + + ); +} + /** 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; onRenameFile?: (oldPath: string, newPath: string) => Promise; onStartRename: () => void; + selectedCount?: number; + selectedPaths?: Set; }) { 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 ( - + <> {children} - {onRenameFile && ( + {onDeleteFile && ( + + + {deleteLabel} + + )} + {onRenameFile && onDeleteFile && !isBulk && } + {onRenameFile && !isBulk && ( Rename )} - {onRenameFile && onDeleteFile && } - {onDeleteFile && ( - - - Delete - - )} - {isDirectory && ( - - - Delete folder? - - This will permanently delete {node.name} and{" "} - {fileCount}{" "} - {fileCount === 1 ? "file" : "files"} inside it. This action cannot be undone. - - - - Cancel - - Delete - - - + {needsConfirmation && ( + + + )} - + ); } diff --git a/apps/web/components/task/file-tree-utils.test.ts b/apps/web/components/task/file-tree-utils.test.ts new file mode 100644 index 0000000000..cfde195681 --- /dev/null +++ b/apps/web/components/task/file-tree-utils.test.ts @@ -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(); + const paths = getVisiblePaths(tree, expanded); + expect(paths).toEqual(["lib", "readme.md"]); + }); +}); diff --git a/apps/web/components/task/file-tree-utils.ts b/apps/web/components/task/file-tree-utils.ts index 352807f903..8b61b34b82 100644 --- a/apps/web/components/task/file-tree-utils.ts +++ b/apps/web/components/task/file-tree-utils.ts @@ -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[] { + 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 { + 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(); + 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(); + 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; +} diff --git a/apps/web/e2e/helpers/git-helper.ts b/apps/web/e2e/helpers/git-helper.ts new file mode 100644 index 0000000000..9430d3eac5 --- /dev/null +++ b/apps/web/e2e/helpers/git-helper.ts @@ -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 { + 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, + }); +} diff --git a/apps/web/e2e/pages/session-page.ts b/apps/web/e2e/pages/session-page.ts index 2005c62c9e..f6a067c094 100644 --- a/apps/web/e2e/pages/session-page.ts +++ b/apps/web/e2e/pages/session-page.ts @@ -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}`); + } } diff --git a/apps/web/e2e/tests/changes-panel-multi-select.spec.ts b/apps/web/e2e/tests/changes-panel-multi-select.spec.ts new file mode 100644 index 0000000000..5112f046c7 --- /dev/null +++ b/apps/web/e2e/tests/changes-panel-multi-select.spec.ts @@ -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 }); + }); +}); diff --git a/apps/web/e2e/tests/file-tree-multi-select.spec.ts b/apps/web/e2e/tests/file-tree-multi-select.spec.ts new file mode 100644 index 0000000000..cf490758f6 --- /dev/null +++ b/apps/web/e2e/tests/file-tree-multi-select.spec.ts @@ -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 }); + }); +}); diff --git a/apps/web/hooks/use-multi-select.test.ts b/apps/web/hooks/use-multi-select.test.ts new file mode 100644 index 0000000000..ef2c98b443 --- /dev/null +++ b/apps/web/hooks/use-multi-select.test.ts @@ -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 { + 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"])); + }); +}); diff --git a/apps/web/hooks/use-multi-select.ts b/apps/web/hooks/use-multi-select.ts new file mode 100644 index 0000000000..216b107508 --- /dev/null +++ b/apps/web/hooks/use-multi-select.ts @@ -0,0 +1,148 @@ +"use client"; + +import { useState, useCallback, useRef, useMemo } from "react"; + +type UseMultiSelectOptions = { + items: string[]; + onSelectionChange?: (selected: Set) => void; +}; + +type UseMultiSelectReturn = { + selectedPaths: Set; + 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) => void; +}; + +export type SelectionParams = { + prev: Set; + path: string; + items: string[]; + isShift: boolean; + isCtrlOrMeta: boolean; + lastClickedRef: React.RefObject; +}; + +/** Exported for testing. */ +export function computeNextSelection(params: SelectionParams): Set { + 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(); + 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>(new Set()); + const lastClickedRef = useRef(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(); + for (const p of rawSelection) { + if (itemSet.has(p)) pruned.add(p); + } + return pruned; + }, [rawSelection, itemSet]); + + const setSelectedPaths = useCallback( + (paths: Set) => { + 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, + }; +} diff --git a/apps/web/package.json b/apps/web/package.json index 55972fdbd8..42b6c6ad64 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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",