mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 12:48:10 +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:
@@ -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