import React from 'react'; import { getRuntimeKey } from '@/lib/runtime-switch'; import { toast } from '@/components/ui'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from '@/components/ui/context-menu'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useDebouncedValue } from '@/hooks/useDebouncedValue'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useFileSearchStore } from '@/stores/useFileSearchStore'; import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore'; import { useUIStore } from '@/stores/useUIStore'; import { useGitStatus } from '@/stores/useGitStore'; import { useDirectoryShowHidden } from '@/lib/directoryShowHidden'; import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored'; import { copyTextToClipboard } from '@/lib/clipboard'; import { cn, getRevealLabelKey } from '@/lib/utils'; import { opencodeClient } from '@/lib/opencode/client'; import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; import { Icon } from "@/components/icon/Icon"; import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib/contextFileOpenGuard'; import { isFilesystemError } from '@/lib/api/files-errors'; import { notifyFileContentInvalidated } from '@/lib/fileContentInvalidation'; import { isBrowserClientRuntime } from '@/lib/desktop'; import { useI18n } from '@/lib/i18n'; type FileNode = { name: string; path: string; type: 'file' | 'directory'; extension?: string; relativePath?: string; }; type UploadConflicts = { directory: string; files: File[]; runtimeKey: string; workspaceRoot: string; }; type UploadOutcome = 'uploaded' | 'conflict' | 'failed'; const MAX_PARALLEL_UPLOADS = 3; const hasExternalFiles = (dataTransfer: DataTransfer): boolean => ( Array.from(dataTransfer.types).includes('Files') ); const getExternalFiles = (dataTransfer: DataTransfer): File[] => { const items = Array.from(dataTransfer.items); if (items.length === 0) return Array.from(dataTransfer.files); return items.flatMap((item) => { if (item.kind !== 'file' || item.webkitGetAsEntry()?.isDirectory) return []; const file = item.getAsFile(); return file ? [file] : []; }); }; const getUploadName = (file: File): string | null => { const name = file.name; if (!name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) { return null; } return name; }; const sortNodes = (items: FileNode[]) => items.slice().sort((a, b) => { if (a.type !== b.type) { return a.type === 'directory' ? -1 : 1; } return a.name.localeCompare(b.name); }); const normalizePath = (value: string): string => { if (!value) return ''; const raw = value.replace(/\\/g, '/'); const hadUncPrefix = raw.startsWith('//'); let normalized = raw.replace(/\/+$/g, ''); normalized = normalized.replace(/\/+/g, '/'); if (hadUncPrefix && !normalized.startsWith('//')) { normalized = `/${normalized}`; } if (normalized === '') { return raw.startsWith('/') ? '/' : ''; } return normalized; }; const getRelativePath = (root: string, path: string): string => { const normalizedPath = normalizePath(path); const normalizedRoot = normalizePath(root).replace(/\/+$/, ''); if (normalizedPath === normalizedRoot) { return '.'; } if (!normalizedRoot || !normalizedPath.startsWith(`${normalizedRoot}/`)) { return normalizedPath; } return normalizedPath.slice(normalizedRoot.length + 1); }; const getDropTargetLabel = (root: string, target: string): string => { const relativePath = getRelativePath(root, target); if (relativePath !== '.') return relativePath; const normalizedRoot = normalizePath(root); return normalizedRoot.split('/').filter(Boolean).pop() ?? normalizedRoot; }; const getParentPath = (value: string): string => { const normalized = normalizePath(value); const separatorIndex = normalized.lastIndexOf('/'); if (separatorIndex < 0) return ''; if (separatorIndex === 0) return '/'; return normalized.slice(0, separatorIndex); }; const isAbsolutePath = (value: string): boolean => { return value.startsWith('/') || value.startsWith('//') || /^[A-Za-z]:\//.test(value); }; const DEFAULT_IGNORED_DIR_NAMES = new Set(['node_modules']); const shouldIgnoreEntryName = (name: string): boolean => DEFAULT_IGNORED_DIR_NAMES.has(name); const shouldIgnorePath = (path: string): boolean => { const normalized = normalizePath(path); return normalized === 'node_modules' || normalized.endsWith('/node_modules') || normalized.includes('/node_modules/'); }; // Module-level per-root cache for the file tree. After P1.1 the component // stays mounted across right-sidebar tab switches, so the cache also stays // warm during that flow. The cache also survives the close-and-reopen flow // (the component remounts but the Map is module-scoped) — without this, every // sidebar reopen would re-list every expanded directory. // // LRU by touchedAt; cap is generous because large repos can have hundreds // of expanded directories and each FileNode is small (~80 bytes). Stale // roots are evicted on the next touch. type FileTreeCache = { childrenByDir: Record; loadErrorsByDir: Record; loadedDirs: Set; touchedAt: number; }; const FILE_TREE_CACHE_MAX_ROOTS = 8; const fileTreeCacheByRoot = new Map(); const fileTreeCacheKey = (root: string): string => JSON.stringify([getRuntimeKey(), root]); const touchCache = (root: string): FileTreeCache | null => { const key = fileTreeCacheKey(root); const entry = fileTreeCacheByRoot.get(key); if (!entry) return null; entry.touchedAt = Date.now(); // Touch on read promotes the key to the end of the Map's iteration order, // so the oldest (front) entry is the next eviction candidate. fileTreeCacheByRoot.delete(key); fileTreeCacheByRoot.set(key, entry); return entry; }; const getOrCreateCache = (root: string): FileTreeCache => { const key = fileTreeCacheKey(root); const existing = fileTreeCacheByRoot.get(key); if (existing) { existing.touchedAt = Date.now(); return existing; } if (fileTreeCacheByRoot.size >= FILE_TREE_CACHE_MAX_ROOTS) { const oldest = fileTreeCacheByRoot.keys().next().value; if (oldest !== undefined) { fileTreeCacheByRoot.delete(oldest); } } const created: FileTreeCache = { childrenByDir: {}, loadErrorsByDir: {}, loadedDirs: new Set(), touchedAt: Date.now(), }; fileTreeCacheByRoot.set(key, created); return created; }; const dropCacheForRoot = (root: string): void => { fileTreeCacheByRoot.delete(fileTreeCacheKey(root)); }; const getFileIcon = (filePath: string, extension?: string): React.ReactNode => { return ; }; // --- Git status indicators (matching FilesView) --- type FileStatus = 'open' | 'modified' | 'git-modified' | 'git-added' | 'git-deleted'; const FileStatusDot: React.FC<{ status: FileStatus }> = ({ status }) => { const color = { open: 'var(--status-info)', modified: 'var(--status-warning)', 'git-modified': 'var(--status-warning)', 'git-added': 'var(--status-success)', 'git-deleted': 'var(--status-error)', }[status]; return ; }; // --- FileRow with context menu (matching FilesView) --- interface FileRowProps { node: FileNode; root: string; isExpanded: boolean; isActive: boolean; isBrowserClient: boolean; status?: FileStatus | null; badge?: { modified: number; added: number } | null; isDropTarget: boolean; canUpload: boolean; permissions: { canRename: boolean; canCreateFile: boolean; canCreateFolder: boolean; canDelete: boolean; canReveal: boolean; }; downloadFile?: (path: string) => Promise; onSelect: (node: FileNode) => void; onToggle: (path: string) => void; onRevealPath: (path: string) => void; onOpenDialog: (type: 'createFile' | 'createFolder' | 'rename' | 'delete', data: { path: string; name?: string; type?: 'file' | 'directory' }) => void; onSetDropTarget: (path: string | null) => void; onDropFiles: (directory: string, dataTransfer: DataTransfer) => void; } const FileRow: React.FC = ({ node, root, isExpanded, isActive, isBrowserClient, status, badge, isDropTarget, canUpload, permissions, downloadFile, onSelect, onToggle, onRevealPath, onOpenDialog, onSetDropTarget, onDropFiles, }) => { const { t } = useI18n(); const isDir = node.type === 'directory'; const uploadDirectory = isDir ? node.path : getParentPath(node.path); const { canRename, canCreateFile, canCreateFolder, canDelete, canReveal } = permissions; const canDownload = !isDir && Boolean(downloadFile); const canRevealPath = canReveal && !isBrowserClient; const hasMenuActions = canRename || canCreateFile || canCreateFolder || canDelete || canDownload || canRevealPath; // Menu open state is local to each row so opening a menu in one row // never re-renders its siblings. Previously this state lived on the // parent, which made every FileRow re-render whenever any menu toggled. const [contextMenuOpen, setContextMenuOpen] = React.useState(false); const [rightClickOpen, setRightClickOpen] = React.useState(false); const handleContextMenu = React.useCallback((event?: React.MouseEvent) => { if (!hasMenuActions) return; event?.preventDefault(); setRightClickOpen(true); }, [hasMenuActions]); const handleInteraction = React.useCallback(() => { if (isDir) { onToggle(node.path); } else { onSelect(node); } }, [isDir, node, onSelect, onToggle]); const handleMenuButtonClick = React.useCallback((event: React.MouseEvent) => { event.stopPropagation(); setRightClickOpen(false); setContextMenuOpen(true); }, []); const renderMenuItems = ({ Item, Separator, }: { Item: React.ElementType; Separator: React.ElementType; }) => ( <> {canRename && ( { e.stopPropagation(); onOpenDialog('rename', node); }}> {t('sidebarFilesTree.menu.rename')} )} { e.stopPropagation(); void copyTextToClipboard(node.path).then((result) => { if (result.ok) { toast.success(t('sidebarFilesTree.toast.pathCopied')); return; } toast.error(t('sidebarFilesTree.toast.copyFailed')); }); }}> {t('sidebarFilesTree.menu.copyPath')} {!isDir && downloadFile && ( { e.stopPropagation(); void downloadFile(node.path).catch((error) => { console.error('Download failed:', error); toast.error(t('sidebarFilesTree.toast.operationFailed')); }); }}> {t(isBrowserClient ? 'sidebarFilesTree.menu.download' : 'sidebarFilesTree.menu.save')} )} {canRevealPath && ( { e.stopPropagation(); onRevealPath(node.path); }}> {t(getRevealLabelKey())} )} {isDir && (canCreateFile || canCreateFolder) && ( <> {canCreateFile && ( { e.stopPropagation(); onOpenDialog('createFile', node); }}> {t('sidebarFilesTree.menu.newFile')} )} {canCreateFolder && ( { e.stopPropagation(); onOpenDialog('createFolder', node); }}> {t('sidebarFilesTree.menu.newFolder')} )} )} {canDelete && ( <> { e.stopPropagation(); onOpenDialog('delete', node); }} className="text-destructive focus:text-destructive" > {t('sidebarFilesTree.menu.delete')} )} ); const handleDragStart = React.useCallback((e: React.DragEvent) => { const path = getRelativePath(root, node.path); if (!path || path === '.') return; e.dataTransfer.setData('application/x-openchamber-file-path', path); e.dataTransfer.effectAllowed = 'copy'; }, [node.path, root]); const handleExternalDragOver = React.useCallback((event: React.DragEvent) => { if (!canUpload || !uploadDirectory || !hasExternalFiles(event.dataTransfer)) return; event.preventDefault(); event.stopPropagation(); event.dataTransfer.dropEffect = 'copy'; onSetDropTarget(uploadDirectory); }, [canUpload, onSetDropTarget, uploadDirectory]); const handleExternalDragLeave = React.useCallback((event: React.DragEvent) => { if (!canUpload || !uploadDirectory || !hasExternalFiles(event.dataTransfer)) return; if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return; event.stopPropagation(); onSetDropTarget(null); }, [canUpload, onSetDropTarget, uploadDirectory]); const handleExternalDrop = React.useCallback((event: React.DragEvent) => { if (!canUpload || !uploadDirectory || !hasExternalFiles(event.dataTransfer)) return; event.preventDefault(); event.stopPropagation(); onDropFiles(uploadDirectory, event.dataTransfer); }, [canUpload, onDropFiles, uploadDirectory]); return ( )}> {hasMenuActions && (
{t('sidebarFilesTree.actions.fileMenuTitle')} setContextMenuOpen(false)}> {renderMenuItems({ Item: DropdownMenuItem, Separator: DropdownMenuSeparator })}
)}
{renderMenuItems({ Item: ContextMenuItem, Separator: ContextMenuSeparator })}
); }; const areFileRowPropsEqual = (prev: FileRowProps, next: FileRowProps): boolean => ( prev.node === next.node && prev.root === next.root && prev.isExpanded === next.isExpanded && prev.isActive === next.isActive && prev.isBrowserClient === next.isBrowserClient && prev.status === next.status && prev.badge === next.badge && prev.isDropTarget === next.isDropTarget && prev.canUpload === next.canUpload && prev.permissions === next.permissions && prev.downloadFile === next.downloadFile && prev.onSelect === next.onSelect && prev.onToggle === next.onToggle && prev.onRevealPath === next.onRevealPath && prev.onOpenDialog === next.onOpenDialog && prev.onSetDropTarget === next.onSetDropTarget && prev.onDropFiles === next.onDropFiles ); const MemoizedFileRow = React.memo(FileRow, areFileRowPropsEqual); // --- Main component --- export const SidebarFilesTree: React.FC = () => { const { t } = useI18n(); const { files, runtime } = useRuntimeAPIs(); const isBrowserClient = isBrowserClientRuntime(runtime.platform); const currentDirectory = useEffectiveDirectory() ?? ''; const root = normalizePath(currentDirectory.trim()); const showHidden = useDirectoryShowHidden(); const showGitignored = useFilesViewShowGitignored(); const searchFiles = useFileSearchStore((state) => state.searchFiles); const openContextFile = useUIStore((state) => state.openContextFile); const gitStatus = useGitStatus(currentDirectory); const [searchQuery, setSearchQuery] = React.useState(''); const debouncedSearchQuery = useDebouncedValue(searchQuery, 200); const searchInputRef = React.useRef(null); const [searchResults, setSearchResults] = React.useState([]); const [searching, setSearching] = React.useState(false); const [dropTarget, setDropTarget] = React.useState(null); const [isUploading, setIsUploading] = React.useState(false); const [uploadConflicts, setUploadConflicts] = React.useState(null); const uploadingRef = React.useRef(false); const rootRef = React.useRef(root); rootRef.current = root; const [childrenByDir, setChildrenByDir] = React.useState>({}); const [loadErrorsByDir, setLoadErrorsByDir] = React.useState>({}); const loadedDirsRef = React.useRef>(new Set()); const inFlightDirsRef = React.useRef>(new Set()); const refreshAbortRef = React.useRef(null); // Hydrate the per-root cache on mount or root change. The cache is // module-scoped so it survives close-and-reopen of the right sidebar; // expanded paths are already persisted via useFilesViewTabsStore, so // combining the two means the tree re-paints with cached data instead // of blanking out and re-listing every directory. React.useEffect(() => { setDropTarget(null); setUploadConflicts(null); if (!root) { setChildrenByDir({}); setLoadErrorsByDir({}); loadedDirsRef.current = new Set(); return; } const cached = touchCache(root); if (cached) { // Shallow-clone so the state and cache hold independent references. // This protects the cache from accidental in-place mutation of state // (a future contributor could otherwise break the contract silently). setChildrenByDir({ ...cached.childrenByDir }); setLoadErrorsByDir({ ...cached.loadErrorsByDir }); loadedDirsRef.current = new Set(cached.loadedDirs); } else { setChildrenByDir({}); setLoadErrorsByDir({}); loadedDirsRef.current = new Set(); } }, [root]); // Mirror local state into the per-root cache. Don't bump touchedAt here: // writes are frequent and the LRU should reflect user attention, not // background re-renders. React.useEffect(() => { if (!root) return; const cache = getOrCreateCache(root); cache.childrenByDir = childrenByDir; }, [root, childrenByDir]); React.useEffect(() => { if (!root) return; const cache = getOrCreateCache(root); cache.loadErrorsByDir = loadErrorsByDir; }, [root, loadErrorsByDir]); // The ref's contents must be persisted to the cache so a remount (e.g. // close-and-reopen of the right sidebar) skips re-listing already-known // directories. Mirror on every change of `root` so the ref → cache sync // happens once per directory; the ref itself updates synchronously inside // `loadDirectory` and isn't tracked by React otherwise. React.useEffect(() => { if (!root) return; const cache = getOrCreateCache(root); cache.loadedDirs = new Set(loadedDirsRef.current); }, [root, childrenByDir, loadErrorsByDir]); // Drop the cache entry for this root on unmount when no data was loaded // (e.g. user opened the tab and immediately switched projects before any // listDirectory round-trip). A populated entry stays so the next mount // rehydrates instantly. React.useEffect(() => () => { if (!root) return; const cache = fileTreeCacheByRoot.get(root); if (cache && cache.loadedDirs.size === 0 && Object.keys(cache.childrenByDir).length === 0) { dropCacheForRoot(root); } }, [root]); const EMPTY_PATHS: string[] = React.useMemo(() => [], []); const EMPTY_CONTEXT_TABS: Array<{ mode: string; targetPath: string | null }> = React.useMemo(() => [], []); const expandedPaths = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.expandedPaths ?? EMPTY_PATHS) : EMPTY_PATHS)); const selectedPath = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.selectedPath ?? null) : null)); const setSelectedPath = useFilesViewTabsStore((state) => state.setSelectedPath); const addOpenPath = useFilesViewTabsStore((state) => state.addOpenPath); const removeOpenPathsByPrefix = useFilesViewTabsStore((state) => state.removeOpenPathsByPrefix); const toggleExpandedPath = useFilesViewTabsStore((state) => state.toggleExpandedPath); const collapseAllExpandedPaths = useFilesViewTabsStore((state) => state.collapseAllExpandedPaths); const contextTabs = useUIStore((state) => (root ? (state.contextPanelByDirectory[root]?.tabs ?? EMPTY_CONTEXT_TABS) : EMPTY_CONTEXT_TABS)); const openContextFilePaths = React.useMemo(() => new Set( contextTabs .map((tab) => (tab.mode === 'file' ? tab.targetPath : null)) .filter((targetPath): targetPath is string => typeof targetPath === 'string' && targetPath.length > 0) .map((targetPath) => normalizePath(targetPath)) ), [contextTabs]); // Dialog state for CRUD operations const [activeDialog, setActiveDialog] = React.useState<'createFile' | 'createFolder' | 'rename' | 'delete' | null>(null); const [dialogData, setDialogData] = React.useState<{ path: string; name?: string; type?: 'file' | 'directory' } | null>(null); const [dialogInputValue, setDialogInputValue] = React.useState(''); const [isDialogSubmitting, setIsDialogSubmitting] = React.useState(false); const canCreateFile = Boolean(files.writeFile); const canCreateFolder = Boolean(files.createDirectory); const canRename = Boolean(files.rename); const canDelete = Boolean(files.delete); const canReveal = Boolean(files.revealPath); const canUpload = Boolean(files.uploadFile); const fileRowPermissions = React.useMemo( () => ({ canRename, canCreateFile, canCreateFolder, canDelete, canReveal }), [canRename, canCreateFile, canCreateFolder, canDelete, canReveal] ); const handleRevealPath = React.useCallback((targetPath: string) => { if (!files.revealPath) return; void files.revealPath(targetPath).catch(() => { toast.error(t('sidebarFilesTree.toast.revealFailed')); }); }, [files, t]); const handleOpenDialog = React.useCallback((type: 'createFile' | 'createFolder' | 'rename' | 'delete', data: { path: string; name?: string; type?: 'file' | 'directory' }) => { setActiveDialog(type); setDialogData(data); setDialogInputValue(type === 'rename' ? data.name || '' : ''); setIsDialogSubmitting(false); }, []); const mapDirectoryEntries = React.useCallback((dirPath: string, entries: Array<{ name: string; path: string; isDirectory: boolean }>): FileNode[] => { const nodes = entries .filter((entry) => entry && typeof entry.name === 'string' && entry.name.length > 0) .filter((entry) => showHidden || !entry.name.startsWith('.')) .filter((entry) => showGitignored || !shouldIgnoreEntryName(entry.name)) .map((entry) => { const name = entry.name; const normalizedEntryPath = normalizePath(entry.path || ''); const path = normalizedEntryPath ? (isAbsolutePath(normalizedEntryPath) ? normalizedEntryPath : normalizePath(`${dirPath}/${normalizedEntryPath}`)) : normalizePath(`${dirPath}/${name}`); const type = entry.isDirectory ? 'directory' : 'file'; const extension = type === 'file' && name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined; return { name, path, type, extension }; }); return sortNodes(nodes); }, [showGitignored, showHidden]); const loadDirectory = React.useCallback(async (dirPath: string, isCancelled?: () => boolean) => { const normalizedDir = normalizePath(dirPath.trim()); if (!normalizedDir) return; if (loadedDirsRef.current.has(normalizedDir) || inFlightDirsRef.current.has(normalizedDir)) return; inFlightDirsRef.current = new Set(inFlightDirsRef.current); inFlightDirsRef.current.add(normalizedDir); const listPromise = files.listDirectory ? files.listDirectory(normalizedDir).then((result) => result.entries.map((entry) => ({ name: entry.name, path: entry.path, isDirectory: entry.isDirectory, }))) : opencodeClient.listLocalDirectory(normalizedDir).then((result) => result.map((entry) => ({ name: entry.name, path: entry.path, isDirectory: entry.isDirectory, }))); try { const entries = await listPromise; if (isCancelled?.()) return; const mapped = mapDirectoryEntries(normalizedDir, entries); loadedDirsRef.current = new Set(loadedDirsRef.current); loadedDirsRef.current.add(normalizedDir); setLoadErrorsByDir((prev) => { if (!prev[normalizedDir]) return prev; const next = { ...prev }; delete next[normalizedDir]; return next; }); setChildrenByDir((prev) => ({ ...prev, [normalizedDir]: mapped })); } catch (error) { if (isCancelled?.()) return; const message = error instanceof Error ? error.message : String(error ?? ''); console.error('Failed to load sidebar directory:', error); setLoadErrorsByDir((prev) => ({ ...prev, [normalizedDir]: message, })); } finally { inFlightDirsRef.current = new Set(inFlightDirsRef.current); inFlightDirsRef.current.delete(normalizedDir); } }, [files, mapDirectoryEntries]); const refreshRoot = React.useCallback(async () => { if (!root) return; // Cancel any previous refresh so stale results for the old root don't // land after the user switches projects. refreshAbortRef.current?.abort(); const controller = new AbortController(); refreshAbortRef.current = controller; try { // Refresh root and every expanded directory under it, but keep the // cached children visible while re-fetching so the tree stays expanded // and does not flash/collapse. Read expanded paths from the store at // call time so this callback stays stable when directories are toggled. const currentExpanded = useFilesViewTabsStore.getState().byRoot[root]?.expandedPaths ?? []; const normalizedExpanded = currentExpanded .map((p) => normalizePath(p)) .filter((normalized): normalized is string => Boolean(normalized) && normalized !== root && normalized.startsWith(`${root}/`), ); const pathsToRefresh = [root, ...normalizedExpanded]; loadedDirsRef.current = new Set(loadedDirsRef.current); for (const dirPath of pathsToRefresh) { loadedDirsRef.current.delete(dirPath); } setLoadErrorsByDir((prev) => { if (Object.keys(prev).length === 0) return prev; const next = { ...prev }; let changed = false; for (const dirPath of pathsToRefresh) { if (dirPath in next) { delete next[dirPath]; changed = true; } } return changed ? next : prev; }); const isCancelled = () => controller.signal.aborted; // Load root first, then expanded children with the same 3-at-a-time // concurrency limit used on startup to avoid API stampede. await loadDirectory(root, isCancelled); for (let i = 0; i < normalizedExpanded.length && !controller.signal.aborted; i += 3) { const batch = normalizedExpanded.slice(i, i + 3); await Promise.all(batch.map((dirPath) => loadDirectory(dirPath, isCancelled))); } } catch (error) { if (controller.signal.aborted) return; console.error('Failed to refresh sidebar tree:', error); } finally { if (refreshAbortRef.current === controller) { refreshAbortRef.current = null; } } }, [loadDirectory, root]); /** * Incrementally refresh a single directory without nuking the rest of the * tree. Only the given directory is reloaded in-place; every other expanded * directory keeps its cached children so the UI does not flash/reset. */ const refreshDirectory = React.useCallback(async (dirPath: string) => { if (!dirPath) { await refreshRoot(); return; } const normalized = normalizePath(dirPath); loadedDirsRef.current = new Set(loadedDirsRef.current); loadedDirsRef.current.delete(normalized); inFlightDirsRef.current = new Set(inFlightDirsRef.current); inFlightDirsRef.current.delete(normalized); await loadDirectory(normalized); }, [loadDirectory, refreshRoot]); React.useEffect(() => { if (!root) return; // Cancel any pending refresh so stale directory listings don't land after // the user switches projects or toggles showHidden / showGitignored. refreshAbortRef.current?.abort(); loadedDirsRef.current = new Set(); inFlightDirsRef.current = new Set(); setLoadErrorsByDir({}); setChildrenByDir((prev) => (Object.keys(prev).length === 0 ? prev : {})); void loadDirectory(root); }, [loadDirectory, root, showHidden, showGitignored]); React.useEffect(() => { if (!root || expandedPaths.length === 0) return; // Sort by depth so parent dirs load before children const toLoad = expandedPaths .map((p) => normalizePath(p)) .filter((normalized): normalized is string => !!normalized && normalized !== root && normalized.startsWith(`${root}/`) && !loadedDirsRef.current.has(normalized) && !inFlightDirsRef.current.has(normalized), ) .sort((a, b) => a.split('/').length - b.split('/').length); if (toLoad.length === 0) return; // Load with concurrency limit to avoid API stampede on startup. // Each per-dir fetch gets a cancellation predicate so the load stops // touching state once the effect tears down (e.g. user collapses the // directory or the directory list changes mid-flight). let cancelled = false; const isCancelled = () => cancelled; void (async () => { for (let i = 0; i < toLoad.length && !cancelled; i += 3) { const batch = toLoad.slice(i, i + 3); await Promise.all(batch.map((dir) => loadDirectory(dir, isCancelled))); } })(); return () => { cancelled = true; }; }, [expandedPaths, loadDirectory, root]); // --- Fuzzy search scoring (matching FilesView) --- React.useEffect(() => { if (!currentDirectory) { setSearchResults([]); setSearching(false); return; } const trimmedQuery = debouncedSearchQuery.trim(); if (!trimmedQuery) { setSearchResults([]); setSearching(false); return; } let cancelled = false; setSearching(true); searchFiles(currentDirectory, trimmedQuery, 150, { includeHidden: showHidden, respectGitignore: !showGitignored, type: 'file', }) .then((hits) => { if (cancelled) return; const filtered = hits.filter((hit) => showGitignored || !shouldIgnorePath(hit.path)); const mapped: FileNode[] = filtered.map((hit) => ({ name: hit.name, path: normalizePath(hit.path), type: 'file', extension: hit.extension, relativePath: hit.relativePath, })); setSearchResults(mapped); }) .catch(() => { if (!cancelled) { setSearchResults([]); } }) .finally(() => { if (!cancelled) { setSearching(false); } }); return () => { cancelled = true; }; }, [currentDirectory, debouncedSearchQuery, searchFiles, showHidden, showGitignored]); // --- Git status helpers (matching FilesView) --- // // statusByPath / badgeByDir are precomputed once per gitStatus change so the // tree render is O(1) per node instead of O(N) per node. Without these // maps, a deep tree with 200 files and 40 directories would do ~8000 // string comparisons on every render. const statusByPath = React.useMemo(() => { const map = new Map(); if (!gitStatus?.files) return map; for (const file of gitStatus.files) { if (file.index === 'A' || file.working_dir === '?') { map.set(file.path, 'git-added'); } else if (file.index === 'D') { map.set(file.path, 'git-deleted'); } else if (file.index === 'M' || file.working_dir === 'M') { map.set(file.path, 'git-modified'); } } return map; }, [gitStatus]); const badgeByDir = React.useMemo(() => { const map = new Map(); if (!gitStatus?.files || !root) return map; for (const file of gitStatus.files) { const isModified = file.index === 'M' || file.working_dir === 'M'; const isAdded = file.index === 'A' || file.working_dir === '?'; if (!isModified && !isAdded) continue; const segments = file.path.split('/'); if (segments.length <= 1) continue; let currentDir = root; for (let i = 0; i < segments.length - 1; i++) { currentDir = `${currentDir}/${segments[i]}`; let entry = map.get(currentDir); if (!entry) { entry = { modified: 0, added: 0 }; map.set(currentDir, entry); } if (isModified) entry.modified++; if (isAdded) entry.added++; } } return map; }, [gitStatus, root]); const getFileStatus = React.useCallback((path: string): FileStatus | null => { if (openContextFilePaths.has(path)) return 'open'; if (statusByPath.size === 0) return null; const relative = path.startsWith(root + '/') ? path.slice(root.length + 1) : path; return statusByPath.get(relative) ?? null; }, [openContextFilePaths, statusByPath, root]); const getFolderBadge = React.useCallback((dirPath: string): { modified: number; added: number } | null => { if (badgeByDir.size === 0) return null; const entry = badgeByDir.get(dirPath); if (!entry) return null; return entry.modified + entry.added > 0 ? entry : null; }, [badgeByDir]); // --- File operations --- const handleOpenFile = React.useCallback(async (node: FileNode) => { if (!root) return; const openValidation = await validateContextFileOpen(files, node.path, { directory: root }); if (!openValidation.ok) { toast.error(getContextFileOpenFailureMessage(openValidation.reason)); return; } setSelectedPath(root, node.path); addOpenPath(root, node.path); openContextFile(root, node.path); }, [addOpenPath, files, openContextFile, root, setSelectedPath]); const toggleDirectory = React.useCallback(async (dirPath: string) => { const normalized = normalizePath(dirPath); if (!root) return; toggleExpandedPath(root, normalized); if (!loadedDirsRef.current.has(normalized)) { await loadDirectory(normalized); } }, [loadDirectory, root, toggleExpandedPath]); const uploadDroppedFiles = React.useCallback(async ( directory: string, droppedFiles: File[], overwrite = false, ) => { const uploadFile = files.uploadFile; if (!uploadFile || droppedFiles.length === 0 || uploadingRef.current || !root) return; const operationRoot = root; const operationRuntime = getRuntimeKey(); uploadingRef.current = true; setIsUploading(true); setDropTarget(directory); if (overwrite) setUploadConflicts(null); const outcomes: UploadOutcome[] = []; for (let index = 0; index < droppedFiles.length; index += MAX_PARALLEL_UPLOADS) { const batch = droppedFiles.slice(index, index + MAX_PARALLEL_UPLOADS); const batchOutcomes = await Promise.all(batch.map(async (file): Promise => { const name = getUploadName(file); if (!name || getRuntimeKey() !== operationRuntime) return 'failed'; try { const result = await uploadFile(normalizePath(`${directory}/${name}`), file, { directory: operationRoot, overwrite, }); return result.success ? 'uploaded' : 'failed'; } catch (error) { if (!overwrite && isFilesystemError(error) && error.reason === 'already-exists') { return 'conflict'; } return 'failed'; } })); outcomes.push(...batchOutcomes); } const uploadedCount = outcomes.filter((outcome) => outcome === 'uploaded').length; const failedCount = outcomes.filter((outcome) => outcome === 'failed').length; const conflictingFiles = droppedFiles.filter((_, index) => outcomes[index] === 'conflict'); const uploadedPaths = droppedFiles.flatMap((file, index) => { const name = getUploadName(file); return outcomes[index] === 'uploaded' && name ? [normalizePath(`${directory}/${name}`)] : []; }); const isCurrentDestination = rootRef.current === operationRoot && getRuntimeKey() === operationRuntime; try { if (uploadedPaths.length > 0) { notifyFileContentInvalidated({ runtimeKey: operationRuntime, paths: uploadedPaths }); } if (uploadedCount > 0 && isCurrentDestination) { await refreshDirectory(directory); } if (uploadedCount > 0) { toast.success(t(conflictingFiles.length > 0 ? 'sidebarFilesTree.toast.uploadedWithoutConflicts' : 'sidebarFilesTree.toast.uploaded')); } if (failedCount > 0) { toast.error(t('sidebarFilesTree.toast.uploadFailed')); } if (conflictingFiles.length > 0 && isCurrentDestination) { setUploadConflicts({ directory, files: conflictingFiles, runtimeKey: operationRuntime, workspaceRoot: operationRoot, }); } } finally { uploadingRef.current = false; setIsUploading(false); setDropTarget(null); } }, [files.uploadFile, refreshDirectory, root, t]); const handleDropFiles = React.useCallback((directory: string, dataTransfer: DataTransfer) => { const droppedFiles = getExternalFiles(dataTransfer); if (droppedFiles.length === 0) return; void uploadDroppedFiles(directory, droppedFiles); }, [uploadDroppedFiles]); const handleRootDragOver = React.useCallback((event: React.DragEvent) => { if (!canUpload || uploadingRef.current || !root || !hasExternalFiles(event.dataTransfer)) return; event.preventDefault(); event.dataTransfer.dropEffect = 'copy'; setDropTarget(root); }, [canUpload, root]); const handleRootDragLeave = React.useCallback((event: React.DragEvent) => { if (!hasExternalFiles(event.dataTransfer)) return; if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return; setDropTarget(null); }, []); const handleRootDrop = React.useCallback((event: React.DragEvent) => { if (!canUpload || uploadingRef.current || !root || !hasExternalFiles(event.dataTransfer)) return; event.preventDefault(); handleDropFiles(root, event.dataTransfer); }, [canUpload, handleDropFiles, root]); // --- Dialog submit (matching FilesView) --- const handleDialogSubmit = React.useCallback(async (e?: React.FormEvent) => { e?.preventDefault(); if (!dialogData || !activeDialog) return; setIsDialogSubmitting(true); const done = () => setIsDialogSubmitting(false); const closeDialog = () => setActiveDialog(null); if (activeDialog === 'createFile') { if (!dialogInputValue.trim()) { toast.error(t('sidebarFilesTree.toast.filenameRequired')); done(); return; } if (!files.writeFile) { toast.error(t('sidebarFilesTree.toast.writeNotSupported')); done(); return; } const parentPath = dialogData.path; const prefix = parentPath ? `${parentPath}/` : ''; const newPath = normalizePath(`${prefix}${dialogInputValue.trim()}`); await files.writeFile(newPath, '') .then(async (result) => { if (result.success) { toast.success(t('sidebarFilesTree.toast.fileCreated')); await refreshDirectory(parentPath); } closeDialog(); }) .catch(() => toast.error(t('sidebarFilesTree.toast.operationFailed'))) .finally(done); return; } if (activeDialog === 'createFolder') { if (!dialogInputValue.trim()) { toast.error(t('sidebarFilesTree.toast.folderNameRequired')); done(); return; } const parentPath = dialogData.path; const prefix = parentPath ? `${parentPath}/` : ''; const newPath = normalizePath(`${prefix}${dialogInputValue.trim()}`); await files.createDirectory(newPath) .then(async (result) => { if (result.success) { toast.success(t('sidebarFilesTree.toast.folderCreated')); await refreshDirectory(parentPath); } closeDialog(); }) .catch(() => toast.error(t('sidebarFilesTree.toast.operationFailed'))) .finally(done); return; } if (activeDialog === 'rename') { if (!dialogInputValue.trim()) { toast.error(t('sidebarFilesTree.toast.nameRequired')); done(); return; } if (!files.rename) { toast.error(t('sidebarFilesTree.toast.renameNotSupported')); done(); return; } const oldPath = dialogData.path; const parentDir = oldPath.split('/').slice(0, -1).join('/'); const prefix = parentDir ? `${parentDir}/` : ''; const newPath = normalizePath(`${prefix}${dialogInputValue.trim()}`); await files.rename(oldPath, newPath) .then(async (result) => { if (result.success) { toast.success(t('sidebarFilesTree.toast.renamedSuccessfully')); await refreshDirectory(parentDir); if (root) { removeOpenPathsByPrefix(root, oldPath); } if (selectedPath === oldPath || (selectedPath && selectedPath.startsWith(`${oldPath}/`))) { setSelectedPath(root, null); } } closeDialog(); }) .catch(() => toast.error(t('sidebarFilesTree.toast.operationFailed'))) .finally(done); return; } if (activeDialog === 'delete') { if (!files.delete) { toast.error(t('sidebarFilesTree.toast.deleteNotSupported')); done(); return; } const deletedPath = dialogData.path; const parentDir = deletedPath.split('/').slice(0, -1).join('/'); await files.delete(deletedPath) .then(async (result) => { if (result.success) { toast.success(t('sidebarFilesTree.toast.deletedSuccessfully')); await refreshDirectory(parentDir); if (root) { removeOpenPathsByPrefix(root, deletedPath); } if (selectedPath === deletedPath || (selectedPath && selectedPath.startsWith(deletedPath + '/'))) { setSelectedPath(root, null); } } closeDialog(); }) .catch(() => toast.error(t('sidebarFilesTree.toast.operationFailed'))) .finally(done); return; } done(); }, [activeDialog, dialogData, dialogInputValue, files, refreshDirectory, removeOpenPathsByPrefix, root, selectedPath, setSelectedPath, t]); // --- Tree rendering (matching FilesView with indent guides) --- function renderTree(dirPath: string, depth: number): React.ReactNode { const nodes = childrenByDir[dirPath] ?? []; return nodes.map((node, index) => { const isDir = node.type === 'directory'; const isExpanded = isDir && expandedPaths.includes(node.path); const isActive = selectedPath === node.path; const isLast = index === nodes.length - 1; return (
  • {depth > 0 && ( <> {isLast && ( )} )} {isDir && isExpanded && (
      {loadErrorsByDir[node.path] ? (
    • {loadErrorsByDir[node.path]}
    • ) : null} {renderTree(node.path, depth + 1)}
    )}
  • ); }); } const hasTree = Boolean(root && childrenByDir[root]); const rootLoadError = root ? loadErrorsByDir[root] : null; const dropTargetLabel = dropTarget ? getDropTargetLabel(root, dropTarget) : ''; return (
    {canCreateFile && ( {t('sidebarFilesTree.actions.newFileTitle')} )} {canCreateFolder && ( {t('sidebarFilesTree.actions.newFolderTitle')} )} {t('sidebarFilesTree.actions.refreshTitle')} {t('sidebarFilesTree.actions.collapseAllTitle')}
    setSearchQuery(event.target.value)} placeholder={t('sidebarFilesTree.search.placeholder')} className="h-8 pl-8 pr-8 typography-meta" /> {searchQuery.trim().length > 0 ? ( ) : null}
      {searching ? (
    • {t('sidebarFilesTree.state.searching')}
    • ) : searchResults.length > 0 ? ( searchResults.map((node) => { const isActive = selectedPath === node.path; return (
    • ); }) ) : rootLoadError ? (
    • {rootLoadError}
    • ) : hasTree && root ? ( renderTree(root, 0) ) : (
    • {t('sidebarFilesTree.state.loading')}
    • )}
    {dropTarget ? (
    {t(isUploading ? 'sidebarFilesTree.drop.uploading' : 'sidebarFilesTree.drop.target', { path: dropTargetLabel })}
    ) : null}
    !open && setUploadConflicts(null)}> {t('sidebarFilesTree.dialog.uploadConflicts.title')} {t('sidebarFilesTree.dialog.uploadConflicts.description', { path: uploadConflicts?.directory ?? '' })} {uploadConflicts?.files.map((file, index) => (
    {file.name}
    ))}
    {/* CRUD dialogs (matching FilesView) */} !open && setActiveDialog(null)}> {activeDialog === 'createFile' && t('sidebarFilesTree.dialog.createFile.title')} {activeDialog === 'createFolder' && t('sidebarFilesTree.dialog.createFolder.title')} {activeDialog === 'rename' && t('sidebarFilesTree.dialog.rename.title')} {activeDialog === 'delete' && t('sidebarFilesTree.dialog.delete.title')} {activeDialog === 'createFile' && t('sidebarFilesTree.dialog.createFile.description', { path: dialogData?.path ?? t('sidebarFilesTree.dialog.rootFallback') })} {activeDialog === 'createFolder' && t('sidebarFilesTree.dialog.createFolder.description', { path: dialogData?.path ?? t('sidebarFilesTree.dialog.rootFallback') })} {activeDialog === 'rename' && t('sidebarFilesTree.dialog.rename.description', { name: dialogData?.name ?? '' })} {activeDialog === 'delete' && t('sidebarFilesTree.dialog.delete.description', { name: dialogData?.name ?? '' })} {activeDialog !== 'delete' && (
    setDialogInputValue(e.target.value)} placeholder={activeDialog === 'rename' ? t('sidebarFilesTree.dialog.rename.placeholder') : t('sidebarFilesTree.dialog.namePlaceholder')} onKeyDown={(e) => { if (e.key === 'Enter') { void handleDialogSubmit(); } }} autoFocus />
    )}
    ); };