diff --git a/packages/client/runtime/src/client/contract/workspaces.ts b/packages/client/runtime/src/client/contract/workspaces.ts index 9238ea5fd0..ea8dfcf57f 100644 --- a/packages/client/runtime/src/client/contract/workspaces.ts +++ b/packages/client/runtime/src/client/contract/workspaces.ts @@ -48,7 +48,10 @@ export interface IWorkspaces { * Create one child directory through the Host's `browse` capability. * @param path - absolute existing parent directory. * @param name - single non-blank path segment. - * @returns the created directory's absolute path. + * @returns the created directory's absolute path, in the shape + * `DirectoryPickerBrowseCapability.createDirectory` contracts: verbatim + * equal to the child's `entries[].path` in the parent's next listing + * (the browser anchors a create landing's selection on that equality). */ createDirectory(path: string, name: string): Promise /** diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index 1dd3319e79..df7f69a19b 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -208,7 +208,8 @@ export class WorkspacesService implements IWorkspaces { * Create one child directory through the Host's `browse` capability. * @param path - absolute existing parent directory. * @param name - single non-blank path segment. - * @returns the created directory's absolute path. + * @returns the created directory's absolute path, in the shape + * `IWorkspaces.createDirectory` contracts. */ async createDirectory(path: string, name: string): Promise { const response = await this.api.host.createDirectory({ path, name }) diff --git a/packages/client/test-runtime/src/workspaces.ts b/packages/client/test-runtime/src/workspaces.ts index d24e7b8c8a..bfaa414e9f 100644 --- a/packages/client/test-runtime/src/workspaces.ts +++ b/packages/client/test-runtime/src/workspaces.ts @@ -143,14 +143,14 @@ export class TestWorkspaces implements IWorkspaces { * @param path - absolute existing parent directory. * @param name - single path segment. * @returns the created directory's absolute path, in the shape - * `DirectoryPickerBrowseCapability.createDirectory` contracts (verbatim - * equal to the child's `entries[].path` in the parent's next listing). + * `IWorkspaces.createDirectory` contracts. */ async createDirectory(path: string, name: string): Promise { this.calls.push({ method: 'createDirectory', args: [path, name] }) const stub = this.stubs.get('createDirectory') if (stub !== undefined) return await (stub(path, name) as Promise) - return `${path}/${name}` + // Canonical join: a bare-root parent must not double the separator. + return path.endsWith('/') ? `${path}${name}` : `${path}/${name}` } /** diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index b170f69ba2..826a5e4326 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -329,12 +329,16 @@ describe('workspaces', () => { await expect(runtime.workspaces.listDirectory()).resolves.toMatchObject({ path: '/home/test', entries: [] }) await expect(runtime.workspaces.listDirectory('/home/test')).resolves.toMatchObject({ path: '/home/test' }) await expect(runtime.workspaces.createDirectory('/home/test', 'fresh')).resolves.toBe('/home/test/fresh') + // Canonical join: a bare-root parent yields /top, not //top (the + // IWorkspaces contract's verbatim entries[].path equality). + await expect(runtime.workspaces.createDirectory('/', 'top')).resolves.toBe('/top') // The recorded signal seat mirrors the production face (undefined here; // cancellation tests pass and observe a real one). expect(runtime.workspaces.calls).toEqual([ { method: 'listDirectory', args: [undefined, undefined] }, { method: 'listDirectory', args: ['/home/test', undefined] }, { method: 'createDirectory', args: ['/home/test', 'fresh'] }, + { method: 'createDirectory', args: ['/', 'top'] }, ]) // Stubs replace the defaults like every sibling method. const listing = { path: '/x', home: '/x', crumbs: [], entries: [] } diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index 7d37891821..2ab8b5a667 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: 01e2b9e5afcfd7c47a3f42a76cc1388a25477334 -README.zh.md: 8cb63048713dc964f92762546de783d2ce6ce5a7 +README.md: d6ed7181ffbec85d11e0abf2aa8d0053173ba4e9 +README.zh.md: 67f5f2bc40297d96bc3fcd3cfba0a3fd26855adc diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 01e2b9e5af..d6ed7181ff 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -19,6 +19,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **Windows hidden attribute is not read** — Node dirents do not expose `FILE_ATTRIBUTE_HIDDEN`, so `hidden` means dot-prefixed on every platform until a native probe is worth its cost. -- **Name-normalizing volumes void the create-path equality** — `createDirectory` promises its return verbatim-equal to the child's next `entries[].path`; Node's namespaced Win32 paths store even trailing-dot/space segments literally, but a volume that rewrites names on storage (NFD normalization on HFS+-style volumes) breaks the match, and the create landing degrades to the documented single-pane / edit-zone fallback. +- **Name-normalizing volumes void the create-path equality** — `createDirectory` promises its return verbatim-equal to the child's next `entries[].path`; Node's namespaced Win32 paths store even trailing-dot/space segments literally, but a volume that rewrites names on storage (NFD normalization on HFS+-style volumes) breaks the match, and the create landing degrades to a two-pane view whose left pane lacks the aria-current row while focus falls back to the crumb edit zone. - **No drive-root enumeration** — on Windows the ancestry stops at the drive root; crossing drives waits for the browser UI's path-entry affordance rather than an enumeration primitive here. - **Whole-filesystem scope** — no per-deployment browse-root restriction; `workspace.create` accepts arbitrary paths today, so a root here would be UX scoping, not a boundary — deferred until a deployment needs it. diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 8cb6304871..67f5f2bc40 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -19,6 +19,6 @@ ## 已知限制与延期工作 - **不读取 Windows 隐藏属性**——Node 的 dirent 不暴露 `FILE_ATTRIBUTE_HIDDEN`,因此在所有平台上 `hidden` 都意味着点前缀,直到原生探测值回其成本为止。 -- **名称规范化的卷会使创建路径等式失效**——`createDirectory` 承诺其返回值与该子项下一次的 `entries[].path` 逐字相等;Node 带命名空间的 Win32 路径连末尾点/空格段都按字面存储,但在存储时改写名称的卷(HFS+ 风格卷上的 NFD 规范化)会破坏这一匹配,创建落地随之退化为文档所述的单栏/编辑区回退。 +- **名称规范化的卷会使创建路径等式失效**——`createDirectory` 承诺其返回值与该子项下一次的 `entries[].path` 逐字相等;Node 带命名空间的 Win32 路径连末尾点/空格段都按字面存储,但在存储时改写名称的卷(HFS+ 风格卷上的 NFD 规范化)会破坏这一匹配,创建落地随之退化为左栏缺少 aria-current 行的双栏视图,同时焦点回落至 crumb 编辑区。 - **不枚举盘符根**——Windows 上祖先链止于盘符根;跨盘依赖浏览器 UI 的路径输入入口,而不是这里的枚举原语。 - **全盘可浏览**——没有按部署限定的浏览根;`workspace.create` 今天就接受任意路径,这里的根只会是 UX 范围而非边界——等到有部署需要时再做。 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 3fff931685..7414e08031 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -41,7 +41,12 @@ export interface DirectoryBrowserProps { open: boolean /** List one directory level (absent path = the Host home directory); the signal aborts a superseded scan on the wire. */ listDirectory: (path?: string, signal?: AbortSignal) => Promise - /** Create one child directory under an existing parent. */ + /** + * Create one child directory under an existing parent; the returned path + * is verbatim the child's `entries[].path` in the parent's next listing + * (`IWorkspaces.createDirectory`'s contract) — the create landing anchors + * its selection and focus on that equality. + */ createDirectory: (path: string, name: string) => Promise /** The operator confirmed a directory (the selection, else the listed level). */ onOpen: (path: string) => void @@ -500,19 +505,23 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }, [child, select]) // Every open starts fresh at the Host home directory; closing invalidates - // any in-flight response so a late arrival cannot repopulate a closed dialog. + // any in-flight response so a late arrival cannot repopulate a closed + // dialog. The per-open state resets live on the CLOSE edge: resetting on + // open would let the reopen's first commit paint one frame of the stale + // view (revealed hidden rows, a pressed toggle) before this passive + // effect runs. useEffect(() => { openGeneration.current += 1 if (open) { - setParent(null) - setSelected(null) - setChild(null) - setCreatingFolder(false) - setShowHidden(false) navigate() return } supersede() + setParent(null) + setSelected(null) + setChild(null) + setCreatingFolder(false) + setShowHidden(false) setError(null) setPathDraft(null) setFolderDraft(null) diff --git a/packages/host/directory-picker-browse/src/client/flow.ts b/packages/host/directory-picker-browse/src/client/flow.ts index 84e49b2c98..878bf7e4db 100644 --- a/packages/host/directory-picker-browse/src/client/flow.ts +++ b/packages/host/directory-picker-browse/src/client/flow.ts @@ -15,7 +15,11 @@ import { DirectoryBrowser } from './DirectoryBrowser.tsx' export interface BrowseFlowInjected { /** List one directory level (absent path = the Host home directory); the signal aborts a superseded scan. */ listDirectory: (path?: string, signal?: AbortSignal) => Promise - /** Create one child directory under an existing parent. */ + /** + * Create one child directory under an existing parent; returns the + * created path in the shape `IWorkspaces.createDirectory` contracts + * (verbatim equal to the child's next `entries[].path`). + */ createDirectory: (path: string, name: string) => Promise /** Localized dialog copy (this package's namespace). */ t: Translate diff --git a/packages/host/directory-picker-browse/tests/service.spec.ts b/packages/host/directory-picker-browse/tests/service.spec.ts index cfb65af627..09b888f5cc 100644 --- a/packages/host/directory-picker-browse/tests/service.spec.ts +++ b/packages/host/directory-picker-browse/tests/service.spec.ts @@ -203,14 +203,18 @@ describe('BrowseDirectoryPicker', () => { }) it('creates one child directory and surfaces it in the next listing', async () => { - const created = await capability.createDirectory(root, 'fresh') - expect(created).toBe(join(root, 'fresh')) + // The composed-form name (U+00E9) doubles as the name-rewriting + // tripwire: a volume that stores names NFD-decomposed hands back a + // different dirent.name and the equality below goes red — the README's + // documented boundary. + const created = await capability.createDirectory(root, 'café') + expect(created).toBe(join(root, 'café')) const listing = await capability.list(root) - expect(listing.entries.map(entry => entry.name)).toContain('fresh') + expect(listing.entries.map(entry => entry.name)).toContain('café') // The contract's cross-method equality: the returned path is verbatim // the child's entries[].path (clients anchor the create landing's // selection and focus on it). - expect(listing.entries.find(entry => entry.name === 'fresh')!.path).toBe(created) + expect(listing.entries.find(entry => entry.name === 'café')!.path).toBe(created) }) it('refuses an existing child with directory-exists', async () => {