fix(host,client): gate the picker affordance on the advertised kind; reject non-absolute browse paths
ds-review-bot round 2. The workspace UI never consulted the advertised directoryPicker kind: under a browse (or merge-added) backend it still rendered 'Open local folder…' and called pickDirectory(), which the host answers with directory-picker-unavailable. The create flow now reads directoryPickerKind() per menu open and renders the dialog affordance only under 'dialog' — browse (until its in-app browser UI lands) and unknown kinds hide the entry, realizing the seam's documented default; a keyless workspace-flow snapshot pins the hidden entry over the browse fixture. The browse backend also resolved wire paths, silently rebasing '' or relative parents under the host process cwd; both primitives now reject non-absolute explicit paths with their business codes, and the seam JSDoc carries the contract.
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md
|
||||
README.md: edd6c2f9373d97832def86bb44658d7c1c68dae9
|
||||
README.zh.md: f7b73dde953d4294d4d157f479fe932adf1a29c4
|
||||
README.md: 58aaf56e1953f00417492d766d8f4ae4a0081c81
|
||||
README.zh.md: 7c7b33e0ea68fefee8f857cb5e67a36ec5a854f5
|
||||
|
||||
@@ -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 renders only when the Host advertises the `dialog` picker interaction (read per flow open through `host.describe`); `browse` — until its in-app browser UI lands — and unknown kinds hide the entry, the seam's documented default. When shown, it 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.
|
||||
|
||||
Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored.
|
||||
|
||||
|
||||
@@ -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 广播 `dialog` 选择交互时渲染(每次流程打开时通过 `host.describe` 读取);`browse`(在其应用内浏览器 UI 落地之前)以及未知 kind 都会隐藏该入口,即 seam 文档化的默认行为。显示时它会委托 Host 的原生单目录选择器,通过对象层接纳返回的路径,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,发生错误后仍可重试。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。
|
||||
|
||||
两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。
|
||||
|
||||
|
||||
@@ -254,6 +254,7 @@ export function WorkspaceBrowser({
|
||||
insertSessionBefore,
|
||||
createWorkspace,
|
||||
pickDirectory,
|
||||
directoryPickerKind,
|
||||
}: WorkspaceBrowserProps) {
|
||||
const workspaces = useWorkspaces(state => state.items)
|
||||
const groupBy = useStore(s => s.groupBy)
|
||||
@@ -372,6 +373,7 @@ export function WorkspaceBrowser({
|
||||
useWorkspaces={useWorkspaces}
|
||||
createWorkspace={createWorkspace}
|
||||
pickDirectory={pickDirectory}
|
||||
directoryPickerKind={directoryPickerKind}
|
||||
onPick={(workspaceId) => {
|
||||
setWsPickerOpen(false)
|
||||
startSession(workspaceId)
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
* 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 css from './WorkspacePicker.module.css'
|
||||
@@ -33,6 +33,8 @@ export interface WorkspaceCreateFlowProps {
|
||||
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
|
||||
/** Open the Host's native single-directory picker. */
|
||||
pickDirectory: () => Promise<string | null>
|
||||
/** The Host's advertised picker interaction (read per flow open); gates which picking affordance renders. */
|
||||
directoryPickerKind: () => Promise<DirectoryPickerKind>
|
||||
/** A real Workspace was picked or created. */
|
||||
onPick: (workspaceId: WorkspaceId) => void
|
||||
/** Close the popover (outside click / Escape / post-pick). */
|
||||
@@ -50,6 +52,7 @@ export function WorkspaceCreateFlow({
|
||||
useWorkspaces,
|
||||
createWorkspace,
|
||||
pickDirectory,
|
||||
directoryPickerKind,
|
||||
onPick,
|
||||
onClose,
|
||||
}: WorkspaceCreateFlowProps) {
|
||||
@@ -69,6 +72,22 @@ export function WorkspaceCreateFlow({
|
||||
const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== ''
|
||||
&& workspaces.some(workspace => workspace.title === normalizedWorkspaceName)
|
||||
|
||||
// The advertised interaction gates the picking affordance: 'dialog' is the
|
||||
// only kind pickDirectory() can serve, so its entry renders under that kind
|
||||
// alone; 'browse' (until the in-app browser UI lands) and unknown kinds
|
||||
// hide the entry, the seam's documented unknown-kind default. Re-read per
|
||||
// flow open — no cache to go stale across reconnects.
|
||||
const [dialogPicker, setDialogPicker] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
void directoryPickerKind()
|
||||
.then((kind) => { setDialogPicker(kind === 'dialog') })
|
||||
// A failed describe hides the entry too: the same Host that cannot
|
||||
// answer describe cannot serve pickDirectory. (Post-unmount settlement
|
||||
// is safe: React 18 no-ops setState on unmounted components.)
|
||||
.catch(() => { setDialogPicker(false) })
|
||||
}, [open, directoryPickerKind])
|
||||
|
||||
const items: MenuEntry[] = [
|
||||
...workspaces.map(workspace => ({
|
||||
id: workspace.workspaceId,
|
||||
@@ -77,7 +96,9 @@ 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 },
|
||||
...(dialogPicker
|
||||
? [{ id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: <IconFolderClose16 size={16} />, disabled: pickingFolder }]
|
||||
: []),
|
||||
{ id: CREATE_NEW, label: 'Create a new workspace', icon: <IconPlusOutline16 size={16} />, disabled: pickingFolder },
|
||||
]
|
||||
|
||||
@@ -229,6 +250,7 @@ export function WorkspacePicker({
|
||||
onClose,
|
||||
createWorkspace,
|
||||
pickDirectory,
|
||||
directoryPickerKind,
|
||||
}: WorkspacePickerProps) {
|
||||
return (
|
||||
<WorkspaceCreateFlow
|
||||
@@ -237,6 +259,7 @@ export function WorkspacePicker({
|
||||
useWorkspaces={useWorkspaces}
|
||||
createWorkspace={createWorkspace}
|
||||
pickDirectory={pickDirectory}
|
||||
directoryPickerKind={directoryPickerKind}
|
||||
onPick={onPick}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
@@ -13,7 +13,7 @@ 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 { DirectoryPickerKind, SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { createWorkspaceViewStore } from '../stores.ts'
|
||||
|
||||
/**
|
||||
@@ -44,6 +44,8 @@ export type WorkspaceBrowserInjected = {
|
||||
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
|
||||
/** Ask the local Host to open its native single-directory picker. */
|
||||
pickDirectory: () => Promise<string | null>
|
||||
/** The Host's advertised picker interaction (read per flow open); gates which picking affordance renders. */
|
||||
directoryPickerKind: () => Promise<DirectoryPickerKind>
|
||||
}
|
||||
|
||||
/** Full browser props: shell owner share + viewing store + injected actions. */
|
||||
@@ -62,6 +64,8 @@ export type WorkspacePickerInjected = {
|
||||
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
|
||||
/** Ask the local Host to open its native single-directory picker. */
|
||||
pickDirectory: () => Promise<string | null>
|
||||
/** The Host's advertised picker interaction (read per flow open); gates which picking affordance renders. */
|
||||
directoryPickerKind: () => Promise<DirectoryPickerKind>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -45,10 +45,12 @@ export function apply(ctx: ClientContext): void {
|
||||
},
|
||||
createWorkspace: input => ctx.workspaces.create(input),
|
||||
pickDirectory: () => ctx.workspaces.pickDirectory(),
|
||||
directoryPickerKind: () => ctx.workspaces.directoryPickerKind(),
|
||||
})
|
||||
const pickerInjected = (): WorkspacePickerInjected => ({
|
||||
createWorkspace: input => ctx.workspaces.create(input),
|
||||
pickDirectory: () => ctx.workspaces.pickDirectory(),
|
||||
directoryPickerKind: () => ctx.workspaces.directoryPickerKind(),
|
||||
})
|
||||
// Declaration-aware registration: each owner's declaring apply may activate
|
||||
// after this one (entry activation order is unconstrained), and a register
|
||||
|
||||
@@ -15,16 +15,17 @@ async function bench() {
|
||||
title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0',
|
||||
}))
|
||||
const pickDirectory = vi.fn(async () => '/tmp/picked')
|
||||
const directoryPickerKind = vi.fn(async () => 'dialog' as const)
|
||||
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, 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 }
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService, create, pickDirectory, directoryPickerKind, startSession, rename, insertSessionBefore, open, clear }
|
||||
}
|
||||
|
||||
type HoleName = 'sidebar.workspaces' | 'conversation.hero.workspace' | 'conversation.empty.workspace'
|
||||
@@ -75,12 +76,16 @@ describe('ui-workspace apply', () => {
|
||||
expect(b.create).toHaveBeenCalledWith({ name: 'project' })
|
||||
await browser.pickDirectory()
|
||||
expect(b.pickDirectory).toHaveBeenCalledOnce()
|
||||
await browser.directoryPickerKind()
|
||||
expect(b.directoryPickerKind).toHaveBeenCalledOnce()
|
||||
|
||||
const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)()
|
||||
await picker.createWorkspace({ path: '/tmp/project' })
|
||||
expect(b.create).toHaveBeenCalledWith({ path: '/tmp/project' })
|
||||
await picker.pickDirectory()
|
||||
expect(b.pickDirectory).toHaveBeenCalledTimes(2)
|
||||
await picker.directoryPickerKind()
|
||||
expect(b.directoryPickerKind).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('unregisters every entry on teardown', async () => {
|
||||
|
||||
@@ -60,6 +60,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
|
||||
insertSessionBefore: vi.fn(async () => {}),
|
||||
createWorkspace: vi.fn(async () => workspace('created', [])),
|
||||
pickDirectory: vi.fn(async () => null),
|
||||
directoryPickerKind: vi.fn(async () => 'dialog' as const),
|
||||
...overrides,
|
||||
}
|
||||
const view = render(<WorkspaceBrowser {...props} />)
|
||||
|
||||
@@ -39,6 +39,7 @@ function mount(
|
||||
items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')],
|
||||
createWorkspace = vi.fn(),
|
||||
pickDirectory = vi.fn(async () => null as string | null),
|
||||
directoryPickerKind = vi.fn(async () => 'dialog'),
|
||||
) {
|
||||
const onPick = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
@@ -53,19 +54,22 @@ function mount(
|
||||
onClose={onClose}
|
||||
createWorkspace={createWorkspace}
|
||||
pickDirectory={pickDirectory}
|
||||
directoryPickerKind={directoryPickerKind}
|
||||
/>
|
||||
)
|
||||
const view = render(
|
||||
renderPicker(items),
|
||||
)
|
||||
return {
|
||||
view, onPick, onClose, createWorkspace, pickDirectory,
|
||||
view, onPick, onClose, createWorkspace, pickDirectory, directoryPickerKind,
|
||||
rerenderItems: (nextItems: readonly WorkspaceView[]) => { view.rerender(renderPicker(nextItems)) },
|
||||
}
|
||||
}
|
||||
|
||||
function chooseItem(name: 'Open local folder…' | 'Create a new workspace'): void {
|
||||
fireEvent.click(screen.getByRole('menuitem', { name }))
|
||||
// findByRole, not getByRole: the folder entry renders only after the advertised
|
||||
// picker kind resolves, one microtask after the menu opens.
|
||||
async function chooseItem(name: 'Open local folder…' | 'Create a new workspace'): Promise<void> {
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name }))
|
||||
}
|
||||
|
||||
describe('WorkspacePicker', () => {
|
||||
@@ -79,7 +83,7 @@ describe('WorkspacePicker', () => {
|
||||
const created = workspace('new', 'New')
|
||||
const createWorkspace = vi.fn(async () => created)
|
||||
const b = mount([], createWorkspace)
|
||||
chooseItem('Create a new workspace')
|
||||
await chooseItem('Create a new workspace')
|
||||
const input = screen.getByLabelText('New workspace name')
|
||||
fireEvent.change(input, { target: { value: 'project-one' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
@@ -92,7 +96,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 chooseItem('Open local folder…')
|
||||
expect(pickDirectory).toHaveBeenCalledOnce()
|
||||
await waitFor(() => { expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' }) })
|
||||
expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' })
|
||||
@@ -101,7 +105,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 chooseItem('Open local folder…')
|
||||
await waitFor(() => { expect(b.pickDirectory).toHaveBeenCalledOnce() })
|
||||
expect(b.createWorkspace).not.toHaveBeenCalled()
|
||||
expect(b.onPick).not.toHaveBeenCalled()
|
||||
@@ -118,7 +122,7 @@ describe('WorkspacePicker', () => {
|
||||
})
|
||||
})
|
||||
const b = mount([], createWorkspace, pickDirectory)
|
||||
chooseItem('Open local folder…')
|
||||
await chooseItem('Open local folder…')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog', { name: 'A workspace with this name already exists' })).toBeTruthy()
|
||||
})
|
||||
@@ -132,7 +136,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 chooseItem('Open local folder…')
|
||||
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,23 +146,23 @@ 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 chooseItem('Open local folder…')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert').textContent).toBe('picker unavailable')
|
||||
})
|
||||
expect(b.createWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes a creation modal when the user cancels', () => {
|
||||
it('closes a creation modal when the user cancels', async () => {
|
||||
mount([])
|
||||
chooseItem('Create a new workspace')
|
||||
await chooseItem('Create a new workspace')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('blocks a create-new name already present in the Workspace list', () => {
|
||||
it('blocks a create-new name already present in the Workspace list', async () => {
|
||||
const b = mount([workspace('alpha', 'Alpha')])
|
||||
chooseItem('Create a new workspace')
|
||||
await chooseItem('Create a new workspace')
|
||||
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: ' Alpha ' } })
|
||||
expect(screen.getByRole('alert').textContent).toBe('A workspace named “Alpha” already exists.')
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Create workspace' }).disabled).toBe(true)
|
||||
@@ -171,7 +175,7 @@ describe('WorkspacePicker', () => {
|
||||
const pending = new Promise<WorkspaceView>((settle) => { resolve = settle })
|
||||
const created = workspace('fresh', 'same-name')
|
||||
const b = mount([], vi.fn(() => pending))
|
||||
chooseItem('Create a new workspace')
|
||||
await chooseItem('Create a new workspace')
|
||||
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'same-name' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
|
||||
@@ -187,7 +191,7 @@ describe('WorkspacePicker', () => {
|
||||
const pending = new Promise<WorkspaceView>((_resolve, rejectPromise) => { reject = rejectPromise })
|
||||
const createWorkspace = vi.fn(() => pending)
|
||||
const b = mount([], createWorkspace)
|
||||
chooseItem('Create a new workspace')
|
||||
await chooseItem('Create a new workspace')
|
||||
const input = screen.getByLabelText('New workspace name')
|
||||
fireEvent.keyDown(input, { key: 'ArrowRight' })
|
||||
fireEvent.change(input, { target: { value: 'broken' } })
|
||||
@@ -204,7 +208,7 @@ describe('WorkspacePicker', () => {
|
||||
|
||||
it('reports non-Error creation failures', async () => {
|
||||
const b = mount([], vi.fn(async () => { throw 'permission denied' }))
|
||||
chooseItem('Create a new workspace')
|
||||
await chooseItem('Create a new workspace')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert').textContent).toBe('Workspace creation failed: permission denied')
|
||||
@@ -217,6 +221,7 @@ describe('WorkspacePicker', () => {
|
||||
<WorkspacePicker
|
||||
open useSessions={hook(sessions)} useWorkspaces={hook(workspaceState([]))}
|
||||
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} pickDirectory={vi.fn()}
|
||||
directoryPickerKind={vi.fn(async () => 'dialog')}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
@@ -230,8 +235,37 @@ describe('WorkspacePicker', () => {
|
||||
<WorkspacePicker
|
||||
open anchorRef={anchor()} useSessions={hook(sessions)} useWorkspaces={hook(state)}
|
||||
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} pickDirectory={vi.fn()}
|
||||
directoryPickerKind={vi.fn(async () => 'dialog')}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('status').textContent).toBe('Loading workspaces…')
|
||||
})
|
||||
|
||||
it('hides the folder affordance unless the Host advertises the dialog interaction', async () => {
|
||||
const b = mount([], vi.fn(), vi.fn(async () => null), vi.fn(async () => 'browse'))
|
||||
await screen.findByRole('menuitem', { name: 'Create a new workspace' })
|
||||
await waitFor(() => { expect(b.directoryPickerKind).toHaveBeenCalled() })
|
||||
expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull()
|
||||
})
|
||||
|
||||
it('hides the folder affordance when the Host cannot answer describe', async () => {
|
||||
const b = mount([], vi.fn(), vi.fn(async () => null), vi.fn(async () => {
|
||||
throw new Error('host unreachable')
|
||||
}))
|
||||
await screen.findByRole('menuitem', { name: 'Create a new workspace' })
|
||||
await waitFor(() => { expect(b.directoryPickerKind).toHaveBeenCalled() })
|
||||
expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull()
|
||||
})
|
||||
|
||||
it('does not read the picker kind while the flow is closed', () => {
|
||||
const directoryPickerKind = vi.fn(async () => 'dialog')
|
||||
render(
|
||||
<WorkspacePicker
|
||||
open={false} anchorRef={anchor()} useSessions={hook(sessions)} useWorkspaces={hook(workspaceState([]))}
|
||||
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} pickDirectory={vi.fn()}
|
||||
directoryPickerKind={directoryPickerKind}
|
||||
/>,
|
||||
)
|
||||
expect(directoryPickerKind).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user