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:
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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)。
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)!
|
||||
|
||||
Reference in New Issue
Block a user