import { useState, useEffect, useRef, Fragment } from 'react'; import type { ReactNode } from 'react'; import { Search, X } from 'lucide-react'; import { Input } from '@/components/ui/input'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Skeleton } from '@/components/ui/skeleton'; import { toast } from '@/components/ui/toast-store'; import type { FileEntry } from '@/lib/stackFilesApi'; import { FileTreeNode } from './FileTreeNode'; interface FileTreeProps { /** Loads directory contents at `relPath` (use '' for the tree root). */ loadDir: (relPath: string) => Promise; /** Stable identity for the source. Changing it remounts the tree. */ sourceKey: string; refreshKey?: number; selectedPath: string; onSelectFile: (relPath: string, entry: FileEntry) => void; onNavigateToCompose?: () => void; onNavigateToEnv?: () => void; // Context menu wiring canEdit?: boolean; onContextMenuRename?: (relPath: string) => void; onContextMenuNewFile?: (dirRelPath: string) => void; onContextMenuNewFolder?: (dirRelPath: string) => void; onContextMenuDelete?: (relPath: string, entry: FileEntry) => void; onContextMenuPermissions?: (relPath: string, entry: FileEntry) => void; } const COMPOSE_NAMES = new Set(['compose.yaml', 'compose.yml']); const ENV_NAMES = new Set(['.env']); // The server caps the response at 1000 entries and exposes the unfiltered // total via X-Total-Count; matching the client guard means a perfectly-sized // directory never shows the truncation hint. const MAX_ENTRIES = 1000; export function FileTree({ loadDir, sourceKey, refreshKey, selectedPath, onSelectFile, onNavigateToCompose, onNavigateToEnv, canEdit = false, onContextMenuRename = () => undefined, onContextMenuNewFile = () => undefined, onContextMenuNewFolder = () => undefined, onContextMenuDelete = () => undefined, onContextMenuPermissions = () => undefined, }: FileTreeProps) { const [rootEntries, setRootEntries] = useState(null); const [rootLoading, setRootLoading] = useState(true); const [error, setError] = useState(null); const [expandedDirs, setExpandedDirs] = useState>(new Set()); const [dirContents, setDirContents] = useState>(new Map()); const [loadingDirs, setLoadingDirs] = useState>(new Set()); const [filter, setFilter] = useState(''); const sourceKeyRef = useRef(sourceKey); const loadDirRef = useRef(loadDir); // Always keep the latest loader in a ref so callers can pass a fresh // function each render without re-triggering the root fetch effect below. loadDirRef.current = loadDir; useEffect(() => { sourceKeyRef.current = sourceKey; let cancelled = false; loadDirRef.current('') .then((entries) => { if (!cancelled) { setRootEntries(entries); setRootLoading(false); } }) .catch((err: unknown) => { if (!cancelled) { const msg = err instanceof Error ? err.message : 'Failed to load files.'; setError(msg); setRootLoading(false); toast.error('Failed to load files.'); } }); return () => { cancelled = true; }; // loadDir is intentionally read through loadDirRef to avoid refetch on // identity-only changes from the parent (StackFileExplorer rebuilds the // arrow on every render). // eslint-disable-next-line react-hooks/exhaustive-deps }, [sourceKey, refreshKey]); function handleDirClick(dirRelPath: string) { if (expandedDirs.has(dirRelPath)) { setExpandedDirs((prev) => { const next = new Set(prev); next.delete(dirRelPath); return next; }); return; } if (dirContents.has(dirRelPath)) { setExpandedDirs((prev) => new Set(prev).add(dirRelPath)); return; } setLoadingDirs((prev) => new Set(prev).add(dirRelPath)); const capturedSourceKey = sourceKey; loadDirRef.current(dirRelPath) .then((entries) => { if (sourceKeyRef.current !== capturedSourceKey) return; setDirContents((prev) => { const next = new Map(prev); next.set(dirRelPath, entries); return next; }); setExpandedDirs((prev) => new Set(prev).add(dirRelPath)); }) .catch((err: unknown) => { const msg = err instanceof Error ? err.message : 'Failed to load directory.'; toast.error(msg); }) .finally(() => { setLoadingDirs((prev) => { const next = new Set(prev); next.delete(dirRelPath); return next; }); }); } function handleFileClick(relPath: string, entry: FileEntry) { if (COMPOSE_NAMES.has(entry.name)) { if (onNavigateToCompose) onNavigateToCompose(); else toast.info('Open the Compose tab to edit this file.'); return; } if (ENV_NAMES.has(entry.name)) { if (onNavigateToEnv) onNavigateToEnv(); else toast.info('Open the Env tab to edit this file.'); return; } onSelectFile(relPath, entry); } const matchesFilter = (name: string): boolean => name.toLowerCase().includes(filter.toLowerCase()); // True when any already-loaded descendant of `dirPath` matches the filter. // Walks dirContents only, so unexpanded subtrees are not falsely shown as // "has match" until the user expands them. Bounded by what the user has // already loaded; no extra fetch. function hasMatchingDescendant(dirPath: string): boolean { const children = dirContents.get(dirPath); if (!children) return false; for (const child of children) { if (matchesFilter(child.name)) return true; if (child.type === 'directory') { const childPath = dirPath ? `${dirPath}/${child.name}` : child.name; if (hasMatchingDescendant(childPath)) return true; } } return false; } function renderEntries(entries: FileEntry[], parentRelPath: string, depth: number): ReactNode { // When the filter is active, keep entries that either match by name OR // are directories with a matching loaded descendant. Without the // ancestor-keep rule, the parent directory of a match would be filtered // out at this level and its loaded children would never render. const filtered = filter ? entries.filter(e => { if (matchesFilter(e.name)) return true; if (e.type !== 'directory') return false; const path = parentRelPath ? `${parentRelPath}/${e.name}` : e.name; return hasMatchingDescendant(path); }) : entries; const capped = filtered.length > MAX_ENTRIES; const visible = capped ? filtered.slice(0, MAX_ENTRIES) : filtered; return ( <> {visible.map((entry) => { const entryRelPath = parentRelPath ? `${parentRelPath}/${entry.name}` : entry.name; const isDir = entry.type === 'directory'; // While a filter is active, auto-expand any directory that is being // kept solely because it has a matching descendant. The user gets // the match in view without manually expanding every ancestor. const isExpanded = expandedDirs.has(entryRelPath) || (filter !== '' && isDir && hasMatchingDescendant(entryRelPath)); const isLoading = loadingDirs.has(entryRelPath); const children = dirContents.get(entryRelPath); return ( { if (isDir) { handleDirClick(entryRelPath); } else { handleFileClick(entryRelPath, entry); } }} canEdit={canEdit} onContextMenuRename={onContextMenuRename} onContextMenuNewFile={onContextMenuNewFile} onContextMenuNewFolder={onContextMenuNewFolder} onContextMenuDelete={onContextMenuDelete} onContextMenuPermissions={onContextMenuPermissions} /> {isDir && isExpanded && children !== undefined && ( children.length === 0 ? (
Empty folder
) : renderEntries(children, entryRelPath, depth + 1) )}
); })} {capped && (
Showing {MAX_ENTRIES} of {filtered.length} - refine the filter or use a shell
)} {filter && filtered.length === 0 && depth === 0 && (
No entries match “{filter}”
)} ); } if (rootLoading) { return (
); } if (error !== null) { return (
{error}
); } if (rootEntries === null || rootEntries.length === 0) { return (
Empty folder
); } return (
setFilter(e.target.value)} className="h-6 text-xs pl-6 pr-6" aria-label="Filter files" /> {filter && ( )}
{renderEntries(rootEntries, '', 0)}
); }