cleanup(web): give adding a Workspace one route

Both Workspace surfaces offered "Open local folder…" and "Create a new
workspace" for one outcome. The browse occupant already carries its own
New folder affordance, so picking a directory covered creating one; the
name dialog only added a second vocabulary and a create target the
operator could neither see nor choose.

The surviving entry is named after the outcome — "Add workspace…" — and a
menu now appears only where there is something to choose between: with no
Workspace listed (the add-only sidebar header, or an empty hero list) the
anchor gesture raises the directory flow directly instead of a one-row
popover. An empty list counts as final only after the list baseline lands,
and a composition with no directory-flow occupant hides the sidebar button
rather than offering a dead one.

WorkspaceCreateFlow becomes WorkspacePickFlow (createOnly -> addOnly) and
the injected createWorkspace narrows to { path }. The host's
workspace.create({ name }) branch and `dsh web --workspace-root` lost their
last product consumer; both are marked at the call site for a follow-up.
This commit is contained in:
creatixchu
2026-07-31 15:47:40 +08:00
parent ef01e2e9af
commit d6231007af
37 changed files with 439 additions and 415 deletions
@@ -500,23 +500,26 @@ describe('WorkspaceBrowser', () => {
}
})
it('rail create-workspace toggles the create-only picker in place, without expanding', () => {
it('rail add-workspace raises the directory flow in place, with no menu and no expansion', () => {
const expandSidebar = vi.fn()
mount({ wide: false, expandSidebar, useWorkspaces: hook(workspaceState([workspace('alpha', [])])) })
fireEvent.click(screen.getByRole('button', { name: '创建工作区' }))
fireEvent.click(screen.getByRole('button', { name: '添加工作区' }))
expect(expandSidebar).not.toHaveBeenCalled()
// createOnly: existing workspaces are not listed, only the create actions.
// Adding is the header's only action, so the gesture IS that action: no
// one-row popover, and existing workspaces stay in the tree below.
expect(screen.queryByRole('menu')).toBeNull()
expect(screen.queryByRole('menuitem', { name: 'alpha' })).toBeNull()
expect(screen.getByRole('menuitem', { name: '打开本地文件夹…' })).toBeTruthy()
// Toggle: open and close in place.
fireEvent.click(screen.getByRole('button', { name: '创建工作区' }))
expect(screen.queryByRole('menu')).toBeNull()
expect(screen.getByTestId('directory-flow')).toBeTruthy()
})
// Escape closes the picker through its own onClose.
fireEvent.click(screen.getByRole('button', { name: '创建工作区' }))
expect(screen.getByRole('menu')).toBeTruthy()
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByRole('menu')).toBeNull()
it('hides the add button when no directory-flow occupant is composed', () => {
mount({
useWorkspaces: hook(workspaceState([workspace('alpha', [])])),
useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => false, subscribe: () => () => {} }),
})
// Nothing to add with, so the header offers no dead button.
expect(screen.queryByRole('button', { name: '添加工作区' })).toBeNull()
expect(screen.getByText('alpha')).toBeTruthy()
})
it('drag reorder reports the anchor to insertSessionBefore and skips no-op drops', () => {