Merge remote-tracking branch 'origin/feat/directory-picker' into feat/workspace-directory-browser

This commit is contained in:
creatixchu
2026-07-29 02:36:47 +08:00
7 changed files with 77 additions and 22 deletions

View File

@@ -254,6 +254,7 @@ export function WorkspaceBrowser({
insertSessionBefore,
createWorkspace,
hasDirectoryFlow,
subscribeDirectoryFlow,
renderSlot,
}: WorkspaceBrowserProps) {
const workspaces = useWorkspaces(state => state.items)
@@ -373,6 +374,7 @@ export function WorkspaceBrowser({
useWorkspaces={useWorkspaces}
createWorkspace={createWorkspace}
hasDirectoryFlow={hasDirectoryFlow}
subscribeDirectoryFlow={subscribeDirectoryFlow}
renderDirectoryFlow={owner => renderSlot('sidebar.workspaces.directoryFlow', owner)}
createOnly
side="right"

View File

@@ -7,7 +7,7 @@
* opens the flow, adopts the picked path, and owns the error surface.
*/
import type { ReactNode, RefObject } from 'react'
import { useCallback, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
import {
Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry,
} from '@deepseek-ai/dsh-client-ui-primitives'
@@ -33,8 +33,10 @@ 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>
/** Whether this surface's directory-flow hole is occupied (read per menu render; empty hides the local-folder entry). */
/** Whether this surface's directory-flow hole is occupied (empty hides the local-folder entry). */
hasDirectoryFlow: () => boolean
/** Registration-change subscription for the same hole (the uSES pair of hasDirectoryFlow). */
subscribeDirectoryFlow: (listener: () => void) => () => void
/** Render this surface's directory-flow hole with the owner conversation (the entry's narrowed renderSlot). */
renderDirectoryFlow: (owner: DirectoryFlowOwnerProps) => ReactNode
/** A real Workspace was picked or created. */
@@ -60,6 +62,7 @@ export function WorkspaceCreateFlow({
useWorkspaces,
createWorkspace,
hasDirectoryFlow,
subscribeDirectoryFlow,
renderDirectoryFlow,
onPick,
onClose,
@@ -91,11 +94,17 @@ export function WorkspaceCreateFlow({
&& workspaces.some(workspace => workspace.title === normalizedWorkspaceName)
// The occupied hole gates the picking affordance: with no composed flow the
// entry simply is not there (the seam's documented no-flow default). Read
// per render while the menu is open — registrations land through plugin
// activation, and the menu re-renders on every toggle.
// entry simply is not there (the seam's documented no-flow default). The
// subscription keeps occupancy live: flow plugins activate (and HMR-reload)
// independently of this menu's renders.
const flowAvailable = useSyncExternalStore(subscribeDirectoryFlow, hasDirectoryFlow)
// An occupant that unloads mid-interaction leaves nobody to cancel: an
// open flow over an empty hole withdraws so the menu actions come back.
useEffect(() => {
if (!flowAvailable) setFlowOpen(false)
}, [flowAvailable])
const createEntries: MenuEntry[] = [
...(hasDirectoryFlow()
...(flowAvailable
? [{ id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: <IconFolderClose16 size={16} />, disabled: flowBusy }]
: []),
{ id: CREATE_NEW, label: 'Create a new workspace', icon: <IconPlusOutline16 size={16} />, disabled: flowBusy },
@@ -288,6 +297,7 @@ export function WorkspacePicker({
onClose,
createWorkspace,
hasDirectoryFlow,
subscribeDirectoryFlow,
renderSlot,
}: WorkspacePickerProps) {
return (
@@ -297,6 +307,7 @@ export function WorkspacePicker({
useWorkspaces={useWorkspaces}
createWorkspace={createWorkspace}
hasDirectoryFlow={hasDirectoryFlow}
subscribeDirectoryFlow={subscribeDirectoryFlow}
renderDirectoryFlow={owner => renderSlot('conversation.hero.workspace.directoryFlow', owner)}
selectedId={selectedId}
onPick={onPick}

View File

@@ -62,11 +62,18 @@ export type DirectoryFlowSlotName =
/** Directory-picking share both trigger surfaces consume. */
export type DirectoryPickingInjected = {
/**
* Whether this surface's directory-flow hole is occupied — read when the
* menu opens; an empty hole hides the "Open local folder…" entry (the
* no-flow composition simply has no picking affordance).
* Whether this surface's directory-flow hole is occupied — an empty hole
* hides the "Open local folder…" entry (the no-flow composition simply has
* no picking affordance).
*/
hasDirectoryFlow: () => boolean
/**
* Subscribe to the hole's registration changes (the uSES pair of
* {@link hasDirectoryFlow}): the trigger surface withdraws an open flow
* whose occupant unloaded mid-interaction — nobody is left to cancel it.
* @returns the unsubscriber.
*/
subscribeDirectoryFlow: (listener: () => void) => () => void
}
/**

View File

@@ -49,10 +49,12 @@ export function apply(ctx: ClientContext): void {
},
createWorkspace: input => ctx.workspaces.create(input),
hasDirectoryFlow: () => ctx.slots.entries('sidebar.workspaces.directoryFlow').length > 0,
subscribeDirectoryFlow: listener => ctx.slots.subscribe('sidebar.workspaces.directoryFlow', listener),
})
const pickerInjected = (): WorkspacePickerInjected => ({
createWorkspace: input => ctx.workspaces.create(input),
hasDirectoryFlow: () => ctx.slots.entries('conversation.hero.workspace.directoryFlow').length > 0,
subscribeDirectoryFlow: listener => ctx.slots.subscribe('conversation.hero.workspace.directoryFlow', listener),
})
// Declaration-aware registration (deferRegistration): each owner's
// declaring apply may activate after this one, and a register into an

View File

@@ -91,11 +91,16 @@ describe('ui-workspace apply', () => {
expect(browser.hasDirectoryFlow()).toBe(false)
expect(picker.hasDirectoryFlow()).toBe(false)
// A flow occupant flips exactly its own surface.
const notified = vi.fn()
const unsubscribe = browser.subscribeDirectoryFlow(notified)
const dispose = b.slots.register({ name: 'sidebar.workspaces.directoryFlow' } as never, () => null)
expect(browser.hasDirectoryFlow()).toBe(true)
expect(picker.hasDirectoryFlow()).toBe(false)
await Promise.resolve()
expect(notified).toHaveBeenCalled()
dispose()
expect(browser.hasDirectoryFlow()).toBe(false)
unsubscribe()
})
it('unregisters every entry on teardown', async () => {

View File

@@ -60,6 +60,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
insertSessionBefore: vi.fn(async () => {}),
createWorkspace: vi.fn(async () => workspace('created', [])),
hasDirectoryFlow: () => true,
subscribeDirectoryFlow: () => () => {},
renderSlot: ((_name: string, owner: { open: boolean }) => (owner.open ? <div data-testid="directory-flow" /> : null)) as never,
...overrides,
}

View File

@@ -50,10 +50,27 @@ function flowProbe() {
return { probe, renderSlot }
}
/** Manual occupancy source: flip() drives the uSES subscription like a real registration change. */
function occupancySource(initial = true) {
let occupied = initial
const listeners = new Set<() => void>()
return {
hasDirectoryFlow: () => occupied,
subscribeDirectoryFlow: (listener: () => void) => {
listeners.add(listener)
return () => { listeners.delete(listener) }
},
flip: (next: boolean) => {
occupied = next
for (const listener of [...listeners]) listener()
},
}
}
function mount(
items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')],
createWorkspace = vi.fn(),
hasDirectoryFlow: () => boolean = () => true,
occupancy = occupancySource(),
) {
const onPick = vi.fn()
const onClose = vi.fn()
@@ -68,7 +85,8 @@ function mount(
onPick={onPick}
onClose={onClose}
createWorkspace={createWorkspace}
hasDirectoryFlow={hasDirectoryFlow}
hasDirectoryFlow={occupancy.hasDirectoryFlow}
subscribeDirectoryFlow={occupancy.subscribeDirectoryFlow}
renderSlot={renderSlot}
/>
)
@@ -76,7 +94,7 @@ function mount(
renderPicker(items),
)
return {
view, onPick, onClose, createWorkspace, probe,
view, onPick, onClose, createWorkspace, probe, occupancy,
rerenderItems: (nextItems: readonly WorkspaceView[]) => { view.rerender(renderPicker(nextItems)) },
}
}
@@ -247,7 +265,7 @@ describe('WorkspacePicker', () => {
<WorkspacePicker
open useSessions={hook(sessions)} useWorkspaces={hook(workspaceState([]))}
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
hasDirectoryFlow={() => true} renderSlot={renderSlot}
hasDirectoryFlow={() => true} subscribeDirectoryFlow={() => () => {}} renderSlot={renderSlot}
/>,
)
expect(screen.queryByRole('menu')).toBeNull()
@@ -262,26 +280,35 @@ describe('WorkspacePicker', () => {
<WorkspacePicker
open anchorRef={anchor()} useSessions={hook(sessions)} useWorkspaces={hook(state)}
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
hasDirectoryFlow={() => true} renderSlot={renderSlot}
hasDirectoryFlow={() => true} subscribeDirectoryFlow={() => () => {}} renderSlot={renderSlot}
/>,
)
expect(screen.getByRole('status').textContent).toBe('Loading workspaces…')
})
it('hides the folder entry while the directory-flow hole is empty', () => {
mount([], vi.fn(), () => false)
mount([], vi.fn(), occupancySource(false))
expect(screen.getByRole('menuitem', { name: 'Create a new workspace' })).toBeTruthy()
expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull()
})
it('shows the folder entry once the hole reports an occupant on a later render', () => {
let occupied = false
const b = mount([], vi.fn(), () => occupied)
it('shows the folder entry when a flow package activates after the first paint', () => {
const b = mount([], vi.fn(), occupancySource(false))
expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull()
// A flow package activating after the first paint is observed on the
// next render — the same cadence as reopening the menu.
occupied = true
b.rerenderItems([])
// Registration changes flow through the subscription, no re-render needed.
act(() => { b.occupancy.flip(true) })
expect(screen.getByRole('menuitem', { name: 'Open local folder…' })).toBeTruthy()
})
it('withdraws an open flow when its occupant unloads, re-enabling the menu actions', () => {
const b = mount([])
chooseItem('Open local folder…')
expect(screen.getByTestId('directory-flow')).toBeTruthy()
// The flow plugin unloads mid-interaction (HMR): nobody is left to
// cancel, so the owner withdraws and the actions come back.
act(() => { b.occupancy.flip(false) })
expect(b.probe.owner!.open).toBe(false)
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Create a new workspace' }).disabled).toBe(false)
expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull()
})
})