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:
@@ -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')
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user