diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index 8a56329d79..a259edb5ff 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: 622571efc14f40e266e88229a60befa6bdcd6db9 -2026-07-28-directory-picker-capability-seam.zh.md: fbe42ccd81544e79af4078157345cf330c0b3510 +2026-07-28-directory-picker-capability-seam.md: 7d6a78a735a02ae1d17616c63da1acf9a9117ba9 +2026-07-28-directory-picker-capability-seam.zh.md: 1306eae397e2a4c11603264d434ced3b4113f9b2 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index 622571efc1..7d6a78a735 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -20,7 +20,7 @@ Placement and policy rulings folded into this decision: - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. - **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the planned show-hidden toggle becomes a client-only change. Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. -- **Listing levels are bounded.** One `list` call materializes at most `maxEntries` 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 probing stops with the bound, and the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. An unbounded level is a memory/responsiveness hole for large or adversarial directories. +- **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. An unbounded level is a memory/responsiveness hole for large or adversarial directories. - **Whole-filesystem scope, no roots config.** `workspace.create` accepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it. - **The native backend stays.** Plugin-form was the point: multiple providers can serve the seam (an Electron shell would provide the `native` interaction through its own dialog API). Kind naming: `dialog` was the first pick and was dropped — the browse interaction also presents a dialog (the in-app modal), so the word failed to discriminate; `native` names where the chooser runs. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index fbe42ccd81..1306eae397 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -20,7 +20,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,计划中的"显示隐藏"开关变成纯客户端改动。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 -- **列举层级有上限。** 单次 `list` 至多物化 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限):候选先按名排序再探测,因此被截断的层级保留排序后的头部、探测随上限一同停止;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 +- **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 - **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 - **native 后端保留。** 插件化正是目的:多方都能提供该 seam(Electron 壳可以经自己的对话框 API 提供 `native` 交互)。kind 命名:最初选了 `dialog` 后被放弃——browse 交互同样以对话框呈现(应用内弹窗),这个词起不到判别作用;`native` 命名的是选择器运行的位置。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 865e97a555..8db82e5cc7 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -533,7 +533,7 @@ export interface Config { } ``` -Source: [`packages/host/directory-picker-browse/src/index.ts:85`](../packages/host/directory-picker-browse/src/index.ts) +Source: [`packages/host/directory-picker-browse/src/index.ts:114`](../packages/host/directory-picker-browse/src/index.ts) ## `@deepseek-ai/dsh-host-webserver` diff --git a/packages/client/test-runtime/src/workspaces.ts b/packages/client/test-runtime/src/workspaces.ts index ee635a5fd8..cc9f60bd5c 100644 --- a/packages/client/test-runtime/src/workspaces.ts +++ b/packages/client/test-runtime/src/workspaces.ts @@ -119,7 +119,7 @@ export class TestWorkspaces implements IWorkspaces { this.calls.push({ method: 'listDirectory', args: [path] }) const stub = this.stubs.get('listDirectory') if (stub !== undefined) return await (stub(path) as Promise) - return { path: '/home/test', home: '/home/test', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [] } + return { path: '/home/test', home: '/home/test', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false } } /** diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index fa6290e1f1..903b0e115b 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -98,9 +98,12 @@ export function WorkspaceCreateFlow({ const flowAvailable = useDirectoryFlow(occupied => occupied) // An occupant that unloads mid-interaction leaves nobody to cancel: an // open flow over an empty hole withdraws so the menu actions come back. + // flowOpen is a dependency because the flow can also OPEN over an already + // empty hole (Choose again after the occupant unloaded with the error + // dialog up) — that transition must snap back too, not just occupancy loss. useEffect(() => { - if (!flowAvailable) setFlowOpen(false) - }, [flowAvailable]) + if (flowOpen && !flowAvailable) setFlowOpen(false) + }, [flowOpen, flowAvailable]) const createEntries: MenuEntry[] = [ ...(flowAvailable ? [{ id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: , disabled: flowBusy }] @@ -224,7 +227,9 @@ export function WorkspaceCreateFlow({ footer={( <> - + {/* Retrying needs an occupant to serve the flow; without one the + * button would open a flow nobody can answer or cancel. */} + )} > diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index 18ddd8b1fd..490a86d782 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -303,6 +303,20 @@ describe('WorkspacePicker', () => { expect(screen.getByRole('menuitem', { name: 'Open local folder…' })).toBeTruthy() }) + it('keeps Choose again inert while the flow occupant is gone, and snaps back a flow opened over an empty hole', async () => { + const b = mount([], vi.fn(async () => { throw new Error('adoption failed') })) + chooseItem('Open local folder…') + await act(async () => { b.probe.owner!.onPicked('/one/project') }) + await waitFor(() => { expect(screen.getByRole('dialog', { name: 'Couldn’t open folder' })).toBeTruthy() }) + // The occupant unloads while the error dialog is up: retrying would open + // a flow nobody can serve or cancel, so the button goes inert. + act(() => { b.occupancy.flip(false) }) + expect(screen.getByRole('button', { name: 'Choose again' }).disabled).toBe(true) + // Cancel stays the way out, and the menu actions are usable again. + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(screen.getByRole('menuitem', { name: 'Create a new workspace' }).disabled).toBe(false) + }) + it('withdraws an open flow when its occupant unloads, re-enabling the menu actions', () => { const b = mount([]) chooseItem('Open local folder…') diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index 01c7f59933..f6cf3fe8d0 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/directory-picker-browse/README.md -README.md: 699bc88ea3f2df6b96861d7a02543dcf5c692a63 -README.zh.md: bac45f708a1a7407bd95b5e1aaa947dc1b16bf26 +README.md: 36349a607cdcf99ce2b8c9ee767ffd4d896a64a3 +README.zh.md: 0108e37bd7c4213bf057a42af2026beb6bf53d45 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 699bc88ea3..36349a607c 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The **in-app browsing backend** of the [directory-picker seam](../directory-picker/README.md): `BrowseDirectoryPicker` registers `ctx.directoryPicker` with the `browse` capability — one-level directory listing and child-directory creation over Node's stdlib, which already carries the per-OS adaptation. Nothing renders on the host display, so this backend serves remote clients the native backend cannot. -Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call materializes at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings): a cut level keeps the name-sorted head, counts hidden rows against the bound, stops probing once the bound is hit, and reports `truncated: true` so the client can say the level is incomplete. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). +Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (a windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated). Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). **Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view, breadcrumb with a click-to-edit path zone, nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index bac45f708a..0108e37bd7 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -4,7 +4,7 @@ [目录选择 seam](../directory-picker/README.md) 的**应用内浏览后端**:`BrowseDirectoryPicker` 以 `browse` 能力注册 `ctx.directoryPicker`——基于 Node 标准库(跨 OS 适配本就由它承担)提供单层目录列举与子目录创建。宿主屏幕上不渲染任何东西,因此该后端能服务 native 后端无法触及的远程客户端。 -行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多物化 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限):被截断的层级保留按名排序的头部、隐藏行计入上限、达到上限即停止探测,并报告 `truncated: true`,供客户端提示层级不完整。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 +行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断)。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 **双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图、带点击即编辑路径区的面包屑、嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 diff --git a/packages/host/directory-picker-browse/package.json b/packages/host/directory-picker-browse/package.json index 192ce614d2..a9f3fc2090 100644 --- a/packages/host/directory-picker-browse/package.json +++ b/packages/host/directory-picker-browse/package.json @@ -33,7 +33,8 @@ "license": "BSD-3-Clause", "dependencies": { "@deepseek-ai/dsh-host-directory-picker": "workspace:^", - "clsx": "^2.0.0" + "clsx": "^2.0.0", + "schemastery": "^3.18.0" }, "peerDependencies": { "@deepseek-ai/dsh-client-locale": "^0.0.1", diff --git a/packages/host/directory-picker-browse/src/client/index.ts b/packages/host/directory-picker-browse/src/client/index.ts index bc409483ab..6eb43829db 100644 --- a/packages/host/directory-picker-browse/src/client/index.ts +++ b/packages/host/directory-picker-browse/src/client/index.ts @@ -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][] = [ + ['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') diff --git a/packages/host/directory-picker-browse/src/index.ts b/packages/host/directory-picker-browse/src/index.ts index b9699cd612..20ebef1c62 100644 --- a/packages/host/directory-picker-browse/src/index.ts +++ b/packages/host/directory-picker-browse/src/index.ts @@ -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 diff --git a/packages/host/directory-picker-browse/tests/client-flow.spec.tsx b/packages/host/directory-picker-browse/tests/client-flow.spec.tsx index 80e8b19db0..8a9b27b5d7 100644 --- a/packages/host/directory-picker-browse/tests/client-flow.spec.tsx +++ b/packages/host/directory-picker-browse/tests/client-flow.spec.tsx @@ -130,6 +130,29 @@ describe('directory-picker-browse client half', () => { } }) + it('rolls back the zh dictionary when a rival already owns the namespace en slot', async () => { + const b = await bench() + b.declare() + const locale = b.ctx.get('locale') as LocaleService + const disposeRival = locale.register('directory-browser', 'en', { 'browser.title': 'rival' }) + const rejections: unknown[] = [] + const onUnhandled = (reason: unknown): void => { rejections.push(reason) } + // cordis re-raises the apply throw as a late rejection (installFailLoud's contract). + process.on('unhandledRejection', onUnhandled) + try { + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await expect(fiber.await()).rejects.toThrow(/already has locale/) + // The zh registration rolled back with the failure: once the rival + // leaves, a fresh registrant owns the whole namespace again. + disposeRival() + const disposeZh = locale.register('directory-browser', 'zh', { 'browser.title': '空闲' }) + disposeZh() + } finally { + await new Promise(resolve => setTimeout(resolve, 0)) + process.off('unhandledRejection', onUnhandled) + } + }) + it('registers the dialog dictionaries and binds this package namespace', async () => { const b = await bench() b.declare() diff --git a/packages/host/directory-picker-browse/tests/service.spec.ts b/packages/host/directory-picker-browse/tests/service.spec.ts index 8e7a2ba049..2b01d0d29e 100644 --- a/packages/host/directory-picker-browse/tests/service.spec.ts +++ b/packages/host/directory-picker-browse/tests/service.spec.ts @@ -7,7 +7,8 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { Context } from 'cordis' import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' import type { DirectoryPickerBrowseCapability } from '@deepseek-ai/dsh-host-directory-picker' -import BrowseDirectoryPicker, { fullyQualified } from '../src/index.ts' +import BrowseDirectoryPicker, { boundedInsert, fullyQualified } from '../src/index.ts' +import type { ListingCandidate } from '../src/index.ts' let root: string let capability: DirectoryPickerBrowseCapability @@ -21,6 +22,13 @@ beforeAll(async () => { await writeFile(join(root, 'notes.txt'), 'not a directory') await symlink(join(root, 'projects'), join(root, 'linked'), 'junction') await symlink(join(root, 'gone'), join(root, 'broken'), 'junction') + try { + await symlink(join(root, 'notes.txt'), join(root, 'file-link')) + } catch { + // Windows denies unprivileged file symlinks; the file-link row only + // feeds the POSIX lanes' coverage of the symlink-to-file arm, and every + // assertion below expects it to be filtered out anyway. + } const ctx = new Context() const fiber = ctx.plugin(BrowseDirectoryPicker) @@ -63,11 +71,31 @@ describe('BrowseDirectoryPicker', () => { const exact = await bounded.list(join(root, 'projects')) expect(exact.entries.map(entry => entry.name)).toEqual(['harness']) expect(exact.truncated).toBe(false) + // A level that fits the window but exceeds the bound (two rows, bound + // one): the in-window extra row proves the cut without any eviction. + await mkdir(join(root, 'projects', 'harness', 'a')) + await mkdir(join(root, 'projects', 'harness', 'b')) + const inWindow = await bounded.list(join(root, 'projects', 'harness')) + expect(inWindow.entries.map(entry => entry.name)).toEqual(['a']) + expect(inWindow.truncated).toBe(true) } finally { await fiber.dispose() } }) + it('boundedInsert keeps the window name-sorted and bounded, reporting evictions', () => { + const candidate = (name: string): ListingCandidate => ({ name, isDirectory: true, isSymbolicLink: false }) + const window: ListingCandidate[] = [] + expect(boundedInsert(window, candidate('m'), 2)).toBe(false) + expect(boundedInsert(window, candidate('z'), 2)).toBe(false) + // A smaller name lands in place and pushes the current largest out. + expect(boundedInsert(window, candidate('a'), 2)).toBe(true) + expect(window.map(entry => entry.name)).toEqual(['a', 'm']) + // A name beyond the window's tail enters last and leaves immediately. + expect(boundedInsert(window, candidate('t'), 2)).toBe(true) + expect(window.map(entry => entry.name)).toEqual(['a', 'm']) + }) + it('reports the ancestry as jump-target crumbs ending at the listed directory', async () => { const listing = await capability.list(join(root, 'projects')) const tail = listing.crumbs.at(-1)! diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36a410c025..f7026700fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2798,6 +2798,9 @@ importers: clsx: specifier: ^2.0.0 version: 2.1.1 + schemastery: + specifier: ^3.18.0 + version: 3.18.0 devDependencies: '@deepseek-ai/dsh-client-locale': specifier: workspace:^