diff --git a/vibn-frontend/components/project/gitea-file-tree.tsx b/vibn-frontend/components/project/gitea-file-tree.tsx index f6e804d..8f8c972 100644 --- a/vibn-frontend/components/project/gitea-file-tree.tsx +++ b/vibn-frontend/components/project/gitea-file-tree.tsx @@ -10,7 +10,9 @@ */ import { useEffect, useState, useCallback } from "react"; -import { ChevronRight, ChevronDown, Folder, FileText, Loader2, AlertCircle } from "lucide-react"; +import { Loader2, AlertCircle } from "lucide-react"; +import { Tree, Folder, File } from "@/components/ui/file-tree"; +import { THEME } from "@/components/project/dashboard-ui"; interface TreeItem { name: string; @@ -45,14 +47,16 @@ export function GiteaFileTree({ const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [expanded, setExpanded] = useState>(new Set()); - const [childrenByPath, setChildrenByPath] = useState>({}); + const [childrenByPath, setChildrenByPath] = useState< + Record + >({}); const [loadingPaths, setLoadingPaths] = useState>(new Set()); const fetchPath = useCallback( async (path: string): Promise => { const res = await fetch( `/api/projects/${projectId}/file?path=${encodeURIComponent(path)}`, - { credentials: "include" } + { credentials: "include" }, ); if (!res.ok) { const body = await res.json().catch(() => ({})); @@ -61,7 +65,7 @@ export function GiteaFileTree({ const data = (await res.json()) as ApiOk; return data.items ?? []; }, - [projectId] + [projectId], ); // Load root whenever projectId or rootPath changes @@ -74,10 +78,37 @@ export function GiteaFileTree({ setChildrenByPath({}); fetchPath(rootPath) - .then(items => { - if (!cancelled) setRootItems(items); + .then(async (items) => { + if (cancelled) return; + + // Auto-expand top-level directories so the tree doesn't look empty + const dirs = items.filter((i) => i.type === "dir").map((i) => i.path); + const newChildrenByPath: Record = {}; + const newExpanded = new Set(); + + // Cap at 10 to avoid API spam on huge repos + const dirsToExpand = dirs.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 (cancelled) return; + + setRootItems(items); + setChildrenByPath(newChildrenByPath); + setExpanded(newExpanded); }) - .catch(err => { + .catch((err) => { if (!cancelled) setError(err.message || "Failed to load"); }) .finally(() => { @@ -91,6 +122,7 @@ export function GiteaFileTree({ const toggleDir = useCallback( async (path: string) => { + // is already open? const isOpen = expanded.has(path); const next = new Set(expanded); if (isOpen) { @@ -100,30 +132,39 @@ export function GiteaFileTree({ } next.add(path); setExpanded(next); + if (childrenByPath[path]) return; - setLoadingPaths(prev => new Set(prev).add(path)); + setLoadingPaths((prev) => new Set(prev).add(path)); try { const items = await fetchPath(path); - setChildrenByPath(prev => ({ ...prev, [path]: items })); + setChildrenByPath((prev) => ({ ...prev, [path]: items })); } catch (err) { console.warn(`[gitea-file-tree] failed to load ${path}:`, err); } finally { - setLoadingPaths(prev => { + setLoadingPaths((prev) => { const n = new Set(prev); n.delete(path); return n; }); } }, - [expanded, childrenByPath, fetchPath] + [expanded, childrenByPath, fetchPath], ); if (loading) { return ( -
- - Loading… +
+ Loading…
); } @@ -131,192 +172,106 @@ export function GiteaFileTree({ if (error) { const isMissingRepo = /no gitea repo/i.test(error); return ( -
- - - {isMissingRepo ? "No Gitea repo connected to this project." : error} - +
+ + {isMissingRepo ? "No Gitea repo connected to this project." : error}
); } if (!rootItems || rootItems.length === 0) { return ( -
- - {rootPath} is empty (or doesn't exist yet). - +
+ + {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)} +
+ ); + } + return ( + onSelectFile?.(item.path)} + isSelectable={!!onSelectFile} + isSelect={selectedPath === item.path} + > + {item.name} + + ); + }; + return ( -
- {rootItems.map(item => ( - - ))} +
+ + {rootItems.map(renderItem)} +
); } -interface NodeProps { - item: TreeItem; - depth: number; - expanded: Set; - loadingPaths: Set; - childrenByPath: Record; - onToggle: (path: string) => void; - onSelectFile?: (path: string) => void; - selectedPath?: string; -} - -function Node({ - item, - depth, - expanded, - loadingPaths, - childrenByPath, - onToggle, - onSelectFile, - selectedPath, -}: NodeProps) { - const isDir = item.type === "dir"; - const isOpen = expanded.has(item.path); - const isLoading = loadingPaths.has(item.path); - const children = childrenByPath[item.path]; - const isSelected = !isDir && selectedPath === item.path; - const fileClickable = !isDir && typeof onSelectFile === "function"; - - const Icon = isDir - ? isOpen - ? ChevronDown - : ChevronRight - : null; - - const indent = depth * 14; - - const interactive = isDir || fileClickable; - const handleClick = () => { - if (isDir) onToggle(item.path); - else if (fileClickable) onSelectFile!(item.path); - }; - - return ( - <> -
- - {Icon && } - - {isDir ? ( - - ) : ( - - )} - {item.name} - {isLoading && ( - - )} -
- {isDir && isOpen && children?.map(child => ( - - ))} - - ); -} - -const INK = { - ink: "#1a1a1a", - mid: "#5f5e5a", - muted: "#a09a90", - stone: "#b5b0a6", - border: "#e8e4dc", -} as const; - -const treeWrap: React.CSSProperties = { - fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", - fontSize: "0.78rem", - color: INK.ink, - flex: 1, - minHeight: 0, - overflowY: "auto", - margin: "-4px -8px", -}; - -const rowStyle: React.CSSProperties = { - display: "flex", - alignItems: "center", - gap: 6, - padding: "3px 8px", - lineHeight: 1.4, - borderRadius: 4, - userSelect: "none", -}; - -const chevronCell: React.CSSProperties = { - width: 12, - display: "inline-flex", - alignItems: "center", - justifyContent: "center", - flexShrink: 0, -}; - -const nameStyle: React.CSSProperties = { - whiteSpace: "nowrap", - overflow: "hidden", - textOverflow: "ellipsis", - minWidth: 0, -}; - -const msgWrap: React.CSSProperties = { - display: "flex", - alignItems: "center", - gap: 8, - padding: "16px 4px", -}; - -const msgText: React.CSSProperties = { - fontSize: "0.82rem", - color: INK.mid, - lineHeight: 1.5, -}; - -const inlineCode: React.CSSProperties = { - fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", - fontSize: "0.78rem", - background: "rgba(0,0,0,0.04)", - padding: "1px 6px", - borderRadius: 4, -}; +// Clean up unused styles