refactor(picker): split the directory-picker faces into their own packages

The browse and native backends were dual-face packages: a Node backend plus a
browser surface under one tsconfig that referenced Client packages. That put
Client projects — and through them the Client runtime — inside the Host
compiler aggregate, which builds before the generated Remote contributions
exist. Each browser half moves to its own Client package, and both backends
become Node-only.

The interaction is still one choice: the adaptive chooser mounts the backend
and its surface as a pair of Loader entries and tears both down in reverse, so
a resolved kind still swaps both faces. Compositions that pin an interaction
directly now pin the pair, and the chooser's runtime-string package list keeps
naming everything a composing app must resolve.
This commit is contained in:
imccyu
2026-08-11 23:33:16 +08:00
parent 070a2a7f1e
commit 40af20cafe
33 changed files with 407 additions and 136 deletions
@@ -32,21 +32,25 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-client-ui-directory-picker": "workspace:^",
"@deepseek-ai/dsh-client-ui-directory-picker-native": "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-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
"@deepseek-ai/dsh-invariants": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/cordis-plugin-include": "workspace:^",
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-client-ui-directory-picker": "workspace:^",
"@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker": "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-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
"@deepseek-ai/dsh-invariants": "workspace:^"
}
}
@@ -1,12 +1,13 @@
/**
* Adaptive chooser of the directory-picker seam: resolves the host's
* situation once at boot (bind host, SSH launch, display session, Linux
* chooser binary) and mounts the matching dual-face backend — `-native` or
* `-browse` — as a real Loader entry in the in-memory root tree. Because the
* backend arrives as an ordinary entry, its browser half is discovered
* exactly as a config-row's would be, so the seam's one-row-swaps-both-faces
* invariant holds for the resolved choice; pinning an interaction remains
* composing that backend row directly instead of this one.
* chooser binary) and mounts the matching interaction — `native` or `browse`
* — as real Loader entries in the in-memory root tree. Each interaction is a
* pair: the Host backend serving the seam capability and the client surface
* occupying ui-workspace's directory-flow holes. Both arrive as ordinary
* entries, so the surface is discovered exactly as a config-row's would be
* and one resolved choice still swaps both faces; pinning an interaction
* remains composing that pair directly instead of this row.
* @module @deepseek-ai/dsh-host-directory-picker-auto
*/
@@ -28,7 +29,7 @@ export const name = 'directory-picker-auto'
export const inject = ['httpServer', 'loader']
/**
* Backend package per resolved kind — fixed composition vocabulary, not a
* Host backend package per resolved kind — fixed composition vocabulary, not a
* tunable. Exported because the reference is a runtime string the static
* config gate cannot see in a yml row: `verify-cordis-config` requires every
* app composing this chooser to declare both values as dependencies.
@@ -39,10 +40,20 @@ export const BACKEND_PACKAGES: Record<DirectoryPickerBackendKind, string> = {
}
/**
* Resolve the backend from one boot-time sample and mount it as a Loader
* entry; the effect's disposer removes the entry and joins the backend
* fiber's teardown, so unloading this plugin returns only after both faces
* of the mounted backend (and their dependents) quiesced.
* Client surface package per resolved kind, mounted with its backend so one
* resolved interaction still composes both faces. Declared as dependencies by
* every composing app for the same reason as {@link BACKEND_PACKAGES}.
*/
export const SURFACE_PACKAGES: Record<DirectoryPickerBackendKind, string> = {
native: '@deepseek-ai/dsh-client-ui-directory-picker-native',
browse: '@deepseek-ai/dsh-client-ui-directory-picker',
}
/**
* Resolve the interaction from one boot-time sample and mount its backend and
* surface as Loader entries; the effect's disposer removes both entries and
* joins their fibers' teardown, so unloading this plugin returns only after
* both faces of the mounted interaction (and their dependents) quiesced.
* @param ctx - cordis context carrying the injected `httpServer` and `loader`.
*/
export async function apply(ctx: Context): Promise<void> {
@@ -54,16 +65,22 @@ export async function apply(ctx: Context): Promise<void> {
})
await ctx.effect(async () => {
// Root-tree create: the Loader root is in-memory (write() is a no-op), so
// the mounted row can never be persisted back into a config file.
const id = await ctx.loader.create({ name: BACKEND_PACKAGES[backend] })
return async () => {
// Tree teardown (group.stop) can have removed the entry already;
// nothing is left to unmount or await then.
const entry = ctx.loader.store[id]
if (entry === undefined) return
// remove() disposes the entry transactionally, so the chooser's unload
// signals completion only after the backend quiesced.
await ctx.loader.remove(id)
// the mounted rows can never be persisted back into a config file. The
// backend lands first: the surface's browser half drives the capability
// the backend registers.
const ids: string[] = []
for (const name of [BACKEND_PACKAGES[backend], SURFACE_PACKAGES[backend]]) {
ids.push(await ctx.loader.create({ name }))
}
}, 'directory-picker-auto: backend entry')
return async () => {
for (const id of ids.reverse()) {
// Tree teardown (group.stop) can have removed the entry already;
// nothing is left to unmount or await then.
if (ctx.loader.store[id] === undefined) continue
// remove() disposes the entry transactionally, so the chooser's unload
// signals completion only after that face quiesced.
await ctx.loader.remove(id)
}
}
}, 'directory-picker-auto: interaction entries')
}
@@ -22,55 +22,25 @@
"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"
],
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-host-directory-picker": "workspace:^",
"clsx": "^2.0.0",
"@deepseek-ai/schemastery": "workspace:^"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "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"
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-workspace",
"@deepseek-ai/dsh-client-locale"
],
"platform": "web"
}
"@deepseek-ai/cordis": "workspace:^"
}
}
@@ -1,428 +0,0 @@
/* Directory-browser dialog (figma 813-23126 family). The shared Modal renders
* headless here — mask, card, Escape only — and this module owns the figma
* frame: 680×500 card (viewport-clamped; upsized from the figma 600×420),
* header (title + crumbs, l3 separator),
* the one-or-two-column Miller content, and the bordered footer. */
/* Doubled class beats Modal's own .dialog regardless of stylesheet order. */
/* Short viewports clamp the card: header/footer are flex-none and the
* columns scroll, so shrinking the height keeps Open/Cancel reachable
* instead of clipping them below a fixed overlay. */
.dialog.dialog {
width: min(680px, 100%);
height: min(500px, calc(100dvh - 32px));
padding: 0;
gap: 0;
/* The Modal card is an l2 surface and the columns below scroll on it:
* rebind the scrollbar indirection to the elevation pair here, on the
* surface, so it inherits down to whichever descendant scrolls (the
* rebinding contract in ui-theme styles/scrollbar.css). */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
/* Card-scope wrapper hosting the path editor's Escape and focus-leave
* observers; display:contents keeps header/content/footer as direct flex
* children of the Modal card. */
.editorScope {
display: contents;
}
/* Header block: pl24 pr14 pt16 pb8, 8px between title row and crumb row. */
.header {
display: flex;
flex-direction: column;
gap: 8px;
flex: none;
padding: 16px 14px 8px 24px;
border-bottom: 1px solid var(--dsw-alias-border-l3);
}
.title {
display: flex;
align-items: flex-end;
min-height: 28px;
margin: 0;
font-size: 16px;
line-height: 24px;
font-weight: 510;
color: var(--dsw-alias-label-primary);
}
/* The bar IS the editor's box in both modes: it carries the rounded outline
* and the inner padding, the crumbs and the input sit inside it, and hovering
* the edit zone lights the whole row rather than the remainder right of the
* crumbs. The negative left margin pays back the border and padding, so the
* crumb (and input) text keeps the column the title sits in. */
.crumbBar {
display: flex;
align-items: center;
gap: 4px;
box-sizing: border-box;
min-height: 24px;
margin-left: -9px;
padding: 0 8px;
border: 1px solid transparent;
border-radius: 8px;
}
/* Lit by the affordance the row belongs to, never by a crumb: a crumb's hover
* offers navigation, not path entry. Editing keeps the outline standing. */
.crumbBar:has(.crumbEditZone:enabled:hover),
.crumbBar:has(.crumbEditZone:focus-visible),
.crumbBar:has(.pathInput) {
border-color: var(--dsw-alias-border-l2);
}
/* Deep chains scroll inside the trail (the effect pins the tail into view)
* so the edit zone to the right never leaves the bar. */
/* The Miller columns keep their own row so a status/error line below never
* competes with the fixed column widths for horizontal space. */
/* A narrow viewport shrinks the dialog below two fixed panes; the row
* scrolls horizontally (the effect pins the child pane into view) so
* descent never hides behind the Modal's clipping. */
.millerRow {
display: flex;
align-items: stretch;
flex: 1 1 0;
min-height: 0;
/* 12px of row gap on each side of the divider; the left side reads wider
* by the column's trailing 8px scrollbar clearance, which is deliberate —
* the thumb needs that room, the right pane's rows do not. */
gap: 12px;
overflow-x: auto;
scrollbar-width: none;
}
.crumbTrail {
display: flex;
align-items: center;
gap: 4px;
flex: 0 1 auto;
min-width: 0;
overflow-x: auto;
scrollbar-width: none;
}
.crumbSeat {
display: inline-flex;
align-items: center;
gap: 4px;
flex: none;
min-width: 0;
}
.crumb {
border: none;
background: transparent;
padding: 0;
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
line-height: 20px;
font-weight: 500;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.crumb:hover {
color: var(--dsw-alias-label-primary);
}
.crumbChevron {
flex: none;
color: var(--dsw-alias-label-tertiary);
}
/* The empty remainder of the bar: a real click target that flips the bar into
* path-edit mode. The pencil glyph seated at its right edge is the standing
* affordance; the outline the gesture lights belongs to the bar, so the whole
* row reads as the box the input will occupy. */
.crumbEditZone {
display: flex;
align-items: center;
justify-content: flex-end;
flex: 1 0 34px;
min-width: 34px;
height: 22px;
padding: 0;
border: none;
background: transparent;
cursor: text;
outline: none;
}
.crumbEditGlyph {
flex: none;
color: var(--dsw-alias-label-tertiary);
}
.crumbEditZone:enabled:hover .crumbEditGlyph,
.crumbEditZone:focus-visible .crumbEditGlyph {
color: var(--dsw-alias-label-primary);
}
.crumbEditZone:disabled {
cursor: default;
}
.crumbEditZone:disabled .crumbEditGlyph {
color: var(--dsw-alias-label-caption);
}
/* Chrome-free: the bar around it draws the box (border, radius, padding). */
.pathInput {
box-sizing: border-box;
flex: 1 1 0;
min-width: 0;
height: 22px;
padding: 0;
border: none;
outline: none;
background: transparent;
font-size: 13px;
line-height: 20px;
color: var(--dsw-alias-label-primary);
}
/* Miller content: symmetric 16px vertical padding so the divider clears the
* header and footer rules evenly; each column scrolls alone (column widths
* live at .column). */
.content {
display: flex;
flex-direction: column;
flex: 1 1 0;
min-height: 0;
/* Anchors the floating loading pill (.loadingFloat). */
position: relative;
/* Right inset is slimmer than the left: the trailing column's own 8px
* scrollbar clearance makes up the optical difference. */
padding: 16px 16px 16px 24px;
}
/* Columns split the row evenly around the divider (a solo column takes the
* whole row); 256px is the floor below which the row scrolls (scrollbar
* hidden, the effect pins the child pane into view) instead of squeezing
* the panes. */
.column {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1 1 0;
min-width: 256px;
overflow-y: auto;
/* The themed scrollbar occupies the column's edge (styled scrollbars are
* classic, gutter-taking ones); the extra clearance keeps the row pills
* clear of the thumb. */
padding-right: 8px;
}
.divider {
flex: none;
width: 1px;
background: var(--dsw-alias-border-l3);
}
.rowSeat {
display: flex;
flex: none;
}
.row {
width: 100%;
display: flex;
align-items: center;
gap: 4px;
height: 28px;
flex: none;
padding: 4px;
border: none;
border-radius: 6px;
background: transparent;
text-align: left;
cursor: pointer;
}
.row:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Selection: pill fill + the open-folder glyph in the info accent. */
.rowSelected,
.rowSelected:hover {
background: var(--dsw-alias-interactive-bg-active, var(--dsw-alias-interactive-bg-hover));
}
.rowIcon {
flex: none;
color: var(--dsw-alias-label-secondary);
}
.rowIconSelected {
flex: none;
color: var(--dsw-alias-button-info-fill);
}
.rowName {
flex: 1 1 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
line-height: 20px;
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
.rowChevron {
flex: none;
color: var(--dsw-alias-label-tertiary);
}
.status,
.error {
padding: 4px;
/* The loading pill occupies the opposite corner while a stale status stays
* visible. Reserve its widest localized footprint so wrapped text cannot
* run underneath it on a narrow card. */
padding-right: 120px;
font-size: 12px;
line-height: 18px;
}
.status {
color: var(--dsw-alias-label-secondary);
}
.error {
color: var(--dsw-alias-state-error-primary);
}
/* The slow-scan indicator floats over the content's bottom-RIGHT corner on
* the card background instead of occupying a row: a scan must never shift
* the columns' height, and the stale view keeps rendering beneath it (it
* only appears at all once a scan outlives SLOW_SCAN_DELAY_MS). Right,
* not left: the truncated/error status rows flow at the bottom LEFT and
* stay on screen through a scan, with their reserved right padding keeping
* both legible even on a narrow card. After .status in the cascade — the
* element carries both classes and this padding must win the
* same-specificity race. */
.loadingFloat {
position: absolute;
right: 16px;
bottom: 8px;
padding: 2px 8px;
background: var(--dsw-alias-bg-layer-2);
}
/* Footer: l3 separator on top, symmetric padding so the row sits vertically
* centered in the bar; New-folder and the show-hidden toggle pin left. */
.footerBar {
display: flex;
align-items: center;
/* Narrow viewports wrap the confirm/cancel pair onto their own row
* instead of clipping Open past the card's hidden overflow. */
flex-wrap: wrap;
gap: 8px;
flex: none;
padding: 16px 24px;
border-top: 1px solid var(--dsw-alias-border-l3);
}
/* Show-hidden toggle: a subtle fixed-label text button left of the gap;
* the pressed state seats a check glyph after the label (Menu's selected
* vocabulary; trailing so the label never shifts) instead of flipping the
* wording. */
.showHiddenToggle {
display: inline-flex;
align-items: center;
gap: 4px;
border: none;
background: transparent;
padding: 0;
font-size: 13px;
line-height: 20px;
font-weight: 500;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
white-space: nowrap;
}
.showHiddenToggle:hover {
color: var(--dsw-alias-label-primary);
}
.showHiddenToggle:disabled {
color: var(--dsw-alias-label-caption);
cursor: default;
}
.showHiddenToggleActive {
color: var(--dsw-alias-label-primary);
}
.footerGap {
flex: 1 1 0;
}
.footerAction {
min-width: 72px;
}
/* Nested create dialog (figma 813:23278): a small centered card. */
.createDialog.createDialog {
width: min(380px, 100%);
padding: 0;
gap: 0;
}
.createBody {
display: flex;
flex-direction: column;
gap: 12px;
padding: 22px 24px 20px;
}
.createTitle {
margin: 0;
font-size: 16px;
line-height: 24px;
font-weight: 510;
color: var(--dsw-alias-label-primary);
}
.createIn {
margin: 0;
font-size: 14px;
line-height: 22px;
color: var(--dsw-alias-label-primary);
}
.createInput {
box-sizing: border-box;
width: 100%;
height: 44px;
padding: 7px 14px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 22px;
outline: none;
background: transparent;
font-size: 14px;
line-height: 22px;
color: var(--dsw-alias-label-primary);
}
.createInput::placeholder {
color: var(--dsw-alias-label-caption);
}
.createActions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
margin-top: 8px;
}
File diff suppressed because it is too large Load Diff
@@ -1,43 +0,0 @@
/**
* The browse picking occupant (package-internal; the `./client` surface
* exposes only the Loader exports). Same-package tests exercise it directly
* through this module.
*/
import { createElement } from 'react'
import type { ReactElement } from 'react'
import type { DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client'
import type { Translate } from '@deepseek-ai/dsh-client-locale/client'
// Type-only: the owner contract of the directory-flow holes.
import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client'
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); the signal aborts a superseded scan. */
listDirectory: (path?: string, signal?: AbortSignal) => Promise<DirectoryListing>
/** Create one child directory under an existing parent. */
createDirectory: (path: string, name: string) => Promise<string>
/** Localized dialog copy (this package's namespace). */
t: Translate
}
/**
* Flow occupant: adapts the hole's owner conversation onto the browser
* dialog — a confirmed directory is the picked path, dismissal is the
* cancellation. Browse failures (unreadable targets, create conflicts) stay
* inside the dialog's own alert surfaces, so the owner's `onError` arm is
* never driven by this occupant.
* @param props - owner conversation plus the injected browse face.
* @returns the dialog element (renders nothing while closed).
*/
export function BrowseDirectoryFlow(props: DirectoryFlowOwnerProps & BrowseFlowInjected): ReactElement {
return createElement(DirectoryBrowser, {
open: props.open,
busy: props.busy,
listDirectory: props.listDirectory,
createDirectory: props.createDirectory,
t: props.t,
onOpen: props.onPicked,
onClose: props.onCancel,
})
}
@@ -1,92 +0,0 @@
/**
* Browser half of the browse directory-picker backend: fills ui-workspace's
* two directory-flow holes with the in-app Select Workspace Directory dialog
* (figma `Harness` 813-23126 family), driving the node half's
* `host.listDirectory`/`host.createDirectory` primitives. Mounting this
* package therefore composes both sides of the browse interaction with one
* 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 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 { BrowseFlowInjected } from './flow.ts'
import { BrowseDirectoryFlow } from './flow.ts'
/** Locale namespace owning the browser dialog's copy. */
const LOCALE_NS = 'directory-browser'
/** Required services (cordis fiber inject): the slot registry, the wire-facing workspace service, and locale. */
export const inject = ['slots', 'workspaces', 'locale']
/**
* Client plugin body: register the dialog's dictionaries and the browse 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 {
ctx.effect(() => {
// The two dictionaries land as a unit: if the second registration hits a
// rival owner of the namespace, the first rolls back before the throw —
// a failed activation must not squat the namespace's other locale.
const disposers: (() => void)[] = []
const dictionaries: [locale: string, dict: Record<string, string>][] = [
['zh', {
'browser.title': '选择工作区目录',
'browser.home': '主目录',
'browser.newFolder': '新建文件夹',
'browser.folderName': '文件夹名称',
'browser.createIn': '在"{name}"中新建文件夹',
'browser.untitledFolder': '未命名文件夹',
'browser.create': '创建',
'browser.cancel': '取消',
'browser.open': '打开',
'browser.editPath': '编辑路径',
'browser.loading': '加载中…',
'browser.truncated': '文件夹过多,仅显示开头部分。',
'browser.showHidden': '显示隐藏文件',
}],
['en', {
'browser.title': 'Select Workspace Directory',
'browser.home': 'Home',
'browser.newFolder': 'New folder',
'browser.folderName': 'Folder name',
'browser.createIn': 'New folder in "{name}"',
'browser.untitledFolder': 'Untitled folder',
'browser.create': 'Create',
'browser.cancel': 'Cancel',
'browser.open': 'Open',
'browser.editPath': 'Edit path',
'browser.loading': 'Loading…',
'browser.truncated': 'Too many folders to list; only the beginning is shown.',
'browser.showHidden': 'Show hidden files',
}],
]
try {
for (const [locale, dict] of dictionaries) disposers.push(ctx.locale.register(LOCALE_NS, locale, dict))
} catch (error) {
for (const dispose of disposers.reverse()) dispose()
throw error
}
return () => { for (const dispose of disposers) dispose() }
}, 'directory-picker-browse: dialog dictionaries')
const injected = (): BrowseFlowInjected => ({
listDirectory: (path, signal) => ctx.workspaces.listDirectory(path, signal),
createDirectory: (path, name) => ctx.workspaces.createDirectory(path, name),
t: ctx.locale.bind(LOCALE_NS),
})
// 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,
}, BrowseDirectoryFlow)
yield ctx.slots.register({
name: 'sidebar.workspaces.directoryFlow', inject: injected,
}, BrowseDirectoryFlow)
}))
}
@@ -1,6 +0,0 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
declare module '*.css'
@@ -1,223 +0,0 @@
// @vitest-environment jsdom
import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client'
import { apply, inject } from '../src/client/index.ts'
import { BrowseDirectoryFlow } from '../src/client/flow.ts'
// The service reads its initial locale from the browser; these specs assert
// the shipped Chinese copy, so they state the browser they assume.
usePinnedBrowserLanguages('zh-CN')
afterEach(cleanup)
const HOLES = ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const
const HOME = '/home/u'
const homeListing: DirectoryListing = {
path: HOME,
home: HOME,
crumbs: [{ name: '/', path: '/', hidden: false }, { name: 'u', path: HOME, hidden: false }],
entries: [{ name: 'Documents', path: `${HOME}/Documents`, hidden: false }],
truncated: false,
}
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
ctx.provide('locale', new LocaleService(ctx))
const listDirectory = vi.fn(async (): Promise<DirectoryListing> => homeListing)
const createDirectory = vi.fn(async (path: string, name: string) => `${path}/${name}`)
ctx.provide('workspaces', { listDirectory, createDirectory } 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, listDirectory, createDirectory, 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-browse client half', () => {
it('declares the services it drives', () => {
expect(inject).toEqual(['slots', 'workspaces', 'locale'])
})
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('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('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) }
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 zh dictionary when a rival already owns the namespace en slot', async () => {
const b = await bench()
b.declare()
const locale = b.ctx.get('locale') as LocaleService
const disposeRival = locale.register('directory-browser', 'en', { 'browser.title': 'rival' })
const rejections: unknown[] = []
const onUnhandled = (reason: unknown): void => { rejections.push(reason) }
// cordis re-raises the apply throw as a late rejection (installFailLoud's contract).
process.on('unhandledRejection', onUnhandled)
try {
const fiber = b.ctx.plugin({ inject: [...inject], apply })
await expect(fiber.await()).rejects.toThrow(/already has locale/)
// The zh registration rolled back with the failure: once the rival
// leaves, a fresh registrant owns the whole namespace again.
disposeRival()
const disposeZh = locale.register('directory-browser', 'zh', { 'browser.title': '空闲' })
disposeZh()
} finally {
await new Promise(resolve => setTimeout(resolve, 0))
process.off('unhandledRejection', onUnhandled)
}
})
it('registers the dialog dictionaries and binds this package namespace', 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 () => { t: (key: string) => string })()
// zh is the shipped default locale.
expect(injected.t('browser.title')).toBe('选择工作区目录')
expect(injected.t('browser.newFolder')).toBe('新建文件夹')
expect(injected.t('browser.showHidden')).toBe('显示隐藏文件')
})
it('drives the injected browse calls through the hole entry', async () => {
const b = await bench()
b.declare()
await b.ctx.plugin({ inject: [...inject], apply }).await()
const entry = b.slots.entries(HOLES[1])[0]!
const injected = (entry.inject as () => {
listDirectory: (path?: string) => Promise<DirectoryListing>
createDirectory: (path: string, name: string) => Promise<string>
})()
await expect(injected.listDirectory()).resolves.toBe(homeListing)
await expect(injected.createDirectory(HOME, 'fresh')).resolves.toBe(`${HOME}/fresh`)
expect(b.listDirectory).toHaveBeenCalledOnce()
expect(b.createDirectory).toHaveBeenCalledWith(HOME, 'fresh')
})
it('adapts the owner conversation onto the dialog: confirm picks, dismissal cancels', async () => {
const props = owner()
const listDirectory = vi.fn(async (): Promise<DirectoryListing> => homeListing)
const t = (key: string): string => key
render(
<BrowseDirectoryFlow
{...props}
listDirectory={listDirectory}
createDirectory={vi.fn(async () => '')}
t={t}
/>,
)
// The dialog opened at home; its confirm (browser.open) adopts the listed level.
const openButton = screen.getByRole<HTMLButtonElement>('button', { name: 'browser.open' })
await waitFor(() => { expect(openButton.disabled).toBe(false) })
fireEvent.click(openButton)
expect(props.onPicked).toHaveBeenCalledWith(HOME)
fireEvent.click(screen.getByRole('button', { name: 'browser.cancel' }))
expect(props.onCancel).toHaveBeenCalled()
expect(props.onError).not.toHaveBeenCalled()
})
it('renders nothing while the flow is closed', () => {
const view = render(
<BrowseDirectoryFlow
{...owner({ open: false })}
listDirectory={vi.fn(async () => homeListing)}
createDirectory={vi.fn(async () => '')}
t={key => key}
/>,
)
expect(view.container.innerHTML).toBe('')
})
})
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
{
"extends": "../../../tsconfig.base.client.json",
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
@@ -16,21 +16,6 @@
},
{
"path": "../../support/invariants"
},
{
"path": "../../client/ui-slots"
},
{
"path": "../../client/ui-primitives"
},
{
"path": "../../client/locale"
},
{
"path": "../../client/runtime"
},
{
"path": "../../client/ui-workspace"
}
]
}
@@ -1,3 +1,15 @@
import { clientBundle } from '../../client/tsdown.client.ts'
import { defineConfig } from 'tsdown'
export default clientBundle('@deepseek-ai/dsh-host-directory-picker-browse', ['lib/types/index.js', 'lib/types/invariant.js'])
/** Node-only backend: listing and creation primitives over the host filesystem. */
export default defineConfig([
{
entry: ['lib/types/index.js', 'lib/types/invariant.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
},
])
@@ -22,10 +22,6 @@
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./worker": {
"types": "./lib/types/win32-dialog-worker.d.ts",
"default": "./lib/worker.cjs"
@@ -37,7 +33,6 @@
"lib/index.js",
"lib/invariant.js",
"lib/worker.cjs",
"lib/client.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
@@ -47,30 +42,12 @@
"koffi": "^3.1.0"
},
"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"
"@deepseek-ai/cordis": "workspace:^"
},
"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:^",
"@types/react": "~18.3.1",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0",
"tsx": "^4.19.2"
},
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-workspace"
],
"platform": "web"
}
}
}
@@ -1,65 +0,0 @@
/**
* 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
}
@@ -1,40 +0,0 @@
/**
* 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)
}))
}
@@ -1,227 +0,0 @@
// @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'
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('')
})
})
@@ -1,5 +1,5 @@
{
"extends": "../../../tsconfig.base.client.json",
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
@@ -19,15 +19,6 @@
},
{
"path": "../../util/native-command"
},
{
"path": "../../client/ui-slots"
},
{
"path": "../../client/runtime"
},
{
"path": "../../client/ui-workspace"
}
]
}
@@ -1,23 +1,31 @@
import { clientBundle } from '../../client/tsdown.client.ts'
import { defineConfig } from 'tsdown'
// The Win32 dialog worker builds as its own CJS entry (mirroring
// dsh-workflow-workerthread's worker): path-loaded by the driver, inlining
// the dialog logic while koffi stays an external native require.
export default clientBundle(
'@deepseek-ai/dsh-host-directory-picker-native',
['lib/types/index.js', 'lib/types/invariant.js'],
/**
* Node-only backend. The Win32 dialog worker builds as its own CJS entry
* (mirroring dsh-workflow-workerthread's worker): path-loaded by the driver,
* inlining the dialog logic while koffi stays an external native require.
*/
export default defineConfig([
{
companions: [{
// The artifact is lib/worker.cjs (the ./worker export the workspace
// constraint keys on), bundled from the descriptive source entry.
entry: { worker: 'lib/types/win32-dialog-worker.js' },
outDir: 'lib',
format: ['cjs'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
}],
entry: ['lib/types/index.js', 'lib/types/invariant.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
},
)
{
// The artifact is lib/worker.cjs (the ./worker export the workspace
// constraint keys on), bundled from the descriptive source entry.
entry: { worker: 'lib/types/win32-dialog-worker.js' },
outDir: 'lib',
format: ['cjs'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
},
])