Files
kandev/apps/web/e2e/tests/file-tree-multi-select.spec.ts
T
José Almeida 2b8513f902 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
2026-04-11 17:43:21 +01:00

426 lines
12 KiB
TypeScript

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 });
});
});