feat(host,client): compose directory picking through slots — dual-face -native, no wire advertisement
ui-workspace's two trigger surfaces each declare a single-kind directory-flow hole (conversation.hero.workspace.directoryFlow / sidebar.workspaces.directoryFlow, same owner contract) and keep only the trigger and the adoption: the Open-local- folder entry renders while the surface's hole is occupied, and the occupant reports one picked path per open through the hole's owner conversation (open/busy/onPicked/onCancel/onError). directory-picker-native becomes dual-face: its browser half fills both holes with a renderless occupant driving host.pickDirectory, so the cordis.yml row that mounts the backend also composes the client interaction — a mismatch is impossible and a second flow package fails at client load. With composition wiring both sides, the host.describe.directoryPicker advertisement and the client's kind branching lose their last consumer: the field, WorkspacesService.directoryPickerKind(), the DirectoryPickerKind wire type, and the picker's per-open describe read are deleted. The connection fixture now serves a deterministic pickDirectory path so the keyless snapshot drives the full pick-then-adopt flow. ui-workspace's hand-rolled declaration deferral is replaced by the deferRegistration helper it duplicated.
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# 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/host/directory-picker-native/README.md
|
||||
README.md: 8e9d6c7c558a6b33c2a37d504c571fd3eda579bb
|
||||
README.zh.md: 68f23698ff0c1a5ee4456a1f121dcf244f09c72b
|
||||
README.md: 0b54c651d4f5382021d0f8832ab4f1146b7652c8
|
||||
README.zh.md: e5ac2762a691a16a7e6d9d6dd9aefc70a59dcd4f
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The **native-OS-chooser backend** of the [directory-picker seam](../directory-picker/README.md): `NativeDirectoryPicker` registers `ctx.directoryPicker` with the `native` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS, an STA PowerShell `FolderBrowserDialog` on Windows, and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable for deterministic tests.
|
||||
The **native-OS-chooser backend** of the [directory-picker seam](../directory-picker/README.md): `NativeDirectoryPicker` registers `ctx.directoryPicker` with the `native` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS, an STA PowerShell `FolderBrowserDialog` on Windows, and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable for deterministic tests. The shared no-shell subprocess runner lives in [`dsh-native-command`](../../util/native-command/README.md).
|
||||
|
||||
**Dual-face package**: the browser half (`./client`) registers a renderless flow occupant into [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes — each `open` request drives `host.pickDirectory` and reports the one outcome (picked path / cancel / failure) through the hole's owner conversation. One cordis.yml row therefore composes both sides of the native interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind).
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
[目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**:`NativeDirectoryPicker` 以 `native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用:macOS 使用 `osascript`,Windows 使用以 STA 模式运行的 PowerShell `FolderBrowserDialog`,Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入,便于确定性测试。
|
||||
[目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**:`NativeDirectoryPicker` 以 `native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用:macOS 使用 `osascript`,Windows 使用以 STA 模式运行的 PowerShell `FolderBrowserDialog`,Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入,便于确定性测试。共享的免 shell 子进程运行器位于 [`dsh-native-command`](../../util/native-command/README.md)。
|
||||
|
||||
**双面包**:browser half(`./client`)向 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞注册一个无渲染的流程占用者——每次 `open` 请求驱动 `host.pickDirectory`,并经洞的 owner 会话上报唯一结果(所选路径/取消/失败)。因此一行 cordis.yml 同时组合原生交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -15,12 +15,17 @@
|
||||
"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"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -31,11 +36,27 @@
|
||||
"@deepseek-ai/dsh-native-command": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-workspace": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"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:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-workspace"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
}
|
||||
|
||||
73
packages/host/directory-picker-native/src/client/index.ts
Normal file
73
packages/host/directory-picker-native/src/client/index.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 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 { useEffect, useRef } from 'react'
|
||||
import type { ReactElement } from 'react'
|
||||
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: the SlotMap merge declaring the directory-flow holes and their owner contract.
|
||||
import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
|
||||
/** Injected face: the wire call the flow drives (bound in apply's closure). */
|
||||
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
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
armed.current = false
|
||||
return
|
||||
}
|
||||
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)) },
|
||||
)
|
||||
}, [open, pick])
|
||||
return null
|
||||
}
|
||||
|
||||
/** 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 (declaration-aware deferral — the declaring
|
||||
* ui-workspace entries may activate later, and an HMR collapse re-declares).
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const injected = (): NativeFlowInjected => ({ pick: () => ctx.workspaces.pickDirectory() })
|
||||
ctx.effect(() => {
|
||||
const deferred = [
|
||||
deferRegistration(ctx.slots, 'conversation.hero.workspace.directoryFlow', NativeDirectoryFlow, () =>
|
||||
ctx.slots.register({ name: 'conversation.hero.workspace.directoryFlow', inject: injected }, NativeDirectoryFlow)),
|
||||
deferRegistration(ctx.slots, 'sidebar.workspaces.directoryFlow', NativeDirectoryFlow, () =>
|
||||
ctx.slots.register({ name: 'sidebar.workspaces.directoryFlow', inject: injected }, NativeDirectoryFlow)),
|
||||
]
|
||||
return () => { for (const entry of deferred) entry.dispose() }
|
||||
}, 'directory-picker-native: flow registrations')
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Cross-platform native single-directory chooser behind the dialog backend's capability. */
|
||||
/** Cross-platform native single-directory chooser behind the native backend's capability. */
|
||||
|
||||
import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command'
|
||||
|
||||
|
||||
123
packages/host/directory-picker-native/tests/client-flow.spec.tsx
Normal file
123
packages/host/directory-picker-native/tests/client-flow.spec.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
// @vitest-environment jsdom
|
||||
import { Context } from '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, NativeDirectoryFlow } from '../src/client/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('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()
|
||||
await act(async () => { resolve('/tmp/project') })
|
||||
expect(second.onPicked).toHaveBeenCalledWith('/tmp/project')
|
||||
expect(first.onPicked).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('')
|
||||
})
|
||||
})
|
||||
@@ -1,19 +1,16 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
"outDir": "lib/types",
|
||||
"types": [
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../directory-picker"
|
||||
},
|
||||
@@ -22,6 +19,15 @@
|
||||
},
|
||||
{
|
||||
"path": "../../util/native-command"
|
||||
},
|
||||
{
|
||||
"path": "../../client/ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../../client/runtime"
|
||||
},
|
||||
{
|
||||
"path": "../../client/ui-workspace"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
3
packages/host/directory-picker-native/tsdown.config.ts
Normal file
3
packages/host/directory-picker-native/tsdown.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../../client/tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-host-directory-picker-native', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
Reference in New Issue
Block a user