feat(gui): add native workspace folder picker

This commit is contained in:
ZiyaZhang
2026-07-27 03:49:49 -07:00
parent d23621f922
commit fef928a7e1
46 changed files with 910 additions and 152 deletions

View File

@@ -250,6 +250,7 @@ export function WorkspaceBrowser({
deleteWorkspace,
insertSessionBefore,
createWorkspace,
pickDirectory,
}: WorkspaceBrowserProps) {
const workspaces = useWorkspaces(state => state.items)
const groupBy = useStore(s => s.groupBy)
@@ -367,6 +368,7 @@ export function WorkspaceBrowser({
anchorRef={wsPlusRef}
useWorkspaces={useWorkspaces}
createWorkspace={createWorkspace}
pickDirectory={pickDirectory}
onPick={(workspaceId) => {
setWsPickerOpen(false)
startSession(workspaceId)

View File

@@ -9,15 +9,17 @@ import { useCallback, useState } from 'react'
import {
Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { WorkspaceId, WorkspaceListState, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
import {
WorkspaceCreateError,
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'
const CREATE_WORKSPACE = '::create-workspace'
const USE_EXISTING = '::use-existing'
const OPEN_LOCAL_FOLDER = '::open-local-folder'
const CREATE_NEW = '::create-new'
type ModalKind = 'path' | 'create' | null
type ModalKind = 'create' | 'folder-error' | null
/** Core flow props: the owner supplies popover control and pick semantics. */
export interface WorkspaceCreateFlowProps {
@@ -29,6 +31,8 @@ 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). */
@@ -45,6 +49,7 @@ export function WorkspaceCreateFlow({
anchorRef,
useWorkspaces,
createWorkspace,
pickDirectory,
onPick,
onClose,
}: WorkspaceCreateFlowProps) {
@@ -55,10 +60,11 @@ export function WorkspaceCreateFlow({
[anchorRef],
)
const [modalKind, setModalKind] = useState<ModalKind>(null)
const [pathDraft, setPathDraft] = useState('')
const [workspaceName, setWorkspaceName] = useState('')
const [creating, setCreating] = useState(false)
const [modalError, setModalError] = useState<string | null>(null)
const [pickingFolder, setPickingFolder] = useState(false)
const [folderConflict, setFolderConflict] = useState(false)
const normalizedWorkspaceName = workspaceName.trim()
const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== ''
&& workspaces.some(workspace => workspace.title === normalizedWorkspaceName)
@@ -68,17 +74,11 @@ export function WorkspaceCreateFlow({
id: workspace.workspaceId as string,
label: workspace.title,
icon: <IconFolderClose16 size={16} />,
disabled: pickingFolder,
})),
...(workspaces.length > 0 ? [{ type: 'separator' as const, id: 'sep-create' }] : []),
{
id: CREATE_WORKSPACE,
label: 'Create workspace',
icon: <IconPlusOutline16 size={16} />,
submenu: [
{ id: USE_EXISTING, label: 'Use an existing folder' },
{ id: CREATE_NEW, label: 'Create a new workspace' },
],
},
{ 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 },
]
const closeModal = (): void => {
@@ -87,12 +87,31 @@ export function WorkspaceCreateFlow({
setModalError(null)
}
const openLocalFolder = (): void => {
if (pickingFolder) return
onClose()
setModalKind(null)
setModalError(null)
setFolderConflict(false)
setPickingFolder(true)
void pickDirectory().then(async (path) => {
if (path === null) return
const workspace = await createWorkspace({ path })
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')
}).finally(() => { setPickingFolder(false) })
}
const handleSelect = (id: string): void => {
if (id === USE_EXISTING) {
onClose()
setPathDraft('')
setModalError(null)
setModalKind('path')
if (pickingFolder) return
if (id === OPEN_LOCAL_FOLDER) {
openLocalFolder()
return
}
if (id === CREATE_NEW) {
@@ -120,11 +139,6 @@ export function WorkspaceCreateFlow({
})
}
const confirmPath = (): void => {
const path = pathDraft.trim()
if (path !== '') create({ path })
}
const confirmCreate = (): void => {
if (normalizedWorkspaceName !== '' && !duplicateWorkspaceName) {
create({ name: normalizedWorkspaceName })
@@ -144,40 +158,21 @@ export function WorkspaceCreateFlow({
/>
{open && workspaceSnapshot.phase === 'pending' && <div className={css.menuStatus} role="status">Loading workspaces…</div>}
<Modal
open={modalKind === 'path'}
open={modalKind === 'folder-error'}
onClose={closeModal}
title="Use an existing folder"
title={folderConflict ? 'A workspace with this name already exists' : 'Couldn’t open folder'}
footer={(
<>
<Button variant="outline" className={css.modalAction!} disabled={creating} onClick={closeModal}>Cancel</Button>
<Button
variant="primary"
className={css.modalAction!}
disabled={creating || pathDraft.trim() === ''}
onClick={confirmPath}
>
Use folder
</Button>
<Button variant="outline" className={css.modalAction!} onClick={closeModal}>Cancel</Button>
<Button variant="primary" className={css.modalAction!} onClick={openLocalFolder}>Choose again</Button>
</>
)}
>
<input
className={css.modalInput}
value={pathDraft}
aria-label="Existing folder path"
autoFocus
disabled={creating}
placeholder="/path/to/project"
onChange={(event) => { setPathDraft(event.target.value) }}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
confirmPath()
}
}}
/>
{creating && <div className={css.modalStatus} role="status">Creating workspace…</div>}
{modalError !== null && <div className={css.modalError} role="alert">{modalError}</div>}
<div className={css.modalError} role="alert">
{folderConflict
? 'Choose a folder with a different name.'
: modalError}
</div>
</Modal>
<Modal
open={modalKind === 'create'}
@@ -235,6 +230,7 @@ export function WorkspacePicker({
onPick,
onClose,
createWorkspace,
pickDirectory,
}: WorkspacePickerProps) {
return (
<WorkspaceCreateFlow
@@ -242,6 +238,7 @@ export function WorkspacePicker({
anchorRef={anchorRef}
useWorkspaces={useWorkspaces}
createWorkspace={createWorkspace}
pickDirectory={pickDirectory}
onPick={onPick}
onClose={onClose}
/>

View File

@@ -42,6 +42,8 @@ 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. */
@@ -58,6 +60,8 @@ export type WorkspaceBrowserProps =
export type WorkspacePickerInjected = {
/** 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

@@ -44,9 +44,11 @@ export function apply(ctx: ClientContext): void {
await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
},
createWorkspace: input => ctx.workspaces.create(input),
pickDirectory: () => ctx.workspaces.pickDirectory(),
})
const pickerInjected = (): WorkspacePickerInjected => ({
createWorkspace: input => ctx.workspaces.create(input),
pickDirectory: () => ctx.workspaces.pickDirectory(),
})
// Declaration-aware registration: each owner's declaring apply may activate
// after this one (entry activation order is unconstrained), and a register