366 lines
10 KiB
TypeScript
366 lines
10 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;
|
|
}
|
|
|
|
// ── In-memory cache to persist tree state across tab navigations ──
|
|
const treeCache: Record<
|
|
string,
|
|
{
|
|
rootItems: TreeItem[];
|
|
childrenByPath: Record<string, TreeItem[]>;
|
|
expanded: Set<string>;
|
|
}
|
|
> = {};
|
|
|
|
export function GiteaFileTree({
|
|
projectId,
|
|
rootPath,
|
|
onSelectFile,
|
|
selectedPath,
|
|
}: GiteaFileTreeProps) {
|
|
const cacheKey = `${projectId}::${rootPath}`;
|
|
|
|
const [rootItems, setRootItems] = useState<TreeItem[] | null>(() => {
|
|
return treeCache[cacheKey]?.rootItems ?? null;
|
|
});
|
|
const [loading, setLoading] = useState(() => !treeCache[cacheKey]);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [expanded, setExpanded] = useState<Set<string>>(() => {
|
|
return treeCache[cacheKey]?.expanded ?? new Set();
|
|
});
|
|
const [childrenByPath, setChildrenByPath] = useState<
|
|
Record<string, TreeItem[]>
|
|
>(() => {
|
|
return treeCache[cacheKey]?.childrenByPath ?? {};
|
|
});
|
|
const [loadingPaths, setLoadingPaths] = useState<Set<string>>(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<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(() => {
|
|
// If we already loaded this from cache on mount, skip fetching again
|
|
if (treeCache[cacheKey]) return;
|
|
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
fetchPath(rootPath)
|
|
.then(async (items) => {
|
|
if (cancelled) return;
|
|
|
|
const newChildrenByPath: Record<string, TreeItem[]> = {};
|
|
const newExpanded = new Set<string>();
|
|
|
|
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 (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 (
|
|
<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'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
|