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
194 lines
7.0 KiB
TypeScript
194 lines
7.0 KiB
TypeScript
import type { FileTreeNode } from "@/lib/types/backend";
|
|
|
|
/** Sort comparator: directories first, then alphabetical by name. */
|
|
export const compareTreeNodes = (a: FileTreeNode, b: FileTreeNode): number => {
|
|
if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
|
|
return a.name.localeCompare(b.name);
|
|
};
|
|
|
|
/**
|
|
* Merge a freshly-fetched tree node into an existing one, preserving
|
|
* already-loaded children so expanded folders don't collapse.
|
|
*/
|
|
export function mergeTreeNodes(existing: FileTreeNode, incoming: FileTreeNode): FileTreeNode {
|
|
if (!incoming.children) return { ...existing, ...incoming, children: existing.children };
|
|
if (!existing.children) return incoming;
|
|
const existingByPath = new Map(existing.children.map((c) => [c.path, c]));
|
|
const mergedChildren = incoming.children.map((inChild) => {
|
|
const exChild = existingByPath.get(inChild.path);
|
|
if (exChild && exChild.is_dir && inChild.is_dir) {
|
|
return mergeTreeNodes(exChild, inChild);
|
|
}
|
|
return inChild;
|
|
});
|
|
return { ...existing, ...incoming, children: mergedChildren };
|
|
}
|
|
|
|
/** Insert a file node into a parent folder, keeping children sorted (dirs first, then alpha). */
|
|
export function insertNodeInTree(
|
|
root: FileTreeNode,
|
|
parentPath: string,
|
|
node: FileTreeNode,
|
|
): FileTreeNode {
|
|
if (root.path === parentPath || (parentPath === "" && root.path === "")) {
|
|
const children = [...(root.children ?? []), node].sort(compareTreeNodes);
|
|
return { ...root, children };
|
|
}
|
|
if (!root.children) return root;
|
|
return { ...root, children: root.children.map((c) => insertNodeInTree(c, parentPath, node)) };
|
|
}
|
|
|
|
export function removeNodeFromTree(root: FileTreeNode, targetPath: string): FileTreeNode {
|
|
if (!root.children) return root;
|
|
const filtered = root.children.filter((c) => c.path !== targetPath);
|
|
return { ...root, children: filtered.map((c) => removeNodeFromTree(c, targetPath)) };
|
|
}
|
|
|
|
/** Rename a node in the tree, updating its name and path. */
|
|
function replacePathPrefix(path: string, oldPrefix: string, newPrefix: string): string {
|
|
if (path === oldPrefix) return newPrefix;
|
|
if (path.startsWith(`${oldPrefix}/`)) return `${newPrefix}${path.slice(oldPrefix.length)}`;
|
|
return path;
|
|
}
|
|
|
|
function renameSubtree(node: FileTreeNode, oldPath: string, newPath: string): FileTreeNode {
|
|
const nextPath = replacePathPrefix(node.path, oldPath, newPath);
|
|
const nextName = nextPath.split("/").pop() || nextPath;
|
|
const nextChildren = node.children?.map((child) => renameSubtree(child, oldPath, newPath));
|
|
return {
|
|
...node,
|
|
name: nextName,
|
|
path: nextPath,
|
|
children: nextChildren,
|
|
};
|
|
}
|
|
|
|
export function treeContainsPath(root: FileTreeNode, targetPath: string): boolean {
|
|
if (root.path === targetPath) return true;
|
|
if (!root.children) return false;
|
|
return root.children.some((child) => treeContainsPath(child, targetPath));
|
|
}
|
|
|
|
export function countFilesInTree(node: FileTreeNode): number {
|
|
if (!node.children || node.children.length === 0) return node.is_dir ? 0 : 1;
|
|
const base = node.is_dir ? 0 : 1;
|
|
return node.children.reduce((sum, child) => sum + countFilesInTree(child), base);
|
|
}
|
|
|
|
export function renameNodeInTree(
|
|
root: FileTreeNode,
|
|
oldPath: string,
|
|
newPath: string,
|
|
): FileTreeNode {
|
|
if (root.path === oldPath) {
|
|
return renameSubtree(root, oldPath, newPath);
|
|
}
|
|
if (!root.children) return root;
|
|
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;
|
|
}
|