feat(web): in-app workspace-directory browser as the shipped picking default

The Open-local-folder flow now branches on the Host's advertised picker
interaction (host.describe.directoryPicker, read per menu open; unknown
kinds hide the entry): dialog keeps the native-chooser flow, and browse
opens the new in-app directory browser (figma Harness 802-56979) —
breadcrumbs rooted at a localized Home crumb, a click-to-edit path zone
right of the crumbs, host-flagged hidden entries filtered client-side,
an inline New-folder row, and Open adopting the listed directory
through the existing workspace-creation error surface. Dialog copy is
localized (ctx.locale, namespace 'workspace'); the plugin re-registers
its entries on locale/change.

apps/cli flips the composed backend from -dialog to -browse, so the
picker works for remote deployments out of the box; -dialog stays a
composable alternative. The workspace-management e2e drops its native
picker monkey-patch and drives the real modal end-to-end via the
path-edit affordance.
This commit is contained in:
creatixchu
2026-07-28 17:15:39 +08:00
parent 716d3ca636
commit 006a6655ee
22 changed files with 913 additions and 72 deletions

View File

@@ -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/client/ui-workspace/README.md
README.md: edd6c2f9373d97832def86bb44658d7c1c68dae9
README.zh.md: f7b73dde953d4294d4d157f479fe932adf1a29c4
README.md: e478670facaccadd49999a5dfeaac369801036e3
README.zh.md: 1348b59ac35aaaa103f8653dad33633bbe7792b1

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Shared Workspace picker plugin. `WorkspaceBrowser` is registered into the sidebar's `sidebar.workspaces` slot and `WorkspacePicker` into the page-local Session Intent hero's `conversation.hero.workspace` slot, so both surfaces use the same menu and creation flow.
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. The flat **Open local folder...** action delegates to the Host's native single-directory picker, adopts a returned path through the object layer, and selects the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors remain retryable. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped.
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. The flat **Open local folder...** action branches on the Host's advertised picker interaction (`host.describe.directoryPicker`, read per menu open; an unknown kind hides the entry): under `dialog` it delegates to the Host's native single-directory chooser, and under `browse` it opens the in-app directory browser (figma 802-56979) — breadcrumbs rooted at a localized Home crumb, a click-to-edit path zone right of the crumbs (Enter navigates, Escape restores), host-flagged hidden entries filtered client-side, an inline New-folder row, and Open adopting the listed directory. Either way adoption goes through the object layer and selects the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors remain retryable. The browser dialog's copy is localized through `ctx.locale` (namespace `workspace`), and the plugin re-registers its entries on `locale/change`. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped.
Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored.
@@ -19,4 +19,5 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **No Session deletion control** — the existing Session menu row remains visual-only; Workspace registration deletion does not delete Sessions.
- **Native folder selection depends on the local Host carrier** — fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal.
- **Native folder selection depends on the local Host carrier** — under a `dialog` composition, fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. The shipped default composes `browse`, which has no such dependence.
- **No show-hidden toggle yet** — the Host flags hidden entries and the browser filters them unconditionally; the toggle is a deferred client-only change.

View File

@@ -4,7 +4,7 @@
共享 Workspace 选择器插件。`WorkspaceBrowser` 注册到侧边栏的 `sidebar.workspaces` slot,`WorkspacePicker` 注册到页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot,因此两个表层使用同一菜单和创建流程。
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。平铺显示的 **打开本地文件夹…** 操作会委托 Host 的原生单目录选择器,通过对象层接纳返回的路径,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,发生错误后仍可重试。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。平铺显示的 **打开本地文件夹…** 操作按 Host 广播的选择器交互形态分支(`host.describe.directoryPicker`,每次菜单打开时读取;未知 kind 隐藏该入口):在 `dialog` 下委托 Host 的原生单目录选择器,在 `browse` 下打开应用内目录浏览器(figma 802-56979)——面包屑以本地化的"主目录"crumb 为根、面包屑右侧空白区点击进入路径编辑态(Enter 导航、Escape 还原)、宿主打标的隐藏条目在客户端过滤、内联新建文件夹行、"打开"接纳当前列出的目录。两条路径的接纳都经由对象层,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,发生错误后仍可重试。浏览器对话框的文案经 `ctx.locale` 本地化(命名空间 `workspace`),插件在 `locale/change` 时重新注册其条目。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。
两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。
@@ -19,4 +19,5 @@
## 已知限制与暂缓事项
- **没有 Session 删除控件**:现有 Session 菜单行仍仅提供视觉效果;删除 Workspace 注册记录不会删除 Session。
- **原生文件夹选择依赖本地 Host 载体**:仅使用 fixture(测试前置数据)的部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试。
- **原生文件夹选择依赖本地 Host 载体**:在 `dialog` 组合下,仅使用 fixture(测试前置数据)的部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试。已发布的默认组合为 `browse`,没有此依赖。
- **尚无"显示隐藏目录"开关**:Host 打标隐藏条目、浏览器无条件过滤;该开关是延期的纯客户端改动。

View File

