Files
kandev/apps/web/components/task/file-browser.tsx
T
João Salavisa cb7b5429d5 fix(tasks): harden multi-branch add_branch and surface it in UI (#1239)
Three areas were broken in the MCP add_branch_to_task flow:

1. Predictable branch names. autoBranchName counted up from existing
   branch-N rows. Concurrent add_branch calls raced onto the same name
   and the worktree directory leaked ordering. Now uses a random
   branch-<3 random chars> suffix via worktree.SmallSuffix.

2. Sibling materialize did not flow to the UI. The materializer
   published AgentctlReady with the NEW sibling worktree's path, which
   the frontend handler wrote onto the session row — clobbering the
   primary's worktree_id/branch and pointing the file browser at the
   sibling instead of the task root. Added TaskWorkspacePath to the
   payload, detected the sibling case on the frontend (existing
   worktree_id != payload's), kept the primary's id/branch, repointed
   worktree_path to the task root, bumped commits refetch, and dropped
   the pre-multi-repo legacy git-status entry so the Changes panel
   stops surfacing the frozen single-repo snapshot. File browser's
   resetKey now includes the worktree count so the tree reloads when
   the sibling lands.

3. workspace_path promotion + agentctl rescan refused to act on
   historical envs. A few envs had workspace_path pointing at the
   source repo's local_path (orchestrator computeWorkspacePath fallback
   to req.RepositoryPath when resp.WorktreePath is empty) instead of
   the primary worktree. The materializer's promotion gate compared
   against env.WorkspacePath's parent, so those envs were never
   promoted, and agentctl's resolveRescanPath only accepted
   parent-of-current so the rescan to the real task root was rejected.
   Trackers stayed on the wrong directory and the sibling's commits
   never appeared in the Commits panel even after a full reload.
   Anchored promotion on env.TaskDirName (basename cross-check) and
   added a recovery branch to resolveRescanPath that accepts any
   absolute directory which actually contains git-repo subdirs — the
   bearer-token middleware already gates the endpoint and
   scanRepositorySubdirs validates the children, so the relaxed check
   stays safe.

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 00:14:00 +01:00

472 lines
16 KiB
TypeScript

"use client";
import React, { useEffect, useMemo, useCallback, useRef, useState } from "react";
import { ScrollArea } from "@kandev/ui/scroll-area";
import type { FileTreeNode, OpenFileTab } from "@/lib/types/backend";
import { useSession } from "@/hooks/domains/session/use-session";
import { useRepository } from "@/hooks/domains/workspace/use-repository";
import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-status";
import { useAppStore } from "@/components/state-provider";
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,
removeNodeFromTree,
FileBrowserToolbar,
FileBrowserContentArea,
} from "./file-browser-parts";
import {
useFileBrowserSearch,
useFileBrowserTree,
useScrollPersistence,
toggleFolderExpand,
fetchAndOpenFile,
} from "./file-browser-hooks";
import { getVisiblePaths, moveNodesInTree, computeMoveTargets } from "./file-tree-utils";
type FileBrowserHeaderProps = {
treeLoaded: boolean;
search: ReturnType<typeof useFileBrowserSearch>;
displayPath: string;
fullPath: string;
copied: boolean;
expandedPathsSize: number;
onCopyPath: (value: string) => void | Promise<void>;
onStartCreate?: () => void;
onOpenFolder: () => void;
onCollapseAll: () => void;
showCreateButton: boolean;
};
function FileBrowserHeader({
treeLoaded,
search,
displayPath,
fullPath,
copied,
expandedPathsSize,
onCopyPath,
onStartCreate,
onOpenFolder,
onCollapseAll,
showCreateButton,
}: FileBrowserHeaderProps) {
if (!treeLoaded) return null;
if (search.isSearchActive) {
return (
<FileBrowserSearchHeader
isSearching={search.isSearching}
localSearchQuery={search.localSearchQuery}
searchInputRef={search.searchInputRef}
onSearchChange={search.handleSearchChange}
onCloseSearch={search.handleCloseSearch}
/>
);
}
return (
<FileBrowserToolbar
displayPath={displayPath}
fullPath={fullPath}
copied={copied}
expandedPathsSize={expandedPathsSize}
onCopyPath={onCopyPath}
onStartCreate={onStartCreate}
onOpenFolder={onOpenFolder}
onStartSearch={() => search.setIsSearchActive(true)}
onCollapseAll={onCollapseAll}
showCreateButton={showCreateButton}
/>
);
}
type FileBrowserProps = {
sessionId: string;
environmentId?: string | null;
onOpenFile: (file: OpenFileTab) => void;
onCreateFile?: (path: string) => Promise<boolean>;
onDeleteFile?: (path: string) => Promise<boolean>;
onRenameFile?: (oldPath: string, newPath: string) => Promise<boolean>;
activeFilePath?: string | null;
};
function useFileBrowserHandlers(
sessionId: string,
onOpenFile: (file: OpenFileTab) => void,
onCreateFile: FileBrowserProps["onCreateFile"],
treeState: ReturnType<typeof useFileBrowserTree>,
) {
const { toast } = useToast();
const [creatingInPath, setCreatingInPath] = useState<string | null>(null);
const [activeFolderPath, setActiveFolderPath] = useState<string>("");
const handleStartCreate = useCallback(() => {
if (activeFolderPath && !treeState.expandedPaths.has(activeFolderPath)) {
treeState.setExpandedPaths((prev) => new Set(prev).add(activeFolderPath));
}
setCreatingInPath(activeFolderPath);
}, [activeFolderPath, treeState]);
const handleCreateFileSubmit = useCallback(
(parentPath: string, name: string) => {
setCreatingInPath(null);
const newPath = parentPath ? `${parentPath}/${name}` : name;
const newNode: FileTreeNode = { name, path: newPath, is_dir: false, size: 0 };
treeState.setTree((prev) => (prev ? insertNodeInTree(prev, parentPath, newNode) : prev));
onCreateFile?.(newPath)
.then((ok) => {
if (!ok) treeState.setTree((prev) => (prev ? removeNodeFromTree(prev, newPath) : prev));
})
.catch(() => {
treeState.setTree((prev) => (prev ? removeNodeFromTree(prev, newPath) : prev));
});
},
[onCreateFile, treeState],
);
const toggleExpand = useCallback(
(node: FileTreeNode) => toggleFolderExpand({ node, sessionId, treeState, setActiveFolderPath }),
[treeState, sessionId],
);
const openFileByPath = useCallback(
(path: string) => fetchAndOpenFile(sessionId, path, onOpenFile, toast),
[sessionId, onOpenFile, toast],
);
const handleCancelCreate = useCallback(() => setCreatingInPath(null), []);
return {
creatingInPath,
activeFolderPath,
handleStartCreate,
handleCreateFileSubmit,
toggleExpand,
openFileByPath,
handleCancelCreate,
};
}
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,
onOpenFile,
onCreateFile,
onDeleteFile,
onRenameFile,
activeFilePath,
}: FileBrowserProps) {
const { session, isFailed: isSessionFailed, errorMessage: sessionError } = useSession(sessionId);
const repository = useRepository(session?.repository_id ?? null);
const gitStatus = useSessionGitStatus(sessionId);
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);
// Worktree count participates in the tree's reset key so an add_branch_to_task
// call that materializes a sibling worktree forces a fresh tree load — the
// backend's workspace_path has just been promoted from the primary worktree
// to the task root, and the cached tree (rooted at the old primary) would
// otherwise still be shown.
const worktreeCount = useAppStore(
(state) => state.sessionWorktreesBySessionId.itemsBySessionId[sessionId]?.length ?? 0,
);
const resetKey = environmentId ? `${environmentId}:${worktreeCount}` : undefined;
const treeState = useFileBrowserTree(sessionId, resetKey);
const isTreeLoaded = !treeState.isLoadingTree && treeState.tree !== null;
useScrollPersistence(sessionId, isTreeLoaded, scrollAreaRef, treeState.tree);
const fileStatuses = useMemo(
() =>
new Map(Object.entries(gitStatus?.files ?? {}).map(([path, info]) => [path, info.status])),
[gitStatus?.files],
);
const fullPath = session?.worktree_path || repository?.local_path || treeState.tree?.path || "";
const displayPath = fullPath.replace(/^\/(?:Users|home)\/[^/]+\//, "~/");
const handlers = useFileBrowserHandlers(sessionId, onOpenFile, onCreateFile, treeState);
const { multiSelect, dnd, handleClickOutside } = useSelectionInteractions(
treeState,
containerRef,
activeFilePath,
onRenameFile,
);
return (
<div
className="flex flex-col h-full"
ref={containerRef}
tabIndex={-1}
onMouseDown={handleClickOutside}
>
<FileBrowserHeader
treeLoaded={Boolean(treeState.tree && treeState.loadState === "loaded")}
search={search}
displayPath={displayPath}
fullPath={fullPath}
copied={copied}
expandedPathsSize={treeState.expandedPaths.size}
onCopyPath={copyPath}
onStartCreate={onCreateFile ? handlers.handleStartCreate : undefined}
onOpenFolder={openFolder}
onCollapseAll={treeState.collapseAll}
showCreateButton={Boolean(onCreateFile)}
/>
<ScrollArea className="flex-1" ref={scrollAreaRef}>
<FileBrowserContentArea
isSearchActive={search.isSearchActive}
searchResults={search.searchResults}
isSessionFailed={isSessionFailed}
sessionError={sessionError}
loadState={treeState.loadState}
isLoadingTree={treeState.isLoadingTree}
tree={treeState.tree}
loadError={treeState.loadError}
creatingInPath={handlers.creatingInPath}
fileStatuses={fileStatuses}
visibleRows={treeState.visibleRows}
activeFolderPath={handlers.activeFolderPath}
activeFilePath={activeFilePath}
visibleLoadingPaths={treeState.visibleLoadingPaths}
onOpenFile={handlers.openFileByPath}
onToggleExpand={handlers.toggleExpand}
onDeleteFile={onDeleteFile}
onRenameFile={onRenameFile}
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>
);
}