Merge remote-tracking branch 'origin/feat/directory-picker' into feat/workspace-directory-browser
# Conflicts: # .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml # .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md # .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md # apps/cli/cordis.yml # apps/cli/package.json # docs/module-graph.md # 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/src/client/WorkspacePicker.tsx # packages/client/ui-workspace/src/client/contract/slots.ts # packages/client/ui-workspace/src/client/index.ts # packages/client/ui-workspace/tests/apply.spec.ts # packages/client/ui-workspace/tests/workspace-browser.spec.tsx # packages/client/ui-workspace/tests/workspace-picker.spec.tsx # pnpm-lock.yaml
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: e478670facaccadd49999a5dfeaac369801036e3
|
||||
README.zh.md: 1348b59ac35aaaa103f8653dad33633bbe7792b1
|
||||
README.md: 8acf819121b46512d38b39ff858bb2bf797cfe96
|
||||
README.zh.md: e97d93f7e38d00af91b43f0df9fb0e3b17ae8ed5
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
Shared Workspace picker plugin. `WorkspaceBrowser` is registered into the sidebar's `sidebar.workspaces` slot and `WorkspacePicker` into the page-local Session Intent hero's `conversation.hero.workspace` slot, so both surfaces use the same menu and creation flow.
|
||||
|
||||
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 branches on the Host's advertised picker interaction (`host.describe.directoryPicker`, read per menu open; an unknown kind hides the entry): under `dialog` it delegates to the Host's native single-directory chooser, and under `browse` it opens the in-app directory browser (figma 802-56979) — breadcrumbs rooted at a localized Home crumb, a click-to-edit path zone right of the crumbs (Enter navigates, Escape restores), host-flagged hidden entries filtered client-side, an inline New-folder row, and Open adopting the listed directory. Either way adoption goes through the object layer and selects the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors remain retryable. The browser dialog's copy is localized through `ctx.locale` (namespace `workspace`), and the plugin re-registers its entries on `locale/change`. **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.
|
||||
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. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Open local folder...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. **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.
|
||||
|
||||
@@ -19,5 +19,4 @@ None; this package neither assembles nor sends a provider request.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No Session deletion control** — the existing Session menu row remains visual-only; Workspace registration deletion does not delete Sessions.
|
||||
- **Native folder selection depends on the local Host carrier** — under a `dialog` composition, fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. The shipped default composes `browse`, which has no such dependence.
|
||||
- **No show-hidden toggle yet** — the Host flags hidden entries and the browser filters them unconditionally; the toggle is a deferred client-only change.
|
||||
- **Native folder selection depends on the local Host carrier** — under the `-native` composition, fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. Remote-capable picking is the `-browse` composition's in-app flow.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
共享 Workspace 选择器插件。`WorkspaceBrowser` 注册到侧边栏的 `sidebar.workspaces` slot,`WorkspacePicker` 注册到页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot,因此两个表层使用同一菜单和创建流程。
|
||||
|
||||
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。平铺显示的 **打开本地文件夹…** 操作按 Host 广播的选择器交互形态分支(`host.describe.directoryPicker`,每次菜单打开时读取;未知 kind 隐藏该入口):在 `dialog` 下委托 Host 的原生单目录选择器,在 `browse` 下打开应用内目录浏览器(figma 802-56979)——面包屑以本地化的"主目录"crumb 为根、面包屑右侧空白区点击进入路径编辑态(Enter 导航、Escape 还原)、宿主打标的隐藏条目在客户端过滤、内联新建文件夹行、"打开"接纳当前列出的目录。两条路径的接纳都经由对象层,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,发生错误后仍可重试。浏览器对话框的文案经 `ctx.locale` 本地化(命名空间 `workspace`),插件在 `locale/change` 时重新注册其条目。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。
|
||||
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。
|
||||
|
||||
两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。
|
||||
|
||||
@@ -19,5 +19,4 @@
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **没有 Session 删除控件**:现有 Session 菜单行仍仅提供视觉效果;删除 Workspace 注册记录不会删除 Session。
|
||||
- **原生文件夹选择依赖本地 Host 载体**:在 `dialog` 组合下,仅使用 fixture(测试前置数据)的部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试。已发布的默认组合为 `browse`,没有此依赖。
|
||||
- **尚无"显示隐藏目录"开关**:Host 打标隐藏条目、浏览器无条件过滤;该开关是延期的纯客户端改动。
|
||||
- **原生文件夹选择依赖本地 Host 载体**:在 `-native` 组合下,仅使用 fixture(测试前置数据)的部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试。可远程的选取是 `-browse` 组合的应用内流程。
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
/* Directory-browser dialog (figma 813-23126 family). The shared Modal renders
|
||||
* headless here — mask, card, Escape only — and this module owns the figma
|
||||
* frame exactly: fixed 600×420 card, header (title + crumbs, l3 separator),
|
||||
* the one-or-two-column Miller content, and the bordered footer. */
|
||||
|
||||
/* Doubled class beats Modal's own .dialog regardless of stylesheet order. */
|
||||
.dialog.dialog {
|
||||
width: min(600px, 100%);
|
||||
height: 420px;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* Header block: pl24 pr14 pt22 pb12, 8px between title row and crumb row. */
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex: none;
|
||||
padding: 22px 14px 12px 24px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l3);
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
min-height: 28px;
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 510;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.crumbBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.crumbSeat {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: none;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.crumb {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.crumb:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.crumbChevron {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* The empty remainder of the bar: invisible, but a real click target that
|
||||
* flips the bar into path-edit mode. */
|
||||
.crumbEditZone {
|
||||
flex: 1 1 0;
|
||||
min-width: 34px;
|
||||
align-self: stretch;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.pathInput {
|
||||
box-sizing: border-box;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* Miller content: pt16 px24; columns are 256 wide (or full width solo) with
|
||||
* the hairline divider centered between them; each column scrolls alone. */
|
||||
.content {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
gap: 20px;
|
||||
padding: 16px 24px 0;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
width: 256px;
|
||||
flex: none;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.columnWide {
|
||||
width: 100%;
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.divider {
|
||||
flex: none;
|
||||
width: 1px;
|
||||
background: var(--dsw-alias-border-l3);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 28px;
|
||||
flex: none;
|
||||
padding: 4px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Selection: pill fill + the open-folder glyph in the info accent. */
|
||||
.rowSelected,
|
||||
.rowSelected:hover {
|
||||
background: var(--dsw-alias-interactive-bg-active, var(--dsw-alias-interactive-bg-hover));
|
||||
}
|
||||
|
||||
.rowIcon {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.rowIconSelected {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-button-info-fill);
|
||||
}
|
||||
|
||||
.rowName {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.rowChevron {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.status,
|
||||
.error {
|
||||
padding: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.status {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* Footer: l3 separator on top, pt12 px24, New-folder pinned left; the fixed
|
||||
* card leaves the figma 28px below the 36px buttons. */
|
||||
.footerBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: none;
|
||||
padding: 12px 24px 28px;
|
||||
border-top: 1px solid var(--dsw-alias-border-l3);
|
||||
}
|
||||
|
||||
.footerGap {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.footerAction {
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
/* Nested create dialog (figma 813:23278): a small centered card. */
|
||||
.createDialog.createDialog {
|
||||
width: min(380px, 100%);
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.createBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 22px 24px 20px;
|
||||
}
|
||||
|
||||
.createTitle {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 510;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.createIn {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.createInput {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 7px 14px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 22px;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.createInput::placeholder {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.createActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
@@ -1,376 +0,0 @@
|
||||
/**
|
||||
* The in-app workspace-directory browser (figma Harness 813-23126 family): a
|
||||
* fixed 600×420 dialog whose header carries the title, the selection-path
|
||||
* breadcrumb, and a click-to-edit path zone; below it a Miller view — one
|
||||
* full-width level until a row is selected, then two 256px columns (level |
|
||||
* selected folder's children) around a hairline divider. Selecting in the
|
||||
* right column shifts the view one level deeper. "New folder" opens a nested
|
||||
* create dialog targeting the selected folder (or the level itself) and
|
||||
* selects the created folder. Open adopts the selected folder, falling back
|
||||
* to the listed level. Pure consumer of the injected browse calls — the
|
||||
* owning flow decides what "Open" means and owns the workspace-creation
|
||||
* error surface. Hidden entries are host-flagged and filtered here (a
|
||||
* show-hidden toggle is deferred work, client-side only).
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
Button, IconChevronRightOutline14, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, Modal,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { Translate } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import css from './DirectoryBrowser.module.css'
|
||||
|
||||
/** Owner-supplied browser props: browse calls, pick semantics, and copy. */
|
||||
export interface DirectoryBrowserProps {
|
||||
/** Dialog visibility (owner-local; closed unmounts nothing but resets on reopen). */
|
||||
open: boolean
|
||||
/** List one directory level (absent path = the Host home directory). */
|
||||
listDirectory: (path?: string) => Promise<DirectoryListing>
|
||||
/** Create one child directory under an existing parent. */
|
||||
createDirectory: (path: string, name: string) => Promise<string>
|
||||
/** The operator confirmed a directory (the selection, else the listed level). */
|
||||
onOpen: (path: string) => void
|
||||
/** Close without picking (mask, Escape, Cancel). */
|
||||
onClose: () => void
|
||||
/** The owner's confirm is in flight: Open disables, the view freezes. */
|
||||
busy: boolean
|
||||
/** Localized copy. */
|
||||
t: Translate
|
||||
}
|
||||
|
||||
/** Failure text: the Host business message when typed, else the throw's text. */
|
||||
function failureText(error: unknown): string {
|
||||
if (error instanceof DirectoryBrowseError) return error.rpcError.message
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
/**
|
||||
* Breadcrumb rows for display: inside the home subtree the chain starts at a
|
||||
* localized Home crumb; outside it the full ancestry shows, the root labeled
|
||||
* by its own path.
|
||||
*/
|
||||
function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryEntry[] {
|
||||
const homeIndex = listing.crumbs.findIndex(crumb => crumb.path === listing.home)
|
||||
if (homeIndex === -1) return listing.crumbs
|
||||
const tail = listing.crumbs.slice(homeIndex + 1)
|
||||
return [{ name: homeLabel, path: listing.home, hidden: false }, ...tail]
|
||||
}
|
||||
|
||||
/** One column of folder rows (the Miller view renders one or two of these). */
|
||||
function LevelColumn({ entries, selectedPath, busy, onPick, wide }: {
|
||||
entries: readonly DirectoryEntry[]
|
||||
selectedPath: string | null
|
||||
busy: boolean
|
||||
onPick: (entry: DirectoryEntry) => void
|
||||
wide: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className={clsx(css.column, wide && css.columnWide)} role="list">
|
||||
{entries.filter(entry => !entry.hidden).map((entry) => {
|
||||
const selected = entry.path === selectedPath
|
||||
return (
|
||||
<button
|
||||
key={entry.path}
|
||||
type="button"
|
||||
role="listitem"
|
||||
aria-current={selected || undefined}
|
||||
className={clsx(css.row, selected && css.rowSelected)}
|
||||
disabled={busy}
|
||||
onClick={() => { onPick(entry) }}
|
||||
>
|
||||
{selected
|
||||
? <IconFolderOpen16 size={16} className={css.rowIconSelected} />
|
||||
: <IconFolderClose16 size={16} className={css.rowIcon} />}
|
||||
<span className={css.rowName}>{entry.name}</span>
|
||||
<IconChevronRightOutline14 size={12} className={css.rowChevron} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the directory-browser dialog.
|
||||
* @param props - owner-controlled browser props.
|
||||
* @returns the dialog element (null while closed, via Modal).
|
||||
*/
|
||||
export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, onClose, busy, t }: DirectoryBrowserProps) {
|
||||
// Miller state: the listed level, the selected row in it, and the selected
|
||||
// folder's own listing (the right column; null while nothing is selected).
|
||||
const [parent, setParent] = useState<DirectoryListing | null>(null)
|
||||
const [selected, setSelected] = useState<DirectoryEntry | null>(null)
|
||||
const [child, setChild] = useState<DirectoryListing | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// Path-edit state: null = breadcrumb mode; a string = the draft being typed.
|
||||
const [pathDraft, setPathDraft] = useState<string | null>(null)
|
||||
// Create-folder state: null = closed; a string = the nested dialog's draft.
|
||||
const [folderDraft, setFolderDraft] = useState<string | null>(null)
|
||||
const [creatingFolder, setCreatingFolder] = useState(false)
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
const requestSeq = useRef(0)
|
||||
|
||||
/** Replace the whole view with one freshly listed level (no selection). */
|
||||
const navigate = useCallback((path?: string) => {
|
||||
const seq = ++requestSeq.current
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
listDirectory(path).then((next) => {
|
||||
if (seq !== requestSeq.current) return
|
||||
setParent(next)
|
||||
setSelected(null)
|
||||
setChild(null)
|
||||
setLoading(false)
|
||||
setPathDraft(null)
|
||||
}, (reason: unknown) => {
|
||||
if (seq !== requestSeq.current) return
|
||||
setLoading(false)
|
||||
setError(failureText(reason))
|
||||
})
|
||||
}, [listDirectory])
|
||||
|
||||
/** Select a row of the listed level and preview its children on the right. */
|
||||
const select = useCallback((entry: DirectoryEntry) => {
|
||||
const seq = ++requestSeq.current
|
||||
setSelected(entry)
|
||||
setChild(null)
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
listDirectory(entry.path).then((next) => {
|
||||
if (seq !== requestSeq.current) return
|
||||
setChild(next)
|
||||
setLoading(false)
|
||||
}, (reason: unknown) => {
|
||||
if (seq !== requestSeq.current) return
|
||||
setLoading(false)
|
||||
setError(failureText(reason))
|
||||
})
|
||||
}, [listDirectory])
|
||||
|
||||
/** A right-column pick advances the view one level: child becomes the level. */
|
||||
const advance = useCallback((entry: DirectoryEntry) => {
|
||||
/* v8 ignore next -- narrowing guard: the right column only renders with a child listing. */
|
||||
if (child === null) return
|
||||
setParent(child)
|
||||
select(entry)
|
||||
}, [child, select])
|
||||
|
||||
// Every open starts fresh at the Host home directory; closing invalidates
|
||||
// any in-flight response so a late arrival cannot repopulate a closed dialog.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setParent(null)
|
||||
setSelected(null)
|
||||
setChild(null)
|
||||
navigate()
|
||||
return
|
||||
}
|
||||
requestSeq.current += 1
|
||||
setError(null)
|
||||
setPathDraft(null)
|
||||
setFolderDraft(null)
|
||||
setCreateError(null)
|
||||
}, [open, navigate])
|
||||
|
||||
/** The folder a create or Open acts on: the selection, else the listed level. */
|
||||
const targetPath = selected?.path ?? parent?.path ?? null
|
||||
const targetName = selected?.name
|
||||
?? (parent === null ? '' : (displayCrumbs(parent, t('browser.home')).at(-1)?.name ?? parent.path))
|
||||
|
||||
const confirmCreate = (): void => {
|
||||
/* v8 ignore next -- reentry fence: the nested dialog only renders with a target and disables while creating. */
|
||||
if (targetPath === null || folderDraft === null || creatingFolder) return
|
||||
const name = folderDraft.trim()
|
||||
if (name === '') return
|
||||
setCreatingFolder(true)
|
||||
setCreateError(null)
|
||||
createDirectory(targetPath, name).then((createdPath) => {
|
||||
setCreatingFolder(false)
|
||||
setFolderDraft(null)
|
||||
// Land like a right-column pick (figma 802:57446 → 813:23278 flow): the
|
||||
// create target becomes the listed level and the new folder its selection.
|
||||
const seq = ++requestSeq.current
|
||||
setLoading(true)
|
||||
listDirectory(targetPath).then((level) => {
|
||||
/* v8 ignore next -- same fence as navigate/select; the modal blocks superseding input */
|
||||
if (seq !== requestSeq.current) return
|
||||
setParent(level)
|
||||
setLoading(false)
|
||||
select({ name, path: createdPath, hidden: false })
|
||||
}, (reason: unknown) => {
|
||||
/* v8 ignore next -- same fence as navigate/select; the modal blocks superseding input */
|
||||
if (seq !== requestSeq.current) return
|
||||
setLoading(false)
|
||||
setError(failureText(reason))
|
||||
})
|
||||
}, (reason: unknown) => {
|
||||
setCreatingFolder(false)
|
||||
setCreateError(failureText(reason))
|
||||
})
|
||||
}
|
||||
|
||||
// After the hooks: a closed dialog renders nothing and evaluates no copy.
|
||||
if (!open) return null
|
||||
|
||||
const crumbSource = child ?? parent
|
||||
const crumbs = crumbSource === null ? [] : displayCrumbs(crumbSource, t('browser.home'))
|
||||
const twoPane = selected !== null
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t('browser.title')}
|
||||
className={clsx(css.dialog)}
|
||||
headless
|
||||
>
|
||||
<div className={css.header}>
|
||||
<h2 className={css.title}>{t('browser.title')}</h2>
|
||||
<div className={css.crumbBar}>
|
||||
{pathDraft === null
|
||||
? (
|
||||
<>
|
||||
{crumbs.map((crumb, index) => (
|
||||
<span key={crumb.path} className={css.crumbSeat}>
|
||||
{index > 0 && <IconChevronRightOutline14 size={12} className={css.crumbChevron} />}
|
||||
<button
|
||||
type="button"
|
||||
className={css.crumb}
|
||||
disabled={busy}
|
||||
onClick={() => { navigate(crumb.path) }}
|
||||
>
|
||||
{crumb.name}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{/* The empty zone right of the crumbs is the path-edit affordance. */}
|
||||
<button
|
||||
type="button"
|
||||
className={css.crumbEditZone}
|
||||
aria-label={t('browser.editPath')}
|
||||
disabled={parent === null || busy}
|
||||
/* v8 ignore next -- narrowing guard: the zone disables while the level is null. */
|
||||
onClick={() => { if (parent !== null) setPathDraft(selected?.path ?? parent.path) }}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<input
|
||||
className={css.pathInput}
|
||||
value={pathDraft}
|
||||
aria-label={t('browser.editPath')}
|
||||
autoFocus
|
||||
disabled={busy}
|
||||
onChange={(event) => { setPathDraft(event.target.value) }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
const target = pathDraft.trim()
|
||||
if (target !== '') navigate(target)
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation()
|
||||
setPathDraft(null)
|
||||
setError(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={css.content}>
|
||||
{parent !== null && (
|
||||
<LevelColumn
|
||||
entries={parent.entries}
|
||||
selectedPath={selected?.path ?? null}
|
||||
busy={busy}
|
||||
onPick={select}
|
||||
wide={!twoPane}
|
||||
/>
|
||||
)}
|
||||
{twoPane && <span className={css.divider} />}
|
||||
{twoPane && child !== null && (
|
||||
<LevelColumn
|
||||
entries={child.entries}
|
||||
selectedPath={null}
|
||||
busy={busy}
|
||||
onPick={advance}
|
||||
wide={false}
|
||||
/>
|
||||
)}
|
||||
{loading && <div className={css.status} role="status">{t('browser.loading')}</div>}
|
||||
{error !== null && <div className={css.error} role="alert">{error}</div>}
|
||||
</div>
|
||||
<div className={css.footerBar}>
|
||||
<Button
|
||||
variant="outline"
|
||||
icon={<IconPlusOutline16 size={14} />}
|
||||
disabled={parent === null || busy || folderDraft !== null}
|
||||
onClick={() => {
|
||||
setFolderDraft('')
|
||||
setCreateError(null)
|
||||
}}
|
||||
>
|
||||
{t('browser.newFolder')}
|
||||
</Button>
|
||||
<span className={css.footerGap} />
|
||||
<Button variant="outline" className={clsx(css.footerAction)} disabled={busy} onClick={onClose}>{t('browser.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={clsx(css.footerAction)}
|
||||
disabled={targetPath === null || loading || busy}
|
||||
/* v8 ignore next -- narrowing guard: Open disables while no target exists. */
|
||||
onClick={() => { if (targetPath !== null) onOpen(targetPath) }}
|
||||
>
|
||||
{t('browser.open')}
|
||||
</Button>
|
||||
</div>
|
||||
{/* Nested create dialog (figma 813:23278): names one folder inside the target. */}
|
||||
<Modal
|
||||
open={folderDraft !== null}
|
||||
onClose={() => { if (!creatingFolder) setFolderDraft(null) }}
|
||||
title={t('browser.newFolder')}
|
||||
className={clsx(css.createDialog)}
|
||||
headless
|
||||
>
|
||||
<div className={css.createBody}>
|
||||
<h3 className={css.createTitle}>{t('browser.newFolder')}</h3>
|
||||
<p className={css.createIn}>{t('browser.createIn', { name: targetName })}</p>
|
||||
<input
|
||||
className={css.createInput}
|
||||
value={folderDraft ?? ''}
|
||||
aria-label={t('browser.folderName')}
|
||||
placeholder={t('browser.untitledFolder')}
|
||||
autoFocus
|
||||
disabled={creatingFolder}
|
||||
onChange={(event) => { setFolderDraft(event.target.value) }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
confirmCreate()
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation()
|
||||
if (!creatingFolder) setFolderDraft(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{createError !== null && <div className={css.error} role="alert">{createError}</div>}
|
||||
<div className={css.createActions}>
|
||||
<Button variant="outline" disabled={creatingFolder} onClick={() => { setFolderDraft(null) }}>{t('browser.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={creatingFolder || folderDraft === null || folderDraft.trim() === ''}
|
||||
onClick={confirmCreate}
|
||||
>
|
||||
{t('browser.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -129,6 +129,7 @@
|
||||
reads the shell's class names): the two icon controls stack as 36x36
|
||||
circles matching the shell's rail rhythm. */
|
||||
.rail .sectionHeader {
|
||||
gap: 0;
|
||||
padding-left: 0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
@@ -207,6 +208,13 @@
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 12px;
|
||||
/* Row trailing content (the relative time, and the hover action buttons
|
||||
that replace it) sits flush against the row's 8px right padding, so an
|
||||
overlaid scrollbar covers it. Reserving the gutter keeps the bar beside
|
||||
the rows instead of on top of them; `stable` holds the reservation when
|
||||
the list is short enough not to scroll, so expanding a group does not
|
||||
shift every row left. */
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
/* One workspace section: header row + expanded session run. Rows inside
|
||||
|
||||
@@ -253,11 +253,8 @@ export function WorkspaceBrowser({
|
||||
deleteWorkspace,
|
||||
insertSessionBefore,
|
||||
createWorkspace,
|
||||
directoryPickerKind,
|
||||
pickDirectory,
|
||||
listDirectory,
|
||||
createDirectory,
|
||||
t,
|
||||
hasDirectoryFlow,
|
||||
renderSlot,
|
||||
}: WorkspaceBrowserProps) {
|
||||
const workspaces = useWorkspaces(state => state.items)
|
||||
const groupBy = useStore(s => s.groupBy)
|
||||
@@ -269,6 +266,7 @@ export function WorkspaceBrowser({
|
||||
// states; the menu anchors on this button).
|
||||
const [wsPickerOpen, setWsPickerOpen] = useState(false)
|
||||
const wsPlusRef = useRef<HTMLButtonElement>(null)
|
||||
const composingRef = useRef(false)
|
||||
|
||||
// Rail search = expand + land in the search box: the flag arms before the
|
||||
// expand request; once the shell flips wide the input mounts and takes focus.
|
||||
@@ -362,7 +360,6 @@ export function WorkspaceBrowser({
|
||||
className={css.iconButton}
|
||||
aria-label="Create workspace"
|
||||
onClick={() => {
|
||||
if (!wide) expandSidebar()
|
||||
setWsPickerOpen(v => !v)
|
||||
}}
|
||||
>
|
||||
@@ -375,11 +372,10 @@ export function WorkspaceBrowser({
|
||||
anchorRef={wsPlusRef}
|
||||
useWorkspaces={useWorkspaces}
|
||||
createWorkspace={createWorkspace}
|
||||
directoryPickerKind={directoryPickerKind}
|
||||
pickDirectory={pickDirectory}
|
||||
listDirectory={listDirectory}
|
||||
createDirectory={createDirectory}
|
||||
t={t}
|
||||
hasDirectoryFlow={hasDirectoryFlow}
|
||||
renderDirectoryFlow={owner => renderSlot('sidebar.workspaces.directoryFlow', owner)}
|
||||
createOnly
|
||||
side="right"
|
||||
onPick={(workspaceId) => {
|
||||
setWsPickerOpen(false)
|
||||
startSession(workspaceId)
|
||||
@@ -467,9 +463,12 @@ export function WorkspaceBrowser({
|
||||
aria-label="Workspace name"
|
||||
autoFocus
|
||||
disabled={renaming}
|
||||
onFocus={(e) => { e.target.select() }}
|
||||
onChange={(e) => { setRenameDraft(e.target.value); setRenameError(null) }}
|
||||
onCompositionStart={() => { composingRef.current = true }}
|
||||
onCompositionEnd={() => { composingRef.current = false }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (e.key === 'Enter' && !composingRef.current) {
|
||||
e.preventDefault()
|
||||
confirmRename()
|
||||
}
|
||||
|
||||
@@ -2,28 +2,29 @@
|
||||
* Workspace pick/create flow. WorkspaceCreateFlow is the reusable core
|
||||
* (menu + path/create dialogs) consumed directly by WorkspaceBrowser (same
|
||||
* package) and wrapped by WorkspacePicker for the conversation empty-state
|
||||
* slot registration.
|
||||
* slot registration. Directory picking itself lives in the composed flow
|
||||
* package's slot occupant (see the contract module doc): this core only
|
||||
* opens the flow, adopts the picked path, and owns the error surface.
|
||||
*/
|
||||
import type { RefObject } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { ReactNode, RefObject } from 'react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import {
|
||||
Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import {
|
||||
WorkspaceCreateError,
|
||||
type DirectoryPickerKind, type WorkspaceId, type WorkspaceListState, type WorkspaceView,
|
||||
type WorkspaceId, type WorkspaceListState, type WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { DirectoryPickingInjected, WorkspacePickerProps } from './contract/slots.ts'
|
||||
import { DirectoryBrowser } from './DirectoryBrowser.tsx'
|
||||
import type { DirectoryFlowOwnerProps, WorkspacePickerProps } from './contract/slots.ts'
|
||||
import css from './WorkspacePicker.module.css'
|
||||
|
||||
const OPEN_LOCAL_FOLDER = '::open-local-folder'
|
||||
const CREATE_NEW = '::create-new'
|
||||
|
||||
type ModalKind = 'create' | 'folder-error' | 'browse' | null
|
||||
type ModalKind = 'create' | 'folder-error' | null
|
||||
|
||||
/** Core flow props: the owner supplies popover control and pick semantics. */
|
||||
export interface WorkspaceCreateFlowProps extends DirectoryPickingInjected {
|
||||
export interface WorkspaceCreateFlowProps {
|
||||
/** Popover visibility (anchor button toggle state, owner-local). */
|
||||
open: boolean
|
||||
/** The anchor button element — the popover's placement anchor. */
|
||||
@@ -32,10 +33,20 @@ export interface WorkspaceCreateFlowProps extends DirectoryPickingInjected {
|
||||
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). */
|
||||
hasDirectoryFlow: () => boolean
|
||||
/** 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. */
|
||||
onPick: (workspaceId: WorkspaceId) => void
|
||||
/** Close the popover (outside click / Escape / post-pick). */
|
||||
onClose: () => void
|
||||
/** Only show create actions (open folder / create new), hide existing workspaces. */
|
||||
createOnly?: boolean
|
||||
/** Menu opening direction relative to the anchor. */
|
||||
side?: 'bottom' | 'top' | 'right'
|
||||
/** Currently active workspace (trailing check in the picker list). */
|
||||
selectedId?: WorkspaceId | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,13 +59,13 @@ export function WorkspaceCreateFlow({
|
||||
anchorRef,
|
||||
useWorkspaces,
|
||||
createWorkspace,
|
||||
directoryPickerKind,
|
||||
pickDirectory,
|
||||
listDirectory,
|
||||
createDirectory,
|
||||
t,
|
||||
hasDirectoryFlow,
|
||||
renderDirectoryFlow,
|
||||
onPick,
|
||||
onClose,
|
||||
createOnly = false,
|
||||
side = 'bottom',
|
||||
selectedId,
|
||||
}: WorkspaceCreateFlowProps) {
|
||||
const workspaceSnapshot = useWorkspaces(state => state)
|
||||
const workspaces = workspaceSnapshot.items
|
||||
@@ -66,52 +77,46 @@ export function WorkspaceCreateFlow({
|
||||
const [workspaceName, setWorkspaceName] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [modalError, setModalError] = useState<string | null>(null)
|
||||
const [flowOpen, setFlowOpen] = useState(false)
|
||||
const [pickingFolder, setPickingFolder] = useState(false)
|
||||
const [folderConflict, setFolderConflict] = useState(false)
|
||||
// The Host's picker interaction: read while the menu is open; 'unknown'
|
||||
// (fetch failure or an unadvertised kind) hides the local-folder entry —
|
||||
// the merge-extensible union's documented default.
|
||||
const [pickerKind, setPickerKind] = useState<DirectoryPickerKind | 'unknown' | null>(null)
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
let stale = false
|
||||
directoryPickerKind().then(
|
||||
// The wire kind is an open string (a merge-added capability advertises
|
||||
// before this client knows it): anything but the two known kinds hides
|
||||
// the entry, as does a fetch failure.
|
||||
(kind) => { if (!stale) setPickerKind(kind === 'dialog' || kind === 'browse' ? kind : 'unknown') },
|
||||
() => { if (!stale) setPickerKind('unknown') },
|
||||
)
|
||||
return () => { stale = true }
|
||||
}, [open, directoryPickerKind])
|
||||
const composingRef = useRef(false)
|
||||
const normalizedWorkspaceName = workspaceName.trim()
|
||||
const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== ''
|
||||
&& workspaces.some(workspace => workspace.title === normalizedWorkspaceName)
|
||||
|
||||
const items: MenuEntry[] = [
|
||||
...workspaces.map(workspace => ({
|
||||
// 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.
|
||||
const createEntries: MenuEntry[] = [
|
||||
...(hasDirectoryFlow()
|
||||
? [{ 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 },
|
||||
]
|
||||
// With workspaces listed, the create actions pin below the scroll region
|
||||
// (divider + always visible); otherwise they ARE the menu.
|
||||
const pinCreate = !createOnly && workspaces.length > 0
|
||||
const items: MenuEntry[] = pinCreate
|
||||
? workspaces.map(workspace => ({
|
||||
id: workspace.workspaceId,
|
||||
label: workspace.title,
|
||||
icon: <IconFolderClose16 size={16} />,
|
||||
disabled: pickingFolder,
|
||||
})),
|
||||
...(workspaces.length > 0 ? [{ type: 'separator' as const, id: 'sep-create' }] : []),
|
||||
...(pickerKind === 'unknown' ? [] : [
|
||||
{ id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: <IconFolderClose16 size={16} />, disabled: pickingFolder || pickerKind === null },
|
||||
]),
|
||||
{ id: CREATE_NEW, label: 'Create a new workspace', icon: <IconPlusOutline16 size={16} />, disabled: pickingFolder },
|
||||
]
|
||||
}))
|
||||
: createEntries
|
||||
|
||||
const closeModal = (): void => {
|
||||
if (creating || pickingFolder) return
|
||||
if (creating) return
|
||||
setModalKind(null)
|
||||
setModalError(null)
|
||||
}
|
||||
|
||||
/** Adopt a chosen directory as a Workspace; failures land in the folder-error dialog. */
|
||||
/** Adopt a picked directory; failures land in the folder-error dialog (Choose again reopens the flow). */
|
||||
const adoptDirectory = (path: string): Promise<void> =>
|
||||
createWorkspace({ path }).then((workspace) => {
|
||||
setModalKind(null)
|
||||
setFlowOpen(false)
|
||||
onPick(workspace.workspaceId)
|
||||
}).catch((reason: unknown) => {
|
||||
setFolderConflict(
|
||||
@@ -119,29 +124,33 @@ export function WorkspaceCreateFlow({
|
||||
&& reason.rpcError.code === 'workspace-name-conflict',
|
||||
)
|
||||
setModalError(reason instanceof Error ? reason.message : String(reason))
|
||||
setFlowOpen(false)
|
||||
setModalKind('folder-error')
|
||||
})
|
||||
|
||||
const openLocalFolder = (): void => {
|
||||
if (pickerKind === 'browse') {
|
||||
onClose()
|
||||
setModalError(null)
|
||||
setFolderConflict(false)
|
||||
setModalKind('browse')
|
||||
return
|
||||
}
|
||||
onClose()
|
||||
setModalKind(null)
|
||||
setModalError(null)
|
||||
setFolderConflict(false)
|
||||
setPickingFolder(true)
|
||||
void pickDirectory().then(async (path) => {
|
||||
if (path === null) return
|
||||
await adoptDirectory(path)
|
||||
}).catch((reason: unknown) => {
|
||||
setModalError(reason instanceof Error ? reason.message : String(reason))
|
||||
setFlowOpen(true)
|
||||
}
|
||||
|
||||
/** Owner side of the flow conversation: adopt keeps the flow open (busy) until the Host answers. */
|
||||
const flowOwner: DirectoryFlowOwnerProps = {
|
||||
open: flowOpen,
|
||||
busy: pickingFolder,
|
||||
onPicked: (path) => {
|
||||
setPickingFolder(true)
|
||||
void adoptDirectory(path).finally(() => { setPickingFolder(false) })
|
||||
},
|
||||
onCancel: () => { setFlowOpen(false) },
|
||||
onError: (message) => {
|
||||
setFlowOpen(false)
|
||||
setFolderConflict(false)
|
||||
setModalError(message)
|
||||
setModalKind('folder-error')
|
||||
}).finally(() => { setPickingFolder(false) })
|
||||
},
|
||||
}
|
||||
|
||||
const handleSelect = (id: string): void => {
|
||||
@@ -151,7 +160,7 @@ export function WorkspaceCreateFlow({
|
||||
}
|
||||
if (id === CREATE_NEW) {
|
||||
onClose()
|
||||
setWorkspaceName('workspace')
|
||||
setWorkspaceName('')
|
||||
setModalError(null)
|
||||
setModalKind('create')
|
||||
return
|
||||
@@ -186,24 +195,16 @@ export function WorkspaceCreateFlow({
|
||||
open={open}
|
||||
anchor={null}
|
||||
items={items}
|
||||
{...pinCreate ? { footer: createEntries } : {}}
|
||||
selectedId={selectedId}
|
||||
onSelect={handleSelect}
|
||||
onClose={onClose}
|
||||
side={side}
|
||||
portal
|
||||
getAnchorRect={getAnchorRect}
|
||||
/>
|
||||
{open && workspaceSnapshot.phase === 'pending' && <div className={css.menuStatus} role="status">Loading workspaces…</div>}
|
||||
<DirectoryBrowser
|
||||
open={modalKind === 'browse'}
|
||||
listDirectory={listDirectory}
|
||||
createDirectory={createDirectory}
|
||||
busy={pickingFolder}
|
||||
t={t}
|
||||
onClose={closeModal}
|
||||
onOpen={(path) => {
|
||||
setPickingFolder(true)
|
||||
void adoptDirectory(path).finally(() => { setPickingFolder(false) })
|
||||
}}
|
||||
/>
|
||||
{renderDirectoryFlow(flowOwner)}
|
||||
<Modal
|
||||
open={modalKind === 'folder-error'}
|
||||
onClose={closeModal}
|
||||
@@ -243,12 +244,15 @@ export function WorkspaceCreateFlow({
|
||||
<input
|
||||
className={css.modalInput}
|
||||
value={workspaceName}
|
||||
placeholder="Workspace name"
|
||||
aria-label="New workspace name"
|
||||
autoFocus
|
||||
disabled={creating}
|
||||
onChange={(event) => { setWorkspaceName(event.target.value); setModalError(null) }}
|
||||
onCompositionStart={() => { composingRef.current = true }}
|
||||
onCompositionEnd={() => { composingRef.current = false }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
if (event.key === 'Enter' && !composingRef.current) {
|
||||
event.preventDefault()
|
||||
confirmCreate()
|
||||
}
|
||||
@@ -274,14 +278,12 @@ export function WorkspacePicker({
|
||||
open,
|
||||
anchorRef,
|
||||
useWorkspaces,
|
||||
selectedId,
|
||||
onPick,
|
||||
onClose,
|
||||
createWorkspace,
|
||||
directoryPickerKind,
|
||||
pickDirectory,
|
||||
listDirectory,
|
||||
createDirectory,
|
||||
t,
|
||||
hasDirectoryFlow,
|
||||
renderSlot,
|
||||
}: WorkspacePickerProps) {
|
||||
return (
|
||||
<WorkspaceCreateFlow
|
||||
@@ -289,11 +291,9 @@ export function WorkspacePicker({
|
||||
anchorRef={anchorRef}
|
||||
useWorkspaces={useWorkspaces}
|
||||
createWorkspace={createWorkspace}
|
||||
directoryPickerKind={directoryPickerKind}
|
||||
pickDirectory={pickDirectory}
|
||||
listDirectory={listDirectory}
|
||||
createDirectory={createDirectory}
|
||||
t={t}
|
||||
hasDirectoryFlow={hasDirectoryFlow}
|
||||
renderDirectoryFlow={owner => renderSlot('conversation.hero.workspace.directoryFlow', owner)}
|
||||
selectedId={selectedId}
|
||||
onPick={onPick}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
@@ -7,36 +7,66 @@
|
||||
* consumes the shell's two-fact owner share (wide / expandSidebar).
|
||||
* - WorkspacePicker fills the conversation empty-state hole (menu +
|
||||
* create dialogs shared with the browser).
|
||||
*
|
||||
* Each registration also declares one **directory-flow hole** (`single`
|
||||
* kind): the slot a composed picker package's client half fills with its
|
||||
* picking interaction — a renderless native-chooser driver or an in-app
|
||||
* browsing dialog. ui-workspace owns the trigger (the "Open local folder…"
|
||||
* menu entry, shown only while the hole is occupied) and the adoption
|
||||
* semantics (`createWorkspace({ path })`, the conflict/error dialog, Choose
|
||||
* again); the occupant owns everything between `open` and the picked path.
|
||||
* Two holes exist because the two menu surfaces are independent slot entries
|
||||
* and a hole has exactly one declaring entry — they carry the same owner
|
||||
* contract and the same occupant.
|
||||
*/
|
||||
import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
// Type-only: pull the owner SlotMap merges into programs that resolve the
|
||||
// runtime shares below.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {
|
||||
DirectoryListing, DirectoryPickerKind, SessionId, WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { Translate } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { createWorkspaceViewStore } from '../stores.ts'
|
||||
|
||||
/**
|
||||
* Directory-picking share both registrations consume: the Host's composed
|
||||
* picker interaction decides which calls the flow drives (`dialog` opens the
|
||||
* native chooser through `pickDirectory`; `browse` drives the in-app browser
|
||||
* through `listDirectory`/`createDirectory`; an unknown kind hides the
|
||||
* local-folder entry — the merge-extensible union's documented default).
|
||||
* Owner share of the directory-flow holes: the complete conversation between
|
||||
* the trigger surface and the picking interaction. The occupant reads `open`
|
||||
* to run/render its interaction and reports exactly one outcome per open.
|
||||
*/
|
||||
export interface DirectoryFlowOwnerProps {
|
||||
/** True while a picking interaction is requested; flipping back to false withdraws the request. */
|
||||
open: boolean
|
||||
/** True while the owner adopts a picked path (`createWorkspace` in flight); occupants disable their commit affordances. */
|
||||
busy: boolean
|
||||
/** The operator picked a directory (absolute host path); the owner adopts it. */
|
||||
onPicked: (path: string) => void
|
||||
/** The operator dismissed the interaction; the owner just closes the flow. */
|
||||
onCancel: () => void
|
||||
/** The interaction itself failed (chooser missing, listing denied); the owner shows its error surface. */
|
||||
onError: (message: string) => void
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
/** Directory-flow hole under the conversation empty-state picker (declared by the WorkspacePicker entry). */
|
||||
'conversation.hero.workspace.directoryFlow': { kind: 'single'; scope: 'root'; owner: DirectoryFlowOwnerProps }
|
||||
/** Directory-flow hole under the sidebar browsing region (declared by the WorkspaceBrowser entry). */
|
||||
'sidebar.workspaces.directoryFlow': { kind: 'single'; scope: 'root'; owner: DirectoryFlowOwnerProps }
|
||||
}
|
||||
}
|
||||
|
||||
/** The two directory-flow holes; a flow package's client half registers its one component into both. */
|
||||
export type DirectoryFlowSlotName =
|
||||
| 'conversation.hero.workspace.directoryFlow'
|
||||
| 'sidebar.workspaces.directoryFlow'
|
||||
|
||||
/** Directory-picking share both trigger surfaces consume. */
|
||||
export type DirectoryPickingInjected = {
|
||||
/** The Host's advertised picker interaction, read per flow open. */
|
||||
directoryPickerKind: () => Promise<DirectoryPickerKind>
|
||||
/** Ask the local Host to open its native single-directory picker (`dialog`). */
|
||||
pickDirectory: () => Promise<string | null>
|
||||
/** List one directory level with breadcrumb ancestry (`browse`). */
|
||||
listDirectory: (path?: string) => Promise<DirectoryListing>
|
||||
/** Create one child directory under an existing parent (`browse`). */
|
||||
createDirectory: (path: string, name: string) => Promise<string>
|
||||
/** Localized picker copy (this package's locale namespace). */
|
||||
t: Translate
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
hasDirectoryFlow: () => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,6 +100,7 @@ export type WorkspaceBrowserInjected = DirectoryPickingInjected & {
|
||||
/** Full browser props: shell owner share + viewing store + injected actions. */
|
||||
export type WorkspaceBrowserProps =
|
||||
PropsRuntime<'sidebar.workspaces'>
|
||||
& PropsRenderSlots<'sidebar.workspaces.directoryFlow'>
|
||||
& PropsStore<ReturnType<typeof createWorkspaceViewStore>>
|
||||
& WorkspaceBrowserInjected
|
||||
|
||||
@@ -89,4 +120,6 @@ export type WorkspacePickerInjected = DirectoryPickingInjected & {
|
||||
* currency, so one composed type serves both registrations.
|
||||
*/
|
||||
export type WorkspacePickerProps =
|
||||
PropsRuntime<'conversation.hero.workspace'> & WorkspacePickerInjected
|
||||
PropsRuntime<'conversation.hero.workspace'>
|
||||
& PropsRenderSlots<'conversation.hero.workspace.directoryFlow'>
|
||||
& WorkspacePickerInjected
|
||||
|
||||
@@ -3,23 +3,23 @@
|
||||
* the sidebar shell's `sidebar.workspaces` hole (the whole browsing region),
|
||||
* and WorkspacePicker fills the conversation hero's picker hole
|
||||
* (`conversation.hero.workspace` — both hero forms). Both read real Host
|
||||
* Workspaces through the global useWorkspaces hook. Export discipline:
|
||||
* Workspaces through the global useWorkspaces hook, and each declares its
|
||||
* own `single` directory-flow child hole for the composed picker package's
|
||||
* client half (see the contract module doc). Export discipline:
|
||||
* packages/client/AGENTS.md.
|
||||
*/
|
||||
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { DirectoryPickingInjected, WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts'
|
||||
import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts'
|
||||
import { createWorkspaceViewStore } from './stores.ts'
|
||||
import { WorkspaceBrowser } from './WorkspaceBrowser.tsx'
|
||||
import { WorkspacePicker } from './WorkspacePicker.tsx'
|
||||
|
||||
export type {
|
||||
DirectoryPickingInjected,
|
||||
DirectoryFlowOwnerProps, DirectoryFlowSlotName, DirectoryPickingInjected,
|
||||
WorkspaceBrowserInjected, WorkspaceBrowserProps, WorkspacePickerInjected, WorkspacePickerProps,
|
||||
} from './contract/slots.ts'
|
||||
|
||||
/** Locale namespace for the picker surfaces (dictionaries registered in apply). */
|
||||
const LOCALE_NS = 'workspace'
|
||||
|
||||
/**
|
||||
* Required services (cordis fiber inject). The target slots are declared by
|
||||
* the ui-sidebar / ui-conversation applies, whose activation order relative
|
||||
@@ -28,7 +28,7 @@ const LOCALE_NS = 'workspace'
|
||||
* provides a waitable service. apply therefore registers via
|
||||
* declaration-aware deferral instead of assuming order.
|
||||
*/
|
||||
export const inject = ['slots', 'sessions', 'workspaces', 'locale']
|
||||
export const inject = ['slots', 'sessions', 'workspaces']
|
||||
|
||||
/**
|
||||
* Register the browser and picker once their slot declarations are on the
|
||||
@@ -37,45 +37,6 @@ export const inject = ['slots', 'sessions', 'workspaces', 'locale']
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => {
|
||||
const disposers = [
|
||||
ctx.locale.register(LOCALE_NS, 'zh', {
|
||||
'browser.title': '选择工作区目录',
|
||||
'browser.home': '主目录',
|
||||
'browser.newFolder': '新建文件夹',
|
||||
'browser.folderName': '文件夹名称',
|
||||
'browser.createIn': '在"{name}"中新建文件夹',
|
||||
'browser.untitledFolder': '未命名文件夹',
|
||||
'browser.create': '创建',
|
||||
'browser.cancel': '取消',
|
||||
'browser.open': '打开',
|
||||
'browser.editPath': '编辑路径',
|
||||
'browser.loading': '加载中…',
|
||||
}),
|
||||
ctx.locale.register(LOCALE_NS, 'en', {
|
||||
'browser.title': 'Select Workspace Directory',
|
||||
'browser.home': 'Home',
|
||||
'browser.newFolder': 'New folder',
|
||||
'browser.folderName': 'Folder name',
|
||||
'browser.createIn': 'New folder in "{name}"',
|
||||
'browser.untitledFolder': 'Untitled folder',
|
||||
'browser.create': 'Create',
|
||||
'browser.cancel': 'Cancel',
|
||||
'browser.open': 'Open',
|
||||
'browser.editPath': 'Edit path',
|
||||
'browser.loading': 'Loading…',
|
||||
}),
|
||||
]
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
}, 'ui-workspace: picker dictionaries')
|
||||
|
||||
const picking = (): DirectoryPickingInjected => ({
|
||||
directoryPickerKind: () => ctx.workspaces.directoryPickerKind(),
|
||||
pickDirectory: () => ctx.workspaces.pickDirectory(),
|
||||
listDirectory: path => ctx.workspaces.listDirectory(path),
|
||||
createDirectory: (path, name) => ctx.workspaces.createDirectory(path, name),
|
||||
t: ctx.locale.bind(LOCALE_NS),
|
||||
})
|
||||
const browserInjected = (): WorkspaceBrowserInjected => ({
|
||||
// Explicit group actions keep their target; unscoped New Session rides
|
||||
// the runtime's shared action (recent-Workspace projection inside).
|
||||
@@ -87,58 +48,40 @@ export function apply(ctx: ClientContext): void {
|
||||
await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
|
||||
},
|
||||
createWorkspace: input => ctx.workspaces.create(input),
|
||||
...picking(),
|
||||
hasDirectoryFlow: () => ctx.slots.entries('sidebar.workspaces.directoryFlow').length > 0,
|
||||
})
|
||||
const pickerInjected = (): WorkspacePickerInjected => ({
|
||||
createWorkspace: input => ctx.workspaces.create(input),
|
||||
...picking(),
|
||||
hasDirectoryFlow: () => ctx.slots.entries('conversation.hero.workspace.directoryFlow').length > 0,
|
||||
})
|
||||
// Declaration-aware registration: each owner's declaring apply may activate
|
||||
// after this one (entry activation order is unconstrained), and a register
|
||||
// into an undeclared slot throws. Register once the declaration is on the
|
||||
// ledger; the subscription also re-registers after an HMR collapse
|
||||
// re-declares the slot (the cascade disposed our entry with it).
|
||||
// Declaration-aware registration (deferRegistration): each owner's
|
||||
// declaring apply may activate after this one, and a register into an
|
||||
// undeclared slot throws; the deferral also re-registers after an HMR
|
||||
// collapse re-declares the slot. Each registration declares its own
|
||||
// directory-flow child hole in the same call (declaration = render
|
||||
// authorization, one table).
|
||||
ctx.effect(() => {
|
||||
const registrations = [
|
||||
{
|
||||
name: 'sidebar.workspaces' as const,
|
||||
component: WorkspaceBrowser,
|
||||
register: () => ctx.slots.register(
|
||||
{ name: 'sidebar.workspaces', store: createWorkspaceViewStore(), inject: browserInjected },
|
||||
const deferred = [
|
||||
deferRegistration(ctx.slots, 'sidebar.workspaces', WorkspaceBrowser, () =>
|
||||
ctx.slots.register(
|
||||
{
|
||||
name: 'sidebar.workspaces',
|
||||
children: { 'sidebar.workspaces.directoryFlow': { kind: 'single', scope: 'root' } },
|
||||
store: createWorkspaceViewStore(),
|
||||
inject: browserInjected,
|
||||
},
|
||||
WorkspaceBrowser,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'conversation.hero.workspace' as const,
|
||||
component: WorkspacePicker,
|
||||
register: () => ctx.slots.register(
|
||||
{ name: 'conversation.hero.workspace', inject: pickerInjected },
|
||||
)),
|
||||
deferRegistration(ctx.slots, 'conversation.hero.workspace', WorkspacePicker, () =>
|
||||
ctx.slots.register(
|
||||
{
|
||||
name: 'conversation.hero.workspace',
|
||||
children: { 'conversation.hero.workspace.directoryFlow': { kind: 'single', scope: 'root' } },
|
||||
inject: pickerInjected,
|
||||
},
|
||||
WorkspacePicker,
|
||||
),
|
||||
},
|
||||
)),
|
||||
]
|
||||
const disposers = new Map<string, () => void>()
|
||||
const tryRegister = (entry: (typeof registrations)[number]): void => {
|
||||
if (ctx.slots.spec(entry.name) === undefined) return
|
||||
if (ctx.slots.entries(entry.name).some(e => e.component === entry.component)) return
|
||||
disposers.set(entry.name, entry.register())
|
||||
}
|
||||
const unsubscribers = registrations.map(entry =>
|
||||
ctx.slots.subscribe(entry.name, () => { tryRegister(entry) }))
|
||||
for (const entry of registrations) tryRegister(entry)
|
||||
// Language switch: re-register both entries so open surfaces re-render
|
||||
// with the other dictionary (the bound t keeps a stable identity).
|
||||
const offLocale = ctx.on('locale/change', () => {
|
||||
for (const [name, dispose] of disposers) {
|
||||
dispose()
|
||||
disposers.delete(name)
|
||||
}
|
||||
for (const entry of registrations) tryRegister(entry)
|
||||
})
|
||||
return () => {
|
||||
offLocale()
|
||||
for (const unsubscribe of unsubscribers) unsubscribe()
|
||||
for (const dispose of disposers.values()) dispose()
|
||||
}
|
||||
return () => { for (const entry of deferred) entry.dispose() }
|
||||
}, 'ui-workspace: browser + picker registrations')
|
||||
}
|
||||
|
||||
@@ -14,29 +14,16 @@ 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 directoryPickerKind = vi.fn(async () => 'browse' as const)
|
||||
const listDirectory = vi.fn(async () => ({ path: '/home/u', home: '/home/u', crumbs: [], entries: [] }))
|
||||
const createDirectory = vi.fn(async () => '/home/u/new')
|
||||
const startSession = vi.fn()
|
||||
const rename = vi.fn(async () => ({}))
|
||||
const insertSessionBefore = vi.fn(async () => ({}))
|
||||
const open = vi.fn()
|
||||
const clear = vi.fn()
|
||||
ctx.provide('workspaces', {
|
||||
create, pickDirectory, directoryPickerKind, listDirectory, createDirectory,
|
||||
startSession, rename, insertSessionBefore,
|
||||
create, startSession, rename, insertSessionBefore,
|
||||
} as never)
|
||||
ctx.provide('sessions', { open, clear } as never)
|
||||
// Structural locale fake: register/bind are the only members apply touches.
|
||||
const localeRegister = vi.fn(() => () => {})
|
||||
const boundT = (key: string): string => key
|
||||
ctx.provide('locale', { register: localeRegister, bind: () => boundT } as never)
|
||||
return {
|
||||
ctx, slots: ctx.get('slots') as SlotsService, create, pickDirectory,
|
||||
directoryPickerKind, listDirectory, createDirectory, localeRegister, boundT,
|
||||
startSession, rename, insertSessionBefore, open, clear,
|
||||
}
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService, create, startSession, rename, insertSessionBefore, open, clear }
|
||||
}
|
||||
|
||||
type HoleName = 'sidebar.workspaces' | 'conversation.hero.workspace' | 'conversation.empty.workspace'
|
||||
@@ -49,7 +36,7 @@ function declare(slots: SlotsService, ...names: HoleName[]): () => void {
|
||||
|
||||
describe('ui-workspace apply', () => {
|
||||
it('declares the services it drives', () => {
|
||||
expect(inject).toEqual(['slots', 'sessions', 'workspaces', 'locale'])
|
||||
expect(inject).toEqual(['slots', 'sessions', 'workspaces'])
|
||||
})
|
||||
|
||||
it('registers browser and pickers for declarations arriving before or after apply', async () => {
|
||||
@@ -85,14 +72,30 @@ 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('declares the two directory-flow holes and reports their occupancy per surface', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots, 'sidebar.workspaces', 'conversation.hero.workspace')
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
// Registration declared the child holes (declaration = render authorization).
|
||||
expect(b.slots.spec('sidebar.workspaces.directoryFlow')).toMatchObject({ kind: 'single' })
|
||||
expect(b.slots.spec('conversation.hero.workspace.directoryFlow')).toMatchObject({ kind: 'single' })
|
||||
|
||||
const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)()
|
||||
const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)()
|
||||
expect(browser.hasDirectoryFlow()).toBe(false)
|
||||
expect(picker.hasDirectoryFlow()).toBe(false)
|
||||
// A flow occupant flips exactly its own surface.
|
||||
const dispose = b.slots.register({ name: 'sidebar.workspaces.directoryFlow' } as never, () => null)
|
||||
expect(browser.hasDirectoryFlow()).toBe(true)
|
||||
expect(picker.hasDirectoryFlow()).toBe(false)
|
||||
dispose()
|
||||
expect(browser.hasDirectoryFlow()).toBe(false)
|
||||
})
|
||||
|
||||
it('unregisters every entry on teardown', async () => {
|
||||
|
||||
48
packages/client/ui-workspace/tests/browser-styles.spec.ts
Normal file
48
packages/client/ui-workspace/tests/browser-styles.spec.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* WorkspaceBrowser scroll-region style contract, asserted against the CSS text
|
||||
* on disk: the session list reserves its scrollbar gutter so the scrollbar
|
||||
* cannot overlay row trailing content, and reserves it whether or not the list
|
||||
* currently overflows so expanding a group does not shift rows sideways.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const css = readFileSync(fileURLToPath(new URL('../src/client/WorkspaceBrowser.module.css', import.meta.url)), 'utf8')
|
||||
|
||||
/**
|
||||
* Declarations of one class rule, keyed by property with whitespace collapsed.
|
||||
* Declaration order and trailing semicolons are normalized away.
|
||||
* @param className - local class name, without the leading dot.
|
||||
* @returns the rule's declarations, or undefined when no such rule exists.
|
||||
*/
|
||||
function declarations(className: string): Map<string, string> | undefined {
|
||||
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
||||
const match = new RegExp(String.raw`(^|[\s,}])\.${className}\s*\{([^{}]*)\}`).exec(withoutComments)
|
||||
if (match === null) return undefined
|
||||
const found = new Map<string, string>()
|
||||
// The body group is unconditional in the pattern; the fallback only satisfies
|
||||
// noUncheckedIndexedAccess.
|
||||
for (const part of (match[2] ?? '').split(';')) {
|
||||
const colon = part.indexOf(':')
|
||||
if (colon === -1) continue
|
||||
found.set(part.slice(0, colon).trim(), part.slice(colon + 1).trim().replace(/\s+/g, ' '))
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
describe('WorkspaceBrowser.module.css list', () => {
|
||||
const list = declarations('list')
|
||||
|
||||
it('is the scrolling region', () => {
|
||||
expect(list).toBeDefined()
|
||||
expect(list!.get('overflow-y')).toBe('auto')
|
||||
})
|
||||
|
||||
it('reserves the scrollbar gutter unconditionally', () => {
|
||||
// Row trailing content sits flush against the row's right padding, so an
|
||||
// overlay scrollbar covers it. `stable` keeps the reservation when the list
|
||||
// is short enough not to scroll, so expanding a group does not shift rows.
|
||||
expect(list!.get('scrollbar-gutter')).toBe('stable')
|
||||
})
|
||||
})
|
||||
@@ -1,405 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import type { DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { DirectoryBrowser } from '../src/client/DirectoryBrowser.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const HOME = '/home/u'
|
||||
const DOCS = `${HOME}/Documents`
|
||||
const HARNESS = `${DOCS}/harness`
|
||||
|
||||
/** Listing fake over a tiny fixed tree; unknown paths reject like the Host. */
|
||||
function listingFor(path?: string): DirectoryListing {
|
||||
const target = path ?? HOME
|
||||
const tree: Record<string, DirectoryListing> = {
|
||||
[HOME]: {
|
||||
path: HOME,
|
||||
home: HOME,
|
||||
crumbs: [
|
||||
{ name: '/', path: '/', hidden: false },
|
||||
{ name: 'home', path: '/home', hidden: false },
|
||||
{ name: 'u', path: HOME, hidden: false },
|
||||
],
|
||||
entries: [
|
||||
{ name: '.config', path: `${HOME}/.config`, hidden: true },
|
||||
{ name: 'Documents', path: DOCS, hidden: false },
|
||||
],
|
||||
},
|
||||
[DOCS]: {
|
||||
path: DOCS,
|
||||
home: HOME,
|
||||
crumbs: [
|
||||
{ name: '/', path: '/', hidden: false },
|
||||
{ name: 'home', path: '/home', hidden: false },
|
||||
{ name: 'u', path: HOME, hidden: false },
|
||||
{ name: 'Documents', path: DOCS, hidden: false },
|
||||
],
|
||||
entries: [{ name: 'harness', path: HARNESS, hidden: false }],
|
||||
},
|
||||
[HARNESS]: {
|
||||
path: HARNESS,
|
||||
home: HOME,
|
||||
crumbs: [
|
||||
{ name: '/', path: '/', hidden: false },
|
||||
{ name: 'home', path: '/home', hidden: false },
|
||||
{ name: 'u', path: HOME, hidden: false },
|
||||
{ name: 'Documents', path: DOCS, hidden: false },
|
||||
{ name: 'harness', path: HARNESS, hidden: false },
|
||||
],
|
||||
entries: [],
|
||||
},
|
||||
}
|
||||
const found = tree[target]
|
||||
if (found === undefined) {
|
||||
throw new DirectoryBrowseError({ code: 'directory-unreadable', message: `cannot list ${target}`, details: { path: target } })
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
function mount(overrides: Partial<Parameters<typeof DirectoryBrowser>[0]> = {}) {
|
||||
const listDirectory = vi.fn(async (path?: string) => listingFor(path))
|
||||
const createDirectory = vi.fn(async (path: string, name: string) => `${path}/${name}`)
|
||||
const onOpen = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
const props = {
|
||||
open: true,
|
||||
listDirectory,
|
||||
createDirectory,
|
||||
onOpen,
|
||||
onClose,
|
||||
busy: false,
|
||||
t: (key: string, params?: Record<string, unknown>) => (params === undefined ? key : `${key}:${String(params.name)}`),
|
||||
...overrides,
|
||||
}
|
||||
const view = render(<DirectoryBrowser {...props} />)
|
||||
return { view, props, listDirectory, createDirectory, onOpen, onClose }
|
||||
}
|
||||
|
||||
/** The rendered level columns, left-to-right. */
|
||||
function columns(): HTMLElement[] {
|
||||
return screen.getAllByRole('list')
|
||||
}
|
||||
|
||||
describe('DirectoryBrowser', () => {
|
||||
it('opens at the Host home as one wide column, hides hidden entries, and roots the crumbs at Home', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
expect(b.listDirectory).toHaveBeenCalledWith(undefined)
|
||||
expect(columns()).toHaveLength(1)
|
||||
expect(screen.getByRole('listitem').textContent).toBe('Documents')
|
||||
expect(screen.queryByText('.config')).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: '/' })).toBeNull()
|
||||
})
|
||||
|
||||
it('selects a row into the two-pane view: children preview right, crumbs follow the selection', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('listitem'))
|
||||
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||
const [level, preview] = columns()
|
||||
const selectedRow = within(level!).getByRole('listitem')
|
||||
expect(selectedRow.textContent).toBe('Documents')
|
||||
expect(selectedRow.getAttribute('aria-current')).toBe('true')
|
||||
expect(within(preview!).getByRole('listitem').textContent).toBe('harness')
|
||||
expect(b.listDirectory).toHaveBeenLastCalledWith(DOCS)
|
||||
expect(screen.getByRole('button', { name: 'Documents' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('advances one level when a right-column row is picked', async () => {
|
||||
mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('listitem'))
|
||||
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||
fireEvent.click(within(columns()[1]!).getByRole('listitem'))
|
||||
await waitFor(() => { expect(screen.getByRole('button', { name: 'harness' })).toBeTruthy() })
|
||||
const [level] = columns()
|
||||
const selectedRow = within(level!).getByRole('listitem')
|
||||
expect(selectedRow.textContent).toBe('harness')
|
||||
expect(selectedRow.getAttribute('aria-current')).toBe('true')
|
||||
})
|
||||
|
||||
it('jumps back through a crumb into a fresh single-column level', async () => {
|
||||
mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('listitem'))
|
||||
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
|
||||
await waitFor(() => { expect(columns()).toHaveLength(1) })
|
||||
expect(screen.getByRole('listitem').textContent).toBe('Documents')
|
||||
expect(screen.getByRole('listitem').getAttribute('aria-current')).toBeNull()
|
||||
})
|
||||
|
||||
it('opens the selection, else the listed level; Cancel closes; busy freezes Open', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.open' }))
|
||||
expect(b.onOpen).toHaveBeenCalledWith(HOME)
|
||||
fireEvent.click(screen.getByRole('listitem'))
|
||||
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.open' }))
|
||||
expect(b.onOpen).toHaveBeenLastCalledWith(DOCS)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.cancel' }))
|
||||
expect(b.onClose).toHaveBeenCalled()
|
||||
|
||||
const busy = mount({ busy: true })
|
||||
await waitFor(() => { expect(busy.listDirectory).toHaveBeenCalled() })
|
||||
expect(screen.getAllByRole<HTMLButtonElement>('button', { name: 'browser.open' }).at(-1)!.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('edits the path from the crumb bar: Enter navigates, Escape restores, blank is ignored', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
expect(input.value).toBe(HOME)
|
||||
fireEvent.change(input, { target: { value: DOCS } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
|
||||
expect(columns()).toHaveLength(1)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const again = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
fireEvent.change(again, { target: { value: ' ' } })
|
||||
fireEvent.keyDown(again, { key: 'Enter' })
|
||||
expect(b.listDirectory).toHaveBeenCalledTimes(2)
|
||||
fireEvent.keyDown(again, { key: 'Escape' })
|
||||
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
|
||||
})
|
||||
|
||||
it('surfaces an unreadable target as an alert and keeps the edit open for correction', async () => {
|
||||
mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText('browser.editPath')
|
||||
fireEvent.change(input, { target: { value: '/nope' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('cannot list /nope') })
|
||||
expect(screen.getByLabelText('browser.editPath')).toBeTruthy()
|
||||
expect(screen.getByRole('listitem').textContent).toBe('Documents')
|
||||
})
|
||||
|
||||
it('folds non-typed failures into readable text (Error message, String otherwise)', async () => {
|
||||
const b = mount({ listDirectory: vi.fn(async () => { throw new Error('socket down') }) })
|
||||
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('socket down') })
|
||||
b.view.rerender(<DirectoryBrowser {...b.props} open={false} />)
|
||||
const raw = mount({ listDirectory: vi.fn(async () => { throw 'raw failure' }) })
|
||||
await waitFor(() => { expect(screen.getAllByRole('alert').at(-1)!.textContent).toBe('raw failure') })
|
||||
expect(raw.onOpen).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders the full ancestry when the level sits outside the home subtree', async () => {
|
||||
const outside: DirectoryListing = {
|
||||
path: '/srv/data',
|
||||
home: HOME,
|
||||
crumbs: [
|
||||
{ name: '/', path: '/', hidden: false },
|
||||
{ name: 'srv', path: '/srv', hidden: false },
|
||||
{ name: 'data', path: '/srv/data', hidden: false },
|
||||
],
|
||||
entries: [],
|
||||
}
|
||||
mount({ listDirectory: vi.fn(async () => outside) })
|
||||
await waitFor(() => { expect(screen.getByRole('button', { name: 'data' })).toBeTruthy() })
|
||||
expect(screen.getByRole('button', { name: '/' })).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: 'browser.home' })).toBeNull()
|
||||
})
|
||||
|
||||
it('creates a folder through the nested dialog and lands with it selected', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('listitem'))
|
||||
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
|
||||
// The nested dialog names the create target (the selected folder).
|
||||
expect(screen.getByText('browser.createIn:Documents')).toBeTruthy()
|
||||
// The created folder becomes listable (like the real backend after mkdir).
|
||||
b.listDirectory.mockImplementation(async (path?: string) => {
|
||||
if (path === `${DOCS}/fresh`) {
|
||||
return {
|
||||
path: `${DOCS}/fresh`, home: HOME,
|
||||
crumbs: [...listingFor(DOCS).crumbs, { name: 'fresh', path: `${DOCS}/fresh`, hidden: false }],
|
||||
entries: [],
|
||||
}
|
||||
}
|
||||
if (path === DOCS) {
|
||||
const docs = listingFor(DOCS)
|
||||
return { ...docs, entries: [...docs.entries, { name: 'fresh', path: `${DOCS}/fresh`, hidden: false }] }
|
||||
}
|
||||
return listingFor(path)
|
||||
})
|
||||
const input = screen.getByLabelText('browser.folderName')
|
||||
fireEvent.change(input, { target: { value: 'fresh' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
await waitFor(() => { expect(b.createDirectory).toHaveBeenCalledWith(DOCS, 'fresh') })
|
||||
// The create target became the level and the new folder its selection.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Documents' })).toBeTruthy()
|
||||
const level = columns()[0]!
|
||||
const rows = within(level).getAllByRole('listitem')
|
||||
expect(rows.some(row => row.textContent === 'fresh' && row.getAttribute('aria-current') === 'true')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the nested dialog open on a creation failure and cancels cleanly', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
b.createDirectory.mockRejectedValueOnce(
|
||||
new DirectoryBrowseError({ code: 'directory-exists', message: 'taken already', details: { path: `${HOME}/x` } }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
|
||||
expect(screen.getByText('browser.createIn:browser.home')).toBeTruthy()
|
||||
const input = screen.getByLabelText('browser.folderName')
|
||||
// A blank name never submits.
|
||||
fireEvent.change(input, { target: { value: ' ' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(b.createDirectory).not.toHaveBeenCalled()
|
||||
fireEvent.change(input, { target: { value: 'x' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('taken already') })
|
||||
fireEvent.keyDown(screen.getByLabelText('browser.folderName'), { key: 'Escape' })
|
||||
await waitFor(() => { expect(screen.queryByLabelText('browser.folderName')).toBeNull() })
|
||||
|
||||
// The nested Cancel button and the nested mask both close only the child dialog.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
|
||||
const nested = screen.getByRole('dialog', { name: 'browser.newFolder' })
|
||||
fireEvent.click(within(nested).getByRole('button', { name: 'browser.cancel' }))
|
||||
await waitFor(() => { expect(screen.queryByLabelText('browser.folderName')).toBeNull() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
|
||||
const masks = document.querySelectorAll('[aria-hidden="true"]')
|
||||
fireEvent.click(masks[masks.length - 1]!)
|
||||
await waitFor(() => { expect(screen.queryByLabelText('browser.folderName')).toBeNull() })
|
||||
expect(screen.getByRole('dialog', { name: 'browser.title' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('surfaces a selection-preview failure while keeping the selection marked', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
b.listDirectory.mockRejectedValueOnce(
|
||||
new DirectoryBrowseError({ code: 'directory-unreadable', message: 'denied', details: { path: DOCS } }))
|
||||
fireEvent.click(screen.getByRole('listitem'))
|
||||
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') })
|
||||
expect(screen.getByRole('listitem').getAttribute('aria-current')).toBe('true')
|
||||
// No preview column arrived for the failed selection.
|
||||
expect(columns()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('surfaces a post-create relist failure on the browser surface', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
|
||||
// Creation succeeds, but relisting the target fails afterwards.
|
||||
b.listDirectory.mockRejectedValueOnce(new Error('level vanished'))
|
||||
const input = screen.getByLabelText('browser.folderName')
|
||||
fireEvent.change(input, { target: { value: 'fresh' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('level vanished') })
|
||||
})
|
||||
|
||||
it('drops a stale child listing that resolves after a crumb jump', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
let resolveSlow!: (value: DirectoryListing) => void
|
||||
const slow = new Promise<DirectoryListing>((settle) => { resolveSlow = settle })
|
||||
b.listDirectory.mockReturnValueOnce(slow)
|
||||
fireEvent.click(screen.getByRole('listitem'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
|
||||
await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledTimes(3) })
|
||||
await waitFor(() => { expect(columns()).toHaveLength(1) })
|
||||
resolveSlow(listingFor(DOCS))
|
||||
await new Promise(settle => setTimeout(settle, 0))
|
||||
// The superseded selection preview did not reopen the second pane.
|
||||
expect(columns()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('drops a stale failure that rejects after a newer navigation', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
let rejectSlow!: (reason: unknown) => void
|
||||
const slow = new Promise<DirectoryListing>((_settle, fail) => { rejectSlow = fail })
|
||||
b.listDirectory.mockReturnValueOnce(slow)
|
||||
fireEvent.click(screen.getByRole('listitem'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
|
||||
await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledTimes(3) })
|
||||
rejectSlow(new Error('too late to matter'))
|
||||
await new Promise(settle => setTimeout(settle, 0))
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
expect(screen.getByRole('listitem').textContent).toBe('Documents')
|
||||
})
|
||||
|
||||
it('drops a stale navigation failure that rejects after a newer jump', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('listitem'))
|
||||
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||
let rejectSlow!: (reason: unknown) => void
|
||||
const slow = new Promise<DirectoryListing>((_settle, fail) => { rejectSlow = fail })
|
||||
b.listDirectory.mockReturnValueOnce(slow)
|
||||
// A slow crumb jump superseded by a second jump.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Documents' }))
|
||||
await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledTimes(4) })
|
||||
rejectSlow(new Error('late nav failure'))
|
||||
await new Promise(settle => setTimeout(settle, 0))
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
|
||||
it('drops a stale navigation listing that resolves after a newer jump', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('listitem'))
|
||||
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||
let resolveSlow!: (value: DirectoryListing) => void
|
||||
const slow = new Promise<DirectoryListing>((settle) => { resolveSlow = settle })
|
||||
b.listDirectory.mockReturnValueOnce(slow)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Documents' }))
|
||||
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
|
||||
resolveSlow(listingFor(undefined))
|
||||
await new Promise(settle => setTimeout(settle, 0))
|
||||
// The stale home listing did not replace the newer Documents level.
|
||||
expect(screen.getByRole('listitem').textContent).toBe('harness')
|
||||
})
|
||||
|
||||
it('names the create target by its path when the level reports no crumbs', async () => {
|
||||
const bare: DirectoryListing = { path: '/srv/data', home: HOME, crumbs: [], entries: [] }
|
||||
mount({ listDirectory: vi.fn(async () => bare) })
|
||||
await waitFor(() => { expect(screen.getByRole('button', { name: 'browser.newFolder' })).toBeTruthy() })
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'browser.newFolder' }).disabled).toBe(false)
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
|
||||
expect(screen.getByText('browser.createIn:/srv/data')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('refuses to close the nested dialog while the creation is in flight', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
let settleCreate!: (path: string) => void
|
||||
b.createDirectory.mockReturnValueOnce(new Promise<string>((settle) => { settleCreate = settle }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
|
||||
const input = screen.getByLabelText('browser.folderName')
|
||||
fireEvent.change(input, { target: { value: 'slow' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
// Escape and the mask are both inert while creating.
|
||||
fireEvent.keyDown(screen.getByLabelText('browser.folderName'), { key: 'Escape' })
|
||||
const masks = document.querySelectorAll('[aria-hidden="true"]')
|
||||
fireEvent.click(masks[masks.length - 1]!)
|
||||
expect(screen.getByLabelText('browser.folderName')).toBeTruthy()
|
||||
settleCreate(`${HOME}/slow`)
|
||||
await waitFor(() => { expect(screen.queryByLabelText('browser.folderName')).toBeNull() })
|
||||
})
|
||||
|
||||
it('starts back at home on reopen', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('listitem'))
|
||||
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||
b.view.rerender(<DirectoryBrowser {...b.props} open={false} />)
|
||||
b.view.rerender(<DirectoryBrowser {...b.props} open />)
|
||||
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') })
|
||||
expect(columns()).toHaveLength(1)
|
||||
expect(b.listDirectory).toHaveBeenLastCalledWith(undefined)
|
||||
})
|
||||
})
|
||||
@@ -59,11 +59,8 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
|
||||
deleteWorkspace: vi.fn(async () => {}),
|
||||
insertSessionBefore: vi.fn(async () => {}),
|
||||
createWorkspace: vi.fn(async () => workspace('created', [])),
|
||||
directoryPickerKind: vi.fn(async () => 'dialog' as const),
|
||||
pickDirectory: vi.fn(async () => null),
|
||||
listDirectory: vi.fn(async () => ({ path: '/home/u', home: '/home/u', crumbs: [], entries: [] })),
|
||||
createDirectory: vi.fn(async () => '/home/u/new'),
|
||||
t: (key: string) => key,
|
||||
hasDirectoryFlow: () => true,
|
||||
renderSlot: ((_name: string, owner: { open: boolean }) => (owner.open ? <div data-testid="directory-flow" /> : null)) as never,
|
||||
...overrides,
|
||||
}
|
||||
const view = render(<WorkspaceBrowser {...props} />)
|
||||
@@ -267,25 +264,21 @@ describe('WorkspaceBrowser', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('rail create-workspace expands the shell and opens the picker; wide toggles in place', () => {
|
||||
it('rail create-workspace toggles the create-only picker in place, without expanding', () => {
|
||||
const expandSidebar = vi.fn()
|
||||
const b = mount({ wide: false, expandSidebar, useWorkspaces: hook(workspaceState([workspace('alpha', [])])) })
|
||||
mount({ wide: false, expandSidebar, useWorkspaces: hook(workspaceState([workspace('alpha', [])])) })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
expect(expandSidebar).toHaveBeenCalledTimes(1)
|
||||
rerender(b, { wide: true })
|
||||
// The picker menu is open (anchored on the +); picking starts a session.
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'alpha' }))
|
||||
expect(b.props.startSession).toHaveBeenCalledWith(wid('alpha'))
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
// Wide toggle: open and close without expand requests.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
expect(screen.getByRole('menu')).toBeTruthy()
|
||||
expect(expandSidebar).not.toHaveBeenCalled()
|
||||
// createOnly: existing workspaces are not listed, only the create actions.
|
||||
expect(screen.queryByRole('menuitem', { name: 'alpha' })).toBeNull()
|
||||
expect(screen.getByRole('menuitem', { name: 'Open local folder…' })).toBeTruthy()
|
||||
// Toggle: open and close in place.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
expect(expandSidebar).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Escape closes the picker through its own onClose.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
expect(screen.getByRole('menu')).toBeTruthy()
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { WorkspaceCreateError } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { DirectoryPickingInjected } from '../src/client/contract/slots.ts'
|
||||
import type { DirectoryFlowOwnerProps } from '../src/client/contract/slots.ts'
|
||||
import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
@@ -36,34 +36,29 @@ function anchor(): { current: HTMLElement } {
|
||||
return { current: element }
|
||||
}
|
||||
|
||||
/** Minimal picking share for direct renders (kind resolves to dialog). */
|
||||
function pickingShare(): DirectoryPickingInjected {
|
||||
return {
|
||||
directoryPickerKind: vi.fn(async () => 'dialog' as const),
|
||||
pickDirectory: vi.fn(async () => null),
|
||||
listDirectory: vi.fn(async () => ({ path: '/home/u', home: '/home/u', crumbs: [{ name: 'u', path: '/home/u', hidden: false }], entries: [] })),
|
||||
createDirectory: vi.fn(async () => '/home/u/new'),
|
||||
t: (key: string) => key,
|
||||
}
|
||||
/**
|
||||
* Probe occupant of the directory-flow hole: records the latest owner
|
||||
* conversation so tests drive onPicked/onCancel/onError like a composed flow
|
||||
* package would, and renders a marker element while the flow is open.
|
||||
*/
|
||||
function flowProbe() {
|
||||
const probe: { owner: DirectoryFlowOwnerProps | undefined } = { owner: undefined }
|
||||
const renderSlot = ((_name: string, owner: DirectoryFlowOwnerProps) => {
|
||||
probe.owner = owner
|
||||
return owner.open ? <div data-testid="directory-flow" data-busy={owner.busy} /> : null
|
||||
}) as never
|
||||
return { probe, renderSlot }
|
||||
}
|
||||
|
||||
function mount(
|
||||
items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')],
|
||||
createWorkspace = vi.fn(),
|
||||
pickDirectory = vi.fn(async () => null as string | null),
|
||||
picking: Partial<DirectoryPickingInjected> = {},
|
||||
hasDirectoryFlow: () => boolean = () => true,
|
||||
) {
|
||||
const onPick = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
const anchorRef = anchor()
|
||||
const share: DirectoryPickingInjected = {
|
||||
directoryPickerKind: vi.fn(async () => 'dialog' as const),
|
||||
pickDirectory,
|
||||
listDirectory: vi.fn(async () => ({ path: '/home/u', home: '/home/u', crumbs: [{ name: 'u', path: '/home/u', hidden: false }], entries: [] })),
|
||||
createDirectory: vi.fn(async () => '/home/u/new'),
|
||||
t: key => key,
|
||||
...picking,
|
||||
}
|
||||
const { probe, renderSlot } = flowProbe()
|
||||
const renderPicker = (nextItems: readonly WorkspaceView[]) => (
|
||||
<WorkspacePicker
|
||||
open
|
||||
@@ -73,14 +68,15 @@ function mount(
|
||||
onPick={onPick}
|
||||
onClose={onClose}
|
||||
createWorkspace={createWorkspace}
|
||||
{...share}
|
||||
hasDirectoryFlow={hasDirectoryFlow}
|
||||
renderSlot={renderSlot}
|
||||
/>
|
||||
)
|
||||
const view = render(
|
||||
renderPicker(items),
|
||||
)
|
||||
return {
|
||||
view, onPick, onClose, createWorkspace, pickDirectory, share,
|
||||
view, onPick, onClose, createWorkspace, probe,
|
||||
rerenderItems: (nextItems: readonly WorkspaceView[]) => { view.rerender(renderPicker(nextItems)) },
|
||||
}
|
||||
}
|
||||
@@ -89,16 +85,6 @@ function chooseItem(name: 'Open local folder…' | 'Create a new workspace'): vo
|
||||
fireEvent.click(screen.getByRole('menuitem', { name }))
|
||||
}
|
||||
|
||||
/** The local-folder entry disables until the Host's picker kind resolves. */
|
||||
async function chooseLocalFolder(): Promise<void> {
|
||||
await waitFor(() => {
|
||||
const item = screen.getByRole('menuitem', { name: 'Open local folder…' })
|
||||
expect(item).not.toHaveProperty('ariaDisabled', 'true')
|
||||
expect(item.getAttribute('aria-disabled')).not.toBe('true')
|
||||
})
|
||||
chooseItem('Open local folder…')
|
||||
}
|
||||
|
||||
describe('WorkspacePicker', () => {
|
||||
it('lists real Workspaces from useWorkspaces and forwards a selected id', () => {
|
||||
const b = mount()
|
||||
@@ -118,65 +104,71 @@ describe('WorkspacePicker', () => {
|
||||
await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) })
|
||||
})
|
||||
|
||||
it('opens a native directory picker, adopts its path, and selects the returned Workspace', async () => {
|
||||
it('opens the composed directory flow, adopts its picked path, and selects the returned Workspace', async () => {
|
||||
const created = { ...workspace('adopted'), path: '/tmp/project', title: 'project' }
|
||||
const createWorkspace = vi.fn(async () => created)
|
||||
const pickDirectory = vi.fn(async () => '/tmp/project')
|
||||
const b = mount([], createWorkspace, pickDirectory)
|
||||
await chooseLocalFolder()
|
||||
expect(pickDirectory).toHaveBeenCalledOnce()
|
||||
await waitFor(() => { expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' }) })
|
||||
const b = mount([], createWorkspace)
|
||||
expect(screen.queryByTestId('directory-flow')).toBeNull()
|
||||
chooseItem('Open local folder…')
|
||||
expect(b.onClose).toHaveBeenCalled()
|
||||
expect(screen.getByTestId('directory-flow')).toBeTruthy()
|
||||
await act(async () => { b.probe.owner!.onPicked('/tmp/project') })
|
||||
expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' })
|
||||
await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) })
|
||||
// Successful adoption withdraws the flow request.
|
||||
expect(screen.queryByTestId('directory-flow')).toBeNull()
|
||||
})
|
||||
|
||||
it('treats native picker cancellation as a silent no-op', async () => {
|
||||
const b = mount([], vi.fn(), vi.fn(async () => null))
|
||||
await chooseLocalFolder()
|
||||
await waitFor(() => { expect(b.pickDirectory).toHaveBeenCalledOnce() })
|
||||
it('treats flow cancellation as a silent no-op', () => {
|
||||
const b = mount([])
|
||||
chooseItem('Open local folder…')
|
||||
act(() => { b.probe.owner!.onCancel() })
|
||||
expect(screen.queryByTestId('directory-flow')).toBeNull()
|
||||
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)
|
||||
it('shows a name conflict and retries by reopening the flow', async () => {
|
||||
const createWorkspace = vi.fn(async () => {
|
||||
throw new WorkspaceCreateError({
|
||||
code: 'workspace-name-conflict', message: 'project already exists', details: { name: 'project' },
|
||||
})
|
||||
})
|
||||
const b = mount([], createWorkspace, pickDirectory)
|
||||
await chooseLocalFolder()
|
||||
const b = mount([], createWorkspace)
|
||||
chooseItem('Open local folder…')
|
||||
await act(async () => { b.probe.owner!.onPicked('/one/project') })
|
||||
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.')
|
||||
// The failed adoption withdrew the flow; Choose again reopens it.
|
||||
expect(b.probe.owner!.open).toBe(false)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Choose again' }))
|
||||
await waitFor(() => { expect(pickDirectory).toHaveBeenCalledTimes(2) })
|
||||
expect(b.probe.owner!.open).toBe(true)
|
||||
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))
|
||||
await chooseLocalFolder()
|
||||
it('disables the create actions and reports busy to the flow while adopting', async () => {
|
||||
let resolve!: (workspace: WorkspaceView) => void
|
||||
const pending = new Promise<WorkspaceView>((settle) => { resolve = settle })
|
||||
const created = workspace('adopted')
|
||||
const b = mount([], vi.fn(() => pending))
|
||||
chooseItem('Open local folder…')
|
||||
act(() => { b.probe.owner!.onPicked('/tmp/project') })
|
||||
expect(b.probe.owner!.busy).toBe(true)
|
||||
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 })
|
||||
await act(async () => { resolve(created); await pending })
|
||||
expect(b.probe.owner!.busy).toBe(false)
|
||||
})
|
||||
|
||||
it('reports non-Error native picker failures', async () => {
|
||||
const b = mount([], vi.fn(), vi.fn(async () => { throw 'picker unavailable' }))
|
||||
await chooseLocalFolder()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert').textContent).toBe('picker unavailable')
|
||||
})
|
||||
it('shows the flow-reported failure in the folder-error surface', () => {
|
||||
const b = mount([])
|
||||
chooseItem('Open local folder…')
|
||||
act(() => { b.probe.owner!.onError('no chooser installed') })
|
||||
expect(screen.getByRole('alert').textContent).toBe('no chooser installed')
|
||||
expect(screen.queryByTestId('directory-flow')).toBeNull()
|
||||
expect(b.createWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -236,6 +228,8 @@ describe('WorkspacePicker', () => {
|
||||
it('reports non-Error creation failures', async () => {
|
||||
const b = mount([], vi.fn(async () => { throw 'permission denied' }))
|
||||
chooseItem('Create a new workspace')
|
||||
// The name field starts empty (no prefill); a name is required to submit.
|
||||
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'broken' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert').textContent).toBe('Workspace creation failed: permission denied')
|
||||
@@ -243,110 +237,13 @@ describe('WorkspacePicker', () => {
|
||||
expect(b.onPick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the in-app browser under the browse capability and adopts the confirmed directory', async () => {
|
||||
const created = { ...workspace('adopted'), path: '/home/u', title: 'u' }
|
||||
const createWorkspace = vi.fn(async () => created)
|
||||
const b = mount([], createWorkspace, vi.fn(), {
|
||||
directoryPickerKind: vi.fn(async () => 'browse' as const),
|
||||
})
|
||||
await chooseLocalFolder()
|
||||
await waitFor(() => { expect(screen.getByRole('dialog', { name: 'browser.title' })).toBeTruthy() })
|
||||
// The dialog listed home; Open adopts the listed directory.
|
||||
await waitFor(() => { expect(b.share.listDirectory).toHaveBeenCalled() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.open' }))
|
||||
await waitFor(() => { expect(createWorkspace).toHaveBeenCalledWith({ path: '/home/u' }) })
|
||||
await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) })
|
||||
expect(b.pickDirectory).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('routes an adoption conflict from the browser into the folder-error dialog, and Choose again reopens the browser', async () => {
|
||||
const createWorkspace = vi.fn(async () => {
|
||||
throw new WorkspaceCreateError({
|
||||
code: 'workspace-name-conflict', message: 'u already exists', details: { name: 'u' },
|
||||
})
|
||||
})
|
||||
const b = mount([], createWorkspace, vi.fn(), {
|
||||
directoryPickerKind: vi.fn(async () => 'browse' as const),
|
||||
})
|
||||
await chooseLocalFolder()
|
||||
await waitFor(() => { expect(screen.getByRole('dialog', { name: 'browser.title' })).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.open' }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog', { name: 'A workspace with this name already exists' })).toBeTruthy()
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Choose again' }))
|
||||
await waitFor(() => { expect(screen.getByRole('dialog', { name: 'browser.title' })).toBeTruthy() })
|
||||
expect(b.onPick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops a picker-kind failure that lands after unmount', async () => {
|
||||
let rejectKind!: (reason: unknown) => void
|
||||
const pending = new Promise<'dialog'>((_settle, fail) => { rejectKind = fail })
|
||||
const b = mount([], vi.fn(), vi.fn(), { directoryPickerKind: vi.fn(() => pending) })
|
||||
b.view.unmount()
|
||||
await act(async () => {
|
||||
rejectKind(new Error('gone'))
|
||||
await pending.catch(() => {})
|
||||
})
|
||||
expect(b.onPick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops a picker-kind resolution that lands after unmount', async () => {
|
||||
let resolveKind!: (kind: 'dialog') => void
|
||||
const pending = new Promise<'dialog'>((settle) => { resolveKind = settle })
|
||||
const b = mount([], vi.fn(), vi.fn(), { directoryPickerKind: vi.fn(() => pending) })
|
||||
b.view.unmount()
|
||||
await act(async () => {
|
||||
resolveKind('dialog')
|
||||
await pending
|
||||
})
|
||||
expect(b.onPick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a browse adoption failure thrown as a plain string', async () => {
|
||||
const b = mount([], vi.fn(async () => { throw 'disk detached' }), vi.fn(), {
|
||||
directoryPickerKind: vi.fn(async () => 'browse' as const),
|
||||
})
|
||||
await chooseLocalFolder()
|
||||
await waitFor(() => { expect(screen.getByRole('dialog', { name: 'browser.title' })).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.open' }))
|
||||
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('disk detached') })
|
||||
expect(b.onPick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a native picker Error by its message', async () => {
|
||||
const b = mount([], vi.fn(), vi.fn(async () => { throw new Error('no chooser installed') }))
|
||||
await chooseLocalFolder()
|
||||
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('no chooser installed') })
|
||||
expect(b.createWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides the local-folder entry for an unrecognized advertised kind', async () => {
|
||||
mount([], vi.fn(), vi.fn(), {
|
||||
directoryPickerKind: vi.fn(async () => 'electron-native'),
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull()
|
||||
})
|
||||
expect(screen.getByRole('menuitem', { name: 'Create a new workspace' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('hides the local-folder entry when the picker kind is unknown', async () => {
|
||||
mount([], vi.fn(), vi.fn(), {
|
||||
directoryPickerKind: vi.fn(async () => { throw new Error('unreachable host') }),
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull()
|
||||
})
|
||||
expect(screen.getByRole('menuitem', { name: 'Create a new workspace' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('waits to show its menu until an optional anchor is available', () => {
|
||||
const { renderSlot } = flowProbe()
|
||||
render(
|
||||
<WorkspacePicker
|
||||
open useSessions={hook(sessions)} useWorkspaces={hook(workspaceState([]))}
|
||||
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} {...pickingShare()}
|
||||
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
|
||||
hasDirectoryFlow={() => true} renderSlot={renderSlot}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
@@ -356,12 +253,31 @@ describe('WorkspacePicker', () => {
|
||||
const state: WorkspaceListState = {
|
||||
...workspaceState([]), phase: 'pending', state: 'loading', baselinesReady: false,
|
||||
}
|
||||
const { renderSlot } = flowProbe()
|
||||
render(
|
||||
<WorkspacePicker
|
||||
open anchorRef={anchor()} useSessions={hook(sessions)} useWorkspaces={hook(state)}
|
||||
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} {...pickingShare()}
|
||||
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
|
||||
hasDirectoryFlow={() => true} 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)
|
||||
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)
|
||||
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([])
|
||||
expect(screen.getByRole('menuitem', { name: 'Open local folder…' })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user