Merge remote-tracking branch 'origin/feat/directory-picker' into feat/workspace-directory-browser

This commit is contained in:
creatixchu
2026-07-29 02:25:06 +08:00
2 changed files with 41 additions and 2 deletions

View File

@@ -35,6 +35,15 @@ export function NativeDirectoryFlow(props: DirectoryFlowOwnerProps & NativeFlowI
// latest handlers, not the ones captured when the chooser opened.
const outcome = useRef(props)
outcome.current = props
// Unmount (HMR replacing the occupant) discards settlements wholesale: the
// dead instance must neither adopt a path nor drive the owner's error
// surface. The wire carries no per-request abort, so the host-side chooser
// survives until answered — its answer just lands nowhere; the replacement
// instance re-arms under the owner's still-open request. An injected-face
// identity change alone (re-registration) keeps the pending settlement:
// the chooser on the host display is still the same dialog.
const alive = useRef(true)
useEffect(() => () => { alive.current = false }, [])
useEffect(() => {
if (!open) {
armed.current = false
@@ -43,8 +52,14 @@ export function NativeDirectoryFlow(props: DirectoryFlowOwnerProps & NativeFlowI
if (armed.current) return
armed.current = true
pick().then(
(path) => { if (path === null) outcome.current.onCancel(); else outcome.current.onPicked(path) },
(reason: unknown) => { outcome.current.onError(reason instanceof Error ? reason.message : String(reason)) },
(path) => {
if (!alive.current) return
if (path === null) outcome.current.onCancel(); else outcome.current.onPicked(path)
},
(reason: unknown) => {
if (!alive.current) return
outcome.current.onError(reason instanceof Error ? reason.message : String(reason))
},
)
}, [open, pick])
return null

View File

@@ -118,6 +118,30 @@ describe('directory-picker-native client half', () => {
expect(first.onPicked).not.toHaveBeenCalled()
})
it('discards a settlement that lands after the flow unmounted', async () => {
let resolve!: (path: string | null) => void
const pick = vi.fn(() => new Promise<string | null>((settle) => { resolve = settle }))
const props = owner()
const view = render(<NativeDirectoryFlow {...props} pick={pick} />)
expect(pick).toHaveBeenCalledOnce()
view.unmount()
// The dead instance must neither adopt nor error; the owner's callbacks
// stay untouched by the orphaned chooser's answer.
await act(async () => { resolve('/tmp/late') })
expect(props.onPicked).not.toHaveBeenCalled()
expect(props.onCancel).not.toHaveBeenCalled()
expect(props.onError).not.toHaveBeenCalled()
// The failure arm is discarded the same way.
let reject!: (reason: unknown) => void
const failing = vi.fn(() => new Promise<string | null>((_settle, rejectPick) => { reject = rejectPick }))
const late = owner()
const failingView = render(<NativeDirectoryFlow {...late} pick={failing} />)
failingView.unmount()
await act(async () => { reject(new Error('too late')) })
expect(late.onError).not.toHaveBeenCalled()
})
it('reports null as cancellation and re-arms after the owner withdraws open', async () => {
const pick = vi.fn(async () => null as string | null)
const props = owner()