Files
vibn-agent-runner/vibn-frontend/components/project/gitea-file-tree.tsx

285 lines
7.4 KiB
TypeScript

"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 } 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;
}
export function GiteaFileTree({
projectId,
rootPath,
onSelectFile,
selectedPath,
}: GiteaFileTreeProps) {
const [rootItems, setRootItems] = useState<TreeItem[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [childrenByPath, setChildrenByPath] = useState<
Record<string, TreeItem[]>
>({});
const [loadingPaths, setLoadingPaths] = useState<Set<string>>(new Set());
const fetchPath = useCallback(
async (path: string): Promise<TreeItem[]> => {
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(() => {
let cancelled = false;
setLoading(true);
setError(null);
setRootItems(null);
setExpanded(new Set());
setChildrenByPath({});
fetchPath(rootPath)
.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<string, TreeItem[]> = {};
const newExpanded = new Set<string>();
// 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) => {
if (!cancelled) setError(err.message || "Failed to load");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [projectId, rootPath, fetchPath]);
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 (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: 12,
color: THEME.muted,
fontSize: "0.85rem",
}}
>
<Loader2 size={14} className="animate-spin" /> Loading
</div>
);
}
if (error) {
const isMissingRepo = /no gitea repo/i.test(error);
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: 12,
color: THEME.danger,
fontSize: "0.85rem",
}}
>
<AlertCircle size={14} />
{isMissingRepo ? "No Gitea repo connected to this project." : error}
</div>
);
}
if (!rootItems || rootItems.length === 0) {
return (
<div
style={{
padding: 12,
color: THEME.muted,
fontSize: "0.85rem",
fontStyle: "italic",
}}
>
<code
style={{
fontFamily: "ui-monospace, monospace",
fontSize: "0.8rem",
padding: "2px 6px",
background: THEME.subtleBg,
borderRadius: 4,
fontStyle: "normal",
color: THEME.ink,
}}
>
{rootPath}
</code>{" "}
is empty (or doesn&apos;t exist yet).
</div>
);
}
const renderItem = (item: TreeItem) => {
const isDir = item.type === "dir";
if (isDir) {
const children = childrenByPath[item.path];
return (
<Folder key={item.path} value={item.path} element={item.name}>
{loadingPaths.has(item.path) && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "4px 8px",
color: THEME.muted,
fontSize: "0.75rem",
}}
>
<Loader2 size={12} className="animate-spin" /> Loading
</div>
)}
{children?.map(renderItem)}
</Folder>
);
}
const { icon: FileIconComponent, color } = getFileIconAndColor(item.name);
return (
<File
key={item.path}
value={item.path}
handleSelect={() => onSelectFile?.(item.path)}
isSelectable={!!onSelectFile}
isSelect={selectedPath === item.path}
fileIcon={
<FileIconComponent size={15} style={{ color, marginRight: 2 }} />
}
>
<span>{item.name}</span>
</File>
);
};
return (
<div
style={{
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
}}
>
<Tree
className="w-full h-full overflow-hidden bg-transparent"
initialExpandedItems={Array.from(expanded)}
onExpandItem={toggleDir}
indicator={true}
>
{rootItems.map(renderItem)}
</Tree>
</div>
);
}
// Clean up unused styles