@@ -25,6 +25,7 @@
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-ui-sidebar"
],
@@ -39,6 +40,7 @@
"clsx": "^2.0.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-locale": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
@@ -47,6 +49,7 @@
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",

View File

@@ -0,0 +1,185 @@
/* Directory-browser dialog (figma 802-56979). The shared Modal owns the mask,
* card, and title row; this module widens the card and rebuilds the figma
* header/footer separators with bleed margins inside the 24px content column. */
.dialog {
width: min(600px, 100%);
}
/* Breadcrumb bar sits visually inside the header block: bleed to the card
* edges, close the header's 12px bottom pad, draw the l3 separator. */
.crumbBar {
display: flex;
align-items: center;
gap: 4px;
min-height: 32px;
margin: -12px -24px 0;
padding: 0 24px 12px;
border-bottom: 1px solid var(--dsw-alias-border-l3);
}
.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: 28px;
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);
}
/* One directory level: 28px rows, r6, folder icon + name + enter chevron. */
.level {
display: flex;
flex-direction: column;
gap: 2px;
margin-top: -4px;
max-height: 320px;
overflow-y: auto;
}
.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);
}
.rowIcon {
flex: none;
color: var(--dsw-alias-label-secondary);
}
.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);
}
.folderRow {
cursor: default;
}
.folderInput {
box-sizing: border-box;
flex: 1 1 0;
min-width: 0;
height: 24px;
padding: 0 6px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 6px;
outline: none;
background: transparent;
font-size: 13px;
line-height: 20px;
color: var(--dsw-alias-label-primary);
}
.folderInput::placeholder {
color: var(--dsw-alias-label-caption);
}
.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: the l3 separator above the action row, New-folder pinned left
* (bleeds across the card; 12px stays below, matching the figma card pad). */
.footerBar {
display: flex;
align-items: center;
gap: 8px;
width: calc(100% + 48px);
margin: 0 -24px -12px;
padding: 12px 24px;
border-top: 1px solid var(--dsw-alias-border-l3);
}
.footerGap {
flex: 1 1 0;
}
.footerAction {
min-width: 72px;
}

View File

