feat(host,client): ship the in-app directory browser as the browse package's client half

directory-picker-browse becomes dual-face: its browser half fills
ui-workspace's two directory-flow holes with the Select Workspace Directory
dialog (figma Harness 813-23126 family — Miller two-column view, breadcrumb
with click-to-edit path zone, nested New-folder dialog), driving the node
half's host.listDirectory/host.createDirectory and owning its locale
namespace (directory-browser, zh default / en). The dialog moves here from
ui-workspace wholesale — the trigger surfaces keep only the flow-hole owner
conversation.

apps/cli flips its one directory-picker row -native -> -browse, swapping the
host backend and the client interaction together; picking now works for
remote deployments out of the box. The keyless workspace-flow snapshot boots
the browse bundle and drives menu -> dialog -> Documents -> project -> Open
against the fixture tree.
This commit is contained in:
creatixchu
2026-07-28 22:22:14 +08:00
parent 05b5059dda
commit 4822622cb7
26 changed files with 1421 additions and 53 deletions

View File

@@ -0,0 +1,269 @@
/* Directory-browser dialog (figma 813-23126 family). The shared Modal renders
* headless here — mask, card, Escape only — and this module owns the figma
* frame exactly: fixed 600×420 card, 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. */
.dialog.dialog {
width: min(600px, 100%);
height: 420px;
padding: 0;
gap: 0;
}
/* Header block: pl24 pr14 pt22 pb12, 8px between title row and crumb row. */
.header {
display: flex;
flex-direction: column;
gap: 8px;
flex: none;
padding: 22px 14px 12px 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);
}
.crumbBar {
display: flex;
align-items: center;
gap: 4px;
min-height: 20px;
}
.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: invisible, but a real click target that
* flips the bar into path-edit mode. */
.crumbEditZone {
flex: 1 1 0;
min-width: 34px;
align-self: stretch;
border: none;
background: transparent;
cursor: text;
}
.pathInput {
box-sizing: border-box;
flex: 1 1 0;
min-width: 0;
height: 24px;
padding: 0 8px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
outline: none;
background: transparent;
font-size: 13px;
line-height: 20px;
color: var(--dsw-alias-label-primary);
}
/* Miller content: pt16 px24; columns are 256 wide (or full width solo) with
* the hairline divider centered between them; each column scrolls alone. */
.content {
display: flex;
align-items: stretch;
flex: 1 1 0;
min-height: 0;
gap: 20px;
padding: 16px 24px 0;
}
.column {
display: flex;
flex-direction: column;
gap: 2px;
width: 256px;
flex: none;
overflow-y: auto;
}
.columnWide {
width: 100%;
flex: 1 1 0;
}
.divider {
flex: none;
width: 1px;
background: var(--dsw-alias-border-l3);
}
.row {
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;
font-size: 12px;
line-height: 18px;
}
.status {
color: var(--dsw-alias-label-secondary);
}
.error {
color: var(--dsw-alias-state-error-primary);
}
/* Footer: l3 separator on top, pt12 px24, New-folder pinned left; the fixed
* card leaves the figma 28px below the 36px buttons. */
.footerBar {
display: flex;
align-items: center;
gap: 8px;
flex: none;
padding: 12px 24px 28px;
border-top: 1px solid var(--dsw-alias-border-l3);
}
.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;
}

View File

