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:
Anso
2026-05-25 00:03:09 -04:00
committed by GitHub
parent ea002cd9a0
commit fcf2222604
5 changed files with 238 additions and 13 deletions
@@ -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 ─────────────────────────────────────────────
+18 -3
View File
@@ -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');
+23 -1
View File
@@ -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(