Files
kandev/apps/web/components/task/file-tree-utils.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

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