import { Button } from "@/components/ui/button"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from "@/components/ui/context-menu"; import { ScrollArea } from "@/components/ui/scroll-area"; import { FileAddIcon, Folder01Icon, FolderAddIcon, Refresh01Icon, Search01Icon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { useEffect, useMemo, useRef, useState } from "react"; import { ExplorerSearch, type ExplorerSearchHandle } from "./ExplorerSearch"; import { FileTreeNode } from "./FileTreeNode"; import { InlineInput } from "./InlineInput"; import { copyToClipboard, revealInFinder } from "./lib/contextActions"; import { fileIconUrl, folderIconUrl } from "./lib/iconResolver"; import { COMPACT_CONTENT, COMPACT_ITEM } from "./lib/menuItemClass"; import { useFileTree } from "./lib/useFileTree"; import { useGlobalShortcuts } from "@/modules/shortcuts"; type Props = { rootPath: string | null; onOpenFile: (path: string, pin?: boolean) => void; onPathRenamed?: (from: string, to: string) => void; onPathDeleted?: (path: string) => void; onRevealInTerminal?: (path: string) => void; onAttachToAgent?: (path: string) => void; }; function basename(path: string): string { const parts = path.split(/[\\/]/).filter(Boolean); return parts.length ? parts[parts.length - 1] : path; } export function FileExplorer({ rootPath, onOpenFile, onPathRenamed, onPathDeleted, onRevealInTerminal, onAttachToAgent, }: Props) { const tree = useFileTree(rootPath, { onPathRenamed, onPathDeleted }); const [selectedPath, setSelectedPath] = useState(null); const [isSearchOpen, setIsSearchOpen] = useState(false); const [isSearchActive, setIsSearchActive] = useState(false); const listRef = useRef(null); const searchRef = useRef(null); type FlatItem = { path: string; isDir: boolean }; const flat = useMemo(() => { if (!rootPath) return []; const out: FlatItem[] = []; const walk = (parent: string) => { const node = tree.nodes[parent]; if (!node || node.status !== "loaded") return; for (const e of node.entries) { const p = tree.joinPath(parent, e.name); const isDir = e.kind === "dir"; out.push({ path: p, isDir }); if (isDir && tree.expanded.has(p)) walk(p); } }; walk(rootPath); return out; }, [rootPath, tree.nodes, tree.expanded, tree.joinPath]); useEffect(() => { if (selectedPath && !flat.some((f) => f.path === selectedPath)) { setSelectedPath(null); } }, [flat, selectedPath]); useGlobalShortcuts({ "explorer.search": () => { if (searchRef.current?.isFocused()) { setIsSearchOpen(false); return; } setIsSearchOpen(true); searchRef.current?.focus(); }, }); if (!rootPath) { return (
No current directory
); } const root = tree.nodes[rootPath]; const pendingAtRoot = tree.pendingCreate?.parentPath === rootPath ? tree.pendingCreate : null; const handleKeyDown = (e: React.KeyboardEvent) => { if (tree.renaming || tree.pendingCreate || isSearchOpen) return; const target = e.target as HTMLElement; if ( target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable ) return; if (flat.length === 0) return; const currentIdx = selectedPath ? flat.findIndex((f) => f.path === selectedPath) : -1; const move = (next: number) => { const clamped = Math.max(0, Math.min(flat.length - 1, next)); const path = flat[clamped].path; setSelectedPath(path); requestAnimationFrame(() => { const el = listRef.current?.querySelector( `[data-fs-path="${CSS.escape(path)}"]` ); el?.scrollIntoView({ block: "nearest" }); }); }; switch (e.key) { case "ArrowDown": e.preventDefault(); move(currentIdx < 0 ? 0 : currentIdx + 1); break; case "ArrowUp": e.preventDefault(); move(currentIdx < 0 ? flat.length - 1 : currentIdx - 1); break; case "ArrowRight": { if (currentIdx < 0) return; e.preventDefault(); const item = flat[currentIdx]; if (item.isDir) { if (!tree.expanded.has(item.path)) tree.toggle(item.path); else move(currentIdx + 1); } break; } case "ArrowLeft": { if (currentIdx < 0) return; e.preventDefault(); const item = flat[currentIdx]; if (item.isDir && tree.expanded.has(item.path)) { tree.toggle(item.path); } else { const parent = item.path.slice(0, item.path.lastIndexOf("/")); if (parent && parent !== rootPath) setSelectedPath(parent); } break; } case "Enter": if (currentIdx < 0) return; e.preventDefault(); { const item = flat[currentIdx]; if (item.isDir) tree.toggle(item.path); else onOpenFile(item.path); } break; } }; return (
{basename(rootPath)}
setIsSearchOpen(false)} onActiveChange={setIsSearchActive} /> {!isSearchActive ? (
{pendingAtRoot && (
)} {root?.status === "loading" && (
Loading…
)} {root?.status === "error" && (
{root.message}
)} {root?.status === "loaded" && root.entries.map((entry) => ( ))}
{ if (tree.renaming || tree.pendingCreate) e.preventDefault(); }} > {onRevealInTerminal && ( onRevealInTerminal(rootPath)} > Open in Terminal )} void revealInFinder(rootPath)} > Reveal in Finder tree.beginCreate(rootPath, "file")} > New File tree.beginCreate(rootPath, "dir")} > New Folder void copyToClipboard(rootPath)} > Copy Path tree.refresh(rootPath)} > Refresh
) : null}
); }