Merge remote-tracking branch 'origin/master' into codex/basic-session-search
# Conflicts: # apps/web/tests/snapshots/question-composer/answered.expected.md # packages/client/connection/tests/fake-api.ts # packages/client/runtime/README.i18n.yaml # packages/client/runtime/tests/fake-api.ts # packages/client/ui-workspace/README.i18n.yaml # packages/client/ui-workspace/README.md # packages/client/ui-workspace/README.zh.md # packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx # packages/client/ui-workspace/tests/apply.spec.ts # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/index.ts # packages/host/apiproxy/src/api/sessions.schema.ts # packages/host/apiproxy/src/fetch/client.ts # packages/host/apiproxy/src/fetch/handler.ts # packages/host/apiproxy/tests/fetch-carrier.spec.ts # packages/host/apiproxy/tests/rpc-schemas.spec.ts # packages/session-query/session-query/tests/search-helpers.spec.ts
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: 751aa67e4f8425a0e0f9819f6ddb664d116ebe66
|
||||
README.zh.md: 4c4180d351578acbb308057858bf45e67ed0a8e0
|
||||
README.md: 84017b65e6ce2447996ef951a9a72a7c1661c376
|
||||
README.zh.md: f4ef5009c2f6f264b798a07078b2ba96276ff0ef
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Shared Workspace browser and picker plugin. `WorkspaceBrowser` fills the sidebar's `sidebar.workspaces` slot, while `WorkspacePicker` fills the page-local Session Intent hero's `conversation.hero.workspace` slot; both surfaces use the same Workspace menu and creation modals.
|
||||
Shared Workspace browser and picker plugin. `WorkspaceBrowser` fills the sidebar's `sidebar.workspaces` slot, while `WorkspacePicker` fills the page-local Session Intent hero's `conversation.hero.workspace` slot; both surfaces use the same Workspace menu and creation flow.
|
||||
|
||||
The browser renders grouped or flat Session rows from the global runtime hooks and owns the Workspace create/rename and in-Workspace reorder flows. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event.
|
||||
|
||||
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 existing-folder and create-new actions first create a real Workspace through the object layer, then select it. Create-new 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 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.
|
||||
|
||||
@@ -22,4 +22,4 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
- **No fuzzy content search or event deep links** — the content backend uses literal token/phrase matching, and selecting a result opens the Session rather than the matching event.
|
||||
- **No Session deletion control** — the existing Session menu row remains visual-only; Workspace registration deletion does not delete Sessions.
|
||||
- **Existing-folder entry is manual path input only** — Host creation failures are shown in the modal.
|
||||
- **Native folder selection depends on the local Host carrier** — fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal.
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
共享 Workspace 浏览器与选择器插件。`WorkspaceBrowser` 填充侧边栏的 `sidebar.workspaces` slot,`WorkspacePicker` 则填充页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot;两个表层使用同一套 Workspace 菜单和创建模态框。
|
||||
共享 Workspace 浏览器与选择器插件。`WorkspaceBrowser` 填充侧边栏的 `sidebar.workspaces` slot,`WorkspacePicker` 则填充页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot;两个表层使用同一套 Workspace 菜单和创建流程。
|
||||
|
||||
该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 创建/重命名和 Workspace 内的重排序流程。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。
|
||||
|
||||
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象;使用现有文件夹和新建操作时,系统会先通过对象层创建真实 Workspace,再将其选中。新建操作会禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。
|
||||
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。平铺显示的 **打开本地文件夹…** 操作会委托 Host 的原生单目录选择器,通过对象层接纳返回的路径,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,发生错误后仍可重试。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。
|
||||
|
||||
两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。
|
||||
|
||||
@@ -22,4 +22,4 @@
|
||||
|
||||
- **没有模糊内容搜索或事件深链接**:内容后端采用字面 token/短语匹配,选择结果会打开 Session,而不是匹配的事件。
|
||||
- **没有 Session 删除控件**:现有 Session 菜单行仍仅提供视觉效果;删除 Workspace 注册记录不会删除 Session。
|
||||
- **现有文件夹入口仅支持手动输入路径**:Host 创建失败会显示在模态框中。
|
||||
- **原生文件夹选择依赖本地 Host 载体**:仅使用 fixture(测试前置数据)的部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试。
|
||||
|
||||
@@ -23,7 +23,10 @@ import { ProjectRowItem, SearchResultItem, SessionNodeItem } from './rows/Rows.t
|
||||
import { WorkspaceCreateFlow } from './WorkspacePicker.tsx'
|
||||
import css from './WorkspaceBrowser.module.css'
|
||||
|
||||
/** Column slide length (--ds-transition-duration-slow): rail-search focus waits it out — focus() forces a synchronous layout and would jank the slide. */
|
||||
/**
|
||||
* Column slide length (--ds-transition-duration-slow): rail-search focus waits it out —
|
||||
* focus() forces a synchronous layout and would jank the slide.
|
||||
*/
|
||||
const EXPAND_SLIDE_MS = 300
|
||||
/** Pause between the latest keystroke and a Host content-search request. */
|
||||
const SEARCH_DEBOUNCE_MS = 250
|
||||
@@ -49,7 +52,7 @@ const GROUP_BY_ITEMS = [
|
||||
|
||||
/** Immutable membership toggle for the local expansion arrays. */
|
||||
function toggled(list: readonly string[], key: string): string[] {
|
||||
return list.includes(key) ? list.filter((k) => k !== key) : [...list, key]
|
||||
return list.includes(key) ? list.filter(k => k !== key) : [...list, key]
|
||||
}
|
||||
|
||||
/** Group-by strategy menu; own open state so it resets with the wide chrome. */
|
||||
@@ -78,7 +81,7 @@ function GroupByMenu({ groupBy, onPick }: {
|
||||
type="button"
|
||||
className={clsx(css.iconButton, css.wide)}
|
||||
aria-label="Group by"
|
||||
onClick={() => { setOpen((v) => !v) }}
|
||||
onClick={() => { setOpen(v => !v) }}
|
||||
>
|
||||
<IconPersonalizationOutline16 />
|
||||
</button>
|
||||
@@ -111,7 +114,7 @@ function SessionTree({
|
||||
useSessions, startSession, open, workspaces,
|
||||
onRenameRequest, onDeleteRequest, insertSessionBefore,
|
||||
}: SessionTreeProps) {
|
||||
const list = useSessions((s) => s)
|
||||
const list = useSessions(s => s)
|
||||
const current = list.current
|
||||
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
|
||||
const [expandedSessions, setExpandedSessions] = useState<string[]>([])
|
||||
@@ -123,7 +126,7 @@ function SessionTree({
|
||||
?? UNGROUPED_KEY
|
||||
useEffect(() => {
|
||||
if (current === undefined || currentGroup === undefined) return
|
||||
setExpandedProjects((l) => (l.includes(currentGroup) ? l : [...l, currentGroup]))
|
||||
setExpandedProjects(l => (l.includes(currentGroup) ? l : [...l, currentGroup]))
|
||||
}, [current, currentGroup])
|
||||
const groups = useMemo(
|
||||
() => deriveGroups(list, workspaces, { expandedProjects, expandedSessions }),
|
||||
@@ -144,22 +147,22 @@ function SessionTree({
|
||||
<div key={group.key} className={css.groupSection}>
|
||||
<ProjectRowItem
|
||||
group={group}
|
||||
onToggle={() => { setExpandedProjects((l) => toggled(l, group.key)) }}
|
||||
onToggle={() => { setExpandedProjects(l => toggled(l, group.key)) }}
|
||||
onCreate={() => {
|
||||
if (group.workspaceId !== undefined) startSession(group.workspaceId)
|
||||
}}
|
||||
actions={group.workspaceId === undefined
|
||||
? undefined
|
||||
: {
|
||||
rename: () => {
|
||||
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
|
||||
if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label)
|
||||
},
|
||||
delete: () => {
|
||||
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
|
||||
if (group.workspaceId !== undefined) onDeleteRequest(group.workspaceId, group.label)
|
||||
},
|
||||
}}
|
||||
rename: () => {
|
||||
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
|
||||
if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label)
|
||||
},
|
||||
delete: () => {
|
||||
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
|
||||
if (group.workspaceId !== undefined) onDeleteRequest(group.workspaceId, group.label)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{group.sessions.map((node, index) => {
|
||||
// Draggable: real-workspace group roots. The drag
|
||||
@@ -204,7 +207,7 @@ function SessionTree({
|
||||
currentId={current}
|
||||
now={now}
|
||||
onOpen={open}
|
||||
onToggle={(id) => { setExpandedSessions((l) => toggled(l, id)) }}
|
||||
onToggle={(id) => { setExpandedSessions(l => toggled(l, id)) }}
|
||||
drag={dragProps}
|
||||
/>
|
||||
)
|
||||
@@ -219,7 +222,7 @@ function SessionTree({
|
||||
|
||||
/** The flat "In one list" body: every session a top-level row, newest-first. */
|
||||
function FlatList({ useSessions, open }: Pick<SessionTreeProps, 'useSessions' | 'open'>) {
|
||||
const list = useSessions((s) => s)
|
||||
const list = useSessions(s => s)
|
||||
const rows = useMemo(() => deriveFlat(list), [list])
|
||||
const now = Date.now()
|
||||
return (
|
||||
@@ -268,7 +271,7 @@ function SearchResults({
|
||||
remote: RemoteSearchState
|
||||
resultLimit: number
|
||||
}) {
|
||||
const list = useSessions((s) => s)
|
||||
const list = useSessions(s => s)
|
||||
const currentRemote = remote.query === query
|
||||
? remote
|
||||
: { query, status: 'loading' as const, items: [], hasMore: false }
|
||||
@@ -334,6 +337,7 @@ export function WorkspaceBrowser({
|
||||
createWorkspace,
|
||||
searchSessions,
|
||||
searchResultLimit,
|
||||
pickDirectory,
|
||||
}: WorkspaceBrowserProps) {
|
||||
const workspaces = useWorkspaces(state => state.items)
|
||||
const groupBy = useStore(s => s.groupBy)
|
||||
@@ -419,7 +423,7 @@ export function WorkspaceBrowser({
|
||||
setRenameError(null)
|
||||
}
|
||||
const confirmRename = () => {
|
||||
if (renameBlocked || renameTarget === null) return
|
||||
if (renameBlocked) return
|
||||
setRenaming(true)
|
||||
setRenameError(null)
|
||||
renameWorkspace(renameTarget.workspaceId, renameTrimmed).then(() => {
|
||||
@@ -495,6 +499,7 @@ export function WorkspaceBrowser({
|
||||
anchorRef={wsPlusRef}
|
||||
useWorkspaces={useWorkspaces}
|
||||
createWorkspace={createWorkspace}
|
||||
pickDirectory={pickDirectory}
|
||||
onPick={(workspaceId) => {
|
||||
setWsPickerOpen(false)
|
||||
startSession(workspaceId)
|
||||
@@ -545,18 +550,18 @@ export function WorkspaceBrowser({
|
||||
<div className={css.listArea}>
|
||||
{wide && (normalizedQuery !== ''
|
||||
? (
|
||||
<SearchResults
|
||||
useSessions={useSessions}
|
||||
open={open}
|
||||
workspaces={workspaces}
|
||||
query={normalizedQuery}
|
||||
remote={remoteSearch}
|
||||
resultLimit={searchResultLimit}
|
||||
/>
|
||||
)
|
||||
<SearchResults
|
||||
useSessions={useSessions}
|
||||
open={open}
|
||||
workspaces={workspaces}
|
||||
query={normalizedQuery}
|
||||
remote={remoteSearch}
|
||||
resultLimit={searchResultLimit}
|
||||
/>
|
||||
)
|
||||
: groupBy === 'flat'
|
||||
? <FlatList useSessions={useSessions} open={open} />
|
||||
: (
|
||||
: (
|
||||
<SessionTree
|
||||
useSessions={useSessions}
|
||||
workspaces={workspaces}
|
||||
@@ -618,7 +623,7 @@ export function WorkspaceBrowser({
|
||||
<Button variant="outline" disabled={deleting} onClick={closeDelete}>Cancel</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={css.deleteAction!}
|
||||
className={css.deleteAction}
|
||||
disabled={deleting}
|
||||
onClick={confirmDelete}
|
||||
>
|
||||
|
||||
@@ -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,30 +60,25 @@ 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)
|
||||
|
||||
const items: MenuEntry[] = [
|
||||
...workspaces.map(workspace => ({
|
||||
id: workspace.workspaceId as string,
|
||||
id: workspace.workspaceId,
|
||||
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,29 @@ export function WorkspaceCreateFlow({
|
||||
setModalError(null)
|
||||
}
|
||||
|
||||
const openLocalFolder = (): void => {
|
||||
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 (id === OPEN_LOCAL_FOLDER) {
|
||||
openLocalFolder()
|
||||
return
|
||||
}
|
||||
if (id === CREATE_NEW) {
|
||||
@@ -120,11 +137,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 +156,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'}
|
||||
@@ -186,10 +179,10 @@ export function WorkspaceCreateFlow({
|
||||
description="The name is used for both the workspace and its new folder."
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" className={css.modalAction!} disabled={creating} onClick={closeModal}>Cancel</Button>
|
||||
<Button variant="outline" className={css.modalAction} disabled={creating} onClick={closeModal}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={css.modalAction!}
|
||||
className={css.modalAction}
|
||||
disabled={creating || normalizedWorkspaceName === '' || duplicateWorkspaceName}
|
||||
onClick={confirmCreate}
|
||||
>
|
||||
@@ -235,6 +228,7 @@ export function WorkspacePicker({
|
||||
onPick,
|
||||
onClose,
|
||||
createWorkspace,
|
||||
pickDirectory,
|
||||
}: WorkspacePickerProps) {
|
||||
return (
|
||||
<WorkspaceCreateFlow
|
||||
@@ -242,6 +236,7 @@ export function WorkspacePicker({
|
||||
anchorRef={anchorRef}
|
||||
useWorkspaces={useWorkspaces}
|
||||
createWorkspace={createWorkspace}
|
||||
pickDirectory={pickDirectory}
|
||||
onPick={onPick}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
@@ -54,6 +54,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. */
|
||||
@@ -69,7 +71,9 @@ 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>
|
||||
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
|
||||
/** Ask the local Host to open its native single-directory picker. */
|
||||
pickDirectory: () => Promise<string | null>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -51,9 +51,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
|
||||
|
||||
@@ -227,37 +227,37 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle,
|
||||
onDragStart={drag === undefined
|
||||
? undefined
|
||||
: (e) => {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
drag.start()
|
||||
}}
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
drag.start()
|
||||
}}
|
||||
onDragEnd={drag?.end}
|
||||
onDragOver={drag === undefined
|
||||
? undefined
|
||||
: (e) => {
|
||||
if (!drag.active) return
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
drag.hover(rowHalf(e))
|
||||
}}
|
||||
if (!drag.active) return
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
drag.hover(rowHalf(e))
|
||||
}}
|
||||
onDrop={drag === undefined
|
||||
? undefined
|
||||
: (e) => {
|
||||
if (!drag.active) return
|
||||
e.preventDefault()
|
||||
drag.drop(rowHalf(e))
|
||||
}}
|
||||
if (!drag.active) return
|
||||
e.preventDefault()
|
||||
drag.drop(rowHalf(e))
|
||||
}}
|
||||
>
|
||||
{row.hasChildren && !flat
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.twist}
|
||||
aria-label={row.expanded ? 'Collapse' : 'Expand'}
|
||||
onClick={(e) => { e.stopPropagation(); onToggle(node.id) }}
|
||||
>
|
||||
<IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} />
|
||||
</button>
|
||||
)
|
||||
<button
|
||||
type="button"
|
||||
className={css.twist}
|
||||
aria-label={row.expanded ? 'Collapse' : 'Expand'}
|
||||
onClick={(e) => { e.stopPropagation(); onToggle(node.id) }}
|
||||
>
|
||||
<IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} />
|
||||
</button>
|
||||
)
|
||||
: null}
|
||||
<span className={css.slot}>{row.running && <StateDot state="ongoing" />}</span>
|
||||
<span className={css.title}>{row.title}</span>
|
||||
|
||||
@@ -14,6 +14,7 @@ async function bench() {
|
||||
path: 'name' in input ? `/projects/${input.name}` : input.path,
|
||||
title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0',
|
||||
}))
|
||||
const pickDirectory = vi.fn(async () => '/tmp/picked')
|
||||
const startSession = vi.fn()
|
||||
const rename = vi.fn(async () => ({}))
|
||||
const insertSessionBefore = vi.fn(async () => ({}))
|
||||
@@ -24,13 +25,14 @@ async function bench() {
|
||||
value: { items: [{ sessionId: 'session' as never, snippet: 'match' }], hasMore: false },
|
||||
}))
|
||||
ctx.provide('workspaces', {
|
||||
create, startSession, rename, insertSessionBefore,
|
||||
create, pickDirectory, startSession, rename, insertSessionBefore,
|
||||
} as never)
|
||||
ctx.provide('sessions', { open, clear, search, searchResultLimit: 20 } as never)
|
||||
return {
|
||||
ctx,
|
||||
slots: ctx.get('slots') as SlotsService,
|
||||
create,
|
||||
pickDirectory,
|
||||
startSession,
|
||||
rename,
|
||||
insertSessionBefore,
|
||||
@@ -93,10 +95,14 @@ describe('ui-workspace apply', () => {
|
||||
expect(b.insertSessionBefore).toHaveBeenCalledWith('ws', 's1', 's2')
|
||||
await browser.createWorkspace({ name: 'project' })
|
||||
expect(b.create).toHaveBeenCalledWith({ name: 'project' })
|
||||
await browser.pickDirectory()
|
||||
expect(b.pickDirectory).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)
|
||||
})
|
||||
|
||||
it('rejects the browser search callback on a runtime business error', async () => {
|
||||
|
||||
@@ -16,7 +16,7 @@ function stubRect(row: HTMLElement): void {
|
||||
row.getBoundingClientRect = () => ({
|
||||
top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34,
|
||||
x: 0, y: 100, toJSON: () => ({}),
|
||||
} as DOMRect)
|
||||
})
|
||||
}
|
||||
|
||||
function dragProps(overrides: Partial<RowDragProps> = {}): RowDragProps {
|
||||
|
||||
@@ -32,7 +32,9 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState =>
|
||||
items, state: 'idle', phase: 'ready', error: null, baselinesReady: true,
|
||||
recentWorkspaceId: items[0]?.workspaceId,
|
||||
})
|
||||
const hook = <T,>(snapshot: T) => <S,>(selector: (state: T) => S): S => selector(snapshot)
|
||||
function hook<T>(snapshot: T) {
|
||||
return function select<S>(selector: (state: T) => S): S { return selector(snapshot) }
|
||||
}
|
||||
|
||||
/** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */
|
||||
function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): void {
|
||||
@@ -59,6 +61,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
|
||||
deleteWorkspace: vi.fn(async () => {}),
|
||||
insertSessionBefore: vi.fn(async () => {}),
|
||||
createWorkspace: vi.fn(async () => workspace('created', [])),
|
||||
pickDirectory: vi.fn(async () => null),
|
||||
...overrides,
|
||||
}
|
||||
const view = render(<WorkspaceBrowser {...props} />)
|
||||
@@ -340,9 +343,9 @@ describe('WorkspaceBrowser', () => {
|
||||
const searchSessions = vi.fn((query: string, _signal: AbortSignal) => query === 'first'
|
||||
? first
|
||||
: Promise.resolve({
|
||||
items: [{ sessionId: sid('second-hit'), snippet: 'second excerpt' }],
|
||||
hasMore: false,
|
||||
}))
|
||||
items: [{ sessionId: sid('second-hit'), snippet: 'second excerpt' }],
|
||||
hasMore: false,
|
||||
}))
|
||||
mount({
|
||||
useSessions: hook(sessionState([
|
||||
summary('first-hit', 2, { displayTitle: 'Old result' }),
|
||||
@@ -475,7 +478,7 @@ describe('WorkspaceBrowser', () => {
|
||||
const [one, , three] = rows as [HTMLElement, HTMLElement, HTMLElement]
|
||||
three.getBoundingClientRect = () => ({
|
||||
top: 200, bottom: 234, left: 0, right: 200, width: 200, height: 34, x: 0, y: 200, toJSON: () => ({}),
|
||||
} as DOMRect)
|
||||
})
|
||||
const dataTransfer = { effectAllowed: '', dropEffect: '' }
|
||||
fireEvent.dragStart(one, { dataTransfer })
|
||||
// Drop on the top half of "three": insert one before three.
|
||||
@@ -488,7 +491,7 @@ describe('WorkspaceBrowser', () => {
|
||||
fireEvent.dragStart(one, { dataTransfer })
|
||||
one.getBoundingClientRect = () => ({
|
||||
top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}),
|
||||
} as DOMRect)
|
||||
})
|
||||
fireDrag(one, 'dragOver', 105)
|
||||
fireDrag(one, 'drop', 105)
|
||||
expect(insertSessionBefore).toHaveBeenCalledTimes(1)
|
||||
@@ -514,7 +517,7 @@ describe('WorkspaceBrowser', () => {
|
||||
const two = screen.getByText('two').closest('[role="treeitem"]') as HTMLElement
|
||||
two.getBoundingClientRect = () => ({
|
||||
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
|
||||
} as DOMRect)
|
||||
})
|
||||
fireDrag(two, 'drop', 155)
|
||||
expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), sid('two'))
|
||||
})
|
||||
@@ -531,7 +534,7 @@ describe('WorkspaceBrowser', () => {
|
||||
const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement]
|
||||
two.getBoundingClientRect = () => ({
|
||||
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
|
||||
} as DOMRect)
|
||||
})
|
||||
const dataTransfer = { effectAllowed: '', dropEffect: '' }
|
||||
fireEvent.dragStart(one, { dataTransfer })
|
||||
fireEvent.dragEnd(one)
|
||||
@@ -560,7 +563,7 @@ describe('WorkspaceBrowser', () => {
|
||||
const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement]
|
||||
two.getBoundingClientRect = () => ({
|
||||
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
|
||||
} as DOMRect)
|
||||
})
|
||||
const dataTransfer = { effectAllowed: '', dropEffect: '' }
|
||||
fireEvent.dragStart(one, { dataTransfer })
|
||||
fireDrag(two, 'drop', 180)
|
||||
@@ -582,13 +585,13 @@ describe('WorkspaceBrowser', () => {
|
||||
const input = screen.getByLabelText<HTMLInputElement>('Workspace name')
|
||||
expect(input.value).toBe('Alpha')
|
||||
// Unchanged and blank names stay blocked.
|
||||
expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Rename' }).disabled).toBe(true)
|
||||
fireEvent.change(input, { target: { value: ' ' } })
|
||||
expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Rename' }).disabled).toBe(true)
|
||||
// A duplicate of another workspace's title shows the inline conflict.
|
||||
fireEvent.change(input, { target: { value: ' Beta ' } })
|
||||
expect(screen.getByRole('alert').textContent).toBe('A workspace named “Beta” already exists.')
|
||||
expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Rename' }).disabled).toBe(true)
|
||||
fireEvent.change(input, { target: { value: 'Gamma' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Rename' }))
|
||||
expect(renameWorkspace).toHaveBeenCalledWith(wid('alpha'), 'Gamma')
|
||||
@@ -651,13 +654,13 @@ describe('WorkspaceBrowser', () => {
|
||||
expect(dialog.textContent).toContain('folder and session logs will be kept')
|
||||
expect(dialog.textContent).toContain('sessions will appear under Ungrouped')
|
||||
|
||||
const confirm = screen.getByRole('button', { name: 'Delete workspace' }) as HTMLButtonElement
|
||||
const confirm = screen.getByRole<HTMLButtonElement>('button', { name: 'Delete workspace' })
|
||||
fireEvent.click(confirm)
|
||||
fireEvent.click(confirm)
|
||||
expect(deleteWorkspace).toHaveBeenCalledOnce()
|
||||
expect(deleteWorkspace).toHaveBeenCalledWith(wid('alpha'))
|
||||
expect(confirm.disabled).toBe(true)
|
||||
expect((screen.getByRole('button', { name: 'Cancel' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Cancel' }).disabled).toBe(true)
|
||||
expect(screen.getByRole('status').textContent).toBe('Deleting workspace…')
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
|
||||
@@ -4,6 +4,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra
|
||||
import type {
|
||||
SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { WorkspaceCreateError } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
@@ -15,7 +16,9 @@ function workspace(id: string, title = id): WorkspaceView {
|
||||
createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
}
|
||||
const hook = <T,>(snapshot: T) => <S,>(selector: (state: T) => S): S => selector(snapshot)
|
||||
function hook<T>(snapshot: T) {
|
||||
return function select<S>(selector: (state: T) => S): S { return selector(snapshot) }
|
||||
}
|
||||
const sessions: SessionListState = {
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
}
|
||||
@@ -32,7 +35,11 @@ function anchor(): { current: HTMLElement } {
|
||||
return { current: element }
|
||||
}
|
||||
|
||||
function mount(items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')], createWorkspace = vi.fn()) {
|
||||
function mount(
|
||||
items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')],
|
||||
createWorkspace = vi.fn(),
|
||||
pickDirectory = vi.fn(async () => null as string | null),
|
||||
) {
|
||||
const onPick = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
const anchorRef = anchor()
|
||||
@@ -45,20 +52,19 @@ function mount(items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')],
|
||||
onPick={onPick}
|
||||
onClose={onClose}
|
||||
createWorkspace={createWorkspace}
|
||||
pickDirectory={pickDirectory}
|
||||
/>
|
||||
)
|
||||
const view = render(
|
||||
renderPicker(items),
|
||||
)
|
||||
return {
|
||||
view, onPick, onClose, createWorkspace,
|
||||
view, onPick, onClose, createWorkspace, pickDirectory,
|
||||
rerenderItems: (nextItems: readonly WorkspaceView[]) => { view.rerender(renderPicker(nextItems)) },
|
||||
}
|
||||
}
|
||||
|
||||
function chooseCreateItem(name: 'Use an existing folder' | 'Create a new workspace'): void {
|
||||
const parent = screen.getByRole('menuitem', { name: 'Create workspace' })
|
||||
fireEvent.mouseEnter(parent.parentElement as HTMLElement)
|
||||
function chooseItem(name: 'Open local folder…' | 'Create a new workspace'): void {
|
||||
fireEvent.click(screen.getByRole('menuitem', { name }))
|
||||
}
|
||||
|
||||
@@ -73,7 +79,7 @@ describe('WorkspacePicker', () => {
|
||||
const created = workspace('new', 'New')
|
||||
const createWorkspace = vi.fn(async () => created)
|
||||
const b = mount([], createWorkspace)
|
||||
chooseCreateItem('Create a new workspace')
|
||||
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' }))
|
||||
@@ -81,34 +87,81 @@ describe('WorkspacePicker', () => {
|
||||
await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) })
|
||||
})
|
||||
|
||||
it('adopts an existing path through the same immediate create action', async () => {
|
||||
const created = workspace('adopted')
|
||||
it('opens a native directory picker, adopts its path, and selects the returned Workspace', async () => {
|
||||
const created = { ...workspace('adopted'), path: '/tmp/project', title: 'project' }
|
||||
const createWorkspace = vi.fn(async () => created)
|
||||
const b = mount([], createWorkspace)
|
||||
chooseCreateItem('Use an existing folder')
|
||||
const input = screen.getByLabelText('Existing folder path')
|
||||
fireEvent.keyDown(input, { key: 'ArrowRight' })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(createWorkspace).not.toHaveBeenCalled()
|
||||
fireEvent.change(input, { target: { value: ' /tmp/project ' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
const pickDirectory = vi.fn(async () => '/tmp/project')
|
||||
const b = mount([], createWorkspace, pickDirectory)
|
||||
chooseItem('Open local folder…')
|
||||
expect(pickDirectory).toHaveBeenCalledOnce()
|
||||
await waitFor(() => { expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' }) })
|
||||
expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' })
|
||||
await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) })
|
||||
})
|
||||
|
||||
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 waitFor(() => { expect(b.pickDirectory).toHaveBeenCalledOnce() })
|
||||
expect(b.createWorkspace).not.toHaveBeenCalled()
|
||||
expect(b.onPick).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows a name conflict and retries through the native picker', async () => {
|
||||
const pickDirectory = vi.fn()
|
||||
.mockResolvedValueOnce('/one/project')
|
||||
.mockResolvedValueOnce(null)
|
||||
const createWorkspace = vi.fn(async () => {
|
||||
throw new WorkspaceCreateError({
|
||||
code: 'workspace-name-conflict', message: 'project already exists', details: { name: 'project' },
|
||||
})
|
||||
})
|
||||
const b = mount([], createWorkspace, pickDirectory)
|
||||
chooseItem('Open local folder…')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog', { name: 'A workspace with this name already exists' })).toBeTruthy()
|
||||
})
|
||||
expect(screen.getByRole('alert').textContent).toBe('Choose a folder with a different name.')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Choose again' }))
|
||||
await waitFor(() => { expect(pickDirectory).toHaveBeenCalledTimes(2) })
|
||||
expect(b.onPick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disables the folder action while the native picker is already open', async () => {
|
||||
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…')
|
||||
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…' }))
|
||||
expect(b.pickDirectory).toHaveBeenCalledTimes(1)
|
||||
await act(async () => { resolve(null); await pending })
|
||||
})
|
||||
|
||||
it('reports non-Error native picker failures', async () => {
|
||||
const b = mount([], vi.fn(), vi.fn(async () => { throw 'picker unavailable' }))
|
||||
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', () => {
|
||||
mount([])
|
||||
chooseCreateItem('Create a new workspace')
|
||||
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', () => {
|
||||
const b = mount([workspace('alpha', 'Alpha')])
|
||||
chooseCreateItem('Create a new workspace')
|
||||
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('button', { name: 'Create workspace' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Create workspace' }).disabled).toBe(true)
|
||||
fireEvent.keyDown(screen.getByLabelText('New workspace name'), { key: 'Enter' })
|
||||
expect(b.createWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -118,7 +171,7 @@ describe('WorkspacePicker', () => {
|
||||
const pending = new Promise<WorkspaceView>((settle) => { resolve = settle })
|
||||
const created = workspace('fresh', 'same-name')
|
||||
const b = mount([], vi.fn(() => pending))
|
||||
chooseCreateItem('Create a new workspace')
|
||||
chooseItem('Create a new workspace')
|
||||
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'same-name' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
|
||||
@@ -134,7 +187,7 @@ describe('WorkspacePicker', () => {
|
||||
const pending = new Promise<WorkspaceView>((_resolve, rejectPromise) => { reject = rejectPromise })
|
||||
const createWorkspace = vi.fn(() => pending)
|
||||
const b = mount([], createWorkspace)
|
||||
chooseCreateItem('Create a new workspace')
|
||||
chooseItem('Create a new workspace')
|
||||
const input = screen.getByLabelText('New workspace name')
|
||||
fireEvent.keyDown(input, { key: 'ArrowRight' })
|
||||
fireEvent.change(input, { target: { value: 'broken' } })
|
||||
@@ -151,7 +204,7 @@ describe('WorkspacePicker', () => {
|
||||
|
||||
it('reports non-Error creation failures', async () => {
|
||||
const b = mount([], vi.fn(async () => { throw 'permission denied' }))
|
||||
chooseCreateItem('Create a new workspace')
|
||||
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')
|
||||
@@ -163,7 +216,7 @@ describe('WorkspacePicker', () => {
|
||||
render(
|
||||
<WorkspacePicker
|
||||
open useSessions={hook(sessions)} useWorkspaces={hook(workspaceState([]))}
|
||||
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
|
||||
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} pickDirectory={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
@@ -176,7 +229,7 @@ describe('WorkspacePicker', () => {
|
||||
render(
|
||||
<WorkspacePicker
|
||||
open anchorRef={anchor()} useSessions={hook(sessions)} useWorkspaces={hook(state)}
|
||||
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
|
||||
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} pickDirectory={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('status').textContent).toBe('Loading workspaces…')
|
||||
|
||||
Reference in New Issue
Block a user