@@ -0,0 +1,376 @@
/**
* The in-app workspace-directory browser (figma Harness 813-23126 family): a
* fixed 600×420 dialog whose header carries the title, the selection-path
* breadcrumb, and a click-to-edit path zone; below it a Miller view — one
* full-width level until a row is selected, then two 256px columns (level |
* selected folder's children) around a hairline divider. Selecting in the
* right column shifts the view one level deeper. "New folder" opens a nested
* create dialog targeting the selected folder (or the level itself) and
* selects the created folder. Open adopts the selected folder, falling back
* to the listed level. Pure consumer of the injected browse calls — the
* owning flow decides what "Open" means and owns the workspace-creation
* error surface. Hidden entries are host-flagged and filtered here (a
* show-hidden toggle is deferred work, client-side only).
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
import {
Button, IconChevronRightOutline14, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, Modal,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client'
import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-runtime/client'
import type { Translate } from '@deepseek-ai/dsh-client-locale/client'
import css from './DirectoryBrowser.module.css'
/** Owner-supplied browser props: browse calls, pick semantics, and copy. */
export interface DirectoryBrowserProps {
/** Dialog visibility (owner-local; closed unmounts nothing but resets on reopen). */
open: boolean
/** List one directory level (absent path = the Host home directory). */
listDirectory: (path?: string) => Promise<DirectoryListing>
/** Create one child directory under an existing parent. */
createDirectory: (path: string, name: string) => Promise<string>
/** The operator confirmed a directory (the selection, else the listed level). */
onOpen: (path: string) => void
/** Close without picking (mask, Escape, Cancel). */
onClose: () => void
/** The owner's confirm is in flight: Open disables, the view freezes. */
busy: boolean
/** Localized copy. */
t: Translate
}
/** Failure text: the Host business message when typed, else the throw's text. */
function failureText(error: unknown): string {
if (error instanceof DirectoryBrowseError) return error.rpcError.message
return error instanceof Error ? error.message : String(error)
}
/**
* Breadcrumb rows for display: inside the home subtree the chain starts at a
* localized Home crumb; outside it the full ancestry shows, the root labeled
* by its own path.
*/
function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryEntry[] {
const homeIndex = listing.crumbs.findIndex(crumb => crumb.path === listing.home)
if (homeIndex === -1) return listing.crumbs
const tail = listing.crumbs.slice(homeIndex + 1)
return [{ name: homeLabel, path: listing.home, hidden: false }, ...tail]
}
/** One column of folder rows (the Miller view renders one or two of these). */
function LevelColumn({ entries, selectedPath, busy, onPick, wide }: {
entries: readonly DirectoryEntry[]
selectedPath: string | null
busy: boolean
onPick: (entry: DirectoryEntry) => void
wide: boolean
}) {
return (
<div className={clsx(css.column, wide && css.columnWide)} role="list">
{entries.filter(entry => !entry.hidden).map((entry) => {
const selected = entry.path === selectedPath
return (
<button
key={entry.path}
type="button"
role="listitem"
aria-current={selected || undefined}
className={clsx(css.row, selected && css.rowSelected)}
disabled={busy}
onClick={() => { onPick(entry) }}
>
{selected
? <IconFolderOpen16 size={16} className={css.rowIconSelected} />
: <IconFolderClose16 size={16} className={css.rowIcon} />}
<span className={css.rowName}>{entry.name}</span>
<IconChevronRightOutline14 size={12} className={css.rowChevron} />
</button>
)
})}
</div>
)
}
/**
* Render the directory-browser dialog.
* @param props - owner-controlled browser props.
* @returns the dialog element (null while closed, via Modal).
*/
export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, onClose, busy, t }: DirectoryBrowserProps) {
// Miller state: the listed level, the selected row in it, and the selected
// folder's own listing (the right column; null while nothing is selected).
const [parent, setParent] = useState<DirectoryListing | null>(null)
const [selected, setSelected] = useState<DirectoryEntry | null>(null)
const [child, setChild] = useState<DirectoryListing | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Path-edit state: null = breadcrumb mode; a string = the draft being typed.
const [pathDraft, setPathDraft] = useState<string | null>(null)
// Create-folder state: null = closed; a string = the nested dialog's draft.
const [folderDraft, setFolderDraft] = useState<string | null>(null)
const [creatingFolder, setCreatingFolder] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
const requestSeq = useRef(0)
/** Replace the whole view with one freshly listed level (no selection). */
const navigate = useCallback((path?: string) => {
const seq = ++requestSeq.current
setLoading(true)
setError(null)
listDirectory(path).then((next) => {
if (seq !== requestSeq.current) return
setParent(next)
setSelected(null)
setChild(null)
setLoading(false)
setPathDraft(null)
}, (reason: unknown) => {
if (seq !== requestSeq.current) return
setLoading(false)
setError(failureText(reason))
})
}, [listDirectory])
/** Select a row of the listed level and preview its children on the right. */
const select = useCallback((entry: DirectoryEntry) => {
const seq = ++requestSeq.current
setSelected(entry)
setChild(null)
setLoading(true)
setError(null)
listDirectory(entry.path).then((next) => {
if (seq !== requestSeq.current) return
setChild(next)
setLoading(false)
}, (reason: unknown) => {
if (seq !== requestSeq.current) return
setLoading(false)
setError(failureText(reason))
})
}, [listDirectory])
/** A right-column pick advances the view one level: child becomes the level. */
const advance = useCallback((entry: DirectoryEntry) => {
/* v8 ignore next -- narrowing guard: the right column only renders with a child listing. */
if (child === null) return
setParent(child)
select(entry)
}, [child, select])
// Every open starts fresh at the Host home directory; closing invalidates
// any in-flight response so a late arrival cannot repopulate a closed dialog.
useEffect(() => {
if (open) {
setParent(null)
setSelected(null)
setChild(null)
navigate()
return
}
requestSeq.current += 1
setError(null)
setPathDraft(null)
setFolderDraft(null)
setCreateError(null)
}, [open, navigate])
/** The folder a create or Open acts on: the selection, else the listed level. */
const targetPath = selected?.path ?? parent?.path ?? null
const targetName = selected?.name
?? (parent === null ? '' : (displayCrumbs(parent, t('browser.home')).at(-1)?.name ?? parent.path))
const confirmCreate = (): void => {
/* v8 ignore next -- reentry fence: the nested dialog only renders with a target and disables while creating. */
if (targetPath === null || folderDraft === null || creatingFolder) return
const name = folderDraft.trim()
if (name === '') return
setCreatingFolder(true)
setCreateError(null)
createDirectory(targetPath, name).then((createdPath) => {
setCreatingFolder(false)
setFolderDraft(null)
// Land like a right-column pick (figma 802:57446 → 813:23278 flow): the
// create target becomes the listed level and the new folder its selection.
const seq = ++requestSeq.current
setLoading(true)
listDirectory(targetPath).then((level) => {
/* v8 ignore next -- same fence as navigate/select; the modal blocks superseding input */
if (seq !== requestSeq.current) return
setParent(level)
setLoading(false)
select({ name, path: createdPath, hidden: false })
}, (reason: unknown) => {
/* v8 ignore next -- same fence as navigate/select; the modal blocks superseding input */
if (seq !== requestSeq.current) return
setLoading(false)
setError(failureText(reason))
})
}, (reason: unknown) => {
setCreatingFolder(false)
setCreateError(failureText(reason))
})
}
// After the hooks: a closed dialog renders nothing and evaluates no copy.
if (!open) return null
const crumbSource = child ?? parent
const crumbs = crumbSource === null ? [] : displayCrumbs(crumbSource, t('browser.home'))
const twoPane = selected !== null
return (
<Modal
open={open}
onClose={onClose}
title={t('browser.title')}
className={clsx(css.dialog)}
headless
>
<div className={css.header}>
<h2 className={css.title}>{t('browser.title')}</h2>
<div className={css.crumbBar}>
{pathDraft === null
? (
<>
{crumbs.map((crumb, index) => (
<span key={crumb.path} className={css.crumbSeat}>
{index > 0 && <IconChevronRightOutline14 size={12} className={css.crumbChevron} />}
<button
type="button"
className={css.crumb}
disabled={busy}
onClick={() => { navigate(crumb.path) }}
>
{crumb.name}
</button>
</span>
))}
{/* The empty zone right of the crumbs is the path-edit affordance. */}
<button
type="button"
className={css.crumbEditZone}
aria-label={t('browser.editPath')}
disabled={parent === null || busy}
/* v8 ignore next -- narrowing guard: the zone disables while the level is null. */
onClick={() => { if (parent !== null) setPathDraft(selected?.path ?? parent.path) }}
/>
</>
)
: (
<input
className={css.pathInput}
value={pathDraft}
aria-label={t('browser.editPath')}
autoFocus
disabled={busy}
onChange={(event) => { setPathDraft(event.target.value) }}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
const target = pathDraft.trim()
if (target !== '') navigate(target)
}
if (event.key === 'Escape') {
event.stopPropagation()
setPathDraft(null)
setError(null)
}
}}
/>
)}
</div>
</div>
<div className={css.content}>
{parent !== null && (
<LevelColumn
entries={parent.entries}
selectedPath={selected?.path ?? null}
busy={busy}
onPick={select}
wide={!twoPane}
/>
)}
{twoPane && <span className={css.divider} />}
{twoPane && child !== null && (
<LevelColumn
entries={child.entries}
selectedPath={null}
busy={busy}
onPick={advance}
wide={false}
/>
)}
{loading && <div className={css.status} role="status">{t('browser.loading')}</div>}
{error !== null && <div className={css.error} role="alert">{error}</div>}
</div>
<div className={css.footerBar}>
<Button
variant="outline"
icon={<IconPlusOutline16 size={14} />}
disabled={parent === null || busy || folderDraft !== null}
onClick={() => {
setFolderDraft('')
setCreateError(null)
}}
>
{t('browser.newFolder')}
</Button>
<span className={css.footerGap} />
<Button variant="outline" className={clsx(css.footerAction)} disabled={busy} onClick={onClose}>{t('browser.cancel')}</Button>
<Button
variant="primary"
className={clsx(css.footerAction)}
disabled={targetPath === null || loading || busy}
/* v8 ignore next -- narrowing guard: Open disables while no target exists. */
onClick={() => { if (targetPath !== null) onOpen(targetPath) }}
>
{t('browser.open')}
</Button>
</div>
{/* Nested create dialog (figma 813:23278): names one folder inside the target. */}
<Modal
open={folderDraft !== null}
onClose={() => { if (!creatingFolder) setFolderDraft(null) }}
title={t('browser.newFolder')}
className={clsx(css.createDialog)}
headless
>
<div className={css.createBody}>
<h3 className={css.createTitle}>{t('browser.newFolder')}</h3>
<p className={css.createIn}>{t('browser.createIn', { name: targetName })}</p>
<input
className={css.createInput}
value={folderDraft ?? ''}
aria-label={t('browser.folderName')}
placeholder={t('browser.untitledFolder')}
autoFocus
disabled={creatingFolder}
onChange={(event) => { setFolderDraft(event.target.value) }}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
confirmCreate()
}
if (event.key === 'Escape') {
event.stopPropagation()
if (!creatingFolder) setFolderDraft(null)
}
}}
/>
{createError !== null && <div className={css.error} role="alert">{createError}</div>}
<div className={css.createActions}>
<Button variant="outline" disabled={creatingFolder} onClick={() => { setFolderDraft(null) }}>{t('browser.cancel')}</Button>
<Button
variant="primary"
disabled={creatingFolder || folderDraft === null || folderDraft.trim() === ''}
onClick={confirmCreate}
>
{t('browser.create')}
</Button>
</div>
</div>
</Modal>
</Modal>
)
}