@@ -0,0 +1,246 @@
/**
* The in-app workspace-directory browser (figma Harness 802-56979): breadcrumb
* header with a click-to-edit path zone, one navigable directory level, an
* inline New-folder row, and the Cancel/Open footer. 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, 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 the listed level. */
createDirectory: (path: string, name: string) => Promise<string>
/** The operator confirmed the currently listed directory. */
onOpen: (path: string) => void
/** Close without picking (mask, Escape, Cancel). */
onClose: () => void
/** The owner's confirm is in flight: Open disables, the level 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]
}
/**
* 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) {
const [listing, setListing] = 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)
// New-folder state: null = no inline row; a string = the name being typed.
const [folderDraft, setFolderDraft] = useState<string | null>(null)
const [creatingFolder, setCreatingFolder] = useState(false)
const requestSeq = useRef(0)
const navigate = useCallback((path?: string) => {
const seq = ++requestSeq.current
setLoading(true)
setError(null)
listDirectory(path).then((next) => {
if (seq !== requestSeq.current) return
setListing(next)
setLoading(false)
setPathDraft(null)
setFolderDraft(null)
}, (reason: unknown) => {
if (seq !== requestSeq.current) return
setLoading(false)
setError(failureText(reason))
})
}, [listDirectory])
// 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) {
setListing(null)
navigate()
return
}
requestSeq.current += 1
setError(null)
setPathDraft(null)
setFolderDraft(null)
}, [open, navigate])
const confirmFolder = (): void => {
if (listing === null || folderDraft === null || creatingFolder) return
const name = folderDraft.trim()
if (name === '') return
setCreatingFolder(true)
setError(null)
createDirectory(listing.path, name).then(() => {
setCreatingFolder(false)
setFolderDraft(null)
navigate(listing.path)
}, (reason: unknown) => {
setCreatingFolder(false)
setError(failureText(reason))
})
}
// After the hooks: a closed dialog renders nothing and evaluates no copy.
if (!open) return null
const crumbs = listing === null ? [] : displayCrumbs(listing, t('browser.home'))
return (
<Modal
open={open}
onClose={onClose}
title={t('browser.title')}
className={clsx(css.dialog)}
footer={(
<div className={css.footerBar}>
<Button
variant="outline"
icon={<IconPlusOutline16 size={14} />}
disabled={listing === null || busy || folderDraft !== null}
onClick={() => { setFolderDraft('') }}
>
{t('browser.newFolder')}
</Button>
<span className={css.footerGap} />
<Button variant="outline" className={css.footerAction} disabled={busy} onClick={onClose}>{t('browser.cancel')}</Button>
<Button
variant="primary"
className={css.footerAction}
disabled={listing === null || loading || busy}
onClick={() => { if (listing !== null) onOpen(listing.path) }}
>
{t('browser.open')}
</Button>
</div>
)}
>
<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={listing === null || busy}
onClick={() => { if (listing !== null) setPathDraft(listing.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 className={css.level} role="list" aria-label={t('browser.title')}>
{folderDraft !== null && listing !== null && (
<div className={clsx(css.row, css.folderRow)}>
<IconFolderClose16 size={16} className={css.rowIcon} />
<input
className={css.folderInput}
value={folderDraft}
aria-label={t('browser.newFolder')}
placeholder={t('browser.folderName')}
autoFocus
disabled={creatingFolder}
onChange={(event) => { setFolderDraft(event.target.value) }}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
confirmFolder()
}
if (event.key === 'Escape') {
event.stopPropagation()
setFolderDraft(null)
}
}}
/>
</div>
)}
{listing?.entries.filter(entry => !entry.hidden).map(entry => (
<button
key={entry.path}
type="button"
role="listitem"
className={css.row}
disabled={busy}
onClick={() => { navigate(entry.path) }}
>
<IconFolderClose16 size={16} className={css.rowIcon} />
<span className={css.rowName}>{entry.name}</span>
<IconChevronRightOutline14 size={12} className={css.rowChevron} />
</button>
))}
{loading && <div className={css.status} role="status">{t('browser.loading')}</div>}
{error !== null && <div className={css.error} role="alert">{error}</div>}
</div>
</Modal>
)
}

View File

@@ -253,7 +253,11 @@ export function WorkspaceBrowser({
deleteWorkspace,
insertSessionBefore,
createWorkspace,
directoryPickerKind,
pickDirectory,
listDirectory,
createDirectory,
t,
}: WorkspaceBrowserProps) {
const workspaces = useWorkspaces(state => state.items)
const groupBy = useStore(s => s.groupBy)
@@ -371,7 +375,11 @@ export function WorkspaceBrowser({
anchorRef={wsPlusRef}
useWorkspaces={useWorkspaces}
createWorkspace={createWorkspace}
directoryPickerKind={directoryPickerKind}
pickDirectory={pickDirectory}
listDirectory={listDirectory}
createDirectory={createDirectory}
t={t}
onPick={(workspaceId) => {
setWsPickerOpen(false)
startSession(workspaceId)

View File

@@ -5,24 +5,25 @@
* slot registration.
*/
import type { RefObject } from 'react'
import { useCallback, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import {
Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry,
} from '@deepseek-ai/dsh-client-ui-primitives'
import {
WorkspaceCreateError,
type WorkspaceId, type WorkspaceListState, type WorkspaceView,
type DirectoryPickerKind, type WorkspaceId, type WorkspaceListState, type WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { WorkspacePickerProps } from './contract/slots.ts'
import type { DirectoryPickingInjected, WorkspacePickerProps } from './contract/slots.ts'
import { DirectoryBrowser } from './DirectoryBrowser.tsx'
import css from './WorkspacePicker.module.css'
const OPEN_LOCAL_FOLDER = '::open-local-folder'
const CREATE_NEW = '::create-new'
type ModalKind = 'create' | 'folder-error' | null
type ModalKind = 'create' | 'folder-error' | 'browse' | null
/** Core flow props: the owner supplies popover control and pick semantics. */
export interface WorkspaceCreateFlowProps {
export interface WorkspaceCreateFlowProps extends DirectoryPickingInjected {
/** Popover visibility (anchor button toggle state, owner-local). */
open: boolean
/** The anchor button element — the popover's placement anchor. */
@@ -31,8 +32,6 @@ export interface WorkspaceCreateFlowProps {
useWorkspaces: <S>(selector: (state: WorkspaceListState) => S) => S
/** Create or adopt a real Host Workspace. */
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
/** Open the Host's native single-directory picker. */
pickDirectory: () => Promise<string | null>
/** A real Workspace was picked or created. */
onPick: (workspaceId: WorkspaceId) => void
/** Close the popover (outside click / Escape / post-pick). */
@@ -49,7 +48,11 @@ export function WorkspaceCreateFlow({
anchorRef,
useWorkspaces,
createWorkspace,
directoryPickerKind,
pickDirectory,
listDirectory,
createDirectory,
t,
onPick,
onClose,
}: WorkspaceCreateFlowProps) {
@@ -65,6 +68,21 @@ export function WorkspaceCreateFlow({
const [modalError, setModalError] = useState<string | null>(null)
const [pickingFolder, setPickingFolder] = useState(false)
const [folderConflict, setFolderConflict] = useState(false)
// The Host's picker interaction: read while the menu is open; 'unknown'
// (fetch failure or an unadvertised kind) hides the local-folder entry —
// the merge-extensible union's documented default.
const [pickerKind, setPickerKind] = useState<DirectoryPickerKind | 'unknown' | null>(null)
useEffect(() => {
if (!open) return
let stale = false
directoryPickerKind().then(
// The wire type is the closed two-kind union today; a fetch failure is
// the reachable 'unknown' arm (an unadvertisable host hides the entry).
(kind) => { if (!stale) setPickerKind(kind) },
() => { if (!stale) setPickerKind('unknown') },
)
return () => { stale = true }
}, [open, directoryPickerKind])
const normalizedWorkspaceName = workspaceName.trim()
const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== ''
&& workspaces.some(workspace => workspace.title === normalizedWorkspaceName)
@@ -77,17 +95,40 @@ export function WorkspaceCreateFlow({
disabled: pickingFolder,
})),
...(workspaces.length > 0 ? [{ type: 'separator' as const, id: 'sep-create' }] : []),
{ id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: <IconFolderClose16 size={16} />, disabled: pickingFolder },
...(pickerKind === 'unknown' ? [] : [
{ id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: <IconFolderClose16 size={16} />, disabled: pickingFolder || pickerKind === null },
]),
{ id: CREATE_NEW, label: 'Create a new workspace', icon: <IconPlusOutline16 size={16} />, disabled: pickingFolder },
]
const closeModal = (): void => {
if (creating) return
if (creating || pickingFolder) return
setModalKind(null)
setModalError(null)
}
/** Adopt a chosen directory as a Workspace; failures land in the folder-error dialog. */
const adoptDirectory = (path: string): Promise<void> =>
createWorkspace({ path }).then((workspace) => {
setModalKind(null)
onPick(workspace.workspaceId)
}).catch((reason: unknown) => {
setFolderConflict(
reason instanceof WorkspaceCreateError
&& reason.rpcError.code === 'workspace-name-conflict',
)
setModalError(reason instanceof Error ? reason.message : String(reason))
setModalKind('folder-error')
})
const openLocalFolder = (): void => {
if (pickerKind === 'browse') {
onClose()
setModalError(null)
setFolderConflict(false)
setModalKind('browse')
return
}
onClose()
setModalKind(null)
setModalError(null)
@@ -95,13 +136,8 @@ export function WorkspaceCreateFlow({
setPickingFolder(true)
void pickDirectory().then(async (path) => {
if (path === null) return
const workspace = await createWorkspace({ path })
onPick(workspace.workspaceId)
await adoptDirectory(path)
}).catch((reason: unknown) => {
setFolderConflict(
reason instanceof WorkspaceCreateError
&& reason.rpcError.code === 'workspace-name-conflict',
)
setModalError(reason instanceof Error ? reason.message : String(reason))
setModalKind('folder-error')
}).finally(() => { setPickingFolder(false) })
@@ -155,6 +191,18 @@ export function WorkspaceCreateFlow({
getAnchorRect={getAnchorRect}
/>
{open && workspaceSnapshot.phase === 'pending' && <div className={css.menuStatus} role="status">Loading workspaces…</div>}
<DirectoryBrowser
open={modalKind === 'browse'}
listDirectory={listDirectory}
createDirectory={createDirectory}
busy={pickingFolder}
t={t}
onClose={closeModal}
onOpen={(path) => {
setPickingFolder(true)
void adoptDirectory(path).finally(() => { setPickingFolder(false) })
}}
/>
<Modal
open={modalKind === 'folder-error'}
onClose={closeModal}
@@ -228,7 +276,11 @@ export function WorkspacePicker({
onPick,
onClose,
createWorkspace,
directoryPickerKind,
pickDirectory,
listDirectory,
createDirectory,
t,
}: WorkspacePickerProps) {
return (
<WorkspaceCreateFlow
@@ -236,7 +288,11 @@ export function WorkspacePicker({
anchorRef={anchorRef}
useWorkspaces={useWorkspaces}
createWorkspace={createWorkspace}
directoryPickerKind={directoryPickerKind}
pickDirectory={pickDirectory}
listDirectory={listDirectory}
createDirectory={createDirectory}
t={t}
onPick={onPick}
onClose={onClose}
/>

View File

@@ -13,15 +13,38 @@ import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
// runtime shares below.
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
import type {
DirectoryListing, DirectoryPickerKind, SessionId, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { Translate } from '@deepseek-ai/dsh-client-locale/client'
import type { createWorkspaceViewStore } from '../stores.ts'
/**
* Directory-picking share both registrations consume: the Host's composed
* picker interaction decides which calls the flow drives (`dialog` opens the
* native chooser through `pickDirectory`; `browse` drives the in-app browser
* through `listDirectory`/`createDirectory`; an unknown kind hides the
* local-folder entry — the merge-extensible union's documented default).
*/
export type DirectoryPickingInjected = {
/** The Host's advertised picker interaction, read per flow open. */
directoryPickerKind: () => Promise<DirectoryPickerKind>
/** Ask the local Host to open its native single-directory picker (`dialog`). */
pickDirectory: () => Promise<string | null>
/** List one directory level with breadcrumb ancestry (`browse`). */
listDirectory: (path?: string) => Promise<DirectoryListing>
/** Create one child directory under an existing parent (`browse`). */
createDirectory: (path: string, name: string) => Promise<string>
/** Localized picker copy (this package's locale namespace). */
t: Translate
}
/**
* Browser-private injected share (arrives via the register inject factory).
* Data reads use the global framework hooks; these are the Host actions the
* browsing region drives.
*/
export type WorkspaceBrowserInjected = {
export type WorkspaceBrowserInjected = DirectoryPickingInjected & {
/**
* Start a New Session in a Workspace: reuse-or-create its blank session
* and open it; with no workspace, clear the selection into the New Session
@@ -42,8 +65,6 @@ export type WorkspaceBrowserInjected = {
insertSessionBefore: (workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId) => Promise<void>
/** Explicitly create or adopt a real Workspace before targeting a Session. */
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
/** Ask the local Host to open its native single-directory picker. */
pickDirectory: () => Promise<string | null>
}
/** Full browser props: shell owner share + viewing store + injected actions. */
@@ -57,11 +78,9 @@ export type WorkspaceBrowserProps =
* callback; this callback creates only the real Host Workspace. A type alias
* supplies the implicit index signature required by the registry.
*/
export type WorkspacePickerInjected = {
export type WorkspacePickerInjected = DirectoryPickingInjected & {
/** Explicitly create or adopt a real Workspace before targeting a Session. */
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
/** Ask the local Host to open its native single-directory picker. */
pickDirectory: () => Promise<string | null>
}
/**

View File

@@ -7,15 +7,19 @@
* packages/client/AGENTS.md.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts'
import type { DirectoryPickingInjected, WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts'
import { createWorkspaceViewStore } from './stores.ts'
import { WorkspaceBrowser } from './WorkspaceBrowser.tsx'
import { WorkspacePicker } from './WorkspacePicker.tsx'
export type {
DirectoryPickingInjected,
WorkspaceBrowserInjected, WorkspaceBrowserProps, WorkspacePickerInjected, WorkspacePickerProps,
} from './contract/slots.ts'
/** Locale namespace for the picker surfaces (dictionaries registered in apply). */
const LOCALE_NS = 'workspace'
/**
* Required services (cordis fiber inject). The target slots are declared by
* the ui-sidebar / ui-conversation applies, whose activation order relative
@@ -24,7 +28,7 @@ export type {
* provides a waitable service. apply therefore registers via
* declaration-aware deferral instead of assuming order.
*/
export const inject = ['slots', 'sessions', 'workspaces']
export const inject = ['slots', 'sessions', 'workspaces', 'locale']
/**
* Register the browser and picker once their slot declarations are on the
@@ -33,6 +37,39 @@ export const inject = ['slots', 'sessions', 'workspaces']
* @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.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.cancel': 'Cancel',
'browser.open': 'Open',
'browser.editPath': 'Edit path',
'browser.loading': 'Loading…',
}),
]
return () => { for (const dispose of disposers) dispose() }
}, 'ui-workspace: picker dictionaries')
const picking = (): DirectoryPickingInjected => ({
directoryPickerKind: () => ctx.workspaces.directoryPickerKind(),
pickDirectory: () => ctx.workspaces.pickDirectory(),
listDirectory: path => ctx.workspaces.listDirectory(path),
createDirectory: (path, name) => ctx.workspaces.createDirectory(path, name),
t: ctx.locale.bind(LOCALE_NS),
})
const browserInjected = (): WorkspaceBrowserInjected => ({
// Explicit group actions keep their target; unscoped New Session rides
// the runtime's shared action (recent-Workspace projection inside).
@@ -44,11 +81,11 @@ export function apply(ctx: ClientContext): void {
await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
},
createWorkspace: input => ctx.workspaces.create(input),
pickDirectory: () => ctx.workspaces.pickDirectory(),
...picking(),
})
const pickerInjected = (): WorkspacePickerInjected => ({
createWorkspace: input => ctx.workspaces.create(input),
pickDirectory: () => ctx.workspaces.pickDirectory(),
...picking(),
})
// Declaration-aware registration: each owner's declaring apply may activate
// after this one (entry activation order is unconstrained), and a register
@@ -83,7 +120,17 @@ export function apply(ctx: ClientContext): void {
const unsubscribers = registrations.map(entry =>
ctx.slots.subscribe(entry.name, () => { tryRegister(entry) }))
for (const entry of registrations) tryRegister(entry)
// Language switch: re-register both entries so open surfaces re-render
// with the other dictionary (the bound t keeps a stable identity).
const offLocale = ctx.on('locale/change', () => {
for (const [name, dispose] of disposers) {
dispose()
disposers.delete(name)
}
for (const entry of registrations) tryRegister(entry)
})
return () => {
offLocale()
for (const unsubscribe of unsubscribers) unsubscribe()
for (const dispose of disposers.values()) dispose()
}

View File

@@ -15,16 +15,28 @@ async function bench() {
title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0',
}))
const pickDirectory = vi.fn(async () => '/tmp/picked')
const directoryPickerKind = vi.fn(async () => 'browse' as const)
const listDirectory = vi.fn(async () => ({ path: '/home/u', home: '/home/u', crumbs: [], entries: [] }))
const createDirectory = vi.fn(async () => '/home/u/new')
const startSession = vi.fn()
const rename = vi.fn(async () => ({}))
const insertSessionBefore = vi.fn(async () => ({}))
const open = vi.fn()
const clear = vi.fn()
ctx.provide('workspaces', {
create, pickDirectory, startSession, rename, insertSessionBefore,
create, pickDirectory, directoryPickerKind, listDirectory, createDirectory,
startSession, rename, insertSessionBefore,
} as never)
ctx.provide('sessions', { open, clear } as never)
return { ctx, slots: ctx.get('slots') as SlotsService, create, pickDirectory, startSession, rename, insertSessionBefore, open, clear }
// Structural locale fake: register/bind are the only members apply touches.
const localeRegister = vi.fn(() => () => {})
const boundT = (key: string): string => key
ctx.provide('locale', { register: localeRegister, bind: () => boundT } as never)
return {
ctx, slots: ctx.get('slots') as SlotsService, create, pickDirectory,
directoryPickerKind, listDirectory, createDirectory, localeRegister, boundT,
startSession, rename, insertSessionBefore, open, clear,
}
}
type HoleName = 'sidebar.workspaces' | 'conversation.hero.workspace' | 'conversation.empty.workspace'
@@ -37,7 +49,7 @@ function declare(slots: SlotsService, ...names: HoleName[]): () => void {
describe('ui-workspace apply', () => {
it('declares the services it drives', () => {
expect(inject).toEqual(['slots', 'sessions', 'workspaces'])
expect(inject).toEqual(['slots', 'sessions', 'workspaces', 'locale'])
})
it('registers browser and pickers for declarations arriving before or after apply', async () => {

View File

@@ -0,0 +1,160 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import type { DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client'
import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-runtime/client'
import { DirectoryBrowser } from '../src/client/DirectoryBrowser.tsx'
afterEach(cleanup)
const HOME = '/home/u'
/** Listing fake over a tiny fixed tree; unknown paths reject like the Host. */
function listingFor(path?: string): DirectoryListing {
const target = path ?? HOME
const tree: Record<string, DirectoryListing> = {
[HOME]: {
path: HOME,
home: HOME,
crumbs: [
{ name: '/', path: '/', hidden: false },
{ name: 'home', path: '/home', hidden: false },
{ name: 'u', path: HOME, hidden: false },
],
entries: [
{ name: '.config', path: `${HOME}/.config`, hidden: true },
{ name: 'Documents', path: `${HOME}/Documents`, hidden: false },
],
},
[`${HOME}/Documents`]: {
path: `${HOME}/Documents`,
home: HOME,
crumbs: [
{ name: '/', path: '/', hidden: false },
{ name: 'home', path: '/home', hidden: false },
{ name: 'u', path: HOME, hidden: false },
{ name: 'Documents', path: `${HOME}/Documents`, hidden: false },
],
entries: [{ name: 'harness', path: `${HOME}/Documents/harness`, hidden: false }],
},
}
const found = tree[target]
if (found === undefined) {
throw new DirectoryBrowseError({ code: 'directory-unreadable', message: `cannot list ${target}`, details: { path: target } })
}
return found
}
function mount(overrides: Partial<Parameters<typeof DirectoryBrowser>[0]> = {}) {
const listDirectory = vi.fn(async (path?: string) => listingFor(path))
const createDirectory = vi.fn(async (path: string, name: string) => `${path}/${name}`)
const onOpen = vi.fn()
const onClose = vi.fn()
const props = {
open: true,
listDirectory,
createDirectory,
onOpen,
onClose,
busy: false,
t: (key: string) => key,
...overrides,
}
const view = render(<DirectoryBrowser {...props} />)
return { view, props, listDirectory, createDirectory, onOpen, onClose }
}
describe('DirectoryBrowser', () => {
it('opens at the Host home, hides hidden entries, and roots the crumbs at Home', async () => {
const b = mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
expect(b.listDirectory).toHaveBeenCalledWith(undefined)
expect(screen.getByRole('listitem').textContent).toBe('Documents')
expect(screen.queryByText('.config')).toBeNull()
// Inside the home subtree the chain collapses to a localized Home crumb.
expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy()
expect(screen.queryByRole('button', { name: '/' })).toBeNull()
})
it('enters a row on click and jumps back through a crumb', async () => {
const b = mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('listitem'))
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
expect(b.listDirectory).toHaveBeenLastCalledWith(`${HOME}/Documents`)
fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') })
})
it('edits the path from the crumb bar: Enter navigates, Escape restores', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
expect(input.value).toBe(HOME)
fireEvent.change(input, { target: { value: `${HOME}/Documents` } })
fireEvent.keyDown(input, { key: 'Enter' })
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
// Escape leaves an opened edit without navigating.
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Escape' })
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
expect(screen.getByRole('listitem').textContent).toBe('harness')
})
it('surfaces an unreadable target as an alert and keeps the edit open for correction', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const input = screen.getByLabelText('browser.editPath')
fireEvent.change(input, { target: { value: '/nope' } })
fireEvent.keyDown(input, { key: 'Enter' })
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('cannot list /nope') })
expect(screen.getByLabelText('browser.editPath')).toBeTruthy()
expect(screen.getByRole('listitem').textContent).toBe('Documents')
})
it('creates a folder inline and refreshes the level; failures land as alerts', async () => {
const b = mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
const input = screen.getByLabelText('browser.newFolder')
fireEvent.change(input, { target: { value: 'fresh' } })
fireEvent.keyDown(input, { key: 'Enter' })
await waitFor(() => { expect(b.createDirectory).toHaveBeenCalledWith(HOME, 'fresh') })
// The level reloads after creation (initial + post-create).
await waitFor(() => { expect(b.listDirectory).toHaveBeenLastCalledWith(HOME) })
b.createDirectory.mockRejectedValueOnce(
new DirectoryBrowseError({ code: 'directory-exists', message: 'taken already', details: { path: `${HOME}/x` } }))
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
const retry = screen.getByLabelText('browser.newFolder')
fireEvent.change(retry, { target: { value: 'x' } })
fireEvent.keyDown(retry, { key: 'Enter' })
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('taken already') })
})
it('confirms the listed directory through Open, closes through Cancel, and freezes while busy', async () => {
const b = mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.open' }))
expect(b.onOpen).toHaveBeenCalledWith(HOME)
fireEvent.click(screen.getByRole('button', { name: 'browser.cancel' }))
expect(b.onClose).toHaveBeenCalled()
const busy = mount({ busy: true })
await waitFor(() => { expect(busy.listDirectory).toHaveBeenCalled() })
expect(screen.getAllByRole<HTMLButtonElement>('button', { name: 'browser.open' }).at(-1)!.disabled).toBe(true)
})
it('starts back at home on reopen', async () => {
const b = mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('listitem'))
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
b.view.rerender(<DirectoryBrowser {...b.props} open={false} />)
b.view.rerender(<DirectoryBrowser {...b.props} open />)
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') })
expect(b.listDirectory).toHaveBeenLastCalledWith(undefined)
})
})

View File

@@ -59,7 +59,11 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
deleteWorkspace: vi.fn(async () => {}),
insertSessionBefore: vi.fn(async () => {}),
createWorkspace: vi.fn(async () => workspace('created', [])),
directoryPickerKind: vi.fn(async () => 'dialog' as const),
pickDirectory: vi.fn(async () => null),
listDirectory: vi.fn(async () => ({ path: '/home/u', home: '/home/u', crumbs: [], entries: [] })),
createDirectory: vi.fn(async () => '/home/u/new'),
t: (key: string) => key,
...overrides,
}
const view = render(<WorkspaceBrowser {...props} />)

View File

@@ -5,6 +5,7 @@ import type {
SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import { WorkspaceCreateError } from '@deepseek-ai/dsh-client-runtime/client'
import type { DirectoryPickingInjected } from '../src/client/contract/slots.ts'
import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx'
afterEach(cleanup)
@@ -35,14 +36,34 @@ function anchor(): { current: HTMLElement } {
return { current: element }
}
/** Minimal picking share for direct renders (kind resolves to dialog). */
function pickingShare(): DirectoryPickingInjected {
return {
directoryPickerKind: vi.fn(async () => 'dialog' as const),
pickDirectory: vi.fn(async () => null),
listDirectory: vi.fn(async () => ({ path: '/home/u', home: '/home/u', crumbs: [], entries: [] })),
createDirectory: vi.fn(async () => '/home/u/new'),
t: (key: string) => key,
}
}
function mount(
items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')],
createWorkspace = vi.fn(),
pickDirectory = vi.fn(async () => null as string | null),
picking: Partial<DirectoryPickingInjected> = {},
) {
const onPick = vi.fn()
const onClose = vi.fn()
const anchorRef = anchor()
const share: DirectoryPickingInjected = {
directoryPickerKind: vi.fn(async () => 'dialog' as const),
pickDirectory,
listDirectory: vi.fn(async () => ({ path: '/home/u', home: '/home/u', crumbs: [], entries: [] })),
createDirectory: vi.fn(async () => '/home/u/new'),
t: key => key,
...picking,
}
const renderPicker = (nextItems: readonly WorkspaceView[]) => (
<WorkspacePicker
open
@@ -52,14 +73,14 @@ function mount(
onPick={onPick}
onClose={onClose}
createWorkspace={createWorkspace}
pickDirectory={pickDirectory}
{...share}
/>
)
const view = render(
renderPicker(items),
)
return {
view, onPick, onClose, createWorkspace, pickDirectory,
view, onPick, onClose, createWorkspace, pickDirectory, share,
rerenderItems: (nextItems: readonly WorkspaceView[]) => { view.rerender(renderPicker(nextItems)) },
}
}
@@ -68,6 +89,16 @@ function chooseItem(name: 'Open local folder…' | 'Create a new workspace'): vo
fireEvent.click(screen.getByRole('menuitem', { name }))
}
/** The local-folder entry disables until the Host's picker kind resolves. */
async function chooseLocalFolder(): Promise<void> {
await waitFor(() => {
const item = screen.getByRole('menuitem', { name: 'Open local folder…' })
expect(item).not.toHaveProperty('ariaDisabled', 'true')
expect(item.getAttribute('aria-disabled')).not.toBe('true')
})
chooseItem('Open local folder…')
}
describe('WorkspacePicker', () => {
it('lists real Workspaces from useWorkspaces and forwards a selected id', () => {
const b = mount()
@@ -92,7 +123,7 @@ describe('WorkspacePicker', () => {
const createWorkspace = vi.fn(async () => created)
const pickDirectory = vi.fn(async () => '/tmp/project')
const b = mount([], createWorkspace, pickDirectory)
chooseItem('Open local folder…')
await chooseLocalFolder()
expect(pickDirectory).toHaveBeenCalledOnce()
await waitFor(() => { expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' }) })
expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' })
@@ -101,7 +132,7 @@ describe('WorkspacePicker', () => {
it('treats native picker cancellation as a silent no-op', async () => {
const b = mount([], vi.fn(), vi.fn(async () => null))
chooseItem('Open local folder…')
await chooseLocalFolder()
await waitFor(() => { expect(b.pickDirectory).toHaveBeenCalledOnce() })
expect(b.createWorkspace).not.toHaveBeenCalled()
expect(b.onPick).not.toHaveBeenCalled()
@@ -118,7 +149,7 @@ describe('WorkspacePicker', () => {
})
})
const b = mount([], createWorkspace, pickDirectory)
chooseItem('Open local folder…')
await chooseLocalFolder()
await waitFor(() => {
expect(screen.getByRole('dialog', { name: 'A workspace with this name already exists' })).toBeTruthy()
})
@@ -132,7 +163,7 @@ describe('WorkspacePicker', () => {
let resolve!: (path: string | null) => void
const pending = new Promise<string | null>((settle) => { resolve = settle })
const b = mount([], vi.fn(), vi.fn(() => pending))
chooseItem('Open local folder…')
await chooseLocalFolder()
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Open local folder…' }).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Create a new workspace' }).disabled).toBe(true)
fireEvent.click(screen.getByRole('menuitem', { name: 'Open local folder…' }))
@@ -142,7 +173,7 @@ describe('WorkspacePicker', () => {
it('reports non-Error native picker failures', async () => {
const b = mount([], vi.fn(), vi.fn(async () => { throw 'picker unavailable' }))
chooseItem('Open local folder…')
await chooseLocalFolder()
await waitFor(() => {
expect(screen.getByRole('alert').textContent).toBe('picker unavailable')
})
@@ -212,11 +243,58 @@ describe('WorkspacePicker', () => {
expect(b.onPick).not.toHaveBeenCalled()
})
it('opens the in-app browser under the browse capability and adopts the confirmed directory', async () => {
const created = { ...workspace('adopted'), path: '/home/u', title: 'u' }
const createWorkspace = vi.fn(async () => created)
const b = mount([], createWorkspace, vi.fn(), {
directoryPickerKind: vi.fn(async () => 'browse' as const),
})
await chooseLocalFolder()
await waitFor(() => { expect(screen.getByRole('dialog', { name: 'browser.title' })).toBeTruthy() })
// The dialog listed home; Open adopts the listed directory.
await waitFor(() => { expect(b.share.listDirectory).toHaveBeenCalled() })
fireEvent.click(screen.getByRole('button', { name: 'browser.open' }))
await waitFor(() => { expect(createWorkspace).toHaveBeenCalledWith({ path: '/home/u' }) })
await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) })
expect(b.pickDirectory).not.toHaveBeenCalled()
expect(screen.queryByRole('dialog')).toBeNull()
})
it('routes an adoption conflict from the browser into the folder-error dialog, and Choose again reopens the browser', async () => {
const createWorkspace = vi.fn(async () => {
throw new WorkspaceCreateError({
code: 'workspace-name-conflict', message: 'u already exists', details: { name: 'u' },
})
})
const b = mount([], createWorkspace, vi.fn(), {
directoryPickerKind: vi.fn(async () => 'browse' as const),
})
await chooseLocalFolder()
await waitFor(() => { expect(screen.getByRole('dialog', { name: 'browser.title' })).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.open' }))
await waitFor(() => {
expect(screen.getByRole('dialog', { name: 'A workspace with this name already exists' })).toBeTruthy()
})
fireEvent.click(screen.getByRole('button', { name: 'Choose again' }))
await waitFor(() => { expect(screen.getByRole('dialog', { name: 'browser.title' })).toBeTruthy() })
expect(b.onPick).not.toHaveBeenCalled()
})
it('hides the local-folder entry when the picker kind is unknown', async () => {
mount([], vi.fn(), vi.fn(), {
directoryPickerKind: vi.fn(async () => { throw new Error('unreachable host') }),
})
await waitFor(() => {
expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull()
})
expect(screen.getByRole('menuitem', { name: 'Create a new workspace' })).toBeTruthy()
})
it('waits to show its menu until an optional anchor is available', () => {
render(
<WorkspacePicker
open useSessions={hook(sessions)} useWorkspaces={hook(workspaceState([]))}
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} pickDirectory={vi.fn()}
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} {...pickingShare()}
/>,
)
expect(screen.queryByRole('menu')).toBeNull()
@@ -229,7 +307,7 @@ describe('WorkspacePicker', () => {
render(
<WorkspacePicker
open anchorRef={anchor()} useSessions={hook(sessions)} useWorkspaces={hook(state)}
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} pickDirectory={vi.fn()}
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} {...pickingShare()}
/>,
)
expect(screen.getByRole('status').textContent).toBe('Loading workspaces…')

View File

@@ -8,6 +8,9 @@
"src"
],
"references": [
{
"path": "../locale"
},
{
"path": "../../../vendor/cordis"
},