From fcf22226041e2eb6845dcf90b1a6b36ed92d57d7 Mon Sep 17 00:00:00 2001 From: Anso Date: Mon, 25 May 2026 00:03:09 -0400 Subject: [PATCH] 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. --- .../src/__tests__/stack-files-routes.test.ts | 35 ++++++++ backend/src/routes/stacks.ts | 21 ++++- backend/src/services/FileSystemService.ts | 24 ++++- frontend/src/components/files/FileTree.tsx | 88 +++++++++++++++++-- .../files/__tests__/FileTree.test.tsx | 83 +++++++++++++++++ 5 files changed, 238 insertions(+), 13 deletions(-) diff --git a/backend/src/__tests__/stack-files-routes.test.ts b/backend/src/__tests__/stack-files-routes.test.ts index adf8180b..7a5122e0 100644 --- a/backend/src/__tests__/stack-files-routes.test.ts +++ b/backend/src/__tests__/stack-files-routes.test.ts @@ -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 ───────────────────────────────────────────── diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index 2093db2a..6c17d3f1 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -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'); diff --git a/backend/src/services/FileSystemService.ts b/backend/src/services/FileSystemService.ts index 1f51ed9b..b89fee32 100644 --- a/backend/src/services/FileSystemService.ts +++ b/backend/src/services/FileSystemService.ts @@ -613,8 +613,25 @@ export class FileSystemService { } async listStackDirectory(stackName: string, relPath: string): Promise { + 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 => { @@ -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( diff --git a/frontend/src/components/files/FileTree.tsx b/frontend/src/components/files/FileTree.tsx index 32bef53d..2365ba2d 100644 --- a/frontend/src/components/files/FileTree.tsx +++ b/frontend/src/components/files/FileTree.tsx @@ -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>(new Set()); const [dirContents, setDirContents] = useState>(new Map()); const [loadingDirs, setLoadingDirs] = useState>(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 && (
- Showing {MAX_ENTRIES} of {entries.length} - refine in shell + Showing {MAX_ENTRIES} of {filtered.length} - refine the filter or use a shell +
+ )} + {filter && filtered.length === 0 && depth === 0 && ( +
+ No entries match “{filter}”
)} @@ -225,10 +272,33 @@ export function FileTree({ } return ( - -
- {renderEntries(rootEntries, '', 0)} +
+
+ + setFilter(e.target.value)} + className="h-6 text-xs pl-6 pr-6" + aria-label="Filter files" + /> + {filter && ( + + )}
- + +
+ {renderEntries(rootEntries, '', 0)} +
+
+
); } diff --git a/frontend/src/components/files/__tests__/FileTree.test.tsx b/frontend/src/components/files/__tests__/FileTree.test.tsx index 99049dd4..c74e45f2 100644 --- a/frontend/src/components/files/__tests__/FileTree.test.tsx +++ b/frontend/src/components/files/__tests__/FileTree.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); + }); });