Procházet zdrojové kódy

fix(host,client): review round 23 — create-path contract at every client declaration; close-edge resets; NFD tripwire; canonical double join

creatixchu před 2 měsíci
rodič
revize
df313b7531

+ 4 - 1
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<string>
   /**

+ 2 - 1
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<string> {
     const response = await this.api.host.createDirectory({ path, name })

+ 3 - 3
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<string> {
     this.calls.push({ method: 'createDirectory', args: [path, name] })
     const stub = this.stubs.get('createDirectory')
     if (stub !== undefined) return await (stub(path, name) as Promise<string>)
-    return `${path}/${name}`
+    // Canonical join: a bare-root parent must not double the separator.
+    return path.endsWith('/') ? `${path}${name}` : `${path}/${name}`
   }
 
   /**

+ 4 - 0
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: [] }

+ 2 - 2
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

+ 1 - 1
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.

+ 1 - 1
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 范围而非边界——等到有部署需要时再做。

+ 16 - 7
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<DirectoryListing>
-  /** 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<string>
   /** 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)

+ 5 - 1
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<DirectoryListing>
-  /** 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<string>
   /** Localized dialog copy (this package's namespace). */
   t: Translate

+ 8 - 4
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 () => {