Merge remote-tracking branch 'origin/master' into feat/web-message-feedback-ui
Adapt to two contract changes master introduced: - The generated Remote face now wraps every business result in RemoteResult, folding carrier failures into an ok:false branch instead of rejecting. The controller reads that envelope at its three call sites and maps a carrier failure onto the same settled shape the controls already render; three specs cover the new branch. - Client packages split their tsconfig into host and client halves, and the host aggregate now compiles any test not named *.client.spec.*. Rename this package's specs to the client convention and drop the ../connection project reference, which pointed at a solution file that no longer carries the client sources. Keep master's mount loop with its rollback-on-failure in api-remotes and add messageFeedbackRemote to it.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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/client/ui-directory-picker-native/README.md
|
||||
README.md: feb944b38a17086e0fcc6e4da52e1f16a4055166
|
||||
README.zh.md: 72296b18af69749428157302744063056a6a50de
|
||||
@@ -0,0 +1,22 @@
|
||||
# @deepseek-ai/dsh-client-ui-directory-picker-native
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Native directory-picker surface: the browser half of the native picking interaction. It fills ui-workspace's two directory-flow holes (`conversation.hero.workspace.directoryFlow` and `sidebar.workspaces.directoryFlow`) with a renderless occupant that answers each `open` request by driving the local Host's OS chooser through `ctx.workspaces.pickDirectory()`, then reports exactly one outcome — a picked path, a cancellation, or a failure — back through the owner conversation. The OS dialog itself belongs to [`dsh-host-directory-picker-native`](../../host/directory-picker-native/README.md); mounting this package composes the surface with that backend from one cordis.yml row, so no client code branches on a capability kind.
|
||||
|
||||
Both registrations install as one transactional effect through nested `slots.inject()` calls, because either declaring entry may activate later or replace its declaration. The occupant arms once per rising `open` edge, so re-renders — including an adoption that keeps `open` true while `busy` — never launch a second chooser, and the owner withdrawing `open` re-arms the next request. Settlements ride a ref so the answer reaches the owner's latest handlers rather than the ones captured when the chooser opened. An unmount (HMR replacing the occupant) discards the settlement wholesale: the wire carries no per-request abort, so the host-side chooser survives until answered, its answer lands nowhere, and the replacement instance re-arms under the owner's still-open request.
|
||||
|
||||
The node half is an empty `apply`: it exists so the plugin appears in the host cordis.yml and Loader, while the browser half ships through `exports["./client"]` and is discovered through the `dsh.client` manifest declaration.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the directory chooser is browser chrome; nothing here reaches a model request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No cancellation of an open chooser** — the wire has no per-request abort, so a chooser already on the host display cannot be closed from the browser; a discarded settlement is simply ignored.
|
||||
- **Local Host carriers only** — an OS dialog opens on the machine running the Host, so in-process and remote-browser deployments need the `-browse` composition instead. Platform failures surface through the owner's retryable folder dialog.
|
||||
@@ -0,0 +1,22 @@
|
||||
# @deepseek-ai/dsh-client-ui-directory-picker-native
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
原生目录选择界面:原生选取交互的浏览器半边。它通过 ui-workspace 的两个 directory-flow 洞(`conversation.hero.workspace.directoryFlow` 与 `sidebar.workspaces.directoryFlow`)装入一个无渲染占位者,每次收到 `open` 请求就用 `ctx.workspaces.pickDirectory()` 驱动本地 Host 的操作系统选择框,然后通过 owner 会话回报恰好一个结果——选中的路径、取消、或失败。系统对话框本身属于 [`dsh-host-directory-picker-native`](../../host/directory-picker-native/README.md);挂载本包即用一行 cordis.yml 把界面与该后端组合起来,因此没有任何客户端代码按能力种类分支。
|
||||
|
||||
两处注册通过嵌套的 `slots.inject()` 作为一个事务性 effect 安装,因为任一声明方条目都可能稍后激活或替换其声明。占位者在每个 `open` 上升沿只武装一次,所以重渲染(包括采纳期间 `busy` 而 `open` 仍为真)都不会再开第二个选择框;owner 撤回 `open` 会为下一次请求重新武装。结果经由 ref 回报,因此答案落到 owner 最新的处理器上,而不是打开选择框时捕获的那一套。卸载(HMR 替换占位者)会整体丢弃该结果:wire 上没有按请求的中止通道,所以 Host 侧的选择框会一直存在到被回答,它的答案无处可落,替换后的实例则在 owner 仍然打开的请求下重新武装。
|
||||
|
||||
node 半边是一个空 `apply`:它的存在只为让插件出现在 host 的 cordis.yml 与 Loader 中,浏览器半边经 `exports["./client"]` 出货,并通过 `dsh.client` 清单声明被发现。
|
||||
|
||||
## 模型体验
|
||||
|
||||
None, as the directory chooser is browser chrome; nothing here reaches a model request.
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;本包既不组装也不发送 provider 请求。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **无法取消已打开的选择框** —— wire 上没有按请求的中止通道,因此已经出现在 Host 显示器上的选择框无法从浏览器关闭;被丢弃的结果只是被忽略。
|
||||
- **仅限本地 Host 载体** —— 系统对话框开在运行 Host 的机器上,所以进程内与远程浏览器部署需要改用 `-browse` 组合。平台失败通过 owner 的可重试文件夹对话框呈现。
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-directory-picker-native",
|
||||
"description": "Native directory-picker surface: the renderless workspace directory-flow occupant driving the host's OS chooser",
|
||||
"version": "0.0.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/client/ui-directory-picker-native"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-workspace"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/react": "~18.3.1",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* The native picking occupant (package-internal; the `./client` surface
|
||||
* exposes only the Loader exports). Same-package tests exercise it directly
|
||||
* through this module.
|
||||
*/
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { ReactElement } from 'react'
|
||||
// Type-only: the owner contract of the directory-flow holes.
|
||||
import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
|
||||
/** Injected face: the wire call the flow drives (bound in apply's closure). */
|
||||
export interface NativeFlowInjected {
|
||||
/** Ask the local Host to open its native single-directory chooser. */
|
||||
pick: () => Promise<string | null>
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderless flow occupant: each rising `open` edge runs exactly one pick and
|
||||
* reports exactly one outcome; the ref arms once per open so re-renders (and
|
||||
* an adoption keeping `open` true while `busy`) never launch a second
|
||||
* chooser. The owner withdrawing `open` re-arms the next request.
|
||||
* @param props - owner conversation plus the injected pick call.
|
||||
* @returns nothing — the native chooser renders on the host display.
|
||||
*/
|
||||
export function NativeDirectoryFlow(props: DirectoryFlowOwnerProps & NativeFlowInjected): ReactElement | null {
|
||||
const { open, pick } = props
|
||||
const armed = useRef(false)
|
||||
// Callbacks ride a ref so the settled pick reports through the owner's
|
||||
// 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(() => {
|
||||
// StrictMode's development replay runs the cleanup once before the real
|
||||
// lifetime: re-arm on setup or every outcome would be discarded.
|
||||
alive.current = true
|
||||
return () => { alive.current = false }
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
armed.current = false
|
||||
return
|
||||
}
|
||||
if (armed.current) return
|
||||
armed.current = true
|
||||
pick().then(
|
||||
(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
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Browser half of the native directory-picker backend: fills ui-workspace's
|
||||
* two directory-flow holes with a renderless occupant that answers each
|
||||
* `open` by driving `host.pickDirectory` (the node half's OS chooser) and
|
||||
* reporting the one outcome — picked path, cancellation, or failure — back
|
||||
* through the owner conversation. Mounting this package therefore composes
|
||||
* both sides of the native interaction with one cordis.yml row; no client
|
||||
* code branches on a capability kind.
|
||||
*/
|
||||
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'
|
||||
import type { NativeFlowInjected } from './flow.ts'
|
||||
import { NativeDirectoryFlow } from './flow.ts'
|
||||
|
||||
|
||||
/** Required services (cordis fiber inject): the slot registry and the wire-facing workspace service. */
|
||||
export const inject = ['slots', 'workspaces']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the renderless native flow into both
|
||||
* directory-flow holes through `slots.inject()` because the ui-workspace
|
||||
* entries may activate later or replace their declarations.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const injected = (): NativeFlowInjected => ({ pick: () => ctx.workspaces.pickDirectory() })
|
||||
// Both declaration lifetimes must be live before the pair installs; the
|
||||
// generator makes the two registrations one transactional effect. The
|
||||
// outer/inner nesting order is arbitrary; neither hole has precedence.
|
||||
ctx.slots.inject('conversation.hero.workspace.directoryFlow', () =>
|
||||
ctx.slots.inject('sidebar.workspaces.directoryFlow', function* () {
|
||||
yield ctx.slots.register({
|
||||
name: 'conversation.hero.workspace.directoryFlow', inject: injected,
|
||||
}, NativeDirectoryFlow)
|
||||
yield ctx.slots.register({
|
||||
name: 'sidebar.workspaces.directoryFlow', inject: injected,
|
||||
}, NativeDirectoryFlow)
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Native directory-picker surface, node half. Pure UI plugin: the empty apply
|
||||
* exists so the plugin appears in the host cordis.yml / Loader; the browser
|
||||
* half ships via exports["./client"], discovered through the package.json
|
||||
* dsh.client declaration. The OS chooser it drives lives in
|
||||
* `@deepseek-ai/dsh-host-directory-picker-native`.
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for this surface plugin. */
|
||||
export function apply(): void {}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-directory-picker-native`.
|
||||
* @module @deepseek-ai/dsh-client-ui-directory-picker-native/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-directory-picker-native'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-ui-directory-picker-native-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the plugin registers a renderless flow occupant into
|
||||
* two workspace holes as one transactional effect, whose disposal the
|
||||
* HMR-safety spec proves, and it retains no state between picks.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -0,0 +1,236 @@
|
||||
// @vitest-environment jsdom
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { afterEach } from 'vitest'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { NativeDirectoryFlow } from '../src/client/flow.ts'
|
||||
import { apply as nodeApply } from '../src/index.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const HOLES = ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
const pickDirectory = vi.fn(async (): Promise<string | null> => '/tmp/picked')
|
||||
ctx.provide('workspaces', { pickDirectory } as never)
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
const declare = () => slots.register({
|
||||
name: 'root',
|
||||
children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])),
|
||||
} as never, () => null)
|
||||
return { ctx, slots, pickDirectory, declare }
|
||||
}
|
||||
|
||||
function owner(overrides: Partial<DirectoryFlowOwnerProps> = {}): DirectoryFlowOwnerProps {
|
||||
return {
|
||||
open: true, busy: false,
|
||||
onPicked: vi.fn(), onCancel: vi.fn(), onError: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('directory-picker-native client half', () => {
|
||||
it('declares the services it drives', () => {
|
||||
expect(inject).toEqual(['slots', 'workspaces'])
|
||||
})
|
||||
|
||||
it('fills both directory-flow holes for declarations before or after apply, and leaves with its fiber', async () => {
|
||||
const before = await bench()
|
||||
before.declare()
|
||||
const fiber = before.ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(1)
|
||||
// Registry-contribution disposal proof: the fiber going down empties the holes.
|
||||
await fiber.dispose()
|
||||
for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(0)
|
||||
|
||||
const after = await bench()
|
||||
await after.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(0)
|
||||
after.declare()
|
||||
await Promise.resolve()
|
||||
for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('fails loudly instead of deduplicating a duplicate package row', async () => {
|
||||
const b = await bench()
|
||||
b.declare()
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const duplicate = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await expect(duplicate.await()).rejects.toThrow(/already has a registration/)
|
||||
for (const hole of HOLES) expect(b.slots.entries(hole)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rolls back wholesale and reports loudly when a rival injection wins declaration activation', async () => {
|
||||
const b = await bench()
|
||||
const rejections: unknown[] = []
|
||||
const onUnhandled = (reason: unknown): void => { rejections.push(reason) }
|
||||
// queueMicrotask throws surface as uncaughtException, not a rejection.
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
process.on('uncaughtException', onUnhandled)
|
||||
try {
|
||||
// The rival subscribes first, so synchronous declaration notifications
|
||||
// let it occupy the pair before this provider's waiting injection runs.
|
||||
b.slots.inject(HOLES[0], () => b.slots.inject(HOLES[1], function* () {
|
||||
yield b.slots.register({ name: HOLES[0] } as never, () => null)
|
||||
yield b.slots.register({ name: HOLES[1] } as never, () => null)
|
||||
}))
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
b.declare()
|
||||
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('rolls back the outer injection when the second hole is already occupied', async () => {
|
||||
const b = await bench()
|
||||
b.declare()
|
||||
// Foreign occupant in the SECOND registered hole: the pair construction
|
||||
// throws after the outer injection installed its subscription.
|
||||
b.slots.register({ name: HOLES[1] } as never, () => null)
|
||||
const rejections: unknown[] = []
|
||||
const onUnhandled = (reason: unknown): void => { rejections.push(reason) }
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
try {
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await expect(fiber.await()).rejects.toThrow(/already has a registration/)
|
||||
// A leaked first deferral would now race this probe registration and
|
||||
// throw from its orphaned subscription against the HERO hole; the
|
||||
// rollback leaves only the activation failure itself (cordis re-raises
|
||||
// the apply throw as a late rejection — installFailLoud's contract).
|
||||
const disposeProbe = b.slots.register({ name: HOLES[0] } as never, () => null)
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
expect(rejections.map(String).filter(text => text.includes(HOLES[0]))).toEqual([])
|
||||
disposeProbe()
|
||||
} finally {
|
||||
process.off('unhandledRejection', onUnhandled)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a second flow occupant at load (single-kind hole)', async () => {
|
||||
const b = await bench()
|
||||
b.declare()
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
expect(() => b.slots.register({ name: HOLES[0] } as never, () => null))
|
||||
.toThrow(/already has a registration/)
|
||||
})
|
||||
|
||||
it('drives the injected pick through the hole entry and reports the picked path', async () => {
|
||||
const b = await bench()
|
||||
b.declare()
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const entry = b.slots.entries(HOLES[0])[0]!
|
||||
const injected = (entry.inject as () => { pick: () => Promise<string | null> })()
|
||||
await expect(injected.pick()).resolves.toBe('/tmp/picked')
|
||||
expect(b.pickDirectory).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('runs one pick per open edge and reports the path to the latest onPicked', async () => {
|
||||
let resolve!: (path: string | null) => void
|
||||
const pick = vi.fn(() => new Promise<string | null>((settle) => { resolve = settle }))
|
||||
const first = owner()
|
||||
const view = render(<NativeDirectoryFlow {...first} pick={pick} />)
|
||||
expect(pick).toHaveBeenCalledOnce()
|
||||
// Re-renders while open (busy flips, handler identity changes) must not relaunch the chooser.
|
||||
const second = owner()
|
||||
view.rerender(<NativeDirectoryFlow {...second} busy pick={pick} />)
|
||||
expect(pick).toHaveBeenCalledOnce()
|
||||
// Even a fresh injected face (re-registration re-runs the inject factory)
|
||||
// must not relaunch while the same request is still open.
|
||||
const replacedPick = vi.fn(() => new Promise<string | null>(() => {}))
|
||||
view.rerender(<NativeDirectoryFlow {...second} busy pick={replacedPick} />)
|
||||
expect(replacedPick).not.toHaveBeenCalled()
|
||||
await act(async () => { resolve('/tmp/project') })
|
||||
expect(second.onPicked).toHaveBeenCalledWith('/tmp/project')
|
||||
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()
|
||||
const view = render(<NativeDirectoryFlow {...props} pick={pick} />)
|
||||
await act(async () => {})
|
||||
expect(props.onCancel).toHaveBeenCalledOnce()
|
||||
expect(props.onPicked).not.toHaveBeenCalled()
|
||||
// Withdraw and reopen: a fresh request runs a fresh pick.
|
||||
view.rerender(<NativeDirectoryFlow {...props} open={false} pick={pick} />)
|
||||
view.rerender(<NativeDirectoryFlow {...props} pick={pick} />)
|
||||
await act(async () => {})
|
||||
expect(pick).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('folds pick failures into onError messages', async () => {
|
||||
const props = owner()
|
||||
render(<NativeDirectoryFlow {...props} pick={vi.fn(async () => { throw new Error('no chooser installed') })} />)
|
||||
await act(async () => {})
|
||||
expect(props.onError).toHaveBeenCalledWith('no chooser installed')
|
||||
|
||||
const nonError = owner()
|
||||
render(<NativeDirectoryFlow {...nonError} pick={vi.fn(async () => { throw 'denied' })} />)
|
||||
await act(async () => {})
|
||||
expect(nonError.onError).toHaveBeenCalledWith('denied')
|
||||
})
|
||||
|
||||
it('renders nothing while closed and while open', () => {
|
||||
const closed = render(<NativeDirectoryFlow {...owner({ open: false })} pick={vi.fn(async () => null)} />)
|
||||
expect(closed.container.innerHTML).toBe('')
|
||||
const opened = render(<NativeDirectoryFlow {...owner()} pick={vi.fn(async () => null)} />)
|
||||
expect(opened.container.innerHTML).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('directory-picker-native node half', () => {
|
||||
// The invariant companion is mounted by the vitest-wide invariant host on
|
||||
// every Context this suite creates; its registration is covered there.
|
||||
it('the node apply is an inert loader seat', () => {
|
||||
expect(() => { nodeApply() }).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../ui-workspace"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-ui-directory-picker-native', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
Reference in New Issue
Block a user