cb7b5429d5
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>
472 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|