Merge remote-tracking branch 'origin/feat/directory-picker' into feat/workspace-directory-browser

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml
#	packages/host/directory-picker-browse/README.i18n.yaml
This commit is contained in:
creatixchu
2026-07-29 03:46:15 +08:00
22 changed files with 107 additions and 20 deletions

View File

@@ -12,6 +12,8 @@
import { mkdir, readdir, stat } from 'node:fs/promises'
import { homedir } from 'node:os'
import { basename, dirname, join, posix, resolve, win32 } from 'node:path'
import type { Context } from 'cordis'
import z from 'schemastery'
import {
DirectoryPicker, DirectoryPickerError,
} from '@deepseek-ai/dsh-host-directory-picker'
@@ -79,14 +81,35 @@ async function directoryRow(parent: string, name: string, isDirectory: boolean,
return { name, path, hidden: name.startsWith('.') }
}
/** Validated plugin configuration. */
export interface Config {
/** Complete-result bound of one listing level; see {@link BrowseDirectoryPicker.Config}. */
maxEntries: number
}
/** The `ctx.directoryPicker` browse implementation (stable capability object per service life). */
export default class BrowseDirectoryPicker extends DirectoryPicker {
/**
* `maxEntries` bounds the complete listing level a single `list` call may
* materialize and put on the wire: at most this many child-directory rows
* (hidden rows included), with `truncated` flagging a cut level. The
* default follows GitHub's web UI, which truncates directory listings at
* 1,000 entries.
*/
static Config: z<Config> = z.object({
maxEntries: z.natural().min(1).default(1000),
})
private readonly browseCapability: DirectoryPickerCapability = {
kind: 'browse',
list: path => this.list(path),
createDirectory: (path, name) => this.createDirectory(path, name),
}
constructor(ctx: Context, private readonly config: Config) {
super(ctx)
}
/**
* The browse interaction capability.
* @returns the stable `browse` capability object.
@@ -115,10 +138,22 @@ export default class BrowseDirectoryPicker extends DirectoryPicker {
} catch (error: unknown) {
throw new DirectoryPickerError('directory-unreadable', target, `cannot list ${target}: ${messageOf(error)}`)
}
const rows = await Promise.all(names.map(entry => directoryRow(target, entry.name, entry.isDirectory, entry.isSymbolicLink)))
const entries = rows.filter((row): row is DirectoryEntry => row !== null)
.sort((a, b) => a.name.localeCompare(b.name))
return { path: target, home, crumbs: ancestryCrumbs(target), entries }
// Sort candidates before probing so the bound keeps the name-sorted head
// of the level and probing (symlink stat) stops with the bound instead of
// touching every child of an oversized directory.
names.sort((a, b) => a.name.localeCompare(b.name))
const entries: DirectoryEntry[] = []
let truncated = false
for (const entry of names) {
const row = await directoryRow(target, entry.name, entry.isDirectory, entry.isSymbolicLink)
if (row === null) continue
if (entries.length === this.config.maxEntries) {
truncated = true
break
}
entries.push(row)
}
return { path: target, home, crumbs: ancestryCrumbs(target), entries, truncated }
}
private async createDirectory(path: string, name: string): Promise<string> {