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
#	packages/host/directory-picker-browse/package.json
#	pnpm-lock.yaml
This commit is contained in:
creatixchu
2026-07-29 04:12:44 +08:00
16 changed files with 155 additions and 35 deletions

View File

@@ -28,8 +28,12 @@ export const inject = ['slots', 'workspaces', 'locale']
*/
export function apply(ctx: ClientContext): void {
ctx.effect(() => {
const disposers = [
ctx.locale.register(LOCALE_NS, 'zh', {
// The two dictionaries land as a unit: if the second registration hits a
// rival owner of the namespace, the first rolls back before the throw —
// a failed activation must not squat the namespace's other locale.
const disposers: (() => void)[] = []
const dictionaries: [locale: string, dict: Record<string, string>][] = [
['zh', {
'browser.title': '选择工作区目录',
'browser.home': '主目录',
'browser.newFolder': '新建文件夹',
@@ -42,8 +46,8 @@ export function apply(ctx: ClientContext): void {
'browser.editPath': '编辑路径',
'browser.loading': '加载中…',
'browser.truncated': '文件夹过多,仅显示开头部分。',
}),
ctx.locale.register(LOCALE_NS, 'en', {
}],
['en', {
'browser.title': 'Select Workspace Directory',
'browser.home': 'Home',
'browser.newFolder': 'New folder',
@@ -56,8 +60,14 @@ export function apply(ctx: ClientContext): void {
'browser.editPath': 'Edit path',
'browser.loading': 'Loading…',
'browser.truncated': 'Too many folders to list; only the beginning is shown.',
}),
}],
]
try {
for (const [locale, dict] of dictionaries) disposers.push(ctx.locale.register(LOCALE_NS, locale, dict))
} catch (error) {
for (const dispose of disposers.reverse()) dispose()
throw error
}
return () => { for (const dispose of disposers) dispose() }
}, 'directory-picker-browse: dialog dictionaries')

View File

@@ -9,7 +9,7 @@
* @module @deepseek-ai/dsh-host-directory-picker-browse
*/
import { mkdir, readdir, stat } from 'node:fs/promises'
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'
@@ -53,6 +53,35 @@ export function fullyQualified(path: string, platform: NodeJS.Platform = process
: 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. */
@@ -127,25 +156,32 @@ export default class BrowseDirectoryPicker extends DirectoryPicker {
throw new DirectoryPickerError('directory-unreadable', path, `cannot list "${path}": not a fully qualified path`)
}
const target = resolve(path ?? home)
let names: { name: string; isDirectory: boolean; isSymbolicLink: boolean }[]
// 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 dirents = await readdir(target, { withFileTypes: true })
names = dirents.map(dirent => ({
name: dirent.name,
isDirectory: dirent.isDirectory(),
isSymbolicLink: dirent.isSymbolicLink(),
}))
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)}`)
}
// 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)
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