ソースを参照

fix(host,client): review round 24 — close-edge facts synced; wire-hop pointer; platform-flavored double join; loading reset

creatixchu 2 ヶ月 前
親
コミット
53e85101b9

+ 4 - 4
packages/client/runtime/src/client/contract/workspaces.ts

@@ -48,10 +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, 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).
+   * @returns the created directory's absolute path, in the shape the wire
+   * `HostApi.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>
   /**

+ 5 - 2
packages/client/test-runtime/src/workspaces.ts

@@ -149,8 +149,11 @@ export class TestWorkspaces implements IWorkspaces {
     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>)
-    // Canonical join: a bare-root parent must not double the separator.
-    return path.endsWith('/') ? `${path}${name}` : `${path}/${name}`
+    // Join in the parent's own separator flavor (a canonical parent ends
+    // with one only when it is a bare root), so the contract's verbatim
+    // equality holds for POSIX and Windows fixture trees alike.
+    const sep = path.includes('\\') ? '\\' : '/'
+    return path.endsWith(sep) ? `${path}${name}` : `${path}${sep}${name}`
   }
 
   /**

+ 6 - 1
packages/client/test-runtime/tests/runtime.spec.tsx

@@ -329,9 +329,12 @@ 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
+    // Canonical join in the parent's own separator flavor: bare roots do
+    // not double the separator, Windows parents keep backslashes (the
     // IWorkspaces contract's verbatim entries[].path equality).
     await expect(runtime.workspaces.createDirectory('/', 'top')).resolves.toBe('/top')
+    await expect(runtime.workspaces.createDirectory('C:\\', 'top')).resolves.toBe('C:\\top')
+    await expect(runtime.workspaces.createDirectory('C:\\Users', 'Alice')).resolves.toBe('C:\\Users\\Alice')
     // The recorded signal seat mirrors the production face (undefined here;
     // cancellation tests pass and observe a real one).
     expect(runtime.workspaces.calls).toEqual([
@@ -339,6 +342,8 @@ describe('workspaces', () => {
       { method: 'listDirectory', args: ['/home/test', undefined] },
       { method: 'createDirectory', args: ['/home/test', 'fresh'] },
       { method: 'createDirectory', args: ['/', 'top'] },
+      { method: 'createDirectory', args: ['C:\\', 'top'] },
+      { method: 'createDirectory', args: ['C:\\Users', 'Alice'] },
     ])
     // Stubs replace the defaults like every sibling method.
     const listing = { path: '/x', home: '/x', crumbs: [], entries: [] }

+ 10 - 6
packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx

@@ -37,7 +37,7 @@ import css from './DirectoryBrowser.module.css'
 
 /** Owner-supplied browser props: browse calls, pick semantics, and copy. */
 export interface DirectoryBrowserProps {
-  /** Dialog visibility (owner-local; closed unmounts nothing but resets on reopen). */
+  /** Dialog visibility (owner-local; closing resets the per-open state, so a reopen starts clean on its first frame). */
   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>
@@ -258,7 +258,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
   const [error, setError] = useState<string | null>(null)
   // Path-edit state: null = breadcrumb mode; a string = the draft being typed.
   const [pathDraft, setPathDraft] = useState<string | null>(null)
-  // Show-hidden toggle state (pure client-side filter, reset on each open).
+  // Show-hidden toggle state (pure client-side filter, reset on close).
   const [showHidden, setShowHidden] = useState(false)
   // Create-folder state: null = closed; a string = the nested dialog's draft.
   const [folderDraft, setFolderDraft] = useState<string | null>(null)
@@ -334,9 +334,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
    * show-hidden toggle's click to decide whether to reclaim the native
    * focus outcome. A probe only: it never gates its caller — a torn-down
    * ref in a landing's close race merely skips the parking, and
-   * committing the landing into a closing dialog is safe (the component
-   * already renders null, and the open effect resets parent/selected/child
-   * on the next open).
+   * committing the landing into a closing dialog is safe: the close edge's
+   * supersede() fences every later settlement, and the same close effect
+   * zeroes parent/selected/child for the one frame that can slip between
+   * the close render and its effect.
    * @returns true when `document.activeElement` is inside the miller row.
    */
   const focusInMillerRows = useCallback((): boolean => {
@@ -509,7 +510,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
   // 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.
+  // effect runs. No automated gate observes that ordering (act() hides the
+  // frame in tests) — this comment is the guard; read it before moving
+  // these back.
   useEffect(() => {
     openGeneration.current += 1
     if (open) {
@@ -522,6 +525,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
     setChild(null)
     setCreatingFolder(false)
     setShowHidden(false)
+    setLoading(false)
     setError(null)
     setPathDraft(null)
     setFolderDraft(null)

+ 1 - 5
packages/host/directory-picker-browse/src/client/flow.ts

@@ -15,11 +15,7 @@ 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; returns the
-   * created path in the shape `IWorkspaces.createDirectory` contracts
-   * (verbatim equal to the child's next `entries[].path`).
-   */
+  /** Create one child directory under an existing parent; returns the created path in the shape `IWorkspaces.createDirectory` contracts. */
   createDirectory: (path: string, name: string) => Promise<string>
   /** Localized dialog copy (this package's namespace). */
   t: Translate