mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-28 12:49:03 +00:00
238 lines
7.6 KiB
TypeScript
238 lines
7.6 KiB
TypeScript
import { useState, useEffect, useRef, Fragment } from 'react';
|
|
import type { ReactNode } from 'react';
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { toast } from '@/components/ui/toast-store';
|
|
import type { FileEntry } from '@/lib/stackFilesApi';
|
|
import { FileTreeNode } from './FileTreeNode';
|
|
|
|
interface FileTreeProps {
|
|
/** Loads directory contents at `relPath` (use '' for the tree root). */
|
|
loadDir: (relPath: string) => Promise<FileEntry[]>;
|
|
/** Stable identity for the source. Changing it remounts the tree. */
|
|
sourceKey: string;
|
|
refreshKey?: number;
|
|
selectedPath: string;
|
|
onSelectFile: (relPath: string, entry: FileEntry) => void;
|
|
onNavigateToCompose?: () => void;
|
|
onNavigateToEnv?: () => void;
|
|
// Context menu wiring
|
|
canEdit?: boolean;
|
|
isPaid?: boolean;
|
|
onContextMenuRename?: (relPath: string) => void;
|
|
onContextMenuNewFile?: (dirRelPath: string) => void;
|
|
onContextMenuNewFolder?: (dirRelPath: string) => void;
|
|
onContextMenuDelete?: (relPath: string, entry: FileEntry) => void;
|
|
onContextMenuPermissions?: (relPath: string, entry: FileEntry) => void;
|
|
}
|
|
|
|
const COMPOSE_NAMES = new Set(['compose.yaml', 'compose.yml']);
|
|
const ENV_NAMES = new Set(['.env']);
|
|
const MAX_ENTRIES = 500;
|
|
|
|
export function FileTree({
|
|
loadDir,
|
|
sourceKey,
|
|
refreshKey,
|
|
selectedPath,
|
|
onSelectFile,
|
|
onNavigateToCompose,
|
|
onNavigateToEnv,
|
|
canEdit = false,
|
|
isPaid = false,
|
|
onContextMenuRename = () => undefined,
|
|
onContextMenuNewFile = () => undefined,
|
|
onContextMenuNewFolder = () => undefined,
|
|
onContextMenuDelete = () => undefined,
|
|
onContextMenuPermissions = () => undefined,
|
|
}: FileTreeProps) {
|
|
const [rootEntries, setRootEntries] = useState<FileEntry[] | null>(null);
|
|
const [rootLoading, setRootLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
|
|
const [dirContents, setDirContents] = useState<Map<string, FileEntry[]>>(new Map());
|
|
const [loadingDirs, setLoadingDirs] = useState<Set<string>>(new Set());
|
|
const sourceKeyRef = useRef(sourceKey);
|
|
const loadDirRef = useRef(loadDir);
|
|
|
|
// Always keep the latest loader in a ref so callers can pass a fresh
|
|
// function each render without re-triggering the root fetch effect below.
|
|
loadDirRef.current = loadDir;
|
|
|
|
useEffect(() => {
|
|
sourceKeyRef.current = sourceKey;
|
|
let cancelled = false;
|
|
|
|
loadDirRef.current('')
|
|
.then((entries) => {
|
|
if (!cancelled) {
|
|
setRootEntries(entries);
|
|
setRootLoading(false);
|
|
}
|
|
})
|
|
.catch((err: unknown) => {
|
|
if (!cancelled) {
|
|
const msg = err instanceof Error ? err.message : 'Failed to load files.';
|
|
setError(msg);
|
|
setRootLoading(false);
|
|
toast.error('Failed to load files.');
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
// loadDir is intentionally read through loadDirRef to avoid refetch on
|
|
// identity-only changes from the parent (StackFileExplorer rebuilds the
|
|
// arrow on every render).
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [sourceKey, refreshKey]);
|
|
|
|
function handleDirClick(dirRelPath: string) {
|
|
if (expandedDirs.has(dirRelPath)) {
|
|
setExpandedDirs((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(dirRelPath);
|
|
return next;
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (dirContents.has(dirRelPath)) {
|
|
setExpandedDirs((prev) => new Set(prev).add(dirRelPath));
|
|
return;
|
|
}
|
|
|
|
setLoadingDirs((prev) => new Set(prev).add(dirRelPath));
|
|
|
|
const capturedSourceKey = sourceKey;
|
|
loadDirRef.current(dirRelPath)
|
|
.then((entries) => {
|
|
if (sourceKeyRef.current !== capturedSourceKey) return;
|
|
setDirContents((prev) => {
|
|
const next = new Map(prev);
|
|
next.set(dirRelPath, entries);
|
|
return next;
|
|
});
|
|
setExpandedDirs((prev) => new Set(prev).add(dirRelPath));
|
|
})
|
|
.catch((err: unknown) => {
|
|
const msg = err instanceof Error ? err.message : 'Failed to load directory.';
|
|
toast.error(msg);
|
|
})
|
|
.finally(() => {
|
|
setLoadingDirs((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(dirRelPath);
|
|
return next;
|
|
});
|
|
});
|
|
}
|
|
|
|
function handleFileClick(relPath: string, entry: FileEntry) {
|
|
if (COMPOSE_NAMES.has(entry.name)) {
|
|
if (onNavigateToCompose) onNavigateToCompose();
|
|
else toast.info('Open the Compose tab to edit this file.');
|
|
return;
|
|
}
|
|
if (ENV_NAMES.has(entry.name)) {
|
|
if (onNavigateToEnv) onNavigateToEnv();
|
|
else toast.info('Open the Env tab to edit this file.');
|
|
return;
|
|
}
|
|
onSelectFile(relPath, entry);
|
|
}
|
|
|
|
function renderEntries(entries: FileEntry[], parentRelPath: string, depth: number): ReactNode {
|
|
const capped = entries.length > MAX_ENTRIES;
|
|
const visible = capped ? entries.slice(0, MAX_ENTRIES) : entries;
|
|
|
|
return (
|
|
<>
|
|
{visible.map((entry) => {
|
|
const entryRelPath = parentRelPath ? `${parentRelPath}/${entry.name}` : entry.name;
|
|
const isDir = entry.type === 'directory';
|
|
const isExpanded = expandedDirs.has(entryRelPath);
|
|
const isLoading = loadingDirs.has(entryRelPath);
|
|
const children = dirContents.get(entryRelPath);
|
|
|
|
return (
|
|
<Fragment key={entryRelPath}>
|
|
<FileTreeNode
|
|
entry={entry}
|
|
relPath={entryRelPath}
|
|
depth={depth}
|
|
isSelected={selectedPath === entryRelPath}
|
|
isExpanded={isExpanded}
|
|
isLoading={isLoading}
|
|
onClick={() => {
|
|
if (isDir) {
|
|
handleDirClick(entryRelPath);
|
|
} else {
|
|
handleFileClick(entryRelPath, entry);
|
|
}
|
|
}}
|
|
canEdit={canEdit}
|
|
isPaid={isPaid}
|
|
onContextMenuRename={onContextMenuRename}
|
|
onContextMenuNewFile={onContextMenuNewFile}
|
|
onContextMenuNewFolder={onContextMenuNewFolder}
|
|
onContextMenuDelete={onContextMenuDelete}
|
|
onContextMenuPermissions={onContextMenuPermissions}
|
|
/>
|
|
{isDir && isExpanded && children !== undefined && (
|
|
children.length === 0
|
|
? (
|
|
<div className="text-xs text-muted-foreground pl-4 py-0.5 italic">
|
|
Empty folder
|
|
</div>
|
|
)
|
|
: renderEntries(children, entryRelPath, depth + 1)
|
|
)}
|
|
</Fragment>
|
|
);
|
|
})}
|
|
{capped && (
|
|
<div className="text-xs text-muted-foreground pl-4 py-0.5">
|
|
Showing {MAX_ENTRIES} of {entries.length} - refine in shell
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (rootLoading) {
|
|
return (
|
|
<div className="flex flex-col gap-1.5 p-2">
|
|
<Skeleton className="h-5 w-full" />
|
|
<Skeleton className="h-5 w-full" />
|
|
<Skeleton className="h-5 w-full" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error !== null) {
|
|
return (
|
|
<div className="p-2 text-xs text-destructive">
|
|
{error}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (rootEntries === null || rootEntries.length === 0) {
|
|
return (
|
|
<div className="p-2 text-xs text-muted-foreground italic">
|
|
Empty folder
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<ScrollArea type="hover" className="h-full">
|
|
<div className="py-1">
|
|
{renderEntries(rootEntries, '', 0)}
|
|
</div>
|
|
</ScrollArea>
|
|
);
|
|
}
|