"use client"; import { ChevronDown, ChevronRight, FileText, Folder } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { DEFAULT_EXCLUDE_PATTERNS } from "@/components/sources/FolderWatchDialog"; import { Spinner } from "@/components/ui/spinner"; import { useElectronAPI } from "@/hooks/use-platform"; import { getSupportedExtensionsSet } from "@/lib/supported-extensions"; interface LocalFilesystemBrowserProps { rootPaths: string[]; searchSpaceId: number; 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[]; } 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; } export function LocalFilesystemBrowser({ rootPaths, searchSpaceId, searchQuery, onOpenFile, }: LocalFilesystemBrowserProps) { const electronAPI = useElectronAPI(); const [rootStateMap, setRootStateMap] = useState>({}); const [expandedFolderKeys, setExpandedFolderKeys] = useState>(new Set()); const supportedExtensions = useMemo(() => Array.from(getSupportedExtensionsSet()), []); useEffect(() => { if (!electronAPI?.listFolderFiles) return; let cancelled = false; for (const rootPath of rootPaths) { setRootStateMap((prev) => ({ ...prev, [rootPath]: { loading: true, error: null, files: prev[rootPath]?.files ?? [], }, })); } void Promise.all( rootPaths.map(async (rootPath) => { try { const files = (await electronAPI.listFolderFiles({ path: rootPath, name: getFolderDisplayName(rootPath), excludePatterns: DEFAULT_EXCLUDE_PATTERNS, fileExtensions: supportedExtensions, rootFolderId: null, searchSpaceId, active: true, })) 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; }; }, [electronAPI, rootPaths, searchSpaceId, supportedExtensions]); 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) => { const isExpanded = expandedFolderKeys.has(folder.key); 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))} {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.

); } return (
{treeByRoot.map(({ rootPath, rootNode, matchCount, totalCount }) => { const state = rootStateMap[rootPath]; if (!state || state.loading) { return (
Loading {getFolderDisplayName(rootPath)}...
); } if (state.error) { return (

Failed to load local folder

{state.error}

); } const isEmpty = totalCount === 0; return (
{renderFolder(rootNode, 0)} {isEmpty && (
No supported files found in this folder.
)} {!isEmpty && matchCount === 0 && searchQuery && (
No matching files in this folder.
)}
); })}
); }