mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-03 23:47:46 +00:00
fcf2222604
* feat(stack-files): cap directory listings at 1000 + add file-tree filter
The file-tree route returned every entry in a directory unbounded.
A logs/ or data/ subfolder with rotated artifacts could produce a
multi-megabyte response and a frontend cap at 500 entries silently
hid the rest with no way for the user to find a specific file.
The list route now caps the response at 1000 entries (the audit's
recommended bound), advertises the unfiltered total via
X-Total-Count, and sets X-Truncated when truncation happened. The
service exposes both a bare-array listStackDirectory (unchanged
contract for callers that just want the array) and a paginated
listStackDirectoryPage that returns {entries, total, truncated}.
The FileTree now offers a search input above the scroll area that
filters loaded entries by name (case-insensitive substring). Clearing
the filter restores the full listing. A non-matching filter shows a
short hint instead of an empty pane. The client-side MAX_ENTRIES
matches the server cap so a perfectly-sized directory never shows
the truncation hint.
* fix(stack-files): filter keeps parent dirs when loaded descendants match
The original filter applied per-render-level inside renderEntries, so a
parent directory whose name did not match was filtered out even when one
of its already-loaded children did. The match was then unreachable: the
parent had been removed from the visible list and its children never got
a chance to render.
Compute matching-descendant once per directory by walking the loaded
dirContents map (no extra fetch, bounded by what the user already
expanded). Keep ancestors of any match in the visible list. Auto-expand
those ancestors for the duration of the filter so the match comes into
view without a manual click on every parent.
Filter scope is still 'what is already loaded'; unexpanded subtrees do
not contribute to ancestor-keep until the user expands them. Two new
tests pin both behaviours.
305 lines
11 KiB
TypeScript
305 lines
11 KiB
TypeScript
import { useState, useEffect, useRef, Fragment } from 'react';
|
|
import type { ReactNode } from 'react';
|
|
import { Search, X } from 'lucide-react';
|
|
import { Input } from '@/components/ui/input';
|
|
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;
|
|
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']);
|
|
// The server caps the response at 1000 entries and exposes the unfiltered
|
|
// total via X-Total-Count; matching the client guard means a perfectly-sized
|
|
// directory never shows the truncation hint.
|
|
const MAX_ENTRIES = 1000;
|
|
|
|
export function FileTree({
|
|
loadDir,
|
|
sourceKey,
|
|
refreshKey,
|
|
selectedPath,
|
|
onSelectFile,
|
|
onNavigateToCompose,
|
|
onNavigateToEnv,
|
|
canEdit = 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 [filter, setFilter] = useState('');
|
|
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);
|
|
}
|
|
|
|
const matchesFilter = (name: string): boolean =>
|
|
name.toLowerCase().includes(filter.toLowerCase());
|
|
|
|
// True when any already-loaded descendant of `dirPath` matches the filter.
|
|
// Walks dirContents only, so unexpanded subtrees are not falsely shown as
|
|
// "has match" until the user expands them. Bounded by what the user has
|
|
// already loaded; no extra fetch.
|
|
function hasMatchingDescendant(dirPath: string): boolean {
|
|
const children = dirContents.get(dirPath);
|
|
if (!children) return false;
|
|
for (const child of children) {
|
|
if (matchesFilter(child.name)) return true;
|
|
if (child.type === 'directory') {
|
|
const childPath = dirPath ? `${dirPath}/${child.name}` : child.name;
|
|
if (hasMatchingDescendant(childPath)) return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function renderEntries(entries: FileEntry[], parentRelPath: string, depth: number): ReactNode {
|
|
// When the filter is active, keep entries that either match by name OR
|
|
// are directories with a matching loaded descendant. Without the
|
|
// ancestor-keep rule, the parent directory of a match would be filtered
|
|
// out at this level and its loaded children would never render.
|
|
const filtered = filter
|
|
? entries.filter(e => {
|
|
if (matchesFilter(e.name)) return true;
|
|
if (e.type !== 'directory') return false;
|
|
const path = parentRelPath ? `${parentRelPath}/${e.name}` : e.name;
|
|
return hasMatchingDescendant(path);
|
|
})
|
|
: entries;
|
|
const capped = filtered.length > MAX_ENTRIES;
|
|
const visible = capped ? filtered.slice(0, MAX_ENTRIES) : filtered;
|
|
|
|
return (
|
|
<>
|
|
{visible.map((entry) => {
|
|
const entryRelPath = parentRelPath ? `${parentRelPath}/${entry.name}` : entry.name;
|
|
const isDir = entry.type === 'directory';
|
|
// While a filter is active, auto-expand any directory that is being
|
|
// kept solely because it has a matching descendant. The user gets
|
|
// the match in view without manually expanding every ancestor.
|
|
const isExpanded = expandedDirs.has(entryRelPath)
|
|
|| (filter !== '' && isDir && hasMatchingDescendant(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}
|
|
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 {filtered.length} - refine the filter or use a shell
|
|
</div>
|
|
)}
|
|
{filter && filtered.length === 0 && depth === 0 && (
|
|
<div className="text-xs text-muted-foreground pl-4 py-0.5 italic">
|
|
No entries match “{filter}”
|
|
</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 (
|
|
<div className="flex flex-col h-full min-h-0">
|
|
<div className="relative px-2 py-1.5 border-b border-glass-border shrink-0">
|
|
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground" strokeWidth={1.5} />
|
|
<Input
|
|
type="text"
|
|
placeholder="Filter files..."
|
|
value={filter}
|
|
onChange={(e) => setFilter(e.target.value)}
|
|
className="h-6 text-xs pl-6 pr-6"
|
|
aria-label="Filter files"
|
|
/>
|
|
{filter && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setFilter('')}
|
|
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
aria-label="Clear filter"
|
|
>
|
|
<X className="w-3 h-3" strokeWidth={1.5} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
<ScrollArea type="hover" className="flex-1 min-h-0">
|
|
<div className="py-1">
|
|
{renderEntries(rootEntries, '', 0)}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
);
|
|
}
|