diff --git a/apps/cli/package.json b/apps/cli/package.json index db4c0c6173..36f1f15424 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -49,6 +49,7 @@ "@deepseek-ai/dsh-fs-policy": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", + "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", diff --git a/packages/client/runtime/src/client/contract/workspaces.ts b/packages/client/runtime/src/client/contract/workspaces.ts index bb50b43277..9238ea5fd0 100644 --- a/packages/client/runtime/src/client/contract/workspaces.ts +++ b/packages/client/runtime/src/client/contract/workspaces.ts @@ -40,9 +40,10 @@ export interface IWorkspaces { /** * List one directory level through the Host's `browse` capability. * @param path - absolute directory to list; absent lists the Host home directory. + * @param signal - aborts the wire request (and the Host's scan) when the caller supersedes it. * @returns the level's listing with breadcrumb ancestry. */ - listDirectory(path?: string): Promise + listDirectory(path?: string, signal?: AbortSignal): Promise /** * Create one child directory through the Host's `browse` capability. * @param path - absolute existing parent directory. diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index acc0d079aa..1dd3319e79 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -195,10 +195,11 @@ export class WorkspacesService implements IWorkspaces { /** * List one directory level through the Host's `browse` capability. * @param path - absolute directory to list; absent lists the Host home directory. + * @param signal - aborts the wire request (and the Host's scan) when the caller supersedes it. * @returns the level's listing with breadcrumb ancestry. */ - async listDirectory(path?: string): Promise { - const response = await this.api.host.listDirectory(path === undefined ? {} : { path }) + async listDirectory(path?: string, signal?: AbortSignal): Promise { + const response = await this.api.host.listDirectory(path === undefined ? {} : { path }, signal) if (!response.result.ok) throw new DirectoryBrowseError(response.result.error) return response.result.value } diff --git a/packages/client/test-runtime/src/workspaces.ts b/packages/client/test-runtime/src/workspaces.ts index b4d91a96b9..3505de0853 100644 --- a/packages/client/test-runtime/src/workspaces.ts +++ b/packages/client/test-runtime/src/workspaces.ts @@ -115,7 +115,7 @@ export class TestWorkspaces implements IWorkspaces { * @param path - absolute directory to list; absent lists the home level. * @returns the level's listing. */ - async listDirectory(path?: string): Promise { + async listDirectory(path?: string, _signal?: AbortSignal): Promise { this.calls.push({ method: 'listDirectory', args: [path] }) const stub = this.stubs.get('listDirectory') if (stub !== undefined) return await (stub(path) as Promise) diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 1705a42e2a..f348f1dd8d 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -27,8 +27,8 @@ import css from './DirectoryBrowser.module.css' export interface DirectoryBrowserProps { /** Dialog visibility (owner-local; closed unmounts nothing but resets on reopen). */ open: boolean - /** List one directory level (absent path = the Host home directory). */ - listDirectory: (path?: string) => Promise + /** 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. */ createDirectory: (path: string, name: string) => Promise /** The operator confirmed a directory (the selection, else the listed level). */ @@ -115,6 +115,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const [creatingFolder, setCreatingFolder] = useState(false) const [createError, setCreateError] = useState(null) const requestSeq = useRef(0) + // The in-flight listing's controller: superseding intent aborts the wire + // request too — the Host stops scanning — instead of only discarding the + // eventual result while the scan keeps consuming host resources. + const scanController = useRef(null) // Bumped on every open/close edge: settlements from a previous open (a // pending creation included) must never mutate a reopened dialog. const openGeneration = useRef(0) @@ -130,18 +134,34 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, useEffect(() => () => { requestSeq.current += 1 openGeneration.current += 1 + scanController.current?.abort() }, []) const compositionGuard = { onCompositionStart: () => { composingRef.current = true }, onCompositionEnd: () => { composingRef.current = false }, } + /** Newer intent wins: invalidate the pending listing's settlement AND abort its wire request. */ + const supersede = useCallback((): number => { + scanController.current?.abort() + scanController.current = null + return ++requestSeq.current + }, []) + + /** Launch one listing under a fresh controller so a later supersession can abort it. */ + const launchListing = useCallback((path: string | undefined): { seq: number; scan: Promise } => { + const seq = supersede() + const controller = new AbortController() + scanController.current = controller + return { seq, scan: listDirectory(path, controller.signal) } + }, [supersede, listDirectory]) + /** Replace the whole view with one freshly listed level (no selection). */ const navigate = useCallback((path?: string) => { - const seq = ++requestSeq.current + const { seq, scan } = launchListing(path) setLoading(true) setError(null) - listDirectory(path).then((next) => { + scan.then((next) => { if (seq !== requestSeq.current) return setParent(next) setSelected(null) @@ -153,16 +173,16 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, setLoading(false) setError(failureText(reason)) }) - }, [listDirectory]) + }, [launchListing]) /** Select a row of the listed level and preview its children on the right. */ const select = useCallback((entry: DirectoryEntry) => { - const seq = ++requestSeq.current + const { seq, scan } = launchListing(entry.path) setSelected(entry) setChild(null) setLoading(true) setError(null) - listDirectory(entry.path).then((next) => { + scan.then((next) => { if (seq !== requestSeq.current) return setChild(next) setLoading(false) @@ -174,7 +194,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // breadcrumb still names the level: fall back to the single pane. setSelected(null) }) - }, [listDirectory]) + }, [launchListing]) /** A right-column pick advances the view one level: child becomes the level. */ const advance = useCallback((entry: DirectoryEntry) => { @@ -196,12 +216,12 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, navigate() return } - requestSeq.current += 1 + supersede() setError(null) setPathDraft(null) setFolderDraft(null) setCreateError(null) - }, [open, navigate]) + }, [open, navigate, supersede]) /** The folder a create or Open acts on: the selection, else the listed level. */ const targetPath = selected?.path ?? parent?.path ?? null @@ -227,9 +247,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, setFolderDraft(null) // Land like a right-column pick (figma 802:57446 → 813:23278 flow): the // create target becomes the listed level and the new folder its selection. - const seq = ++requestSeq.current + const { seq, scan } = launchListing(targetPath) setLoading(true) - listDirectory(targetPath).then((level) => { + scan.then((level) => { /* v8 ignore next -- same fence as navigate/select; the modal blocks superseding input */ if (seq !== requestSeq.current) return setParent(level) @@ -324,7 +344,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // Opening the editor supersedes any pending listing: a // settlement landing before the first keystroke would // otherwise close the editor via navigate's draft reset. - requestSeq.current += 1 + supersede() setLoading(false) setPathDraft(selected?.path ?? parent?.path ?? '') }} @@ -342,7 +362,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // Editing the draft supersedes any in-flight navigation: // its completion must neither clear the newer text nor // repopulate the view with the older path. - requestSeq.current += 1 + supersede() setLoading(false) setPathDraft(event.target.value) }} @@ -361,7 +381,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // 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 + supersede() setLoading(false) setPathDraft(null) setError(null) diff --git a/packages/host/directory-picker-browse/src/client/flow.ts b/packages/host/directory-picker-browse/src/client/flow.ts index 662c40de62..84e49b2c98 100644 --- a/packages/host/directory-picker-browse/src/client/flow.ts +++ b/packages/host/directory-picker-browse/src/client/flow.ts @@ -13,8 +13,8 @@ import { DirectoryBrowser } from './DirectoryBrowser.tsx' /** Injected face: the browse wire calls and copy the dialog drives (bound in apply's closure). */ export interface BrowseFlowInjected { - /** List one directory level (absent path = the Host home directory). */ - listDirectory: (path?: string) => Promise + /** 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. */ createDirectory: (path: string, name: string) => Promise /** Localized dialog copy (this package's namespace). */ diff --git a/packages/host/directory-picker-browse/src/client/index.ts b/packages/host/directory-picker-browse/src/client/index.ts index 6eb43829db..8ec0ffc5f9 100644 --- a/packages/host/directory-picker-browse/src/client/index.ts +++ b/packages/host/directory-picker-browse/src/client/index.ts @@ -72,7 +72,7 @@ export function apply(ctx: ClientContext): void { }, 'directory-picker-browse: dialog dictionaries') const injected = (): BrowseFlowInjected => ({ - listDirectory: path => ctx.workspaces.listDirectory(path), + listDirectory: (path, signal) => ctx.workspaces.listDirectory(path, signal), createDirectory: (path, name) => ctx.workspaces.createDirectory(path, name), t: ctx.locale.bind(LOCALE_NS), }) diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index f0c54ad421..babe7c8bf7 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -95,7 +95,7 @@ describe('DirectoryBrowser', () => { it('opens at the Host home as one wide column, hides hidden entries, and roots the crumbs at Home', async () => { const b = mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) - expect(b.listDirectory).toHaveBeenCalledWith(undefined) + expect(b.listDirectory).toHaveBeenCalledWith(undefined, expect.any(AbortSignal)) expect(columns()).toHaveLength(1) expect(screen.getByRole('listitem').textContent).toBe('Documents') expect(screen.queryByText('.config')).toBeNull() @@ -113,7 +113,7 @@ describe('DirectoryBrowser', () => { expect(selectedRow.textContent).toBe('Documents') expect(rowButton(selectedRow).getAttribute('aria-current')).toBe('true') expect(within(preview!).getByRole('listitem').textContent).toBe('harness') - expect(b.listDirectory).toHaveBeenLastCalledWith(DOCS) + expect(b.listDirectory).toHaveBeenLastCalledWith(DOCS, expect.any(AbortSignal)) expect(within(screen.getByRole('navigation')).getByRole('button', { name: 'Documents' })).toBeTruthy() }) @@ -130,6 +130,29 @@ describe('DirectoryBrowser', () => { expect(rowButton(selectedRow).getAttribute('aria-current')).toBe('true') }) + it('aborts a superseded listing on the wire, and the in-flight one on close', async () => { + const signals: (AbortSignal | undefined)[] = [] + const gates: (() => void)[] = [] + const listDirectory = vi.fn((path?: string, signal?: AbortSignal) => { + signals.push(signal) + if (signals.length === 1) return Promise.resolve(listingFor(path)) + // Later listings hang until released: supersession must abort them + // on the wire, not merely discard their eventual results. + return new Promise((resolve) => { gates.push(() => { resolve(listingFor(path)) }) }) + }) + const b = mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(rowButton(screen.getByRole('listitem'))) + expect(signals).toHaveLength(2) + // A crumb jump supersedes the hanging preview: its request aborts. + fireEvent.click(screen.getByRole('button', { name: 'browser.home' })) + expect(signals[1]?.aborted).toBe(true) + expect(signals[2]?.aborted).toBe(false) + // Closing the dialog aborts the still-pending navigation too. + b.view.rerender() + expect(signals[2]?.aborted).toBe(true) + }) + it('jumps back through a crumb into a fresh single-column level', async () => { mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) @@ -195,7 +218,7 @@ describe('DirectoryBrowser', () => { // rows nor status behind. await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') }) expect(listDirectory).toHaveBeenCalledTimes(2) - expect(listDirectory).toHaveBeenLastCalledWith(undefined) + expect(listDirectory).toHaveBeenLastCalledWith(undefined, expect.any(AbortSignal)) }) it('passes the entered path to the Host untrimmed (trim only gates blank drafts)', async () => { @@ -207,7 +230,7 @@ describe('DirectoryBrowser', () => { fireEvent.change(input, { target: { value: `${DOCS} ` } }) fireEvent.keyDown(input, { key: 'Enter' }) // A trailing space may name a real directory; trimming would list its sibling. - await waitFor(() => { expect(listDirectory).toHaveBeenLastCalledWith(`${DOCS} `) }) + await waitFor(() => { expect(listDirectory).toHaveBeenLastCalledWith(`${DOCS} `, expect.any(AbortSignal)) }) }) it('surfaces an unreadable target as an alert and keeps the edit open for correction', async () => { @@ -347,7 +370,7 @@ describe('DirectoryBrowser', () => { expect(b.listDirectory.mock.calls.length).toBe(listCalls) fireEvent.compositionEnd(pathInput) fireEvent.keyDown(pathInput, { key: 'Enter' }) - await waitFor(() => { expect(b.listDirectory).toHaveBeenLastCalledWith(DOCS) }) + await waitFor(() => { expect(b.listDirectory).toHaveBeenLastCalledWith(DOCS, expect.any(AbortSignal)) }) // Create dialog: same guard. fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) const nameInput = screen.getByLabelText('browser.folderName') @@ -764,6 +787,6 @@ describe('DirectoryBrowser', () => { b.view.rerender() await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') }) expect(columns()).toHaveLength(1) - expect(b.listDirectory).toHaveBeenLastCalledWith(undefined) + expect(b.listDirectory).toHaveBeenLastCalledWith(undefined, expect.any(AbortSignal)) }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f7026700fa..bc013a08a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -215,6 +215,9 @@ importers: '@deepseek-ai/dsh-host-directory-picker-browse': specifier: workspace:^ version: link:../../packages/host/directory-picker-browse + '@deepseek-ai/dsh-host-directory-picker-native': + specifier: workspace:^ + version: link:../../packages/host/directory-picker-native '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../packages/host/webserver