2b8513f902
* 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
426 lines
12 KiB
TypeScript
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 });
|
|
});
|
|
});
|