Merge remote-tracking branch 'origin/feat/directory-picker' into feat/workspace-directory-browser
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user