da970ea269
The browse level now streams through opendir into a name-sorted window of maxEntries + 1 candidates (boundedInsert), so memory stays O(maxEntries) no matter how many children a directory holds and enterability probing touches only windowed candidates; a windowed broken symlink is not backfilled since the eviction already marks the level truncated. schemastery joins the package's runtime dependencies (the source launcher and isolated installs failed to resolve the value import). The folder-error dialog's Choose again goes inert while the flow hole is empty, and the withdrawal effect also keys on the open transition, so a flow can never open over a hole nobody serves.
221 lines
9.5 KiB
TypeScript
221 lines
9.5 KiB
TypeScript
/**
|
|
* Browse backend of the directory-picker seam: registers `ctx.directoryPicker`
|
|
* with the `browse` capability — one-level directory listing and child-directory
|
|
* creation over the host filesystem via Node's stdlib (which already carries
|
|
* the per-OS adaptation). Nothing renders on the host display, so this backend
|
|
* serves remote clients the dialog backend cannot. Policy decisions (hidden
|
|
* entries flagged but returned, symlinks followed, whole-filesystem scope) are
|
|
* recorded in the directory-picker seam Agent Note.
|
|
* @module @deepseek-ai/dsh-host-directory-picker-browse
|
|
*/
|
|
|
|
import { mkdir, opendir, 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'
|
|
import type {
|
|
DirectoryEntry, DirectoryListing, DirectoryPickerCapability,
|
|
} from '@deepseek-ai/dsh-host-directory-picker'
|
|
|
|
/**
|
|
* Ancestor chain from the filesystem root to `target` inclusive — the
|
|
* breadcrumb rows of a listing, every one a jump target.
|
|
*/
|
|
function ancestryCrumbs(target: string): DirectoryEntry[] {
|
|
const crumbs: DirectoryEntry[] = []
|
|
let current = target
|
|
for (;;) {
|
|
const parent = dirname(current)
|
|
// basename of a root is '' — label the root crumb by its full path ('/', 'C:\').
|
|
crumbs.unshift({ name: parent === current ? current : basename(current), path: current, hidden: false })
|
|
if (parent === current) return crumbs
|
|
current = parent
|
|
}
|
|
}
|
|
|
|
/**
|
|
* True when the path names one fixed filesystem location regardless of
|
|
* process state: POSIX-absolute on POSIX; on Windows only drive-qualified
|
|
* (`C:\…`) or complete UNC (`\\server\share…`) forms. Rooted drive-less
|
|
* forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`)
|
|
* pass `isAbsolute` yet still resolve against the process's current drive.
|
|
* @param path - candidate path.
|
|
* @param platform - replaces `process.platform` for deterministic tests.
|
|
* @returns whether the path is fully qualified on the platform.
|
|
*/
|
|
export function fullyQualified(path: string, platform: NodeJS.Platform = process.platform): boolean {
|
|
return platform === 'win32'
|
|
? win32.isAbsolute(path) && /^(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/]+[\\/]+[^\\/]+)/.test(path)
|
|
: posix.isAbsolute(path)
|
|
}
|
|
|
|
/** One streamed listing candidate: the dirent facts a row needs, nothing else retained. */
|
|
export interface ListingCandidate {
|
|
/** Base name within the streamed level. */
|
|
name: string
|
|
/** Dirent says directory (no probe needed). */
|
|
isDirectory: boolean
|
|
/** Dirent says symlink (enterability needs a stat probe). */
|
|
isSymbolicLink: boolean
|
|
}
|
|
|
|
/**
|
|
* Insert a streamed candidate into the name-sorted bounded window, evicting
|
|
* the name-largest candidate when the window exceeds `keep`. Memory over an
|
|
* arbitrarily large level therefore stays O(keep) regardless of how many
|
|
* children the directory holds.
|
|
* @param window - the name-ascending window, mutated in place.
|
|
* @param candidate - the streamed candidate to place.
|
|
* @param keep - the window bound.
|
|
* @returns true when an eviction happened (the level has candidates beyond the window).
|
|
*/
|
|
export function boundedInsert(window: ListingCandidate[], candidate: ListingCandidate, keep: number): boolean {
|
|
const at = window.findIndex(existing => candidate.name.localeCompare(existing.name) < 0)
|
|
if (at === -1) window.push(candidate)
|
|
else window.splice(at, 0, candidate)
|
|
if (window.length <= keep) return false
|
|
window.pop()
|
|
return true
|
|
}
|
|
|
|
/** Message text of an unknown thrown value. */
|
|
function messageOf(error: unknown): string {
|
|
/* v8 ignore next -- node:fs rejects with Error instances; the String arm only satisfies the unknown narrowing. */
|
|
return error instanceof Error ? error.message : String(error)
|
|
}
|
|
|
|
/**
|
|
* One listing row for a dirent, following symlinks to directories; null for
|
|
* non-directories and broken/cyclic links (skipped silently — the browser
|
|
* shows what can be entered, and a broken link cannot).
|
|
*/
|
|
async function directoryRow(parent: string, name: string, isDirectory: boolean, isSymbolicLink: boolean): Promise<DirectoryEntry | null> {
|
|
const path = join(parent, name)
|
|
let enterable = isDirectory
|
|
if (!enterable && isSymbolicLink) {
|
|
try {
|
|
enterable = (await stat(path)).isDirectory()
|
|
} catch {
|
|
// Broken or cyclic symlink: stat is the probe, failure means "not enterable".
|
|
return null
|
|
}
|
|
}
|
|
if (!enterable) return null
|
|
// POSIX hidden convention; Windows' hidden attribute is not exposed by
|
|
// dirents (Known Limitations). The client owns whether hidden rows show.
|
|
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.
|
|
*/
|
|
capability(): DirectoryPickerCapability {
|
|
return this.browseCapability
|
|
}
|
|
|
|
private async list(path?: string): Promise<DirectoryListing> {
|
|
const home = homedir()
|
|
// The seam contract takes fully qualified paths only; resolve() would
|
|
// silently rebase a relative or empty wire value under the host process
|
|
// cwd (or, for rooted drive-less Windows forms, its current drive).
|
|
if (path !== undefined && !fullyQualified(path)) {
|
|
throw new DirectoryPickerError('directory-unreadable', path, `cannot list "${path}": not a fully qualified path`)
|
|
}
|
|
const target = resolve(path ?? home)
|
|
// Stream the level (opendir, one dirent at a time) into a name-sorted
|
|
// window of maxEntries + 1 candidates: memory stays bounded no matter how
|
|
// many children the directory holds, the window keeps the name-sorted
|
|
// head, and the +1 slot lets an in-window extra row prove the cut. A
|
|
// window candidate that turns out non-enterable (broken symlink) is not
|
|
// backfilled from beyond the window — an eviction already marks the
|
|
// level truncated, which stays the honest answer.
|
|
const keep = this.config.maxEntries + 1
|
|
const window: ListingCandidate[] = []
|
|
let evicted = false
|
|
try {
|
|
const level = await opendir(target)
|
|
for await (const dirent of level) {
|
|
// Only rows a browser could enter contend for the window; dirent
|
|
// says "directory" outright, a symlink needs the later stat probe.
|
|
if (!dirent.isDirectory() && !dirent.isSymbolicLink()) continue
|
|
const candidate = { name: dirent.name, isDirectory: dirent.isDirectory(), isSymbolicLink: dirent.isSymbolicLink() }
|
|
if (boundedInsert(window, candidate, keep)) evicted = true
|
|
}
|
|
} catch (error: unknown) {
|
|
throw new DirectoryPickerError('directory-unreadable', target, `cannot list ${target}: ${messageOf(error)}`)
|
|
}
|
|
const entries: DirectoryEntry[] = []
|
|
let truncated = evicted
|
|
for (const candidate of window) {
|
|
const row = await directoryRow(target, candidate.name, candidate.isDirectory, candidate.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> {
|
|
// Same fully-qualified fence as list: never rebase a parent under the
|
|
// cwd or the current drive.
|
|
if (!fullyQualified(path)) {
|
|
throw new DirectoryPickerError('directory-create-failed', path, `cannot create under "${path}": not a fully qualified parent path`)
|
|
}
|
|
const parent = resolve(path)
|
|
// The backend owns segment validation (the wire schema also refuses these,
|
|
// but direct service consumers must hit the same fence).
|
|
if (name.trim() === '' || name === '.' || name === '..' || /[/\\]/.test(name)) {
|
|
throw new DirectoryPickerError('directory-create-failed', join(parent, name), `"${name}" is not a single path segment`)
|
|
}
|
|
const target = join(parent, name)
|
|
try {
|
|
// Non-recursive: the parent is the directory the browser is showing, so
|
|
// a missing parent is a real failure, not a level to invent.
|
|
await mkdir(target)
|
|
return target
|
|
} catch (error: unknown) {
|
|
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST') {
|
|
throw new DirectoryPickerError('directory-exists', target, `${target} already exists`)
|
|
}
|
|
throw new DirectoryPickerError('directory-create-failed', target, `cannot create ${target}: ${messageOf(error)}`)
|
|
}
|
|
}
|
|
}
|