View File

@@ -0,0 +1,109 @@
/**
* 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 { createElement } from 'react'
import type { ReactElement } from 'react'
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
import type { ClientContext, DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client'
import type { Translate } from '@deepseek-ai/dsh-client-locale/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'
import { DirectoryBrowser } from './DirectoryBrowser.tsx'
/** Locale namespace owning the browser dialog's copy. */
const LOCALE_NS = 'directory-browser'
/** Injected face: the browse wire calls and copy the dialog drives (bound in apply's closure). */
interface BrowseFlowInjected {
/** List one directory level (absent path = the Host home directory). */
listDirectory: (path?: string) => 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,
})
}
/** 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 (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 {
ctx.effect(() => {
const disposers = [
ctx.locale.register(LOCALE_NS, 'zh', {
'browser.title': '选择工作区目录',
'browser.home': '主目录',
'browser.newFolder': '新建文件夹',
'browser.folderName': '文件夹名称',
'browser.createIn': '在"{name}"中新建文件夹',
'browser.untitledFolder': '未命名文件夹',
'browser.create': '创建',
'browser.cancel': '取消',
'browser.open': '打开',
'browser.editPath': '编辑路径',
'browser.loading': '加载中…',
}),
ctx.locale.register(LOCALE_NS, '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…',
}),
]
return () => { for (const dispose of disposers) dispose() }
}, 'directory-picker-browse: dialog dictionaries')
const injected = (): BrowseFlowInjected => ({
listDirectory: path => ctx.workspaces.listDirectory(path),
createDirectory: (path, name) => ctx.workspaces.createDirectory(path, name),
t: ctx.locale.bind(LOCALE_NS),
})
ctx.effect(() => {
const deferred = [
deferRegistration(ctx.slots, 'conversation.hero.workspace.directoryFlow', BrowseDirectoryFlow, () =>
ctx.slots.register({ name: 'conversation.hero.workspace.directoryFlow', inject: injected }, BrowseDirectoryFlow)),
deferRegistration(ctx.slots, 'sidebar.workspaces.directoryFlow', BrowseDirectoryFlow, () =>
ctx.slots.register({ name: 'sidebar.workspaces.directoryFlow', inject: injected }, BrowseDirectoryFlow)),
]
return () => { for (const entry of deferred) entry.dispose() }
}, 'directory-picker-browse: flow registrations')
}

View File

@@ -0,0 +1,6 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
declare module '*.css'