fix(host): escape cancels a launched navigation; browse joins deferGroupRegistration
- Escape in the path editor now supersedes a navigation the editor already launched (request-sequence bump + loading reset), so a late success cannot jump to the cancelled path; the single-pane fallback keeps covering a superseded selection preview (ds-review-bot). - The browse flow's pair registration rides ui-slots' new deferGroupRegistration (one occupant, both holes, as a unit — construction or late rival conflicts roll back wholesale and fail loud), mirroring -native and deleting the would-be clone.
This commit is contained in:
4 files changed
+76
-19
No files matched your search
@@ -342,13 +342,19 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation()
|
||||
// Cancel also withdraws a navigation the editor already
|
||||
// launched: its late success must not jump to the
|
||||
// cancelled path, so the pending request is superseded
|
||||
// and the view leaves the loading state.
|
||||
requestSeq.current += 1
|
||||
setLoading(false)
|
||||
setPathDraft(null)
|
||||
setError(null)
|
||||
// Editing may have superseded the selection's preview
|
||||
// request; a selection with no preview and nothing in
|
||||
// flight would render a half-empty two-pane view, so
|
||||
// cancel falls back to the single-pane level.
|
||||
if (child === null && !loading) setSelected(null)
|
||||
// request; a selection with no preview would render a
|
||||
// half-empty two-pane view, so cancel falls back to the
|
||||
// single-pane level.
|
||||
if (child === null) setSelected(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* cordis.yml row; no client code branches on a capability kind. The dialog's
|
||||
* copy is locale-registered here — the flow package owns its own strings.
|
||||
*/
|
||||
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { deferGroupRegistration } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: pulls the SlotMap merge declaring the directory-flow holes.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
@@ -65,19 +65,15 @@ export function apply(ctx: ClientContext): void {
|
||||
t: ctx.locale.bind(LOCALE_NS),
|
||||
})
|
||||
ctx.effect(() => {
|
||||
// Constructing the pair can throw halfway (a declared hole already
|
||||
// occupied registers synchronously): roll the earlier deferral back so
|
||||
// no live subscription outlives the failed fiber.
|
||||
const deferred: ReturnType<typeof deferRegistration>[] = []
|
||||
try {
|
||||
deferred.push(deferRegistration(ctx.slots, 'conversation.hero.workspace.directoryFlow', BrowseDirectoryFlow, () =>
|
||||
ctx.slots.register({ name: 'conversation.hero.workspace.directoryFlow', inject: injected }, BrowseDirectoryFlow)))
|
||||
deferred.push(deferRegistration(ctx.slots, 'sidebar.workspaces.directoryFlow', BrowseDirectoryFlow, () =>
|
||||
ctx.slots.register({ name: 'sidebar.workspaces.directoryFlow', inject: injected }, BrowseDirectoryFlow)))
|
||||
} catch (error) {
|
||||
for (const entry of deferred) entry.dispose()
|
||||
throw error
|
||||
}
|
||||
return () => { for (const entry of deferred) entry.dispose() }
|
||||
// One occupant, both holes, as a unit: construction or late conflicts
|
||||
// (holes declared after rival providers activated) roll the whole pair
|
||||
// back and fail loud — semantics owned by deferGroupRegistration.
|
||||
const group = deferGroupRegistration(
|
||||
ctx.slots,
|
||||
['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const,
|
||||
BrowseDirectoryFlow,
|
||||
name => ctx.slots.register({ name, inject: injected }, BrowseDirectoryFlow),
|
||||
)
|
||||
return () => { group.dispose() }
|
||||
}, 'directory-picker-browse: flow registrations')
|
||||
}
|
||||
@@ -92,6 +92,43 @@ describe('directory-picker-browse client half', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('rolls back wholesale and reports loudly when a rival provider wins after deferred activation', async () => {
|
||||
const b = await bench()
|
||||
const rejections: unknown[] = []
|
||||
const onUnhandled = (reason: unknown): void => { rejections.push(reason) }
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
process.on('uncaughtException', onUnhandled)
|
||||
try {
|
||||
// This provider activates BEFORE any hole exists: both deferrals wait.
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
b.declare()
|
||||
// A rival occupies both holes ahead of the pending microtask flush.
|
||||
b.slots.register({ name: HOLES[0] } as never, () => null)
|
||||
b.slots.register({ name: HOLES[1] } as never, () => null)
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
// The rival keeps both holes; this provider rolled back wholesale and
|
||||
// surfaced the conflict on the fail-loud channel — no partial mix.
|
||||
for (const hole of HOLES) expect(b.slots.entries(hole)).toHaveLength(1)
|
||||
expect(rejections.map(String).join('\n')).toContain('already has a registration')
|
||||
|
||||
// Non-Error conflicts wrap before the loud rethrow (same channel).
|
||||
const c = await bench()
|
||||
await c.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const original = c.slots.register.bind(c.slots)
|
||||
const slotsAny = c.slots as { register: typeof original }
|
||||
slotsAny.register = ((options: never, component: never) => {
|
||||
if ((options as { name?: string }).name === HOLES[0]) throw 'string conflict'
|
||||
return original(options, component)
|
||||
}) as typeof original
|
||||
c.declare()
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
expect(rejections.map(String).join('\n')).toContain('string conflict')
|
||||
} finally {
|
||||
process.off('unhandledRejection', onUnhandled)
|
||||
process.off('uncaughtException', onUnhandled)
|
||||
}
|
||||
})
|
||||
|
||||
it('registers the dialog dictionaries and binds this package namespace', async () => {
|
||||
const b = await bench()
|
||||
b.declare()
|
||||
|
||||
@@ -352,6 +352,24 @@ describe('DirectoryBrowser', () => {
|
||||
expect(screen.getByLabelText('browser.editPath')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('ignores a pending navigation that settles after Escape cancelled the editor', async () => {
|
||||
const pending: ((listing: DirectoryListing) => void)[] = []
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText('browser.editPath')
|
||||
b.listDirectory.mockImplementation(() =>
|
||||
new Promise<DirectoryListing>((settle) => { pending.push(settle) }))
|
||||
fireEvent.change(input, { target: { value: DOCS } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
// The cancelled navigation settling late must not jump the view to DOCS.
|
||||
await act(async () => { pending.shift()!(listingFor(DOCS)) })
|
||||
expect(screen.queryByText('harness')).toBeNull()
|
||||
expect(screen.getByText('Documents')).toBeTruthy()
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps a newer path edit when an older slow navigation settles', async () => {
|
||||
const pending: ((listing: DirectoryListing) => void)[] = []
|
||||
const b = mount()
|
||||
|
||||
Reference in New Issue
Block a user