"use client"; import { ChevronDown, ChevronRight, FileText, Folder, FolderOpen } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { DEFAULT_EXCLUDE_PATTERNS } from "@/components/sources/FolderWatchDialog"; import { Skeleton } from "@/components/ui/skeleton"; import { Spinner } from "@/components/ui/spinner"; import { useElectronAPI } from "@/hooks/use-platform"; import { getSupportedExtensionsSet } from "@/lib/supported-extensions"; interface LocalFilesystemBrowserProps { rootPaths: string[]; searchSpaceId: number; active?: boolean; searchQuery?: string; onOpenFile: (fullPath: string) => void; } interface LocalFolderFileEntry { relativePath: string; fullPath: string; size: number; mtimeMs: number; } type RootLoadState = { loading: boolean; error: string | null; files: LocalFolderFileEntry[]; }; interface LocalFolderNode { key: string; name: string; folders: Map; files: LocalFolderFileEntry[]; } type LocalRootMount = { mount: string; rootPath: string; }; type MountLoadStatus = "idle" | "loading" | "complete" | "error"; const getFolderDisplayName = (rootPath: string): string => rootPath.split(/[\\/]/).at(-1) || rootPath; function createFolderNode(key: string, name: string): LocalFolderNode { return { key, name, folders: new Map(), files: [], }; } function getFileName(pathValue: string): string { return pathValue.split(/[\\/]/).at(-1) || pathValue; } function toVirtualPath(relativePath: string): string { const normalized = relativePath.replace(/\\/g, "/").replace(/^\/+/, ""); return `/${normalized}`; } function normalizeRootPathForLookup(rootPath: string, isWindows: boolean): string { const normalized = rootPath.replace(/\\/g, "/").replace(/\/+$/, ""); return isWindows ? normalized.toLowerCase() : normalized; } function toMountedVirtualPath(mount: string, relativePath: string): string { return `/${mount}${toVirtualPath(relativePath)}`; } export function LocalFilesystemBrowser({ rootPaths, searchSpaceId, active = true, searchQuery, onOpenFile, }: LocalFilesystemBrowserProps) { const electronAPI = useElectronAPI(); const [rootStateMap, setRootStateMap] = useState>({}); const [expandedFolderKeys, setExpandedFolderKeys] = useState>(new Set()); const [mountByRootKey, setMountByRootKey] = useState>(new Map()); const [mountStatus, setMountStatus] = useState("idle"); const [mountRefreshInFlight, setMountRefreshInFlight] = useState(false); const [reloadNonceByRoot, setReloadNonceByRoot] = useState>({}); const lastLoadedSignatureByRootRef = useRef>(new Map()); const hasLoadedMountsOnceRef = useRef(false); const hasResolvedAtLeastOneRootRef = useRef(false); const supportedExtensions = useMemo(() => Array.from(getSupportedExtensionsSet()), []); const isWindowsPlatform = electronAPI?.versions.platform === "win32"; useEffect(() => { if (!active) return; if (!electronAPI?.listAgentFilesystemFiles) { for (const rootPath of rootPaths) { setRootStateMap((prev) => ({ ...prev, [rootPath]: { loading: false, error: "Desktop app update required for local mode browsing.", files: [], }, })); } return; } const rootEntries = rootPaths.map((rootPath) => ({ rootPath, rootKey: normalizeRootPathForLookup(rootPath, isWindowsPlatform), })); const activeRootKeys = new Set(rootEntries.map((entry) => entry.rootKey)); for (const key of Array.from(lastLoadedSignatureByRootRef.current.keys())) { if (!activeRootKeys.has(key)) { lastLoadedSignatureByRootRef.current.delete(key); } } const rootsToReload = rootEntries.filter(({ rootKey }) => { const nonce = reloadNonceByRoot[rootKey] ?? 0; const signature = `${searchSpaceId}:${rootKey}:${nonce}`; return lastLoadedSignatureByRootRef.current.get(rootKey) !== signature; }); if (rootsToReload.length === 0) { return; } for (const { rootKey } of rootsToReload) { const nonce = reloadNonceByRoot[rootKey] ?? 0; lastLoadedSignatureByRootRef.current.set( rootKey, `${searchSpaceId}:${rootKey}:${nonce}` ); } let cancelled = false; for (const { rootPath } of rootsToReload) { setRootStateMap((prev) => ({ ...prev, [rootPath]: { loading: true, error: null, files: prev[rootPath]?.files ?? [], }, })); } void Promise.all( rootsToReload.map(async ({ rootPath }) => { try { const files = (await electronAPI.listAgentFilesystemFiles({ rootPath, searchSpaceId, excludePatterns: DEFAULT_EXCLUDE_PATTERNS, fileExtensions: supportedExtensions, })) as LocalFolderFileEntry[]; if (cancelled) return; setRootStateMap((prev) => ({ ...prev, [rootPath]: { loading: false, error: null, files, }, })); } catch (error) { if (cancelled) return; setRootStateMap((prev) => ({ ...prev, [rootPath]: { loading: false, error: error instanceof Error ? error.message : "Failed to read folder", files: [], }, })); } }) ); return () => { cancelled = true; }; }, [active, electronAPI, isWindowsPlatform, reloadNonceByRoot, rootPaths, searchSpaceId, supportedExtensions]); useEffect(() => { if (active) return; lastLoadedSignatureByRootRef.current.clear(); }, [active]); useEffect(() => { if (!electronAPI?.startAgentFilesystemTreeWatch) return; if (!electronAPI?.stopAgentFilesystemTreeWatch) return; if (!electronAPI?.onAgentFilesystemTreeDirty) return; if (!active) return; if (rootPaths.length === 0) { void electronAPI.stopAgentFilesystemTreeWatch(searchSpaceId); return; } const unsubscribe = electronAPI.onAgentFilesystemTreeDirty((event) => { if ((event.searchSpaceId ?? null) !== (searchSpaceId ?? null)) { return; } const eventRootKey = normalizeRootPathForLookup(event.rootPath, isWindowsPlatform); const knownRootKeys = new Set( rootPaths.map((rootPath) => normalizeRootPathForLookup(rootPath, isWindowsPlatform)) ); if (!knownRootKeys.has(eventRootKey)) { setReloadNonceByRoot((prev) => { const next = { ...prev }; for (const rootKey of knownRootKeys) { next[rootKey] = (prev[rootKey] ?? 0) + 1; } return next; }); return; } setReloadNonceByRoot((prev) => ({ ...prev, [eventRootKey]: (prev[eventRootKey] ?? 0) + 1, })); }); void electronAPI.startAgentFilesystemTreeWatch({ searchSpaceId, rootPaths, excludePatterns: DEFAULT_EXCLUDE_PATTERNS, fileExtensions: supportedExtensions, }); return () => { unsubscribe(); void electronAPI.stopAgentFilesystemTreeWatch(searchSpaceId); }; }, [active, electronAPI, isWindowsPlatform, rootPaths, searchSpaceId, supportedExtensions]); useEffect(() => { if (!electronAPI?.getAgentFilesystemMounts) { setMountStatus("error"); setMountByRootKey(new Map()); return; } if (rootPaths.length === 0) { setMountByRootKey(new Map()); setMountStatus("complete"); setMountRefreshInFlight(false); hasLoadedMountsOnceRef.current = true; return; } let cancelled = false; const isInitialMountLoad = !hasLoadedMountsOnceRef.current; if (isInitialMountLoad) { setMountStatus("loading"); } else { setMountRefreshInFlight(true); } void electronAPI .getAgentFilesystemMounts(searchSpaceId) .then((mounts: LocalRootMount[]) => { if (cancelled) return; const next = new Map(); for (const entry of mounts) { const normalizedRootKey = normalizeRootPathForLookup(entry.rootPath, isWindowsPlatform); next.set(normalizedRootKey, entry.mount); } setMountByRootKey(next); setMountStatus("complete"); hasLoadedMountsOnceRef.current = true; }) .catch(() => { if (cancelled) return; if (isInitialMountLoad) { setMountByRootKey(new Map()); setMountStatus("error"); } }) .finally(() => { if (cancelled) return; setMountRefreshInFlight(false); }); return () => { cancelled = true; }; }, [electronAPI, isWindowsPlatform, rootPaths, searchSpaceId]); const treeByRoot = useMemo(() => { const query = searchQuery?.trim().toLowerCase() ?? ""; const hasQuery = query.length > 0; return rootPaths.map((rootPath) => { const rootNode = createFolderNode(rootPath, getFolderDisplayName(rootPath)); const allFiles = rootStateMap[rootPath]?.files ?? []; const files = hasQuery ? allFiles.filter((file) => { const relativePath = file.relativePath.toLowerCase(); const fileName = getFileName(file.relativePath).toLowerCase(); return relativePath.includes(query) || fileName.includes(query); }) : allFiles; for (const file of files) { const parts = file.relativePath.split(/[\\/]/).filter(Boolean); let cursor = rootNode; for (let i = 0; i < parts.length - 1; i++) { const part = parts[i]; const folderKey = `${cursor.key}/${part}`; if (!cursor.folders.has(part)) { cursor.folders.set(part, createFolderNode(folderKey, part)); } cursor = cursor.folders.get(part) as LocalFolderNode; } cursor.files.push(file); } return { rootPath, rootNode, matchCount: files.length, totalCount: allFiles.length }; }); }, [rootPaths, rootStateMap, searchQuery]); const toggleFolder = useCallback((folderKey: string) => { setExpandedFolderKeys((prev) => { const next = new Set(prev); if (next.has(folderKey)) { next.delete(folderKey); } else { next.add(folderKey); } return next; }); }, []); const renderFolder = useCallback( (folder: LocalFolderNode, depth: number, mount: string) => { const isExpanded = expandedFolderKeys.has(folder.key); const FolderIcon = isExpanded ? FolderOpen : Folder; const childFolders = Array.from(folder.folders.values()).sort((a, b) => a.name.localeCompare(b.name) ); const files = [...folder.files].sort((a, b) => a.relativePath.localeCompare(b.relativePath)); return (
{isExpanded && ( <> {childFolders.map((childFolder) => renderFolder(childFolder, depth + 1, mount))} {files.map((file) => ( ))} )}
); }, [expandedFolderKeys, onOpenFile, toggleFolder] ); if (rootPaths.length === 0) { return (

No local folder selected

Add a local folder above to browse files in desktop mode.

); } const allRootsLoaded = rootPaths.every((rootPath) => { const state = rootStateMap[rootPath]; return !!state && !state.loading; }); const mountsSettled = mountStatus === "complete" || mountStatus === "error"; if (allRootsLoaded && mountsSettled && rootPaths.length > 0) { hasResolvedAtLeastOneRootRef.current = true; } const showInitialLoading = !hasResolvedAtLeastOneRootRef.current && (!allRootsLoaded || !mountsSettled); if (showInitialLoading) { const rows = [ { id: "local-row-1", widthClass: "w-44" }, { id: "local-row-2", widthClass: "w-32" }, { id: "local-row-3", widthClass: "w-32" }, { id: "local-row-4", widthClass: "w-44" }, { id: "local-row-5", widthClass: "w-32" }, { id: "local-row-6", widthClass: "w-32" }, { id: "local-row-7", widthClass: "w-44" }, { id: "local-row-8", widthClass: "w-32" }, ]; return (
{rows.map((row) => (
))}
); } return (
{treeByRoot.map(({ rootPath, rootNode, matchCount, totalCount }) => { const state = rootStateMap[rootPath]; const rootKey = normalizeRootPathForLookup(rootPath, isWindowsPlatform); const mount = mountByRootKey.get(rootKey); if (!state || state.loading) { return (
Loading {getFolderDisplayName(rootPath)}...
); } if (state.error) { return (

Failed to load local folder

{state.error}

); } const isEmpty = totalCount === 0; return (
{mount ? renderFolder(rootNode, 0, mount) : null} {!mount && (mountRefreshInFlight || mountStatus === "loading") && (
Loading {getFolderDisplayName(rootPath)}...
)} {!mount && mountStatus === "complete" && !mountRefreshInFlight && (
Unable to resolve mounted root for this folder.
)} {!mount && mountStatus === "error" && (
Failed to resolve local folder mounts.
)} {isEmpty && (
No supported files found in this folder.
)} {!isEmpty && matchCount === 0 && searchQuery && (
No matching files in this folder.
)}
); })}
); }