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

323 lines
8.1 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 { ChevronRight, ChevronDown, Folder, FileText, Loader2, AlertCircle } from "lucide-react";
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(items => {
if (!cancelled) setRootItems(items);
})
.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) => {
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={msgWrap}>
<Loader2 size={14} className="animate-spin" style={{ color: INK.muted }} />
<span style={msgText}>Loading</span>
</div>
);
}
if (error) {
const isMissingRepo = /no gitea repo/i.test(error);
return (
<div style={msgWrap}>
<AlertCircle size={14} style={{ color: INK.muted }} />
<span style={msgText}>
{isMissingRepo ? "No Gitea repo connected to this project." : error}
</span>
</div>
);
}
if (!rootItems || rootItems.length === 0) {
return (
<div style={msgWrap}>
<span style={msgText}>
<code style={inlineCode}>{rootPath}</code> is empty (or doesn't exist yet).
</span>
</div>
);
}
return (
<div style={treeWrap}>
{rootItems.map(item => (
<Node
key={item.path}
item={item}
depth={0}
expanded={expanded}
loadingPaths={loadingPaths}
childrenByPath={childrenByPath}
onToggle={toggleDir}
onSelectFile={onSelectFile}
selectedPath={selectedPath}
/>
))}
</div>
);
}
interface NodeProps {
item: TreeItem;
depth: number;
expanded: Set<string>;
loadingPaths: Set<string>;
childrenByPath: Record<string, TreeItem[]>;
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 (
<>
<div
style={{
...rowStyle,
paddingLeft: 6 + indent,
cursor: interactive ? "pointer" : "default",
background: isSelected ? "rgba(26,26,26,0.06)" : "transparent",
color: isSelected ? INK.ink : undefined,
fontWeight: isSelected ? 600 : 400,
}}
onClick={interactive ? handleClick : undefined}
role={interactive ? "button" : undefined}
aria-expanded={isDir ? isOpen : undefined}
aria-current={isSelected ? "true" : undefined}
>
<span style={chevronCell}>
{Icon && <Icon size={12} style={{ color: INK.mid }} />}
</span>
{isDir ? (
<Folder size={13} style={{ color: INK.stone, flexShrink: 0 }} />
) : (
<FileText size={13} style={{ color: isSelected ? INK.ink : INK.muted, flexShrink: 0 }} />
)}
<span style={nameStyle}>{item.name}</span>
{isLoading && (
<Loader2 size={11} className="animate-spin" style={{ color: INK.muted, marginLeft: "auto" }} />
)}
</div>
{isDir && isOpen && children?.map(child => (
<Node
key={child.path}
item={child}
depth={depth + 1}
expanded={expanded}
loadingPaths={loadingPaths}
childrenByPath={childrenByPath}
onToggle={onToggle}
onSelectFile={onSelectFile}
selectedPath={selectedPath}
/>
))}
</>
);
}
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,
};