mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-03 15:37:44 +00:00
feat(stack-files): cap directory listings at 1000 + add file-tree filter (#1208)
* 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.
This commit is contained in:
@@ -141,6 +141,41 @@ describe('GET /api/stacks/:stackName/files', () => {
|
||||
.set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('truncates a directory with more than 1000 entries and advertises totals in headers', async () => {
|
||||
const subdir = path.join(stacksDir, STACK, 'huge');
|
||||
await fs.mkdir(subdir, { recursive: true });
|
||||
// Seed 1100 small files. Names sort lexicographically so we can pin the
|
||||
// truncation boundary by inspecting the last returned entry.
|
||||
const targetCount = 1100;
|
||||
for (let i = 0; i < targetCount; i++) {
|
||||
const name = `f${String(i).padStart(5, '0')}.txt`;
|
||||
await fs.writeFile(path.join(subdir, name), '');
|
||||
}
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/stacks/${STACK}/files`)
|
||||
.query({ path: 'huge' })
|
||||
.set('Cookie', adminCookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
expect(res.body.length).toBe(1000);
|
||||
expect(res.headers['x-total-count']).toBe(String(targetCount));
|
||||
expect(res.headers['x-returned-count']).toBe('1000');
|
||||
expect(res.headers['x-truncated']).toBe('true');
|
||||
|
||||
await fs.rm(subdir, { recursive: true, force: true });
|
||||
}, 30000);
|
||||
|
||||
it('does not set X-Truncated when the directory fits under the limit', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/stacks/${STACK}/files`)
|
||||
.set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['x-truncated']).toBeUndefined();
|
||||
expect(res.headers['x-total-count']).toBe(String(res.body.length));
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /:stackName/files/content ─────────────────────────────────────────────
|
||||
|
||||
@@ -1374,6 +1374,8 @@ function isSafeUploadFilename(rawName: string): boolean {
|
||||
return path.basename(rawName) === rawName;
|
||||
}
|
||||
|
||||
const DIR_LIST_LIMIT = 1000;
|
||||
|
||||
stacksRouter.get('/:stackName/files', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return;
|
||||
@@ -1384,9 +1386,22 @@ stacksRouter.get('/:stackName/files', async (req: Request, res: Response) => {
|
||||
const startedAt = Date.now();
|
||||
logFileDiag('list start', { stackName, relPath, nodeId: req.nodeId });
|
||||
try {
|
||||
const entries = await FileSystemService.getInstance(req.nodeId).listStackDirectory(stackName, relPath);
|
||||
logFileDiag('list complete', { stackName, relPath, nodeId: req.nodeId, entries: entries.length, elapsedMs: Date.now() - startedAt });
|
||||
return res.json(entries);
|
||||
const result = await FileSystemService.getInstance(req.nodeId).listStackDirectoryPage(stackName, relPath, { limit: DIR_LIST_LIMIT });
|
||||
// Expose pagination context via headers; the JSON body stays
|
||||
// FileEntry[] for backward compatibility with any direct API caller.
|
||||
res.setHeader('X-Total-Count', String(result.total));
|
||||
res.setHeader('X-Returned-Count', String(result.entries.length));
|
||||
if (result.truncated) res.setHeader('X-Truncated', 'true');
|
||||
logFileDiag('list complete', {
|
||||
stackName,
|
||||
relPath,
|
||||
nodeId: req.nodeId,
|
||||
returned: result.entries.length,
|
||||
total: result.total,
|
||||
truncated: result.truncated,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
});
|
||||
return res.json(result.entries);
|
||||
} catch (err: unknown) {
|
||||
logFileOperation('warn', 'list failed', { nodeId: req.nodeId, errorCode: fsErrorCode(err) });
|
||||
return sendFsError(res, err, 'Failed to list directory');
|
||||
|
||||
@@ -613,8 +613,25 @@ export class FileSystemService {
|
||||
}
|
||||
|
||||
async listStackDirectory(stackName: string, relPath: string): Promise<FileEntry[]> {
|
||||
const page = await this.listStackDirectoryPage(stackName, relPath, {});
|
||||
return page.entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pagination-aware variant. Returns the sorted entries (optionally truncated
|
||||
* to `limit`) along with the unfiltered `total` so the route can advertise
|
||||
* how much was elided. Callers that just want the unbounded array should
|
||||
* keep using listStackDirectory; the route uses this variant to cap the
|
||||
* payload for unusually large directories without losing the count.
|
||||
*/
|
||||
async listStackDirectoryPage(
|
||||
stackName: string,
|
||||
relPath: string,
|
||||
opts: { limit?: number },
|
||||
): Promise<{ entries: FileEntry[]; total: number; truncated: boolean }> {
|
||||
const safePath = await this.resolveSafeStackPath(stackName, relPath);
|
||||
const dirents = await fsPromises.readdir(safePath, { withFileTypes: true });
|
||||
const total = dirents.length;
|
||||
|
||||
const entries = await Promise.all(
|
||||
dirents.map(async (dirent): Promise<FileEntry> => {
|
||||
@@ -643,11 +660,16 @@ export class FileSystemService {
|
||||
})
|
||||
);
|
||||
|
||||
return entries.sort((a, b) => {
|
||||
const sorted = entries.sort((a, b) => {
|
||||
if (a.type === 'directory' && b.type !== 'directory') return -1;
|
||||
if (a.type !== 'directory' && b.type === 'directory') return 1;
|
||||
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
||||
});
|
||||
|
||||
if (opts.limit !== undefined && sorted.length > opts.limit) {
|
||||
return { entries: sorted.slice(0, opts.limit), total, truncated: true };
|
||||
}
|
||||
return { entries: sorted, total, truncated: false };
|
||||
}
|
||||
|
||||
async readStackFile(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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';
|
||||
@@ -27,7 +29,10 @@ interface FileTreeProps {
|
||||
|
||||
const COMPOSE_NAMES = new Set(['compose.yaml', 'compose.yml']);
|
||||
const ENV_NAMES = new Set(['.env']);
|
||||
const MAX_ENTRIES = 500;
|
||||
// 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,
|
||||
@@ -50,6 +55,7 @@ export function FileTree({
|
||||
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);
|
||||
|
||||
@@ -141,16 +147,52 @@ export function FileTree({
|
||||
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 {
|
||||
const capped = entries.length > MAX_ENTRIES;
|
||||
const visible = capped ? entries.slice(0, MAX_ENTRIES) : entries;
|
||||
// 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';
|
||||
const isExpanded = expandedDirs.has(entryRelPath);
|
||||
// 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);
|
||||
|
||||
@@ -191,7 +233,12 @@ export function FileTree({
|
||||
})}
|
||||
{capped && (
|
||||
<div className="text-xs text-muted-foreground pl-4 py-0.5">
|
||||
Showing {MAX_ENTRIES} of {entries.length} - refine in shell
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
@@ -225,10 +272,33 @@ export function FileTree({
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea type="hover" className="h-full">
|
||||
<div className="py-1">
|
||||
{renderEntries(rootEntries, '', 0)}
|
||||
<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>
|
||||
<ScrollArea type="hover" className="flex-1 min-h-0">
|
||||
<div className="py-1">
|
||||
{renderEntries(rootEntries, '', 0)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -166,4 +166,87 @@ describe('FileTree', () => {
|
||||
|
||||
expect(await screen.findByText(/empty folder/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters visible entries by name as the user types', async () => {
|
||||
const entries = [makeDir('src'), makeFile('README.md'), makeFile('Notes.txt'), makeFile('config.yaml')];
|
||||
mockLoadDir.mockReturnValue(fakeOk(entries));
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<FileTree {...defaultProps()} />);
|
||||
await screen.findByText('README.md');
|
||||
expect(screen.getByText('Notes.txt')).toBeInTheDocument();
|
||||
expect(screen.getByText('config.yaml')).toBeInTheDocument();
|
||||
|
||||
const filter = screen.getByLabelText(/filter files/i);
|
||||
await user.type(filter, 'note');
|
||||
|
||||
// Only Notes.txt survives (case-insensitive substring).
|
||||
expect(screen.getByText('Notes.txt')).toBeInTheDocument();
|
||||
expect(screen.queryByText('README.md')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('config.yaml')).not.toBeInTheDocument();
|
||||
|
||||
// Clear button restores the full listing.
|
||||
await user.click(screen.getByLabelText(/clear filter/i));
|
||||
expect(screen.getByText('README.md')).toBeInTheDocument();
|
||||
expect(screen.getByText('Notes.txt')).toBeInTheDocument();
|
||||
expect(screen.getByText('config.yaml')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an empty-match hint when the filter matches nothing', async () => {
|
||||
mockLoadDir.mockReturnValue(fakeOk([makeFile('README.md')]));
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<FileTree {...defaultProps()} />);
|
||||
await screen.findByText('README.md');
|
||||
|
||||
const filter = screen.getByLabelText(/filter files/i);
|
||||
await user.type(filter, 'xyzzy');
|
||||
|
||||
expect(screen.queryByText('README.md')).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/no entries match/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps a parent directory visible when a loaded descendant matches and auto-expands it', async () => {
|
||||
// Root has `src` (dir) and `README.md`. The user expands `src` so its
|
||||
// contents are loaded. Then they filter on a child of `src` whose name
|
||||
// does not match the parent.
|
||||
mockLoadDir
|
||||
.mockReturnValueOnce(fakeOk([makeDir('src'), makeFile('README.md')]))
|
||||
.mockReturnValueOnce(fakeOk([makeFile('app.ts'), makeFile('lib.ts')]));
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<FileTree {...defaultProps()} />);
|
||||
await screen.findByText('src');
|
||||
await user.click(screen.getByText('src'));
|
||||
await screen.findByText('app.ts');
|
||||
|
||||
const filter = screen.getByLabelText(/filter files/i);
|
||||
await user.type(filter, 'app');
|
||||
|
||||
// `src` survives because it has a matching loaded descendant.
|
||||
expect(screen.getByText('src')).toBeInTheDocument();
|
||||
// The match itself is visible (src auto-expands while filter is active).
|
||||
expect(screen.getByText('app.ts')).toBeInTheDocument();
|
||||
// Non-matching siblings at root and inside `src` are filtered out.
|
||||
expect(screen.queryByText('README.md')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('lib.ts')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not keep an unexpanded parent visible (filter only sees loaded entries)', async () => {
|
||||
// Root has `src` (never expanded) and `README.md`. The filter can only
|
||||
// judge directories by their loaded contents; an un-fetched subtree
|
||||
// contributes nothing to the ancestor-keep rule.
|
||||
mockLoadDir.mockReturnValue(fakeOk([makeDir('src'), makeFile('README.md')]));
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<FileTree {...defaultProps()} />);
|
||||
await screen.findByText('src');
|
||||
|
||||
const filter = screen.getByLabelText(/filter files/i);
|
||||
await user.type(filter, 'app');
|
||||
|
||||
expect(screen.queryByText('src')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('README.md')).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/no entries match/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user