"use client"; /** * Lazy-expanding file tree for a Gitea repo path. * * Wraps `GET /api/projects/[projectId]/file?path=…` which returns a * directory listing. Directories expand inline on click and lazy-load * their children; files render as leaves and (for now) link out to * Gitea's web UI on click. */ import { useEffect, useState, useCallback, useRef } from "react"; import { Loader2, AlertCircle } from "lucide-react"; import { Tree, Folder, File } from "@/components/ui/file-tree"; import { THEME } from "@/components/project/dashboard-ui"; import { getFileIconAndColor } from "@/components/project/file-icons"; interface TreeItem { name: string; path: string; type: "file" | "dir" | "symlink"; size?: number; } interface ApiOk { type: "dir"; items: TreeItem[]; } interface GiteaFileTreeProps { projectId: string; /** Repo path to root the tree at, e.g. "apps/web" */ rootPath: string; /** Fires when the user clicks a file row. When omitted, files * are not interactive. */ onSelectFile?: (path: string) => void; /** Path of the currently-selected file, used to highlight the row. */ selectedPath?: string; /** If true, automatically selects the best available file (e.g. page.tsx, package.json) on mount. */ autoSelect?: boolean; } // ── In-memory cache to persist tree state across tab navigations ── const treeCache: Record< string, { rootItems: TreeItem[]; childrenByPath: Record; expanded: Set; } > = {}; function pickBestFile(paths: string[]): string | undefined { const PREFERRED = [ "src/app/page.tsx", "src/app/page.jsx", "app/page.tsx", "app/page.jsx", "src/pages/index.tsx", "src/pages/index.jsx", "pages/index.tsx", "pages/index.jsx", "src/App.tsx", "src/App.jsx", "src/main.tsx", "src/main.jsx", "src/index.ts", "src/index.js", "package.json", "README.md", ]; for (const p of PREFERRED) { if (paths.includes(p)) return p; } const fallbackExts = [".tsx", ".ts", ".jsx", ".js", ".json"]; for (const ext of fallbackExts) { const found = paths.find((p) => p.endsWith(ext)); if (found) return found; } return paths[0]; } export function GiteaFileTree({ projectId, rootPath, onSelectFile, selectedPath, autoSelect, }: GiteaFileTreeProps) { const cacheKey = `${projectId}::${rootPath}`; const autoSelectedRef = useRef(false); const [rootItems, setRootItems] = useState(() => { return treeCache[cacheKey]?.rootItems ?? null; }); const [loading, setLoading] = useState(() => !treeCache[cacheKey]); const [error, setError] = useState(null); const [expanded, setExpanded] = useState>(() => { return treeCache[cacheKey]?.expanded ?? new Set(); }); const [childrenByPath, setChildrenByPath] = useState< Record >(() => { return treeCache[cacheKey]?.childrenByPath ?? {}; }); const [loadingPaths, setLoadingPaths] = useState>(new Set()); // Keep cache synced with state updates useEffect(() => { if (rootItems) { treeCache[cacheKey] = { rootItems, childrenByPath, expanded, }; } }, [cacheKey, rootItems, childrenByPath, expanded]); const fetchPath = useCallback( async (path: string): Promise => { const res = await fetch( `/api/projects/${projectId}/file?path=${encodeURIComponent(path)}`, { credentials: "include" }, ); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error || `HTTP ${res.status}`); } const data = (await res.json()) as ApiOk; return data.items ?? []; }, [projectId], ); // Load root whenever projectId or rootPath changes useEffect(() => { // If we already loaded this from cache on mount, skip fetching again if (treeCache[cacheKey]) { if (autoSelect && !autoSelectedRef.current && onSelectFile) { autoSelectedRef.current = true; const cached = treeCache[cacheKey]; const allItems = [ ...cached.rootItems, ...Object.values(cached.childrenByPath).flat(), ]; const filePaths = allItems .filter((i) => i.type === "file") .map((i) => i.path); const best = pickBestFile(filePaths); if (best) onSelectFile(best); } return; } let cancelled = false; setLoading(true); setError(null); fetchPath(rootPath) .then(async (items) => { if (cancelled) return; const newChildrenByPath: Record = {}; const newExpanded = new Set(); const rootDirs = items.filter((i) => i.type === "dir"); const rootDirNames = rootDirs.map((i) => i.name); const hasSrc = rootDirNames.includes("src"); const hasAppOrComponents = rootDirNames.includes("app") || rootDirNames.includes("components") || rootDirNames.includes("pages"); if (hasSrc || hasAppOrComponents) { // Smart default: expand app/components/pages, and src -> app/components/pages if (hasSrc) { const srcItem = rootDirs.find((i) => i.name === "src")!; try { const srcItems = await fetchPath(srcItem.path); newChildrenByPath[srcItem.path] = srcItems; newExpanded.add(srcItem.path); const srcDirs = srcItems.filter((i) => i.type === "dir"); const subPromises = []; for (const target of ["app", "components", "pages", "lib"]) { const subItem = srcDirs.find((i) => i.name === target); if (subItem) { subPromises.push( fetchPath(subItem.path).then((children) => { newChildrenByPath[subItem.path] = children; newExpanded.add(subItem.path); }), ); } } await Promise.all(subPromises); } catch (e) { console.warn(`[gitea-file-tree] failed to auto-expand src`); } } if (hasAppOrComponents) { const subPromises = []; for (const target of ["app", "components", "pages", "lib"]) { const rootItem = rootDirs.find((i) => i.name === target); if (rootItem) { subPromises.push( fetchPath(rootItem.path).then((children) => { newChildrenByPath[rootItem.path] = children; newExpanded.add(rootItem.path); }), ); } } await Promise.all(subPromises); } } else { // Fallback: auto-expand up to 10 top-level directories so it doesn't look empty const dirsToExpand = rootDirs.map((i) => i.path).slice(0, 10); await Promise.all( dirsToExpand.map(async (dirPath) => { try { const children = await fetchPath(dirPath); newChildrenByPath[dirPath] = children; newExpanded.add(dirPath); } catch (e) { console.warn( `[gitea-file-tree] failed to auto-expand ${dirPath}`, ); } }), ); } if (autoSelect && !autoSelectedRef.current && onSelectFile) { autoSelectedRef.current = true; const allItems = [ ...items, ...Object.values(newChildrenByPath).flat(), ]; const filePaths = allItems .filter((i) => i.type === "file") .map((i) => i.path); const best = pickBestFile(filePaths); if (best) onSelectFile(best); } if (cancelled) return; setRootItems(items); setChildrenByPath(newChildrenByPath); setExpanded(newExpanded); }) .catch((err) => { if (!cancelled) setError(err.message || "Failed to load"); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [projectId, rootPath, fetchPath, cacheKey]); const toggleDir = useCallback( async (path: string) => { // is already open? const isOpen = expanded.has(path); const next = new Set(expanded); if (isOpen) { next.delete(path); setExpanded(next); return; } next.add(path); setExpanded(next); if (childrenByPath[path]) return; setLoadingPaths((prev) => new Set(prev).add(path)); try { const items = await fetchPath(path); setChildrenByPath((prev) => ({ ...prev, [path]: items })); } catch (err) { console.warn(`[gitea-file-tree] failed to load ${path}:`, err); } finally { setLoadingPaths((prev) => { const n = new Set(prev); n.delete(path); return n; }); } }, [expanded, childrenByPath, fetchPath], ); if (loading) { return (
Loading…
); } if (error) { const isMissingRepo = /no gitea repo/i.test(error); return (
{isMissingRepo ? "No Gitea repo connected to this project." : error}
); } if (!rootItems || rootItems.length === 0) { return (
{rootPath} {" "} is empty (or doesn't exist yet).
); } const renderItem = (item: TreeItem) => { const isDir = item.type === "dir"; if (isDir) { const children = childrenByPath[item.path]; return ( {loadingPaths.has(item.path) && (
Loading…
)} {children?.map(renderItem)}
); } const { icon: FileIconComponent, color } = getFileIconAndColor(item.name); return ( onSelectFile?.(item.path)} isSelectable={!!onSelectFile} isSelect={selectedPath === item.path} fileIcon={ } > {item.name} ); }; return (
{rootItems.map(renderItem)}
); } // Clean up unused styles