feat(codebase): auto-expand top-level directories in file tree
This commit is contained in:
@@ -10,7 +10,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from "react";
|
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 {
|
interface TreeItem {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -45,14 +47,16 @@ export function GiteaFileTree({
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
const [childrenByPath, setChildrenByPath] = useState<Record<string, TreeItem[]>>({});
|
const [childrenByPath, setChildrenByPath] = useState<
|
||||||
|
Record<string, TreeItem[]>
|
||||||
|
>({});
|
||||||
const [loadingPaths, setLoadingPaths] = useState<Set<string>>(new Set());
|
const [loadingPaths, setLoadingPaths] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const fetchPath = useCallback(
|
const fetchPath = useCallback(
|
||||||
async (path: string): Promise<TreeItem[]> => {
|
async (path: string): Promise<TreeItem[]> => {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/projects/${projectId}/file?path=${encodeURIComponent(path)}`,
|
`/api/projects/${projectId}/file?path=${encodeURIComponent(path)}`,
|
||||||
{ credentials: "include" }
|
{ credentials: "include" },
|
||||||
);
|
);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
@@ -61,7 +65,7 @@ export function GiteaFileTree({
|
|||||||
const data = (await res.json()) as ApiOk;
|
const data = (await res.json()) as ApiOk;
|
||||||
return data.items ?? [];
|
return data.items ?? [];
|
||||||
},
|
},
|
||||||
[projectId]
|
[projectId],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Load root whenever projectId or rootPath changes
|
// Load root whenever projectId or rootPath changes
|
||||||
@@ -74,10 +78,37 @@ export function GiteaFileTree({
|
|||||||
setChildrenByPath({});
|
setChildrenByPath({});
|
||||||
|
|
||||||
fetchPath(rootPath)
|
fetchPath(rootPath)
|
||||||
.then(items => {
|
.then(async (items) => {
|
||||||
if (!cancelled) setRootItems(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 => {
|
.catch((err) => {
|
||||||
if (!cancelled) setError(err.message || "Failed to load");
|
if (!cancelled) setError(err.message || "Failed to load");
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@@ -91,6 +122,7 @@ export function GiteaFileTree({
|
|||||||
|
|
||||||
const toggleDir = useCallback(
|
const toggleDir = useCallback(
|
||||||
async (path: string) => {
|
async (path: string) => {
|
||||||
|
// is already open?
|
||||||
const isOpen = expanded.has(path);
|
const isOpen = expanded.has(path);
|
||||||
const next = new Set(expanded);
|
const next = new Set(expanded);
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
@@ -100,30 +132,39 @@ export function GiteaFileTree({
|
|||||||
}
|
}
|
||||||
next.add(path);
|
next.add(path);
|
||||||
setExpanded(next);
|
setExpanded(next);
|
||||||
|
|
||||||
if (childrenByPath[path]) return;
|
if (childrenByPath[path]) return;
|
||||||
|
|
||||||
setLoadingPaths(prev => new Set(prev).add(path));
|
setLoadingPaths((prev) => new Set(prev).add(path));
|
||||||
try {
|
try {
|
||||||
const items = await fetchPath(path);
|
const items = await fetchPath(path);
|
||||||
setChildrenByPath(prev => ({ ...prev, [path]: items }));
|
setChildrenByPath((prev) => ({ ...prev, [path]: items }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`[gitea-file-tree] failed to load ${path}:`, err);
|
console.warn(`[gitea-file-tree] failed to load ${path}:`, err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingPaths(prev => {
|
setLoadingPaths((prev) => {
|
||||||
const n = new Set(prev);
|
const n = new Set(prev);
|
||||||
n.delete(path);
|
n.delete(path);
|
||||||
return n;
|
return n;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[expanded, childrenByPath, fetchPath]
|
[expanded, childrenByPath, fetchPath],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={msgWrap}>
|
<div
|
||||||
<Loader2 size={14} className="animate-spin" style={{ color: INK.muted }} />
|
style={{
|
||||||
<span style={msgText}>Loading…</span>
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
padding: 12,
|
||||||
|
color: THEME.muted,
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Loader2 size={14} className="animate-spin" /> Loading…
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -131,192 +172,106 @@ export function GiteaFileTree({
|
|||||||
if (error) {
|
if (error) {
|
||||||
const isMissingRepo = /no gitea repo/i.test(error);
|
const isMissingRepo = /no gitea repo/i.test(error);
|
||||||
return (
|
return (
|
||||||
<div style={msgWrap}>
|
<div
|
||||||
<AlertCircle size={14} style={{ color: INK.muted }} />
|
style={{
|
||||||
<span style={msgText}>
|
display: "flex",
|
||||||
{isMissingRepo ? "No Gitea repo connected to this project." : error}
|
alignItems: "center",
|
||||||
</span>
|
gap: 8,
|
||||||
|
padding: 12,
|
||||||
|
color: THEME.danger,
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertCircle size={14} />
|
||||||
|
{isMissingRepo ? "No Gitea repo connected to this project." : error}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!rootItems || rootItems.length === 0) {
|
if (!rootItems || rootItems.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div style={msgWrap}>
|
<div
|
||||||
<span style={msgText}>
|
style={{
|
||||||
<code style={inlineCode}>{rootPath}</code> is empty (or doesn't exist yet).
|
padding: 12,
|
||||||
</span>
|
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>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<File
|
||||||
|
key={item.path}
|
||||||
|
value={item.path}
|
||||||
|
handleSelect={() => onSelectFile?.(item.path)}
|
||||||
|
isSelectable={!!onSelectFile}
|
||||||
|
isSelect={selectedPath === item.path}
|
||||||
|
>
|
||||||
|
<span>{item.name}</span>
|
||||||
|
</File>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={treeWrap}>
|
<div
|
||||||
{rootItems.map(item => (
|
style={{
|
||||||
<Node
|
flex: 1,
|
||||||
key={item.path}
|
minHeight: 0,
|
||||||
item={item}
|
display: "flex",
|
||||||
depth={0}
|
flexDirection: "column",
|
||||||
expanded={expanded}
|
}}
|
||||||
loadingPaths={loadingPaths}
|
>
|
||||||
childrenByPath={childrenByPath}
|
<Tree
|
||||||
onToggle={toggleDir}
|
className="w-full h-full overflow-hidden bg-transparent"
|
||||||
onSelectFile={onSelectFile}
|
initialExpandedItems={Array.from(expanded)}
|
||||||
selectedPath={selectedPath}
|
onExpandItem={toggleDir}
|
||||||
/>
|
indicator={true}
|
||||||
))}
|
>
|
||||||
|
{rootItems.map(renderItem)}
|
||||||
|
</Tree>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NodeProps {
|
// Clean up unused styles
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|||||||
Reference in New Issue
Block a user