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