"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; displayPath: string; fullPath: string; copied: boolean; expandedPathsSize: number; onCopyPath: (value: string) => void | Promise; 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 ( ); } return ( search.setIsSearchActive(true)} onCollapseAll={onCollapseAll} showCreateButton={showCreateButton} /> ); } type FileBrowserProps = { sessionId: string; environmentId?: string | null; onOpenFile: (file: OpenFileTab) => void; onCreateFile?: (path: string) => Promise; onDeleteFile?: (path: string) => Promise; onRenameFile?: (oldPath: string, newPath: string) => Promise; activeFilePath?: string | null; }; function useFileBrowserHandlers( sessionId: string, onOpenFile: (file: OpenFileTab) => void, onCreateFile: FileBrowserProps["onCreateFile"], treeState: ReturnType, ) { const { toast } = useToast(); const [creatingInPath, setCreatingInPath] = useState(null); const [activeFolderPath, setActiveFolderPath] = useState(""); 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; setSelectedPaths: (paths: Set) => void; onRenameFile: (oldPath: string, newPath: string) => Promise; }; function executeMoveFiles(params: MoveFilesParams, toast: ReturnType["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, selectedPaths: Set, setSelectedPaths: (paths: Set) => void, onRenameFile?: (oldPath: string, newPath: string) => Promise, ) { const { toast } = useToast(); const [isDragging, setIsDragging] = useState(false); const [dragOverPath, setDragOverPath] = useState(null); const dragPathsRef = useRef([]); 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>>, ) { 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, containerRef: React.RefObject, activeFilePath: string | null | undefined, onRenameFile?: (oldPath: string, newPath: string) => Promise, ) { 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, 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(null); const containerRef = useRef(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 (
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} />
); }