fix(host): bound listDirectory levels at a configurable maxEntries
One list call now materializes at most maxEntries child rows (config, default 1000 - GitHub's web-UI directory-listing bound). Candidates sort before probing so a cut level keeps the name-sorted head and symlink probing stops with the bound, and DirectoryListing carries a required truncated flag on the seam and the wire so clients can state incompleteness instead of silently missing tail entries.
This commit is contained in:
@@ -47,6 +47,7 @@ export const hostListDirectoryValueSchema = z.object({
|
||||
home: z.string(),
|
||||
crumbs: z.array(directoryEntrySchema),
|
||||
entries: z.array(directoryEntrySchema),
|
||||
truncated: z.boolean(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'host.listDirectory'>>>
|
||||
|
||||
/** host.createDirectory request payload: name must be one plain path segment. */
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface DirectoryListing {
|
||||
crumbs: DirectoryEntry[]
|
||||
/** Direct child directories, name-sorted; symlinks to directories included. */
|
||||
entries: DirectoryEntry[]
|
||||
/** True when the backend cut `entries` at its complete-result bound (the name-sorted tail is absent). */
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
/** Host-level unary methods. */
|
||||
|
||||
@@ -159,6 +159,7 @@ const BROWSE_STUB: DirectoryPickerCapability = {
|
||||
home: '/home/user',
|
||||
crumbs: [{ name: '/', path: '/', hidden: false }],
|
||||
entries: [{ name: 'projects', path: `${target}/projects`, hidden: false }],
|
||||
truncated: false,
|
||||
}
|
||||
},
|
||||
createDirectory: async (path, name) => {
|
||||
|
||||
@@ -50,7 +50,7 @@ function scriptedApi(overrides: {
|
||||
host: {
|
||||
describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0 }),
|
||||
pickDirectory: r => ok(r, { path: null }),
|
||||
listDirectory: r => ok(r, { path: '/t', home: '/t', crumbs: [], entries: [] }),
|
||||
listDirectory: r => ok(r, { path: '/t', home: '/t', crumbs: [], entries: [], truncated: false }),
|
||||
createDirectory: r => ok(r, { path: '/t/new' }),
|
||||
openPath: r => ok(r, { opened: true as const }),
|
||||
...overrides.host,
|
||||
|
||||
@@ -82,7 +82,7 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { path: null } } }
|
||||
},
|
||||
async listDirectory(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { path: '/w', home: '/w', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [] } } }
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { path: '/w', home: '/w', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false } } }
|
||||
},
|
||||
async createDirectory(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { path: '/w/new' } } }
|
||||
@@ -224,7 +224,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
|
||||
const listed = await c.host.listDirectory({ path: '/w' })
|
||||
expect(listed.result).toEqual({
|
||||
ok: true,
|
||||
value: { path: '/w', home: '/w', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [] },
|
||||
value: { path: '/w', home: '/w', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false },
|
||||
})
|
||||
const home = await c.host.listDirectory({})
|
||||
expect(home.result).toMatchObject({ ok: true, value: { home: '/w' } })
|
||||
|
||||
@@ -231,8 +231,11 @@ describe('host domain schemas', () => {
|
||||
home: '/home/u',
|
||||
crumbs: [{ name: '/', path: '/', hidden: false }, { name: 'p', path: '/home/u/p', hidden: false }],
|
||||
entries: [{ name: '.dot', path: '/home/u/p/.dot', hidden: true }],
|
||||
truncated: false,
|
||||
})
|
||||
expect(listing.entries[0]?.hidden).toBe(true)
|
||||
// The flag is part of the wire value, not an optional decoration.
|
||||
expect(() => hostListDirectoryValueSchema.parse({ path: '/x', home: '/x', crumbs: [], entries: [] })).toThrow()
|
||||
expect(hostCreateDirectoryRequestSchema.parse({ path: '/x', name: 'new' })).toEqual({ path: '/x', name: 'new' })
|
||||
for (const name of ['', ' ', '.', '..', 'a/b', 'a\\b']) {
|
||||
expect(() => hostCreateDirectoryRequestSchema.parse({ path: '/x', name })).toThrow()
|
||||
|
||||
Reference in New Issue
Block a user