From 006a6655ee78db607524e1c1f000273f488ff96b Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 17:15:39 +0800 Subject: [PATCH 01/30] feat(web): in-app workspace-directory browser as the shipped picking default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Open-local-folder flow now branches on the Host's advertised picker interaction (host.describe.directoryPicker, read per menu open; unknown kinds hide the entry): dialog keeps the native-chooser flow, and browse opens the new in-app directory browser (figma Harness 802-56979) — breadcrumbs rooted at a localized Home crumb, a click-to-edit path zone right of the crumbs, host-flagged hidden entries filtered client-side, an inline New-folder row, and Open adopting the listed directory through the existing workspace-creation error surface. Dialog copy is localized (ctx.locale, namespace 'workspace'); the plugin re-registers its entries on locale/change. apps/cli flips the composed backend from -dialog to -browse, so the picker works for remote deployments out of the box; -dialog stays a composable alternative. The workspace-management e2e drops its native picker monkey-patch and drives the real modal end-to-end via the path-edit affordance. --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 2 +- ...-28-directory-picker-capability-seam.zh.md | 2 +- apps/cli/cordis.yml | 4 +- apps/cli/package.json | 2 +- apps/web/tests/workspace-management.e2e.ts | 53 ++-- packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 5 +- packages/client/ui-workspace/README.zh.md | 5 +- packages/client/ui-workspace/package.json | 3 + .../src/client/DirectoryBrowser.module.css | 185 +++++++++++++ .../src/client/DirectoryBrowser.tsx | 246 ++++++++++++++++++ .../src/client/WorkspaceBrowser.tsx | 8 + .../src/client/WorkspacePicker.tsx | 86 ++++-- .../ui-workspace/src/client/contract/slots.ts | 33 ++- .../client/ui-workspace/src/client/index.ts | 55 +++- .../client/ui-workspace/tests/apply.spec.ts | 18 +- .../tests/directory-browser.spec.tsx | 160 ++++++++++++ .../tests/workspace-browser.spec.tsx | 4 + .../tests/workspace-picker.spec.tsx | 96 ++++++- packages/client/ui-workspace/tsconfig.json | 3 + pnpm-lock.yaml | 7 +- 22 files changed, 913 insertions(+), 72 deletions(-) create mode 100644 packages/client/ui-workspace/src/client/DirectoryBrowser.module.css create mode 100644 packages/client/ui-workspace/src/client/DirectoryBrowser.tsx create mode 100644 packages/client/ui-workspace/tests/directory-browser.spec.tsx diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index ccef0728d9..6790dafee5 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: 8d9d34e7aed4525b243380a4a90801fe59bfc213 -2026-07-28-directory-picker-capability-seam.zh.md: 282f3905c3551912915088f70247260310f442cb +2026-07-28-directory-picker-capability-seam.md: 2bfb80965cec020bac0995173cf5b44ff6f14e37 +2026-07-28-directory-picker-capability-seam.zh.md: ab303e5eca8ae18413e4d2496a6fae3774630e6a diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index 8d9d34e7ae..2bfb80965c 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -30,7 +30,7 @@ Placement and policy rulings folded into this decision: ## Consequences -- `cordis.yml` chooses the interaction; `apps/cli` currently mounts `-dialog` (unchanged behavior), and the in-app browser PR flips the default to `-browse` with the GUI branching on `describe`. +- `cordis.yml` chooses the interaction; `apps/cli` mounts `-browse` (the in-app browser is the shipped default), the GUI branches on `describe`, and `-dialog` stays a composable alternative for host-display deployments. - The wire gains `host.listDirectory`/`host.createDirectory`, four error codes, and the `describe.directoryPicker` field; the connection fixture serves a deterministic browse tree for keyless assembled tests. - A future interaction (or an Electron `dialog` provider) is one backend package plus a client branch — no gateway surgery. - `ApiProxyDefaults.pickDirectory` (test-only injection) is gone; tests provide a stub `ctx.directoryPicker` like any other service. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index 282f3905c3..ab303e5eca 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -30,7 +30,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick ## 后果 -- `cordis.yml` 决定交互形态;`apps/cli` 当前挂 `-dialog`(行为不变),应用内浏览器 PR 将把默认翻到 `-browse` 并让 GUI 按 `describe` 分支。 +- `cordis.yml` 决定交互形态;`apps/cli` 挂 `-browse`(应用内浏览器为发布默认),GUI 按 `describe` 分支,`-dialog` 作为面向宿主屏幕部署的可组合备选保留。 - 协议新增 `host.listDirectory`/`host.createDirectory`、四个错误码与 `describe.directoryPicker` 字段;connection fixture 提供确定性浏览树供无密钥组装测试使用。 - 未来的新交互(或 Electron 的 `dialog` 提供方)只是一个后端包加一个客户端分支——无需网关手术。 - `ApiProxyDefaults.pickDirectory`(仅测试注入)删除;测试像提供其他服务一样提供 stub `ctx.directoryPicker`。 diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index 1ca28ecdaa..59ca20a802 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -234,9 +234,9 @@ # shares. provider/model are the host default routing — the profile json's # mapping target (user config overrides these engineering defaults). # Directory-picking backend consumed by the gateway's host.* picker RPCs. -# Swap point: mount '-browse' instead for the in-app browser (remote-capable). +# Swap point: mount '-dialog' instead for the native OS chooser (host-display only). - id: directory-picker - name: '@deepseek-ai/dsh-host-directory-picker-dialog' + name: '@deepseek-ai/dsh-host-directory-picker-browse' - id: api-gateway name: '@deepseek-ai/dsh-host-apiproxy' diff --git a/apps/cli/package.json b/apps/cli/package.json index 90b51e0397..c40903de87 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -48,7 +48,7 @@ "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", - "@deepseek-ai/dsh-host-directory-picker-dialog": "workspace:^", + "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index fd18d89087..674969debe 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -30,14 +30,40 @@ describe('web e2e: workspace management (create / rename / flat view / hover car let browser: Browser let page: Page let tripwire: ReturnType - let pickedDirectory: string | null = null + + /** + * Drive the in-app browser to a directory via its path-edit affordance, + * confirm it, and wait for the adoption to settle host-side (workspace + * registered + the flow's New-Session agent up), so later test steps can't + * race the in-flight blank-session attach. + */ + async function openLocalFolder(path: string, options: { waitForAgent?: boolean } = {}): Promise { + const agentsBefore = scaffold.ctx.agents.list().length + await page.getByRole('button', { name: 'Create workspace' }).click() + await page.getByRole('menuitem', { name: 'Open local folder…' }).click() + const dialog = page.getByRole('dialog', { name: '选择工作区目录' }) + await dialog.waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: '编辑路径' }).click() + await dialog.getByLabel('编辑路径').fill(path) + await dialog.getByLabel('编辑路径').press('Enter') + await dialog.getByRole('button', { name: '打开' }).click() + await dialog.waitFor({ state: 'hidden', timeout: 10_000 }) + await expect.poll( + () => scaffold.ctx.workspace.resolveByPath(path), + { timeout: 10_000 }, + ).not.toBeUndefined() + // First adoption births a blank Session+Agent whose workspace attach must + // settle before a test may delete the registration; the reuse path (same + // canonical cwd already has a blank session) creates no agent, so callers + // opt in only where a fresh attach is possible. + if (options.waitForAgent === true) { + await expect.poll(() => scaffold.ctx.agents.list().length, { timeout: 10_000 }) + .toBeGreaterThan(agentsBefore) + } + } beforeAll(async () => { scaffold = await launchWebScaffold({}) - scaffold.ctx.apiProxy.host.pickDirectory = request => Promise.resolve({ - rpcId: request.rpcId, - result: { ok: true, value: { path: pickedDirectory } }, - }) // Seed one cold session (Ungrouped bucket) for the flat view + hover card. const sessionCwd = join(scaffold.workspaceCwd, 'workspace') await mkdir(sessionCwd, { recursive: true }) @@ -137,14 +163,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car collect() }) // Register the scaffold's existing project directory through the real UI. - pickedDirectory = scaffold.workspaceCwd - await page.getByRole('button', { name: 'Create workspace' }).click() - await page.getByRole('menuitem', { name: 'Open local folder…' }).click() - - await expect.poll( - () => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd), - { timeout: 10_000 }, - ).not.toBeUndefined() + await openLocalFolder(scaffold.workspaceCwd, { waitForAgent: true }) const workspace = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd) if (workspace === undefined) throw new Error('GUI did not register the existing project directory') await workspace.attachSession(SessionId(SEED_ID)) @@ -200,9 +219,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car // Re-registering the exact deleted path immediately, without a reload, is // a supported reversible flow. It creates a fresh Workspace id without // re-adopting the retained Session. - pickedDirectory = scaffold.workspaceCwd - await page.getByRole('button', { name: 'Create workspace' }).click() - await page.getByRole('menuitem', { name: 'Open local folder…' }).click() + await openLocalFolder(scaffold.workspaceCwd) await expect.poll( () => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd), { timeout: 10_000 }, @@ -272,9 +289,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car collect() }) - pickedDirectory = oldPath - await page.getByRole('button', { name: 'Create workspace' }).click() - await page.getByRole('menuitem', { name: 'Open local folder…' }).click() + await openLocalFolder(oldPath) await expect.poll( () => scaffold.ctx.workspace.resolveByPath(oldPath), { timeout: 10_000 }, diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 0d78a8d648..2b9868cdc4 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -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: edd6c2f9373d97832def86bb44658d7c1c68dae9 -README.zh.md: f7b73dde953d4294d4d157f479fe932adf1a29c4 +README.md: e478670facaccadd49999a5dfeaac369801036e3 +README.zh.md: 1348b59ac35aaaa103f8653dad33633bbe7792b1 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index edd6c2f937..e478670fac 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -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 delegates to the Host's native single-directory picker, adopts a returned path through the object layer, and selects the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors remain retryable. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. +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. 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,4 +19,5 @@ 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** — fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. +- **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. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index f7b73dde95..1348b59ac3 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -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 的原生单目录选择器,通过对象层接纳返回的路径,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,发生错误后仍可重试。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。 +该选择器通过全局 `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 下。 两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。 @@ -19,4 +19,5 @@ ## 已知限制与暂缓事项 - **没有 Session 删除控件**:现有 Session 菜单行仍仅提供视觉效果;删除 Workspace 注册记录不会删除 Session。 -- **原生文件夹选择依赖本地 Host 载体**:仅使用 fixture(测试前置数据)的部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试。 +- **原生文件夹选择依赖本地 Host 载体**:在 `dialog` 组合下,仅使用 fixture(测试前置数据)的部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试。已发布的默认组合为 `browse`,没有此依赖。 +- **尚无"显示隐藏目录"开关**:Host 打标隐藏条目、浏览器无条件过滤;该开关是延期的纯客户端改动。 diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index c36486d2fd..29d1ecfa36 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -25,6 +25,7 @@ "dshClient": { "inject": [ "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-sidebar" ], @@ -39,6 +40,7 @@ "clsx": "^2.0.0" }, "peerDependencies": { + "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", @@ -47,6 +49,7 @@ "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", diff --git a/packages/client/ui-workspace/src/client/DirectoryBrowser.module.css b/packages/client/ui-workspace/src/client/DirectoryBrowser.module.css new file mode 100644 index 0000000000..b936148cd8 --- /dev/null +++ b/packages/client/ui-workspace/src/client/DirectoryBrowser.module.css @@ -0,0 +1,185 @@ +/* Directory-browser dialog (figma 802-56979). The shared Modal owns the mask, + * card, and title row; this module widens the card and rebuilds the figma + * header/footer separators with bleed margins inside the 24px content column. */ + +.dialog { + width: min(600px, 100%); +} + +/* Breadcrumb bar sits visually inside the header block: bleed to the card + * edges, close the header's 12px bottom pad, draw the l3 separator. */ +.crumbBar { + display: flex; + align-items: center; + gap: 4px; + min-height: 32px; + margin: -12px -24px 0; + padding: 0 24px 12px; + border-bottom: 1px solid var(--dsw-alias-border-l3); +} + +.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: 28px; + 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); +} + +/* One directory level: 28px rows, r6, folder icon + name + enter chevron. */ +.level { + display: flex; + flex-direction: column; + gap: 2px; + margin-top: -4px; + max-height: 320px; + overflow-y: auto; +} + +.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); +} + +.rowIcon { + flex: none; + color: var(--dsw-alias-label-secondary); +} + +.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); +} + +.folderRow { + cursor: default; +} + +.folderInput { + box-sizing: border-box; + flex: 1 1 0; + min-width: 0; + height: 24px; + padding: 0 6px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 6px; + outline: none; + background: transparent; + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-label-primary); +} + +.folderInput::placeholder { + color: var(--dsw-alias-label-caption); +} + +.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: the l3 separator above the action row, New-folder pinned left + * (bleeds across the card; 12px stays below, matching the figma card pad). */ +.footerBar { + display: flex; + align-items: center; + gap: 8px; + width: calc(100% + 48px); + margin: 0 -24px -12px; + padding: 12px 24px; + border-top: 1px solid var(--dsw-alias-border-l3); +} + +.footerGap { + flex: 1 1 0; +} + +.footerAction { + min-width: 72px; +} diff --git a/packages/client/ui-workspace/src/client/DirectoryBrowser.tsx b/packages/client/ui-workspace/src/client/DirectoryBrowser.tsx new file mode 100644 index 0000000000..0301d9e27f --- /dev/null +++ b/packages/client/ui-workspace/src/client/DirectoryBrowser.tsx @@ -0,0 +1,246 @@ +/** + * The in-app workspace-directory browser (figma Harness 802-56979): breadcrumb + * header with a click-to-edit path zone, one navigable directory level, an + * inline New-folder row, and the Cancel/Open footer. 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, 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 + /** Create one child directory under the listed level. */ + createDirectory: (path: string, name: string) => Promise + /** The operator confirmed the currently listed directory. */ + onOpen: (path: string) => void + /** Close without picking (mask, Escape, Cancel). */ + onClose: () => void + /** The owner's confirm is in flight: Open disables, the level 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] +} + +/** + * 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) { + const [listing, setListing] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + // Path-edit state: null = breadcrumb mode; a string = the draft being typed. + const [pathDraft, setPathDraft] = useState(null) + // New-folder state: null = no inline row; a string = the name being typed. + const [folderDraft, setFolderDraft] = useState(null) + const [creatingFolder, setCreatingFolder] = useState(false) + const requestSeq = useRef(0) + + const navigate = useCallback((path?: string) => { + const seq = ++requestSeq.current + setLoading(true) + setError(null) + listDirectory(path).then((next) => { + if (seq !== requestSeq.current) return + setListing(next) + setLoading(false) + setPathDraft(null) + setFolderDraft(null) + }, (reason: unknown) => { + if (seq !== requestSeq.current) return + setLoading(false) + setError(failureText(reason)) + }) + }, [listDirectory]) + + // 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) { + setListing(null) + navigate() + return + } + requestSeq.current += 1 + setError(null) + setPathDraft(null) + setFolderDraft(null) + }, [open, navigate]) + + const confirmFolder = (): void => { + if (listing === null || folderDraft === null || creatingFolder) return + const name = folderDraft.trim() + if (name === '') return + setCreatingFolder(true) + setError(null) + createDirectory(listing.path, name).then(() => { + setCreatingFolder(false) + setFolderDraft(null) + navigate(listing.path) + }, (reason: unknown) => { + setCreatingFolder(false) + setError(failureText(reason)) + }) + } + + // After the hooks: a closed dialog renders nothing and evaluates no copy. + if (!open) return null + + const crumbs = listing === null ? [] : displayCrumbs(listing, t('browser.home')) + + return ( + + + + + + + )} + > +
+ {pathDraft === null + ? ( + <> + {crumbs.map((crumb, index) => ( + + {index > 0 && } + + + ))} + {/* The empty zone right of the crumbs is the path-edit affordance. */} +
+
+ {folderDraft !== null && listing !== null && ( +
+ + { setFolderDraft(event.target.value) }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + confirmFolder() + } + if (event.key === 'Escape') { + event.stopPropagation() + setFolderDraft(null) + } + }} + /> +
+ )} + {listing?.entries.filter(entry => !entry.hidden).map(entry => ( + + ))} + {loading &&
{t('browser.loading')}
} + {error !== null &&
{error}
} +
+
+ ) +} diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index ea1753a63e..f15c8090fe 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -253,7 +253,11 @@ export function WorkspaceBrowser({ deleteWorkspace, insertSessionBefore, createWorkspace, + directoryPickerKind, pickDirectory, + listDirectory, + createDirectory, + t, }: WorkspaceBrowserProps) { const workspaces = useWorkspaces(state => state.items) const groupBy = useStore(s => s.groupBy) @@ -371,7 +375,11 @@ export function WorkspaceBrowser({ anchorRef={wsPlusRef} useWorkspaces={useWorkspaces} createWorkspace={createWorkspace} + directoryPickerKind={directoryPickerKind} pickDirectory={pickDirectory} + listDirectory={listDirectory} + createDirectory={createDirectory} + t={t} onPick={(workspaceId) => { setWsPickerOpen(false) startSession(workspaceId) diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index 4ec84c7d3d..fb7edd815a 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -5,24 +5,25 @@ * slot registration. */ import type { RefObject } from 'react' -import { useCallback, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry, } from '@deepseek-ai/dsh-client-ui-primitives' import { WorkspaceCreateError, - type WorkspaceId, type WorkspaceListState, type WorkspaceView, + type DirectoryPickerKind, type WorkspaceId, type WorkspaceListState, type WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' -import type { WorkspacePickerProps } from './contract/slots.ts' +import type { DirectoryPickingInjected, WorkspacePickerProps } from './contract/slots.ts' +import { DirectoryBrowser } from './DirectoryBrowser.tsx' import css from './WorkspacePicker.module.css' const OPEN_LOCAL_FOLDER = '::open-local-folder' const CREATE_NEW = '::create-new' -type ModalKind = 'create' | 'folder-error' | null +type ModalKind = 'create' | 'folder-error' | 'browse' | null /** Core flow props: the owner supplies popover control and pick semantics. */ -export interface WorkspaceCreateFlowProps { +export interface WorkspaceCreateFlowProps extends DirectoryPickingInjected { /** Popover visibility (anchor button toggle state, owner-local). */ open: boolean /** The anchor button element — the popover's placement anchor. */ @@ -31,8 +32,6 @@ export interface WorkspaceCreateFlowProps { useWorkspaces: (selector: (state: WorkspaceListState) => S) => S /** Create or adopt a real Host Workspace. */ createWorkspace: (input: { name: string } | { path: string }) => Promise - /** Open the Host's native single-directory picker. */ - pickDirectory: () => Promise /** A real Workspace was picked or created. */ onPick: (workspaceId: WorkspaceId) => void /** Close the popover (outside click / Escape / post-pick). */ @@ -49,7 +48,11 @@ export function WorkspaceCreateFlow({ anchorRef, useWorkspaces, createWorkspace, + directoryPickerKind, pickDirectory, + listDirectory, + createDirectory, + t, onPick, onClose, }: WorkspaceCreateFlowProps) { @@ -65,6 +68,21 @@ export function WorkspaceCreateFlow({ const [modalError, setModalError] = useState(null) 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(null) + useEffect(() => { + if (!open) return + let stale = false + directoryPickerKind().then( + // The wire type is the closed two-kind union today; a fetch failure is + // the reachable 'unknown' arm (an unadvertisable host hides the entry). + (kind) => { if (!stale) setPickerKind(kind) }, + () => { if (!stale) setPickerKind('unknown') }, + ) + return () => { stale = true } + }, [open, directoryPickerKind]) const normalizedWorkspaceName = workspaceName.trim() const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== '' && workspaces.some(workspace => workspace.title === normalizedWorkspaceName) @@ -77,17 +95,40 @@ export function WorkspaceCreateFlow({ disabled: pickingFolder, })), ...(workspaces.length > 0 ? [{ type: 'separator' as const, id: 'sep-create' }] : []), - { id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: , disabled: pickingFolder }, + ...(pickerKind === 'unknown' ? [] : [ + { id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: , disabled: pickingFolder || pickerKind === null }, + ]), { id: CREATE_NEW, label: 'Create a new workspace', icon: , disabled: pickingFolder }, ] const closeModal = (): void => { - if (creating) return + if (creating || pickingFolder) return setModalKind(null) setModalError(null) } + /** Adopt a chosen directory as a Workspace; failures land in the folder-error dialog. */ + const adoptDirectory = (path: string): Promise => + createWorkspace({ path }).then((workspace) => { + setModalKind(null) + onPick(workspace.workspaceId) + }).catch((reason: unknown) => { + setFolderConflict( + reason instanceof WorkspaceCreateError + && reason.rpcError.code === 'workspace-name-conflict', + ) + setModalError(reason instanceof Error ? reason.message : String(reason)) + setModalKind('folder-error') + }) + const openLocalFolder = (): void => { + if (pickerKind === 'browse') { + onClose() + setModalError(null) + setFolderConflict(false) + setModalKind('browse') + return + } onClose() setModalKind(null) setModalError(null) @@ -95,13 +136,8 @@ export function WorkspaceCreateFlow({ setPickingFolder(true) void pickDirectory().then(async (path) => { if (path === null) return - const workspace = await createWorkspace({ path }) - onPick(workspace.workspaceId) + await adoptDirectory(path) }).catch((reason: unknown) => { - setFolderConflict( - reason instanceof WorkspaceCreateError - && reason.rpcError.code === 'workspace-name-conflict', - ) setModalError(reason instanceof Error ? reason.message : String(reason)) setModalKind('folder-error') }).finally(() => { setPickingFolder(false) }) @@ -155,6 +191,18 @@ export function WorkspaceCreateFlow({ getAnchorRect={getAnchorRect} /> {open && workspaceSnapshot.phase === 'pending' &&
Loading workspaces…
} + { + setPickingFolder(true) + void adoptDirectory(path).finally(() => { setPickingFolder(false) }) + }} + /> diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index fcdd0e304e..b604c3ece0 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -13,15 +13,38 @@ import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' // 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 { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/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 { 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). + */ +export type DirectoryPickingInjected = { + /** The Host's advertised picker interaction, read per flow open. */ + directoryPickerKind: () => Promise + /** Ask the local Host to open its native single-directory picker (`dialog`). */ + pickDirectory: () => Promise + /** List one directory level with breadcrumb ancestry (`browse`). */ + listDirectory: (path?: string) => Promise + /** Create one child directory under an existing parent (`browse`). */ + createDirectory: (path: string, name: string) => Promise + /** Localized picker copy (this package's locale namespace). */ + t: Translate +} + /** * Browser-private injected share (arrives via the register inject factory). * Data reads use the global framework hooks; these are the Host actions the * browsing region drives. */ -export type WorkspaceBrowserInjected = { +export type WorkspaceBrowserInjected = DirectoryPickingInjected & { /** * Start a New Session in a Workspace: reuse-or-create its blank session * and open it; with no workspace, clear the selection into the New Session @@ -42,8 +65,6 @@ export type WorkspaceBrowserInjected = { insertSessionBefore: (workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId) => Promise /** Explicitly create or adopt a real Workspace before targeting a Session. */ createWorkspace: (input: { name: string } | { path: string }) => Promise - /** Ask the local Host to open its native single-directory picker. */ - pickDirectory: () => Promise } /** Full browser props: shell owner share + viewing store + injected actions. */ @@ -57,11 +78,9 @@ export type WorkspaceBrowserProps = * callback; this callback creates only the real Host Workspace. A type alias * supplies the implicit index signature required by the registry. */ -export type WorkspacePickerInjected = { +export type WorkspacePickerInjected = DirectoryPickingInjected & { /** Explicitly create or adopt a real Workspace before targeting a Session. */ createWorkspace: (input: { name: string } | { path: string }) => Promise - /** Ask the local Host to open its native single-directory picker. */ - pickDirectory: () => Promise } /** diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index f27448f926..4c400109f5 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -7,15 +7,19 @@ * packages/client/AGENTS.md. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' -import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts' +import type { DirectoryPickingInjected, WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts' import { createWorkspaceViewStore } from './stores.ts' import { WorkspaceBrowser } from './WorkspaceBrowser.tsx' import { WorkspacePicker } from './WorkspacePicker.tsx' export type { + 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 @@ -24,7 +28,7 @@ export type { * provides a waitable service. apply therefore registers via * declaration-aware deferral instead of assuming order. */ -export const inject = ['slots', 'sessions', 'workspaces'] +export const inject = ['slots', 'sessions', 'workspaces', 'locale'] /** * Register the browser and picker once their slot declarations are on the @@ -33,6 +37,39 @@ export const inject = ['slots', 'sessions', 'workspaces'] * @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.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.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). @@ -44,11 +81,11 @@ export function apply(ctx: ClientContext): void { await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId) }, createWorkspace: input => ctx.workspaces.create(input), - pickDirectory: () => ctx.workspaces.pickDirectory(), + ...picking(), }) const pickerInjected = (): WorkspacePickerInjected => ({ createWorkspace: input => ctx.workspaces.create(input), - pickDirectory: () => ctx.workspaces.pickDirectory(), + ...picking(), }) // Declaration-aware registration: each owner's declaring apply may activate // after this one (entry activation order is unconstrained), and a register @@ -83,7 +120,17 @@ export function apply(ctx: ClientContext): void { 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() } diff --git a/packages/client/ui-workspace/tests/apply.spec.ts b/packages/client/ui-workspace/tests/apply.spec.ts index 24434f22aa..9e29117916 100644 --- a/packages/client/ui-workspace/tests/apply.spec.ts +++ b/packages/client/ui-workspace/tests/apply.spec.ts @@ -15,16 +15,28 @@ async function bench() { 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, startSession, rename, insertSessionBefore, + create, pickDirectory, directoryPickerKind, listDirectory, createDirectory, + startSession, rename, insertSessionBefore, } as never) ctx.provide('sessions', { open, clear } as never) - return { ctx, slots: ctx.get('slots') as SlotsService, create, pickDirectory, startSession, rename, insertSessionBefore, open, clear } + // 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, + } } type HoleName = 'sidebar.workspaces' | 'conversation.hero.workspace' | 'conversation.empty.workspace' @@ -37,7 +49,7 @@ function declare(slots: SlotsService, ...names: HoleName[]): () => void { describe('ui-workspace apply', () => { it('declares the services it drives', () => { - expect(inject).toEqual(['slots', 'sessions', 'workspaces']) + expect(inject).toEqual(['slots', 'sessions', 'workspaces', 'locale']) }) it('registers browser and pickers for declarations arriving before or after apply', async () => { diff --git a/packages/client/ui-workspace/tests/directory-browser.spec.tsx b/packages/client/ui-workspace/tests/directory-browser.spec.tsx new file mode 100644 index 0000000000..5b58f0667e --- /dev/null +++ b/packages/client/ui-workspace/tests/directory-browser.spec.tsx @@ -0,0 +1,160 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor } 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' + +/** 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 = { + [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: `${HOME}/Documents`, hidden: false }, + ], + }, + [`${HOME}/Documents`]: { + path: `${HOME}/Documents`, + home: HOME, + crumbs: [ + { name: '/', path: '/', hidden: false }, + { name: 'home', path: '/home', hidden: false }, + { name: 'u', path: HOME, hidden: false }, + { name: 'Documents', path: `${HOME}/Documents`, hidden: false }, + ], + entries: [{ name: 'harness', path: `${HOME}/Documents/harness`, hidden: false }], + }, + } + 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[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) => key, + ...overrides, + } + const view = render() + return { view, props, listDirectory, createDirectory, onOpen, onClose } +} + +describe('DirectoryBrowser', () => { + it('opens at the Host home, 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(screen.getByRole('listitem').textContent).toBe('Documents') + expect(screen.queryByText('.config')).toBeNull() + // Inside the home subtree the chain collapses to a localized Home crumb. + expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy() + expect(screen.queryByRole('button', { name: '/' })).toBeNull() + }) + + it('enters a row on click and jumps back through a crumb', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('listitem')) + await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') }) + expect(b.listDirectory).toHaveBeenLastCalledWith(`${HOME}/Documents`) + fireEvent.click(screen.getByRole('button', { name: 'browser.home' })) + await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') }) + }) + + it('edits the path from the crumb bar: Enter navigates, Escape restores', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + expect(input.value).toBe(HOME) + fireEvent.change(input, { target: { value: `${HOME}/Documents` } }) + fireEvent.keyDown(input, { key: 'Enter' }) + await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') }) + // Escape leaves an opened edit without navigating. + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Escape' }) + expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() + expect(screen.getByRole('listitem').textContent).toBe('harness') + }) + + 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('creates a folder inline and refreshes the level; failures land as alerts', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + const input = screen.getByLabelText('browser.newFolder') + fireEvent.change(input, { target: { value: 'fresh' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + await waitFor(() => { expect(b.createDirectory).toHaveBeenCalledWith(HOME, 'fresh') }) + // The level reloads after creation (initial + post-create). + await waitFor(() => { expect(b.listDirectory).toHaveBeenLastCalledWith(HOME) }) + + b.createDirectory.mockRejectedValueOnce( + new DirectoryBrowseError({ code: 'directory-exists', message: 'taken already', details: { path: `${HOME}/x` } })) + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + const retry = screen.getByLabelText('browser.newFolder') + fireEvent.change(retry, { target: { value: 'x' } }) + fireEvent.keyDown(retry, { key: 'Enter' }) + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('taken already') }) + }) + + it('confirms the listed directory through Open, closes through Cancel, and freezes while busy', 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('button', { name: 'browser.cancel' })) + expect(b.onClose).toHaveBeenCalled() + + const busy = mount({ busy: true }) + await waitFor(() => { expect(busy.listDirectory).toHaveBeenCalled() }) + expect(screen.getAllByRole('button', { name: 'browser.open' }).at(-1)!.disabled).toBe(true) + }) + + 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(screen.getByRole('listitem').textContent).toBe('harness') }) + b.view.rerender() + b.view.rerender() + await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') }) + expect(b.listDirectory).toHaveBeenLastCalledWith(undefined) + }) +}) diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index 7b8462f6db..6919571052 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -59,7 +59,11 @@ function mount(overrides: Partial = {}) { 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, ...overrides, } const view = render() diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index 6cad175ff3..d400cf0435 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -5,6 +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 { WorkspacePicker } from '../src/client/WorkspacePicker.tsx' afterEach(cleanup) @@ -35,14 +36,34 @@ 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: [], entries: [] })), + createDirectory: vi.fn(async () => '/home/u/new'), + t: (key: string) => key, + } +} + function mount( items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')], createWorkspace = vi.fn(), pickDirectory = vi.fn(async () => null as string | null), + picking: Partial = {}, ) { 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: [], entries: [] })), + createDirectory: vi.fn(async () => '/home/u/new'), + t: key => key, + ...picking, + } const renderPicker = (nextItems: readonly WorkspaceView[]) => ( ) const view = render( renderPicker(items), ) return { - view, onPick, onClose, createWorkspace, pickDirectory, + view, onPick, onClose, createWorkspace, pickDirectory, share, rerenderItems: (nextItems: readonly WorkspaceView[]) => { view.rerender(renderPicker(nextItems)) }, } } @@ -68,6 +89,16 @@ 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 { + 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() @@ -92,7 +123,7 @@ describe('WorkspacePicker', () => { const createWorkspace = vi.fn(async () => created) const pickDirectory = vi.fn(async () => '/tmp/project') const b = mount([], createWorkspace, pickDirectory) - chooseItem('Open local folder…') + await chooseLocalFolder() expect(pickDirectory).toHaveBeenCalledOnce() await waitFor(() => { expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' }) }) expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' }) @@ -101,7 +132,7 @@ describe('WorkspacePicker', () => { it('treats native picker cancellation as a silent no-op', async () => { const b = mount([], vi.fn(), vi.fn(async () => null)) - chooseItem('Open local folder…') + await chooseLocalFolder() await waitFor(() => { expect(b.pickDirectory).toHaveBeenCalledOnce() }) expect(b.createWorkspace).not.toHaveBeenCalled() expect(b.onPick).not.toHaveBeenCalled() @@ -118,7 +149,7 @@ describe('WorkspacePicker', () => { }) }) const b = mount([], createWorkspace, pickDirectory) - chooseItem('Open local folder…') + await chooseLocalFolder() await waitFor(() => { expect(screen.getByRole('dialog', { name: 'A workspace with this name already exists' })).toBeTruthy() }) @@ -132,7 +163,7 @@ describe('WorkspacePicker', () => { let resolve!: (path: string | null) => void const pending = new Promise((settle) => { resolve = settle }) const b = mount([], vi.fn(), vi.fn(() => pending)) - chooseItem('Open local folder…') + await chooseLocalFolder() expect(screen.getByRole('menuitem', { name: 'Open local folder…' }).disabled).toBe(true) expect(screen.getByRole('menuitem', { name: 'Create a new workspace' }).disabled).toBe(true) fireEvent.click(screen.getByRole('menuitem', { name: 'Open local folder…' })) @@ -142,7 +173,7 @@ describe('WorkspacePicker', () => { it('reports non-Error native picker failures', async () => { const b = mount([], vi.fn(), vi.fn(async () => { throw 'picker unavailable' })) - chooseItem('Open local folder…') + await chooseLocalFolder() await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('picker unavailable') }) @@ -212,11 +243,58 @@ 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('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', () => { render( , ) expect(screen.queryByRole('menu')).toBeNull() @@ -229,7 +307,7 @@ describe('WorkspacePicker', () => { render( , ) expect(screen.getByRole('status').textContent).toBe('Loading workspaces…') diff --git a/packages/client/ui-workspace/tsconfig.json b/packages/client/ui-workspace/tsconfig.json index a2679cccb4..76babb3fea 100644 --- a/packages/client/ui-workspace/tsconfig.json +++ b/packages/client/ui-workspace/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../locale" + }, { "path": "../../../vendor/cordis" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a47a1ea140..b3a1b5fa9f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -212,9 +212,9 @@ importers: '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../../packages/host/apiproxy - '@deepseek-ai/dsh-host-directory-picker-dialog': + '@deepseek-ai/dsh-host-directory-picker-browse': specifier: workspace:^ - version: link:../../packages/host/directory-picker-dialog + version: link:../../packages/host/directory-picker-browse '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../packages/host/webserver @@ -1391,6 +1391,9 @@ importers: specifier: ^2.0.0 version: 2.1.1 devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime From 2b64341bc0d7553c87dbbf5c39d92a6b3eee9f6d Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 17:33:19 +0800 Subject: [PATCH 02/30] fix(web): align the directory browser with the figma frame and merge the seam tip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dialog now owns the figma structure through a new headless Modal mode (mask/card/Escape stay shared): header block with the title and crumbs 8px apart above the l3 separator (no close chrome — the figma frame has none), 16px to the level, and the 12px card bottom. The picker-kind narrowing returns for the merged open describe kind — an unrecognized advertised kind hides the local-folder entry, now covered alongside the stale-navigation failure arm and the unmount races. --- docs/module-graph.md | 11 +- packages/client/ui-primitives/src/Modal.tsx | 38 +++-- .../src/client/DirectoryBrowser.module.css | 52 ++++--- .../src/client/DirectoryBrowser.tsx | 143 +++++++++--------- .../src/client/WorkspacePicker.tsx | 7 +- .../tests/directory-browser.spec.tsx | 82 ++++++++++ .../tests/workspace-picker.spec.tsx | 52 +++++++ 7 files changed, 276 insertions(+), 109 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 03f4bee072..31217705ba 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -292,10 +292,6 @@ flowchart TD pkg_client_ui_slash --> pkg_client_runtime pkg_client_ui_slash --> pkg_client_ui_slots pkg_client_ui_slash --> pkg_invariants - pkg_client_ui_workspace --> pkg_client_runtime - pkg_client_ui_workspace --> pkg_client_ui_primitives - pkg_client_ui_workspace --> pkg_client_ui_slots - pkg_client_ui_workspace --> pkg_invariants pkg_helper --> pkg_brand pkg_helper --> pkg_invariants pkg_helper --> pkg_subprocess @@ -350,6 +346,11 @@ flowchart TD pkg_client_ui_theme --> pkg_client_ui_primitives pkg_client_ui_theme --> pkg_client_ui_slots pkg_client_ui_theme --> pkg_invariants + pkg_client_ui_workspace --> pkg_client_locale + pkg_client_ui_workspace --> pkg_client_runtime + pkg_client_ui_workspace --> pkg_client_ui_primitives + pkg_client_ui_workspace --> pkg_client_ui_slots + pkg_client_ui_workspace --> pkg_invariants pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm @@ -944,7 +945,6 @@ flowchart TD | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | @@ -961,6 +961,7 @@ flowchart TD | [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | diff --git a/packages/client/ui-primitives/src/Modal.tsx b/packages/client/ui-primitives/src/Modal.tsx index 820ff3d7a3..ef790c8b6a 100644 --- a/packages/client/ui-primitives/src/Modal.tsx +++ b/packages/client/ui-primitives/src/Modal.tsx @@ -12,13 +12,16 @@ import css from './Modal.module.css' * Render a centered modal over a blurred page mask. * @param props.open - whether the dialog is showing. * @param props.onClose - Escape or mask click. - * @param props.title - dialog heading. + * @param props.title - dialog heading (aria-label in every mode). * @param props.description - optional supporting sentence under the title. * @param props.children - body (inputs, etc.). * @param props.footer - action row (Cancel / Create). + * @param props.headless - render children directly in the card (no default + * header/close/body chrome) for dialogs whose figma frame owns its own + * header structure; mask, card, Escape, and aria-label remain. * @returns null when closed; otherwise the overlay tree. */ -export function Modal({ open, onClose, title, description, children, footer, className }: { +export function Modal({ open, onClose, title, description, children, footer, className, headless = false }: { open: boolean onClose: () => void title: string @@ -26,6 +29,7 @@ export function Modal({ open, onClose, title, description, children, footer, cla children?: ReactNode footer?: ReactNode className?: string + headless?: boolean }) { useEffect(() => { if (!open) return @@ -47,19 +51,25 @@ export function Modal({ open, onClose, title, description, children, footer, cla aria-modal="true" aria-label={title} > -
-
-

{title}

- -
- {description !== undefined && description !== '' && ( -

{description}

+ {headless + ? children + : ( + <> +
+
+

{title}

+ +
+ {description !== undefined && description !== '' && ( +

{description}

+ )} + {children !== undefined &&
{children}
} +
+ {footer !== undefined &&
{footer}
} + )} - {children !== undefined &&
{children}
} -
- {footer !== undefined &&
{footer}
} ) diff --git a/packages/client/ui-workspace/src/client/DirectoryBrowser.module.css b/packages/client/ui-workspace/src/client/DirectoryBrowser.module.css index b936148cd8..14f3e4f2ca 100644 --- a/packages/client/ui-workspace/src/client/DirectoryBrowser.module.css +++ b/packages/client/ui-workspace/src/client/DirectoryBrowser.module.css @@ -1,21 +1,40 @@ -/* Directory-browser dialog (figma 802-56979). The shared Modal owns the mask, - * card, and title row; this module widens the card and rebuilds the figma - * header/footer separators with bleed margins inside the 24px content column. */ +/* Directory-browser dialog (figma 802-56979). The shared Modal renders + * headless here — mask, card, Escape only — and this module owns the figma + * frame exactly: header (title + crumbs, l3 separator), one directory level, + * and the bordered footer. Card: w600 r24, bottom pad 12, no close chrome. */ -.dialog { +/* Doubled class beats Modal's own .dialog regardless of stylesheet order. */ +.dialog.dialog { width: min(600px, 100%); + padding: 0 0 12px; + gap: 16px; +} + +/* Header block: pl24 pr14 pt22 pb12, 8px between title row and crumb row. */ +.header { + display: flex; + flex-direction: column; + gap: 8px; + 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); } -/* Breadcrumb bar sits visually inside the header block: bleed to the card - * edges, close the header's 12px bottom pad, draw the l3 separator. */ .crumbBar { display: flex; align-items: center; gap: 4px; - min-height: 32px; - margin: -12px -24px 0; - padding: 0 24px 12px; - border-bottom: 1px solid var(--dsw-alias-border-l3); + min-height: 20px; } .crumbSeat { @@ -65,7 +84,7 @@ box-sizing: border-box; flex: 1 1 0; min-width: 0; - height: 28px; + height: 24px; padding: 0 8px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; @@ -76,12 +95,12 @@ color: var(--dsw-alias-label-primary); } -/* One directory level: 28px rows, r6, folder icon + name + enter chevron. */ +/* One directory level: content column pt16 px24, 28px rows with 2px gaps. */ .level { display: flex; flex-direction: column; gap: 2px; - margin-top: -4px; + padding: 16px 24px 0; max-height: 320px; overflow-y: auto; } @@ -164,15 +183,12 @@ color: var(--dsw-alias-state-error-primary); } -/* Footer: the l3 separator above the action row, New-folder pinned left - * (bleeds across the card; 12px stays below, matching the figma card pad). */ +/* Footer: l3 separator on top, pt12 px24, New-folder pinned left. */ .footerBar { display: flex; align-items: center; gap: 8px; - width: calc(100% + 48px); - margin: 0 -24px -12px; - padding: 12px 24px; + padding: 12px 24px 0; border-top: 1px solid var(--dsw-alias-border-l3); } diff --git a/packages/client/ui-workspace/src/client/DirectoryBrowser.tsx b/packages/client/ui-workspace/src/client/DirectoryBrowser.tsx index 0301d9e27f..9454693969 100644 --- a/packages/client/ui-workspace/src/client/DirectoryBrowser.tsx +++ b/packages/client/ui-workspace/src/client/DirectoryBrowser.tsx @@ -100,6 +100,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }, [open, navigate]) const confirmFolder = (): void => { + /* v8 ignore next -- reentry fence: the inline row only renders with a listing and a draft, and the input disables while creating. */ if (listing === null || folderDraft === null || creatingFolder) return const name = folderDraft.trim() if (name === '') return @@ -126,78 +127,61 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, onClose={onClose} title={t('browser.title')} className={clsx(css.dialog)} - footer={( -
- - - - -
- )} + headless > -
- {pathDraft === null - ? ( - <> - {crumbs.map((crumb, index) => ( - - {index > 0 && } - - - ))} - {/* The empty zone right of the crumbs is the path-edit affordance. */} - + + ))} + {/* The empty zone right of the crumbs is the path-edit affordance. */} +
{folderDraft !== null && listing !== null && ( @@ -241,6 +225,27 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, {loading &&
{t('browser.loading')}
} {error !== null &&
{error}
}
+
+ + + + +
) } diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index fb7edd815a..3cacbe44b7 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -76,9 +76,10 @@ export function WorkspaceCreateFlow({ if (!open) return let stale = false directoryPickerKind().then( - // The wire type is the closed two-kind union today; a fetch failure is - // the reachable 'unknown' arm (an unadvertisable host hides the entry). - (kind) => { if (!stale) setPickerKind(kind) }, + // 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 } diff --git a/packages/client/ui-workspace/tests/directory-browser.spec.tsx b/packages/client/ui-workspace/tests/directory-browser.spec.tsx index 5b58f0667e..a78527d5f1 100644 --- a/packages/client/ui-workspace/tests/directory-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/directory-browser.spec.tsx @@ -147,6 +147,88 @@ describe('DirectoryBrowser', () => { expect(screen.getAllByRole('button', { name: 'browser.open' }).at(-1)!.disabled).toBe(true) }) + it('renders the full ancestry when the listing 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('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() + 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('cancels the inline folder row with Escape and ignores a blank name', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + const input = screen.getByLabelText('browser.newFolder') + fireEvent.change(input, { target: { value: ' ' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + expect(b.createDirectory).not.toHaveBeenCalled() + fireEvent.keyDown(input, { key: 'Escape' }) + expect(screen.queryByLabelText('browser.newFolder')).toBeNull() + }) + + it('ignores a blank path draft on Enter', async () => { + const b = 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: ' ' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + // Only the initial home listing ran; the blank draft navigated nowhere. + expect(b.listDirectory).toHaveBeenCalledTimes(1) + }) + + it('drops a stale listing that resolves after a newer navigation', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + // The next navigation (into Documents) hangs; a Home-crumb jump supersedes it. + let resolveSlow!: (value: DirectoryListing) => void + const slow = new Promise((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(screen.getByRole('listitem').textContent).toBe('Documents') }) + resolveSlow(listingFor(`${HOME}/Documents`)) + await new Promise(settle => setTimeout(settle, 0)) + // The stale Documents listing did not clobber the newer Home level. + expect(screen.getByRole('listitem').textContent).toBe('Documents') + }) + + 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((_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)) + // The superseded failure surfaces no alert over the newer level. + expect(screen.queryByRole('alert')).toBeNull() + expect(screen.getByRole('listitem').textContent).toBe('Documents') + }) + it('starts back at home on reopen', async () => { const b = mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index d400cf0435..0c634fbd60 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -280,6 +280,58 @@ describe('WorkspacePicker', () => { 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') }), From 99643e59a180619fde26f2da90725514cc0f8aca Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 18:04:21 +0800 Subject: [PATCH 03/30] feat(web): miller two-pane directory browser per the full figma flow The single-column dialog missed the design's interaction model (figma 813-23126/813-23278, sibling frames of the linked node): selection is not navigation. The browser now opens as one wide level; selecting a row keeps it marked (pill + open-folder accent) and previews its children in a second 256px column across a hairline divider, a right-column pick advances one level, and the breadcrumb follows the selection. New folder becomes the design's nested create dialog ("New folder in ...", Untitled-folder placeholder, Cancel/Create), creating inside the selection and landing with the new folder selected. Open adopts the selection, falling back to the listed level, so the e2e path-edit flow is unchanged. The card is the design's fixed 600x420 with per-column scrolling. --- .../src/client/DirectoryBrowser.module.css | 136 ++++++-- .../src/client/DirectoryBrowser.tsx | 267 ++++++++++---- .../client/ui-workspace/src/client/index.ts | 6 + .../tests/directory-browser.spec.tsx | 327 +++++++++++++----- .../tests/workspace-picker.spec.tsx | 4 +- 5 files changed, 553 insertions(+), 187 deletions(-) diff --git a/packages/client/ui-workspace/src/client/DirectoryBrowser.module.css b/packages/client/ui-workspace/src/client/DirectoryBrowser.module.css index 14f3e4f2ca..f59a74aa7e 100644 --- a/packages/client/ui-workspace/src/client/DirectoryBrowser.module.css +++ b/packages/client/ui-workspace/src/client/DirectoryBrowser.module.css @@ -1,13 +1,14 @@ -/* Directory-browser dialog (figma 802-56979). The shared Modal renders +/* 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: header (title + crumbs, l3 separator), one directory level, - * and the bordered footer. Card: w600 r24, bottom pad 12, no close chrome. */ + * 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%); - padding: 0 0 12px; - gap: 16px; + height: 420px; + padding: 0; + gap: 0; } /* Header block: pl24 pr14 pt22 pb12, 8px between title row and crumb row. */ @@ -15,6 +16,7 @@ display: flex; flex-direction: column; gap: 8px; + flex: none; padding: 22px 14px 12px 24px; border-bottom: 1px solid var(--dsw-alias-border-l3); } @@ -95,16 +97,37 @@ color: var(--dsw-alias-label-primary); } -/* One directory level: content column pt16 px24, 28px rows with 2px gaps. */ -.level { +/* 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; - padding: 16px 24px 0; - max-height: 320px; + 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; @@ -123,11 +146,22 @@ 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; @@ -145,29 +179,6 @@ color: var(--dsw-alias-label-tertiary); } -.folderRow { - cursor: default; -} - -.folderInput { - box-sizing: border-box; - flex: 1 1 0; - min-width: 0; - height: 24px; - padding: 0 6px; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 6px; - outline: none; - background: transparent; - font-size: 13px; - line-height: 20px; - color: var(--dsw-alias-label-primary); -} - -.folderInput::placeholder { - color: var(--dsw-alias-label-caption); -} - .status, .error { padding: 4px; @@ -183,12 +194,14 @@ color: var(--dsw-alias-state-error-primary); } -/* Footer: l3 separator on top, pt12 px24, New-folder pinned left. */ +/* 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; - padding: 12px 24px 0; + flex: none; + padding: 12px 24px 28px; border-top: 1px solid var(--dsw-alias-border-l3); } @@ -199,3 +212,58 @@ .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; +} diff --git a/packages/client/ui-workspace/src/client/DirectoryBrowser.tsx b/packages/client/ui-workspace/src/client/DirectoryBrowser.tsx index 9454693969..28c0281c56 100644 --- a/packages/client/ui-workspace/src/client/DirectoryBrowser.tsx +++ b/packages/client/ui-workspace/src/client/DirectoryBrowser.tsx @@ -1,15 +1,21 @@ /** - * The in-app workspace-directory browser (figma Harness 802-56979): breadcrumb - * header with a click-to-edit path zone, one navigable directory level, an - * inline New-folder row, and the Cancel/Open footer. 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). + * 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, IconPlusOutline16, Modal, + 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' @@ -22,13 +28,13 @@ export interface DirectoryBrowserProps { open: boolean /** List one directory level (absent path = the Host home directory). */ listDirectory: (path?: string) => Promise - /** Create one child directory under the listed level. */ + /** Create one child directory under an existing parent. */ createDirectory: (path: string, name: string) => Promise - /** The operator confirmed the currently listed directory. */ + /** 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 level freezes. */ + /** The owner's confirm is in flight: Open disables, the view freezes. */ busy: boolean /** Localized copy. */ t: Translate @@ -52,32 +58,73 @@ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryE 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 ( +
+ {entries.filter(entry => !entry.hidden).map((entry) => { + const selected = entry.path === selectedPath + return ( + + ) + })} +
+ ) +} + /** * 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) { - const [listing, setListing] = useState(null) + // 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(null) + const [selected, setSelected] = useState(null) + const [child, setChild] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) // Path-edit state: null = breadcrumb mode; a string = the draft being typed. const [pathDraft, setPathDraft] = useState(null) - // New-folder state: null = no inline row; a string = the name being typed. + // Create-folder state: null = closed; a string = the nested dialog's draft. const [folderDraft, setFolderDraft] = useState(null) const [creatingFolder, setCreatingFolder] = useState(false) + const [createError, setCreateError] = useState(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 - setListing(next) + setParent(next) + setSelected(null) + setChild(null) setLoading(false) setPathDraft(null) - setFolderDraft(null) }, (reason: unknown) => { if (seq !== requestSeq.current) return setLoading(false) @@ -85,11 +132,39 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }) }, [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) { - setListing(null) + setParent(null) + setSelected(null) + setChild(null) navigate() return } @@ -97,29 +172,56 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, setError(null) setPathDraft(null) setFolderDraft(null) + setCreateError(null) }, [open, navigate]) - const confirmFolder = (): void => { - /* v8 ignore next -- reentry fence: the inline row only renders with a listing and a draft, and the input disables while creating. */ - if (listing === null || folderDraft === null || creatingFolder) return + /** 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) - setError(null) - createDirectory(listing.path, name).then(() => { + setCreateError(null) + createDirectory(targetPath, name).then((createdPath) => { setCreatingFolder(false) setFolderDraft(null) - navigate(listing.path) + // 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) => { + // Same seq fence as navigate/select; the nested dialog blocks + // superseding input during this relist. + /* v8 ignore next */ + if (seq !== requestSeq.current) return + setParent(level) + setLoading(false) + select({ name, path: createdPath, hidden: false }) + }, (reason: unknown) => { + // Same seq fence as navigate/select; the nested dialog blocks + // superseding input during this relist. + /* v8 ignore next */ + if (seq !== requestSeq.current) return + setLoading(false) + setError(failureText(reason)) + }) }, (reason: unknown) => { setCreatingFolder(false) - setError(failureText(reason)) + setCreateError(failureText(reason)) }) } // After the hooks: a closed dialog renders nothing and evaluates no copy. if (!open) return null - const crumbs = listing === null ? [] : displayCrumbs(listing, t('browser.home')) + const crumbSource = child ?? parent + const crumbs = crumbSource === null ? [] : displayCrumbs(crumbSource, t('browser.home')) + const twoPane = selected !== null return ( { if (listing !== null) setPathDraft(listing.path) }} + 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) }} /> ) @@ -183,45 +285,26 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, )} -
- {folderDraft !== null && listing !== null && ( -
- - { setFolderDraft(event.target.value) }} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault() - confirmFolder() - } - if (event.key === 'Escape') { - event.stopPropagation() - setFolderDraft(null) - } - }} - /> -
+
+ {parent !== null && ( + + )} + {twoPane && } + {twoPane && child !== null && ( + )} - {listing?.entries.filter(entry => !entry.hidden).map(entry => ( - - ))} {loading &&
{t('browser.loading')}
} {error !== null &&
{error}
}
@@ -229,8 +312,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, @@ -239,13 +325,56 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
+ {/* Nested create dialog (figma 813:23278): names one folder inside the target. */} + { if (!creatingFolder) setFolderDraft(null) }} + title={t('browser.newFolder')} + className={clsx(css.createDialog)} + headless + > +
+

{t('browser.newFolder')}

+

{t('browser.createIn', { name: targetName })}

+ { 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 &&
{createError}
} +
+ + +
+
+
) } diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 4c400109f5..91c62b8fa8 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -44,6 +44,9 @@ export function apply(ctx: ClientContext): void { 'browser.home': '主目录', 'browser.newFolder': '新建文件夹', 'browser.folderName': '文件夹名称', + 'browser.createIn': '在"{name}"中新建文件夹', + 'browser.untitledFolder': '未命名文件夹', + 'browser.create': '创建', 'browser.cancel': '取消', 'browser.open': '打开', 'browser.editPath': '编辑路径', @@ -54,6 +57,9 @@ export function apply(ctx: ClientContext): void { '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', diff --git a/packages/client/ui-workspace/tests/directory-browser.spec.tsx b/packages/client/ui-workspace/tests/directory-browser.spec.tsx index a78527d5f1..28c25b367f 100644 --- a/packages/client/ui-workspace/tests/directory-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/directory-browser.spec.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +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' @@ -8,6 +8,8 @@ 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 { @@ -23,19 +25,31 @@ function listingFor(path?: string): DirectoryListing { ], entries: [ { name: '.config', path: `${HOME}/.config`, hidden: true }, - { name: 'Documents', path: `${HOME}/Documents`, hidden: false }, + { name: 'Documents', path: DOCS, hidden: false }, ], }, - [`${HOME}/Documents`]: { - path: `${HOME}/Documents`, + [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: `${HOME}/Documents`, hidden: false }, + { name: 'Documents', path: DOCS, hidden: false }, ], - entries: [{ name: 'harness', path: `${HOME}/Documents/harness`, 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] @@ -57,49 +71,102 @@ function mount(overrides: Partial[0]> = {}) onOpen, onClose, busy: false, - t: (key: string) => key, + t: (key: string, params?: Record) => (params === undefined ? key : `${key}:${String(params.name)}`), ...overrides, } const view = render() 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, hides hidden entries, and roots the crumbs at Home', async () => { + 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() - // Inside the home subtree the chain collapses to a localized Home crumb. expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy() expect(screen.queryByRole('button', { name: '/' })).toBeNull() }) - it('enters a row on click and jumps back through a crumb', async () => { + 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(screen.getByRole('listitem').textContent).toBe('harness') }) - expect(b.listDirectory).toHaveBeenLastCalledWith(`${HOME}/Documents`) - fireEvent.click(screen.getByRole('button', { name: 'browser.home' })) - await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') }) + 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('edits the path from the crumb bar: Enter navigates, Escape restores', async () => { + 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('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('browser.editPath') expect(input.value).toBe(HOME) - fireEvent.change(input, { target: { value: `${HOME}/Documents` } }) + fireEvent.change(input, { target: { value: DOCS } }) fireEvent.keyDown(input, { key: 'Enter' }) await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') }) - // Escape leaves an opened edit without navigating. + expect(columns()).toHaveLength(1) fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) - fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Escape' }) + const again = screen.getByLabelText('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() - expect(screen.getByRole('listitem').textContent).toBe('harness') }) it('surfaces an unreadable target as an alert and keeps the edit open for correction', async () => { @@ -114,40 +181,16 @@ describe('DirectoryBrowser', () => { expect(screen.getByRole('listitem').textContent).toBe('Documents') }) - it('creates a folder inline and refreshes the level; failures land as alerts', async () => { - const b = mount() - await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) - fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) - const input = screen.getByLabelText('browser.newFolder') - fireEvent.change(input, { target: { value: 'fresh' } }) - fireEvent.keyDown(input, { key: 'Enter' }) - await waitFor(() => { expect(b.createDirectory).toHaveBeenCalledWith(HOME, 'fresh') }) - // The level reloads after creation (initial + post-create). - await waitFor(() => { expect(b.listDirectory).toHaveBeenLastCalledWith(HOME) }) - - b.createDirectory.mockRejectedValueOnce( - new DirectoryBrowseError({ code: 'directory-exists', message: 'taken already', details: { path: `${HOME}/x` } })) - fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) - const retry = screen.getByLabelText('browser.newFolder') - fireEvent.change(retry, { target: { value: 'x' } }) - fireEvent.keyDown(retry, { key: 'Enter' }) - await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('taken already') }) + 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() + 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('confirms the listed directory through Open, closes through Cancel, and freezes while busy', 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('button', { name: 'browser.cancel' })) - expect(b.onClose).toHaveBeenCalled() - - const busy = mount({ busy: true }) - await waitFor(() => { expect(busy.listDirectory).toHaveBeenCalled() }) - expect(screen.getAllByRole('button', { name: 'browser.open' }).at(-1)!.disabled).toBe(true) - }) - - it('renders the full ancestry when the listing sits outside the home subtree', async () => { + it('renders the full ancestry when the level sits outside the home subtree', async () => { const outside: DirectoryListing = { path: '/srv/data', home: HOME, @@ -164,53 +207,110 @@ describe('DirectoryBrowser', () => { expect(screen.queryByRole('button', { name: 'browser.home' })).toBeNull() }) - 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() - 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('cancels the inline folder row with Escape and ignores a blank name', async () => { + 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' })) - const input = screen.getByLabelText('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.keyDown(input, { key: 'Escape' }) - expect(screen.queryByLabelText('browser.newFolder')).toBeNull() - }) - - it('ignores a blank path draft on Enter', async () => { - const b = 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: ' ' } }) + fireEvent.change(input, { target: { value: 'x' } }) fireEvent.keyDown(input, { key: 'Enter' }) - // Only the initial home listing ran; the blank draft navigated nowhere. - expect(b.listDirectory).toHaveBeenCalledTimes(1) + 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('drops a stale listing that resolves after a newer navigation', async () => { + 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() }) - // The next navigation (into Documents) hangs; a Home-crumb jump supersedes it. let resolveSlow!: (value: DirectoryListing) => void const slow = new Promise((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(screen.getByRole('listitem').textContent).toBe('Documents') }) - resolveSlow(listingFor(`${HOME}/Documents`)) + await waitFor(() => { expect(columns()).toHaveLength(1) }) + resolveSlow(listingFor(DOCS)) await new Promise(settle => setTimeout(settle, 0)) - // The stale Documents listing did not clobber the newer Home level. - expect(screen.getByRole('listitem').textContent).toBe('Documents') + // 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 () => { @@ -224,19 +324,82 @@ describe('DirectoryBrowser', () => { await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledTimes(3) }) rejectSlow(new Error('too late to matter')) await new Promise(settle => setTimeout(settle, 0)) - // The superseded failure surfaces no alert over the newer level. 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((_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((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('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((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(screen.getByRole('listitem').textContent).toBe('harness') }) + await waitFor(() => { expect(columns()).toHaveLength(2) }) b.view.rerender() b.view.rerender() await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') }) + expect(columns()).toHaveLength(1) expect(b.listDirectory).toHaveBeenLastCalledWith(undefined) }) }) diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index 0c634fbd60..2d7531a710 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -41,7 +41,7 @@ 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: [], entries: [] })), + 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, } @@ -59,7 +59,7 @@ function mount( const share: DirectoryPickingInjected = { directoryPickerKind: vi.fn(async () => 'dialog' as const), pickDirectory, - listDirectory: vi.fn(async () => ({ path: '/home/u', home: '/home/u', crumbs: [], entries: [] })), + 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, From 5f3c53d8351bb616b27a0b1ef94b4e1a6df19796 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 18:04:50 +0800 Subject: [PATCH 04/30] style(web): keep the v8 ignore reasons inline per the invariant rule --- .../client/ui-workspace/src/client/DirectoryBrowser.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/client/ui-workspace/src/client/DirectoryBrowser.tsx b/packages/client/ui-workspace/src/client/DirectoryBrowser.tsx index 28c0281c56..49cbff9fb3 100644 --- a/packages/client/ui-workspace/src/client/DirectoryBrowser.tsx +++ b/packages/client/ui-workspace/src/client/DirectoryBrowser.tsx @@ -195,17 +195,13 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const seq = ++requestSeq.current setLoading(true) listDirectory(targetPath).then((level) => { - // Same seq fence as navigate/select; the nested dialog blocks - // superseding input during this relist. - /* v8 ignore next */ + /* 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) => { - // Same seq fence as navigate/select; the nested dialog blocks - // superseding input during this relist. - /* v8 ignore next */ + /* v8 ignore next -- same fence as navigate/select; the modal blocks superseding input */ if (seq !== requestSeq.current) return setLoading(false) setError(failureText(reason)) From 4822622cb726cb2e08454c5acdfd18c249166481 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 22:22:14 +0800 Subject: [PATCH 05/30] feat(host,client): ship the in-app directory browser as the browse package's client half MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit directory-picker-browse becomes dual-face: its browser half fills ui-workspace's two directory-flow holes with the Select Workspace Directory dialog (figma Harness 813-23126 family — Miller two-column view, breadcrumb with click-to-edit path zone, nested New-folder dialog), driving the node half's host.listDirectory/host.createDirectory and owning its locale namespace (directory-browser, zh default / en). The dialog moves here from ui-workspace wholesale — the trigger surfaces keep only the flow-hole owner conversation. apps/cli flips its one directory-picker row -native -> -browse, swapping the host backend and the client interaction together; picking now works for remote deployments out of the box. The keyless workspace-flow snapshot boots the browse bundle and drives menu -> dialog -> Documents -> project -> Open against the fixture tree. --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 2 +- ...-28-directory-picker-capability-seam.zh.md | 2 +- apps/cli/cordis.yml | 4 +- apps/cli/package.json | 2 +- apps/web/tests/workspace-flow.snapshot.ts | 19 +- docs/module-graph.md | 30 +- packages/client/ui-workspace/package.json | 3 - packages/host/README.i18n.yaml | 4 +- packages/host/README.md | 2 +- packages/host/README.zh.md | 2 +- .../directory-picker-browse/README.i18n.yaml | 4 +- .../host/directory-picker-browse/README.md | 2 + .../host/directory-picker-browse/README.zh.md | 2 + .../host/directory-picker-browse/package.json | 33 +- .../src/client/DirectoryBrowser.module.css | 269 ++++++++++++ .../src/client/DirectoryBrowser.tsx | 376 ++++++++++++++++ .../src/client/index.ts | 109 +++++ .../src/css-modules.d.ts | 6 + .../tests/client-flow.spec.tsx | 127 ++++++ .../tests/directory-browser.spec.tsx | 405 ++++++++++++++++++ .../directory-picker-browse/tsconfig.json | 28 +- .../directory-picker-browse/tsdown.config.ts | 3 + pnpm-lock.yaml | 31 +- tsconfig.client.json | 3 + tsconfig.host.json | 2 +- 26 files changed, 1421 insertions(+), 53 deletions(-) create mode 100644 packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css create mode 100644 packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx create mode 100644 packages/host/directory-picker-browse/src/client/index.ts create mode 100644 packages/host/directory-picker-browse/src/css-modules.d.ts create mode 100644 packages/host/directory-picker-browse/tests/client-flow.spec.tsx create mode 100644 packages/host/directory-picker-browse/tests/directory-browser.spec.tsx create mode 100644 packages/host/directory-picker-browse/tsdown.config.ts diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index b49544ddfb..8f618cc681 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: ce5a2695345e29db5739df206965720559783ce3 -2026-07-28-directory-picker-capability-seam.zh.md: 5b73c3c48493d4f178a523db19bc124eda9c7cca +2026-07-28-directory-picker-capability-seam.md: df31b43e0678815847a48844b69928bdd1bb5175 +2026-07-28-directory-picker-capability-seam.zh.md: 0840c020d4826c74583f100251b6f3352a3b4b4a diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index ce5a269534..df31b43e06 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -32,7 +32,7 @@ Placement and policy rulings folded into this decision: ## Consequences -- `cordis.yml` chooses the interaction; `apps/cli` currently mounts `-native` (unchanged behavior). The in-app browser PR flips that one row to `-browse`, swapping backend and UI together. +- `cordis.yml` chooses the interaction; `apps/cli` mounts `-browse` (the shipped default — remote-capable picking out of the box), one row having swapped backend and UI together; `-native` remains the host-display alternative. - The wire gains `host.listDirectory`/`host.createDirectory` and four error codes; the connection fixture serves a deterministic browse tree and a deterministic `pickDirectory` path for keyless assembled tests. - A future interaction (or an Electron provider of the `native` interaction) is one dual-face backend package — no gateway surgery, no ui-workspace edits. - `ApiProxyDefaults.pickDirectory` (test-only injection) is gone; tests provide a stub `ctx.directoryPicker` like any other service. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index 5b73c3c484..0840c020d4 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -32,7 +32,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick ## 后果 -- `cordis.yml` 决定交互形态;`apps/cli` 当前挂 `-native`(行为不变)。应用内浏览器 PR 只翻这一行到 `-browse`,后端与 UI 同时切换。 +- `cordis.yml` 决定交互形态;`apps/cli` 挂 `-browse`(随附默认——开箱即得可远程的选取),一行同时切换了后端与 UI;`-native` 仍是宿主屏幕方案。 - 协议新增 `host.listDirectory`/`host.createDirectory` 与四个错误码;connection fixture 提供确定性浏览树与确定性 `pickDirectory` 路径供无密钥组装测试使用。 - 未来的新交互(或提供 `native` 交互的 Electron 实现)只是一个双面后端包——无需网关手术,也不动 ui-workspace。 - `ApiProxyDefaults.pickDirectory`(仅测试注入)删除;测试像提供其他服务一样提供 stub `ctx.directoryPicker`。 diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index c6058890d8..361619b38b 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -243,9 +243,9 @@ # Directory-picking package, dual-face: the node half serves the gateway's # host.* picker RPCs, the browser half fills ui-workspace's directory-flow # slots — one row composes the whole interaction. Swap point: mount -# '-browse' instead for the in-app browser (remote-capable). +# '-native' instead for the host-display OS chooser. - id: directory-picker - name: '@deepseek-ai/dsh-host-directory-picker-native' + name: '@deepseek-ai/dsh-host-directory-picker-browse' - id: api-gateway name: '@deepseek-ai/dsh-host-apiproxy' diff --git a/apps/cli/package.json b/apps/cli/package.json index cc24c1fd88..e753e5ba3d 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -48,7 +48,7 @@ "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", - "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", + "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts index ca98e7b176..d7cb66e0af 100644 --- a/apps/web/tests/workspace-flow.snapshot.ts +++ b/apps/web/tests/workspace-flow.snapshot.ts @@ -40,11 +40,11 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ // Dual-face host package: its browser half fills the directory-flow holes // (the same composition row apps/cli mounts for the node-side backend). { - id: '@deepseek-ai/dsh-host-directory-picker-native', - dir: '../host/directory-picker-native', - url: '/plugins/directory-picker-native.js', + id: '@deepseek-ai/dsh-host-directory-picker-browse', + dir: '../host/directory-picker-browse', + url: '/plugins/directory-picker-browse.js', rev: 'fx', - inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-workspace'], + inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-workspace', '@deepseek-ai/dsh-client-locale'], }, ] @@ -183,7 +183,7 @@ it('locks the composer in the New Session view state until a Workspace is chosen `) }) -it('adopts a directory through the composed native flow and lands in its blank session', async () => { +it('adopts a directory through the composed in-app browse flow and lands in its blank session', async () => { boot('?fixture=empty') await findLockedComposer() @@ -194,9 +194,12 @@ it('adopts a directory through the composed native flow and lands in its blank s expect(within(menu).getAllByRole('menuitem').map(item => visibleText(item))) .toEqual(['Open local folder…', 'Create a new workspace']) fireEvent.click(within(menu).getByRole('menuitem', { name: 'Open local folder…' })) - // The renderless native flow drives the fixture's deterministic pick and - // the owner adopts the returned path into a real Workspace. - await act(async () => {}) + // The browse occupant renders the Select Workspace Directory dialog at the + // fixture home; select Documents, advance into project, and adopt it. + const dialog = await screen.findByRole('dialog', { name: '选择工作区目录' }) + fireEvent.click(await within(dialog).findByRole('listitem', { name: /Documents/ })) + fireEvent.click(await within(dialog).findByRole('listitem', { name: /^project/ })) + fireEvent.click(within(dialog).getByRole('button', { name: '打开' })) await findHeroComposer() await waitFor(() => { expect(visibleText(screen.getByRole('tree', { name: 'Sessions' }))).toContain('project') diff --git a/docs/module-graph.md b/docs/module-graph.md index 320c6c85e4..1ff7428fed 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -266,7 +266,6 @@ flowchart TD pkg_jsonrpc_demo --> pkg_invariants pkg_host_apiproxy --> pkg_invariants pkg_host_directory_picker --> pkg_invariants - pkg_host_directory_picker_browse --> pkg_invariants pkg_host_webserver --> pkg_invariants pkg_storage --> pkg_invariants pkg_subprocess --> pkg_invariants @@ -296,10 +295,6 @@ flowchart TD pkg_client_ui_slash --> pkg_client_runtime pkg_client_ui_slash --> pkg_client_ui_slots pkg_client_ui_slash --> pkg_invariants - pkg_client_ui_workspace --> pkg_client_runtime - pkg_client_ui_workspace --> pkg_client_ui_primitives - pkg_client_ui_workspace --> pkg_client_ui_slots - pkg_client_ui_workspace --> pkg_invariants pkg_helper --> pkg_brand pkg_helper --> pkg_invariants pkg_helper --> pkg_subprocess @@ -354,10 +349,11 @@ flowchart TD pkg_client_ui_theme --> pkg_client_ui_primitives pkg_client_ui_theme --> pkg_client_ui_slots pkg_client_ui_theme --> pkg_invariants - pkg_host_directory_picker_native --> pkg_client_runtime - pkg_host_directory_picker_native --> pkg_client_ui_slots - pkg_host_directory_picker_native --> pkg_client_ui_workspace - pkg_host_directory_picker_native --> pkg_invariants + pkg_client_ui_workspace --> pkg_client_locale + pkg_client_ui_workspace --> pkg_client_runtime + pkg_client_ui_workspace --> pkg_client_ui_primitives + pkg_client_ui_workspace --> pkg_client_ui_slots + pkg_client_ui_workspace --> pkg_invariants pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm @@ -422,6 +418,16 @@ flowchart TD pkg_code_runtime_worker --> pkg_invariants pkg_code_runtime_worker --> pkg_session pkg_code_runtime_worker --> pkg_timeout + pkg_host_directory_picker_browse --> pkg_client_locale + pkg_host_directory_picker_browse --> pkg_client_runtime + pkg_host_directory_picker_browse --> pkg_client_ui_primitives + pkg_host_directory_picker_browse --> pkg_client_ui_slots + pkg_host_directory_picker_browse --> pkg_client_ui_workspace + pkg_host_directory_picker_browse --> pkg_invariants + pkg_host_directory_picker_native --> pkg_client_runtime + pkg_host_directory_picker_native --> pkg_client_ui_slots + pkg_host_directory_picker_native --> pkg_client_ui_workspace + pkg_host_directory_picker_native --> pkg_invariants pkg_lsp_local --> pkg_brand pkg_lsp_local --> pkg_invariants pkg_lsp_local --> pkg_llm @@ -946,7 +952,6 @@ flowchart TD | [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/support/invariants) | | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) | | [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/support/invariants) | -| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`invariants`](../packages/support/invariants) | | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) | | [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`invariants`](../packages/support/invariants) | @@ -958,7 +963,6 @@ flowchart TD | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | @@ -975,7 +979,7 @@ flowchart TD | [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -995,6 +999,8 @@ flowchart TD | [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | +| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | +| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index 29d1ecfa36..c36486d2fd 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -25,7 +25,6 @@ "dshClient": { "inject": [ "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-sidebar" ], @@ -40,7 +39,6 @@ "clsx": "^2.0.0" }, "peerDependencies": { - "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", @@ -49,7 +47,6 @@ "react": "^18.2.0" }, "devDependencies": { - "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", diff --git a/packages/host/README.i18n.yaml b/packages/host/README.i18n.yaml index 9a6deda327..afc0e2a695 100644 --- a/packages/host/README.i18n.yaml +++ b/packages/host/README.i18n.yaml @@ -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/host/README.md -README.md: 7df0ecc4a362be1149188d133233307b1fc48c8a -README.zh.md: 90d5ea2b0947d2cff9ba06e89b6225b39dad7fce +README.md: d44770f70be16c12f44b78155089e092a3e9bba0 +README.zh.md: 2b6878b08be6489dcd510a0a0e0f0e833c2a8014 diff --git a/packages/host/README.md b/packages/host/README.md index 7df0ecc4a3..d44770f70b 100644 --- a/packages/host/README.md +++ b/packages/host/README.md @@ -10,6 +10,6 @@ The host side of the dsh web GUI: the API gateway every client shape shares, and | `webserver/` | Plain HTTP route-registration carrier: `node:http` server listening on activation; routes register as named `exact`/`prefix` handlers | `ctx.httpServer` | | `directory-picker/` | Workspace-directory picking seam: discriminated `native`/`browse` capability the gateway's picker RPCs delegate to | `ctx.directoryPicker` | | `directory-picker-native/` | Dual-face native interaction: OS-chooser backend (osascript / PowerShell / Zenity+KDialog, host-display only) + the browser half filling ui-workspace's directory-flow slots | (registers `ctx.directoryPicker`) | -| `directory-picker-browse/` | In-app browsing backend: listing/creation primitives over Node stdlib; remote-capable | (registers `ctx.directoryPicker`) | +| `directory-picker-browse/` | Dual-face browse interaction: listing/creation primitives over Node stdlib (remote-capable) + the browser half rendering the in-app Select Workspace Directory dialog | (registers `ctx.directoryPicker`) | `apiproxy` is transport-agnostic by design — it registers no routes; carriers wrap `ctx.apiProxy` themselves. The HTTP carrier route (with its `/api` browser-trust fence) is mounted by [`client/connection`](../client/connection/README.md)'s node half, which is why that package lives in the client group: it owns both ends of the wire. diff --git a/packages/host/README.zh.md b/packages/host/README.zh.md index 90d5ea2b09..2b6878b08b 100644 --- a/packages/host/README.zh.md +++ b/packages/host/README.zh.md @@ -10,6 +10,6 @@ dsh web GUI 的宿主侧:所有客户端形态共用的 API 网关,以及承 | `webserver/` | 纯 HTTP 路由注册载体:激活即监听的 `node:http` 服务器;路由以命名的 `exact`/`prefix` 处理器注册 | `ctx.httpServer` | | `directory-picker/` | 工作区目录选择 seam:网关的 picker RPC 委托的可辨识 `native`/`browse` 能力 | `ctx.directoryPicker` | | `directory-picker-native/` | 双面原生交互:OS 选择器后端(osascript/PowerShell/Zenity+KDialog,仅宿主屏幕可用)+ 填入 ui-workspace 目录流 slot 的 browser half | (注册 `ctx.directoryPicker`) | -| `directory-picker-browse/` | 应用内浏览后端:基于 Node 标准库的列举/创建原语;支持远程 | (注册 `ctx.directoryPicker`) | +| `directory-picker-browse/` | 双面浏览交互:基于 Node 标准库的列举/创建原语(可远程)+ 渲染应用内选择工作区目录对话框的 browser half | (注册 `ctx.directoryPicker`) | `apiproxy` 在设计上与传输方式无关——它不注册任何路由;载体自行包装 `ctx.apiProxy`。HTTP 载体路由(连同其 `/api` 浏览器信任栅栏)由 [`client/connection`](../client/connection/README.md) 的 node 半侧挂载,这正是该包住在 client 组的原因:它拥有这条线的两端。 diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index 34c3e35a79..c0da9fd2e9 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/README.i18n.yaml @@ -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/host/directory-picker-browse/README.md -README.md: 688a60894cb0ab066a6d501e4df310f8d31bfb5f -README.zh.md: c19bccc2ff9268cb7a6c931da4671bebc9f76b0c +README.md: 5cec04b68c4c81c4ab1db4b2a5709b5f56139dc0 +README.zh.md: 1a26b496c1b30c5c90c5b7f123292a1c57ddb74a diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 688a60894c..5cec04b68c 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -6,6 +6,8 @@ The **in-app browsing backend** of the [directory-picker seam](../directory-pick Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). +**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view, breadcrumb with a click-to-edit path zone, nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). + ## Model Experience None, as the backend serves the GUI host's directory selection; nothing here reaches a model request. diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index c19bccc2ff..1a26b496c1 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -6,6 +6,8 @@ 行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 +**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图、带点击即编辑路径区的面包屑、嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 + ## 模型体验 无。该后端服务于 GUI 宿主的目录选择;这里没有任何内容进入模型请求。 diff --git a/packages/host/directory-picker-browse/package.json b/packages/host/directory-picker-browse/package.json index c1e8626bed..192ce614d2 100644 --- a/packages/host/directory-picker-browse/package.json +++ b/packages/host/directory-picker-browse/package.json @@ -15,26 +15,53 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/client.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", "dependencies": { - "@deepseek-ai/dsh-host-directory-picker": "workspace:^" + "@deepseek-ai/dsh-host-directory-picker": "workspace:^", + "clsx": "^2.0.0" }, "peerDependencies": { + "@deepseek-ai/dsh-client-locale": "^0.0.1", + "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", + "@deepseek-ai/dsh-client-ui-workspace": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "dshClient": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-workspace", + "@deepseek-ai/dsh-client-locale" + ], + "platform": "web" } } diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css new file mode 100644 index 0000000000..f59a74aa7e --- /dev/null +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -0,0 +1,269 @@ +/* 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; +} diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx new file mode 100644 index 0000000000..49cbff9fb3 --- /dev/null +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -0,0 +1,376 @@ +/** + * 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 + /** Create one child directory under an existing parent. */ + createDirectory: (path: string, name: string) => Promise + /** 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 ( +
+ {entries.filter(entry => !entry.hidden).map((entry) => { + const selected = entry.path === selectedPath + return ( + + ) + })} +
+ ) +} + +/** + * 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(null) + const [selected, setSelected] = useState(null) + const [child, setChild] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + // Path-edit state: null = breadcrumb mode; a string = the draft being typed. + const [pathDraft, setPathDraft] = useState(null) + // Create-folder state: null = closed; a string = the nested dialog's draft. + const [folderDraft, setFolderDraft] = useState(null) + const [creatingFolder, setCreatingFolder] = useState(false) + const [createError, setCreateError] = useState(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 ( + +
+

{t('browser.title')}

+
+ {pathDraft === null + ? ( + <> + {crumbs.map((crumb, index) => ( + + {index > 0 && } + + + ))} + {/* The empty zone right of the crumbs is the path-edit affordance. */} +
+
+
+ {parent !== null && ( + + )} + {twoPane && } + {twoPane && child !== null && ( + + )} + {loading &&
{t('browser.loading')}
} + {error !== null &&
{error}
} +
+
+ + + + +
+ {/* Nested create dialog (figma 813:23278): names one folder inside the target. */} + { if (!creatingFolder) setFolderDraft(null) }} + title={t('browser.newFolder')} + className={clsx(css.createDialog)} + headless + > +
+

{t('browser.newFolder')}

+

{t('browser.createIn', { name: targetName })}

+ { 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 &&
{createError}
} +
+ + +
+
+
+
+ ) +} diff --git a/packages/host/directory-picker-browse/src/client/index.ts b/packages/host/directory-picker-browse/src/client/index.ts new file mode 100644 index 0000000000..804d23a91f --- /dev/null +++ b/packages/host/directory-picker-browse/src/client/index.ts @@ -0,0 +1,109 @@ +/** + * Browser half of the browse directory-picker backend: fills ui-workspace's + * two directory-flow holes with the in-app Select Workspace Directory dialog + * (figma `Harness` 813-23126 family), driving the node half's + * `host.listDirectory`/`host.createDirectory` primitives. Mounting this + * package therefore composes both sides of the browse interaction with one + * cordis.yml row; no client code branches on a capability kind. The dialog's + * copy is locale-registered here — the flow package owns its own strings. + */ +import { createElement } from 'react' +import type { ReactElement } from 'react' +import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' +import type { ClientContext, DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client' +import type { Translate } from '@deepseek-ai/dsh-client-locale/client' +// Type-only: the SlotMap merge declaring the directory-flow holes and their owner contract. +import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client' +import { DirectoryBrowser } from './DirectoryBrowser.tsx' + +/** Locale namespace owning the browser dialog's copy. */ +const LOCALE_NS = 'directory-browser' + +/** Injected face: the browse wire calls and copy the dialog drives (bound in apply's closure). */ +interface BrowseFlowInjected { + /** List one directory level (absent path = the Host home directory). */ + listDirectory: (path?: string) => Promise + /** Create one child directory under an existing parent. */ + createDirectory: (path: string, name: string) => Promise + /** Localized dialog copy (this package's namespace). */ + t: Translate +} + +/** + * Flow occupant: adapts the hole's owner conversation onto the browser + * dialog — a confirmed directory is the picked path, dismissal is the + * cancellation. Browse failures (unreadable targets, create conflicts) stay + * inside the dialog's own alert surfaces, so the owner's `onError` arm is + * never driven by this occupant. + * @param props - owner conversation plus the injected browse face. + * @returns the dialog element (renders nothing while closed). + */ +export function BrowseDirectoryFlow(props: DirectoryFlowOwnerProps & BrowseFlowInjected): ReactElement { + return createElement(DirectoryBrowser, { + open: props.open, + busy: props.busy, + listDirectory: props.listDirectory, + createDirectory: props.createDirectory, + t: props.t, + onOpen: props.onPicked, + onClose: props.onCancel, + }) +} + +/** Required services (cordis fiber inject): the slot registry, the wire-facing workspace service, and locale. */ +export const inject = ['slots', 'workspaces', 'locale'] + +/** + * Client plugin body: register the dialog's dictionaries and the browse flow + * into both directory-flow holes (declaration-aware deferral — the declaring + * ui-workspace entries may activate later, and an HMR collapse re-declares). + * @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() } + }, 'directory-picker-browse: dialog dictionaries') + + const injected = (): BrowseFlowInjected => ({ + listDirectory: path => ctx.workspaces.listDirectory(path), + createDirectory: (path, name) => ctx.workspaces.createDirectory(path, name), + t: ctx.locale.bind(LOCALE_NS), + }) + ctx.effect(() => { + const deferred = [ + deferRegistration(ctx.slots, 'conversation.hero.workspace.directoryFlow', BrowseDirectoryFlow, () => + ctx.slots.register({ name: 'conversation.hero.workspace.directoryFlow', inject: injected }, BrowseDirectoryFlow)), + deferRegistration(ctx.slots, 'sidebar.workspaces.directoryFlow', BrowseDirectoryFlow, () => + ctx.slots.register({ name: 'sidebar.workspaces.directoryFlow', inject: injected }, BrowseDirectoryFlow)), + ] + return () => { for (const entry of deferred) entry.dispose() } + }, 'directory-picker-browse: flow registrations') +} diff --git a/packages/host/directory-picker-browse/src/css-modules.d.ts b/packages/host/directory-picker-browse/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/host/directory-picker-browse/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/host/directory-picker-browse/tests/client-flow.spec.tsx b/packages/host/directory-picker-browse/tests/client-flow.spec.tsx new file mode 100644 index 0000000000..9d0dfc004f --- /dev/null +++ b/packages/host/directory-picker-browse/tests/client-flow.spec.tsx @@ -0,0 +1,127 @@ +// @vitest-environment jsdom +import { Context } from 'cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import type { DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client' +import { apply, BrowseDirectoryFlow, inject } from '../src/client/index.ts' + +afterEach(cleanup) + +const HOLES = ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const + +const HOME = '/home/u' +const homeListing: DirectoryListing = { + path: HOME, + home: HOME, + crumbs: [{ name: '/', path: '/', hidden: false }, { name: 'u', path: HOME, hidden: false }], + entries: [{ name: 'Documents', path: `${HOME}/Documents`, hidden: false }], +} + +async function bench() { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + ctx.provide('locale', new LocaleService(ctx)) + const listDirectory = vi.fn(async (): Promise => homeListing) + const createDirectory = vi.fn(async (path: string, name: string) => `${path}/${name}`) + ctx.provide('workspaces', { listDirectory, createDirectory } as never) + const slots = ctx.get('slots') as SlotsService + const declare = () => slots.register({ + name: 'root', + children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])), + } as never, () => null) + return { ctx, slots, listDirectory, createDirectory, declare } +} + +function owner(overrides: Partial = {}): DirectoryFlowOwnerProps { + return { + open: true, busy: false, + onPicked: vi.fn(), onCancel: vi.fn(), onError: vi.fn(), + ...overrides, + } +} + +describe('directory-picker-browse client half', () => { + it('declares the services it drives', () => { + expect(inject).toEqual(['slots', 'workspaces', 'locale']) + }) + + it('fills both directory-flow holes for declarations before or after apply, and leaves with its fiber', async () => { + const before = await bench() + before.declare() + const fiber = before.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(1) + // Registry-contribution disposal proof: the fiber going down empties the holes. + await fiber.dispose() + for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(0) + + const after = await bench() + await after.ctx.plugin({ inject: [...inject], apply }).await() + for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(0) + after.declare() + await Promise.resolve() + for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(1) + }) + + it('registers the dialog dictionaries and binds this package namespace', async () => { + const b = await bench() + b.declare() + await b.ctx.plugin({ inject: [...inject], apply }).await() + const entry = b.slots.entries(HOLES[0])[0]! + const injected = (entry.inject as () => { t: (key: string) => string })() + // zh is the shipped default locale. + expect(injected.t('browser.title')).toBe('选择工作区目录') + expect(injected.t('browser.newFolder')).toBe('新建文件夹') + }) + + it('drives the injected browse calls through the hole entry', async () => { + const b = await bench() + b.declare() + await b.ctx.plugin({ inject: [...inject], apply }).await() + const entry = b.slots.entries(HOLES[1])[0]! + const injected = (entry.inject as () => { + listDirectory: (path?: string) => Promise + createDirectory: (path: string, name: string) => Promise + })() + await expect(injected.listDirectory()).resolves.toBe(homeListing) + await expect(injected.createDirectory(HOME, 'fresh')).resolves.toBe(`${HOME}/fresh`) + expect(b.listDirectory).toHaveBeenCalledOnce() + expect(b.createDirectory).toHaveBeenCalledWith(HOME, 'fresh') + }) + + it('adapts the owner conversation onto the dialog: confirm picks, dismissal cancels', async () => { + const props = owner() + const listDirectory = vi.fn(async (): Promise => homeListing) + const t = (key: string): string => key + render( + '')} + t={t} + />, + ) + // The dialog opened at home; its confirm (browser.open) adopts the listed level. + const openButton = await screen.findByRole('button', { name: 'browser.open' }) + openButton.click() + expect(props.onPicked).toHaveBeenCalledWith(HOME) + screen.getByRole('button', { name: 'browser.cancel' }).click() + expect(props.onCancel).toHaveBeenCalled() + expect(props.onError).not.toHaveBeenCalled() + }) + + it('renders nothing while the flow is closed', () => { + const view = render( + homeListing)} + createDirectory={vi.fn(async () => '')} + t={key => key} + />, + ) + expect(view.container.innerHTML).toBe('') + }) +}) diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx new file mode 100644 index 0000000000..28c25b367f --- /dev/null +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -0,0 +1,405 @@ +// @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 = { + [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[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) => (params === undefined ? key : `${key}:${String(params.name)}`), + ...overrides, + } + const view = render() + 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('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('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('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() + 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((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((_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((_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((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('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((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() + b.view.rerender() + await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') }) + expect(columns()).toHaveLength(1) + expect(b.listDirectory).toHaveBeenLastCalledWith(undefined) + }) +}) diff --git a/packages/host/directory-picker-browse/tsconfig.json b/packages/host/directory-picker-browse/tsconfig.json index 99ca673189..00dcdf8fde 100644 --- a/packages/host/directory-picker-browse/tsconfig.json +++ b/packages/host/directory-picker-browse/tsconfig.json @@ -1,24 +1,36 @@ { - "extends": "../../../tsconfig.base.json", + "extends": "../../../tsconfig.base.client.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib/types" + "outDir": "lib/types", + "types": [ + "node" + ] }, "include": [ "src" ], "references": [ - { - "path": "../../../vendor/cosmokit" - }, - { - "path": "../../../vendor/cordis" - }, { "path": "../directory-picker" }, { "path": "../../support/invariants" + }, + { + "path": "../../client/ui-slots" + }, + { + "path": "../../client/ui-primitives" + }, + { + "path": "../../client/locale" + }, + { + "path": "../../client/runtime" + }, + { + "path": "../../client/ui-workspace" } ] } diff --git a/packages/host/directory-picker-browse/tsdown.config.ts b/packages/host/directory-picker-browse/tsdown.config.ts new file mode 100644 index 0000000000..4b2be38c3d --- /dev/null +++ b/packages/host/directory-picker-browse/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../../client/tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-host-directory-picker-browse', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 94d5dbb3cd..1c09fd14e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -212,9 +212,9 @@ importers: '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../../packages/host/apiproxy - '@deepseek-ai/dsh-host-directory-picker-native': + '@deepseek-ai/dsh-host-directory-picker-browse': specifier: workspace:^ - version: link:../../packages/host/directory-picker-native + version: link:../../packages/host/directory-picker-browse '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../packages/host/webserver @@ -1412,9 +1412,6 @@ importers: specifier: ^2.0.0 version: 2.1.1 devDependencies: - '@deepseek-ai/dsh-client-locale': - specifier: workspace:^ - version: link:../locale '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime @@ -2746,13 +2743,37 @@ importers: '@deepseek-ai/dsh-host-directory-picker': specifier: workspace:^ version: link:../directory-picker + clsx: + specifier: ^2.0.0 + version: 2.1.1 devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../../client/locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../../client/runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../../client/ui-primitives + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../../client/ui-slots + '@deepseek-ai/dsh-client-ui-workspace': + specifier: workspace:^ + version: link:../../client/ui-workspace '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + react: + specifier: ^18.2.0 + version: 18.3.1 packages/host/directory-picker-native: dependencies: diff --git a/tsconfig.client.json b/tsconfig.client.json index 5eb8ffa5a6..61f050d32a 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -19,6 +19,8 @@ "packages/client/*/src/css-modules.d.ts", "packages/client/*/tests/**/*.ts", "packages/client/*/tests/**/*.tsx", + "packages/host/directory-picker-browse/tests/**/*.ts", + "packages/host/directory-picker-browse/tests/**/*.tsx", "packages/host/directory-picker-native/tests/**/*.ts", "packages/host/directory-picker-native/tests/**/*.tsx", "packages/client/tsdown.client.ts", @@ -33,6 +35,7 @@ // browser half registers the picking flow into ui-workspace's slot — // client-side Context merges keep it out of the host program. { "path": "./packages/host/directory-picker-native" }, + { "path": "./packages/host/directory-picker-browse" }, { "path": "./packages/client/ui-slots" }, { "path": "./packages/client/ui-primitives" }, { "path": "./packages/client/web-react" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 4ea1881acb..3263effe01 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -33,6 +33,7 @@ ], "exclude": [ "packages/client/**", + "packages/host/directory-picker-browse/**", "packages/host/directory-picker-native/**", "scripts/client-bundle-purity.spec.ts" ], @@ -168,7 +169,6 @@ { "path": "./packages/mcp/mcp-client" }, { "path": "./packages/host/apiproxy" }, { "path": "./packages/host/directory-picker" }, - { "path": "./packages/host/directory-picker-browse" }, { "path": "./packages/host/webserver" }, { "path": "./packages/sdk/sdk-client" }, { "path": "./packages/sdk/helper" }, From a19ea11cfd67dc1775e5dd72a019a080693ed70c Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 22:58:39 +0800 Subject: [PATCH 06/30] doc: regenerate the module graph after ui-workspace dropped its locale edge --- docs/module-graph.md | 35 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 1ff7428fed..61059e96f3 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -295,6 +295,10 @@ flowchart TD pkg_client_ui_slash --> pkg_client_runtime pkg_client_ui_slash --> pkg_client_ui_slots pkg_client_ui_slash --> pkg_invariants + pkg_client_ui_workspace --> pkg_client_runtime + pkg_client_ui_workspace --> pkg_client_ui_primitives + pkg_client_ui_workspace --> pkg_client_ui_slots + pkg_client_ui_workspace --> pkg_invariants pkg_helper --> pkg_brand pkg_helper --> pkg_invariants pkg_helper --> pkg_subprocess @@ -349,11 +353,16 @@ flowchart TD pkg_client_ui_theme --> pkg_client_ui_primitives pkg_client_ui_theme --> pkg_client_ui_slots pkg_client_ui_theme --> pkg_invariants - pkg_client_ui_workspace --> pkg_client_locale - pkg_client_ui_workspace --> pkg_client_runtime - pkg_client_ui_workspace --> pkg_client_ui_primitives - pkg_client_ui_workspace --> pkg_client_ui_slots - pkg_client_ui_workspace --> pkg_invariants + pkg_host_directory_picker_browse --> pkg_client_locale + pkg_host_directory_picker_browse --> pkg_client_runtime + pkg_host_directory_picker_browse --> pkg_client_ui_primitives + pkg_host_directory_picker_browse --> pkg_client_ui_slots + pkg_host_directory_picker_browse --> pkg_client_ui_workspace + pkg_host_directory_picker_browse --> pkg_invariants + pkg_host_directory_picker_native --> pkg_client_runtime + pkg_host_directory_picker_native --> pkg_client_ui_slots + pkg_host_directory_picker_native --> pkg_client_ui_workspace + pkg_host_directory_picker_native --> pkg_invariants pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm @@ -418,16 +427,6 @@ flowchart TD pkg_code_runtime_worker --> pkg_invariants pkg_code_runtime_worker --> pkg_session pkg_code_runtime_worker --> pkg_timeout - pkg_host_directory_picker_browse --> pkg_client_locale - pkg_host_directory_picker_browse --> pkg_client_runtime - pkg_host_directory_picker_browse --> pkg_client_ui_primitives - pkg_host_directory_picker_browse --> pkg_client_ui_slots - pkg_host_directory_picker_browse --> pkg_client_ui_workspace - pkg_host_directory_picker_browse --> pkg_invariants - pkg_host_directory_picker_native --> pkg_client_runtime - pkg_host_directory_picker_native --> pkg_client_ui_slots - pkg_host_directory_picker_native --> pkg_client_ui_workspace - pkg_host_directory_picker_native --> pkg_invariants pkg_lsp_local --> pkg_brand pkg_lsp_local --> pkg_invariants pkg_lsp_local --> pkg_llm @@ -963,6 +962,7 @@ flowchart TD | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | @@ -979,7 +979,8 @@ flowchart TD | [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | +| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -999,8 +1000,6 @@ flowchart TD | [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | -| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | -| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | From 0d9ac53fb04eca7b829aa3c54d04fd86f29d6e36 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 23:04:33 +0800 Subject: [PATCH 07/30] test(web): give the browse-dialog snapshot finds the lane's standard 10s timeout CI's cold jsdom needs more than findByRole's 1s default between opening the dialog and the fixture listing's first paint; the surrounding helpers already wait 10s. --- apps/web/tests/workspace-flow.snapshot.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts index d7cb66e0af..e6efbd75ea 100644 --- a/apps/web/tests/workspace-flow.snapshot.ts +++ b/apps/web/tests/workspace-flow.snapshot.ts @@ -196,9 +196,9 @@ it('adopts a directory through the composed in-app browse flow and lands in its fireEvent.click(within(menu).getByRole('menuitem', { name: 'Open local folder…' })) // The browse occupant renders the Select Workspace Directory dialog at the // fixture home; select Documents, advance into project, and adopt it. - const dialog = await screen.findByRole('dialog', { name: '选择工作区目录' }) - fireEvent.click(await within(dialog).findByRole('listitem', { name: /Documents/ })) - fireEvent.click(await within(dialog).findByRole('listitem', { name: /^project/ })) + const dialog = await screen.findByRole('dialog', { name: '选择工作区目录' }, { timeout: 10_000 }) + fireEvent.click(await within(dialog).findByRole('listitem', { name: /Documents/ }, { timeout: 10_000 })) + fireEvent.click(await within(dialog).findByRole('listitem', { name: /^project/ }, { timeout: 10_000 })) fireEvent.click(within(dialog).getByRole('button', { name: '打开' })) await findHeroComposer() await waitFor(() => { From 6cd63f741c07e8804dde2f5fc322d6ed55522ec4 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 23:21:27 +0800 Subject: [PATCH 08/30] =?UTF-8?q?fix(host):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20Escape=20scoping,=20relist=20gating,=20crumb=20overflow,=20a?= =?UTF-8?q?ria=20golden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Escape (and the mask) now reaches only the topmost dialog: while the nested New-folder dialog is up the browser ignores its own Modal close, and the nested dialog's in-flight fence keeps both open during creation. - New folder disables while any listing loads, so a slow post-create relist/select sequence cannot host a second create against a target the pending listing is about to change. - Deep ancestry scrolls inside a dedicated crumb trail whose tail is pinned into view; the path-edit zone keeps its reserved width instead of being clipped by the card, preserving cross-drive path entry. - The workspace-management e2e records a directory-browser aria golden at a staged tree (host HOME pointed at the scaffold cwd collapses ancestry into the Home crumb, keeping the artifact machine-independent), and the keyless snapshot's row targeting goes through visible label text — listitem accessible-name computation differs across dom-accessibility-api environments (the CI-only miss). --- .../directory-browser.expected.md | 20 +++++++ apps/web/tests/workspace-flow.snapshot.ts | 7 ++- apps/web/tests/workspace-management.e2e.ts | 40 +++++++++++-- .../src/client/DirectoryBrowser.module.css | 14 ++++- .../src/client/DirectoryBrowser.tsx | 47 +++++++++------ .../tests/directory-browser.spec.tsx | 59 ++++++++++++++++++- 6 files changed, 161 insertions(+), 26 deletions(-) create mode 100644 apps/web/tests/snapshots/workspace-management/directory-browser.expected.md diff --git a/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md b/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md new file mode 100644 index 0000000000..e394b2cdb6 --- /dev/null +++ b/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md @@ -0,0 +1,20 @@ +- dialog "选择工作区目录": + - heading "选择工作区目录" [level=2] + - button "主目录" + - img + - button "browse-golden" + - button "编辑路径" + - list: + - listitem: + - img + - text: alpha + - img + - listitem: + - img + - text: beta + - img + - button "新建文件夹": + - img + - text: 新建文件夹 + - button "取消" + - button "打开" diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts index e6efbd75ea..8d3316ab0a 100644 --- a/apps/web/tests/workspace-flow.snapshot.ts +++ b/apps/web/tests/workspace-flow.snapshot.ts @@ -197,8 +197,11 @@ it('adopts a directory through the composed in-app browse flow and lands in its // The browse occupant renders the Select Workspace Directory dialog at the // fixture home; select Documents, advance into project, and adopt it. const dialog = await screen.findByRole('dialog', { name: '选择工作区目录' }, { timeout: 10_000 }) - fireEvent.click(await within(dialog).findByRole('listitem', { name: /Documents/ }, { timeout: 10_000 })) - fireEvent.click(await within(dialog).findByRole('listitem', { name: /^project/ }, { timeout: 10_000 })) + // Row targeting goes through the visible label text: listitem accessible-name + // computation differs across dom-accessibility-api environments, while the + // row's name span is stable (clicks bubble to the row button). + fireEvent.click(await within(dialog).findByText('Documents', {}, { timeout: 10_000 })) + fireEvent.click(await within(dialog).findByText('project', {}, { timeout: 10_000 })) fireEvent.click(within(dialog).getByRole('button', { name: '打开' })) await findHeroComposer() await waitFor(() => { diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index 674969debe..a034140bf2 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -13,8 +13,8 @@ import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { SessionId } from '@deepseek-ai/dsh-session' import { - acknowledgeReloadConnectionLoss, assertFixtureInventory, launchWebScaffold, seedSession, watchConsole, - webSnapshotMode, type WebScaffold, + acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { saveFailureShot } from './support.ts' @@ -23,6 +23,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/workspace-management', i // spec needs any one cold session row, not new recorded content. const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url)) const MODE = webSnapshotMode() +const BROWSER_EXPECTED = join(SNAPSHOT_DIR, 'directory-browser.expected.md') const SEED_ID = 'workspace-management-web-e2e' describe('web e2e: workspace management (create / rename / flat view / hover card)', () => { @@ -345,6 +346,35 @@ describe('web e2e: workspace management (create / rename / flat view / hover car expect(tripwire.pageErrors).toEqual([]) }, 90_000) + it('matches the directory-browser dialog aria golden at a staged directory', async () => { + // A staged subtree under the scaffold cwd keeps the listing deterministic + // (normalizeAria scrubs the cwd), and pointing the in-process host's HOME + // at the cwd collapses the breadcrumb ancestry into the Home crumb — no + // machine-specific path segments or real $HOME contents enter the golden. + const staged = join(scaffold.workspaceCwd, 'browse-golden') + await mkdir(join(staged, 'alpha'), { recursive: true }) + await mkdir(join(staged, 'beta'), { recursive: true }) + const realHome = process.env.HOME + process.env.HOME = scaffold.workspaceCwd + try { + await page.getByRole('button', { name: 'Create workspace' }).click() + await page.getByRole('menuitem', { name: 'Open local folder…' }).click() + const dialog = page.getByRole('dialog', { name: '选择工作区目录' }) + await dialog.waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: '编辑路径' }).click() + await dialog.getByLabel('编辑路径').fill(staged) + await dialog.getByLabel('编辑路径').press('Enter') + await expect.poll(() => dialog.getByText('alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(BROWSER_EXPECTED, snapshot, MODE) + await dialog.getByRole('button', { name: '取消' }).click() + await dialog.waitFor({ state: 'hidden', timeout: 10_000 }) + } finally { + process.env.HOME = realHome + } + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + it('shows the session hover card after a dwell on the row', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-hover')) // Expand Ungrouped to reveal the seeded session row, then dwell on it @@ -376,8 +406,8 @@ describe('web e2e: workspace management (create / rename / flat view / hover car it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => { expect(tripwire.warnings).toEqual([]) - // This spec mints no fixture directory contents of its own; the seed it - // reuses is owned (and inventory-guarded) by seeded-history. - await assertFixtureInventory(SNAPSHOT_DIR, ['.gitkeep']) + // The directory-browser aria golden is this spec's one owned artifact; + // the seed it reuses is owned (and inventory-guarded) by seeded-history. + await assertFixtureInventory(SNAPSHOT_DIR, ['.gitkeep', 'directory-browser.expected.md']) }) }) diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css index f59a74aa7e..ad2107f54f 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -39,6 +39,18 @@ min-height: 20px; } +/* Deep chains scroll inside the trail (the effect pins the tail into view) + * so the edit zone to the right never leaves the bar. */ +.crumbTrail { + display: flex; + align-items: center; + gap: 4px; + flex: 0 1 auto; + min-width: 0; + overflow-x: auto; + scrollbar-width: none; +} + .crumbSeat { display: inline-flex; align-items: center; @@ -74,7 +86,7 @@ /* 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; + flex: 1 0 34px; min-width: 34px; align-self: stretch; border: none; diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 49cbff9fb3..a3bbc576a7 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -112,6 +112,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const [creatingFolder, setCreatingFolder] = useState(false) const [createError, setCreateError] = useState(null) const requestSeq = useRef(0) + // Deep ancestry overflows the trail; keep its tail (the current directory + // and the edit zone beside it) in view whenever the chain changes. + const crumbTrailRef = useRef(null) /** Replace the whole view with one freshly listed level (no selection). */ const navigate = useCallback((path?: string) => { @@ -213,16 +216,24 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, } // 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 crumbTail = crumbs.at(-1)?.path + useEffect(() => { + const trail = crumbTrailRef.current + if (trail !== null) trail.scrollLeft = trail.scrollWidth + }, [crumbTail]) + + if (!open) return null const twoPane = selected !== null return ( { if (folderDraft === null) onClose() }} title={t('browser.title')} className={clsx(css.dialog)} headless @@ -233,19 +244,21 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, {pathDraft === null ? ( <> - {crumbs.map((crumb, index) => ( - - {index > 0 && } - - - ))} + + {crumbs.map((crumb, index) => ( + + {index > 0 && } + + + ))} + {/* The empty zone right of the crumbs is the path-edit affordance. */} - + + // The wrapper carries the list semantics; the row keeps its NATIVE + // button role so assistive technology exposes an actionable control. + + + ) })} @@ -260,7 +262,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, {pathDraft === null ? ( <> - + {crumbs.map((crumb, index) => ( {index > 0 && } @@ -292,7 +294,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, value={pathDraft} aria-label={t('browser.editPath')} autoFocus - disabled={busy} + disabled={parentInert} onChange={(event) => { setPathDraft(event.target.value) }} onKeyDown={(event) => { if (event.key === 'Enter') { diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index 5b656496d8..4022d12754 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -83,6 +83,11 @@ function columns(): HTMLElement[] { return screen.getAllByRole('list') } +/** The actionable button inside a listitem seat (rows keep native button semantics). */ +function rowButton(item: HTMLElement): HTMLButtonElement { + return within(item).getByRole('button') +} + 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() @@ -98,39 +103,39 @@ describe('DirectoryBrowser', () => { 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')) + fireEvent.click(rowButton(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(rowButton(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() + expect(within(screen.getByRole('navigation')).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')) + fireEvent.click(rowButton(screen.getByRole('listitem'))) await waitFor(() => { expect(columns()).toHaveLength(2) }) - fireEvent.click(within(columns()[1]!).getByRole('listitem')) + fireEvent.click(rowButton(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') + expect(rowButton(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')) + fireEvent.click(rowButton(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() + expect(rowButton(screen.getByRole('listitem')).getAttribute('aria-current')).toBeNull() }) it('opens the selection, else the listed level; Cancel closes; busy freezes Open', async () => { @@ -138,7 +143,7 @@ describe('DirectoryBrowser', () => { 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')) + fireEvent.click(rowButton(screen.getByRole('listitem'))) await waitFor(() => { expect(columns()).toHaveLength(2) }) fireEvent.click(screen.getByRole('button', { name: 'browser.open' })) expect(b.onOpen).toHaveBeenLastCalledWith(DOCS) @@ -282,8 +287,8 @@ describe('DirectoryBrowser', () => { expect(cancels.map(button => button.disabled).sort()).toEqual([false, true]) expect(screen.getByRole('button', { name: 'browser.open' }).disabled).toBe(true) expect(screen.getByRole('button', { name: 'browser.editPath' }).disabled).toBe(true) - for (const row of screen.getAllByRole('listitem')) { - expect(row.disabled).toBe(true) + for (const row of screen.getAllByRole('listitem')) { + expect(rowButton(row).disabled).toBe(true) } }) @@ -326,7 +331,7 @@ describe('DirectoryBrowser', () => { 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')) + fireEvent.click(rowButton(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). @@ -352,10 +357,10 @@ describe('DirectoryBrowser', () => { 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() + expect(within(screen.getByRole('navigation')).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) + expect(rows.some(row => row.textContent === 'fresh' && rowButton(row).getAttribute('aria-current') === 'true')).toBe(true) }) }) @@ -394,9 +399,9 @@ describe('DirectoryBrowser', () => { 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')) + fireEvent.click(rowButton(screen.getByRole('listitem'))) await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') }) - expect(screen.getByRole('listitem').getAttribute('aria-current')).toBe('true') + expect(rowButton(screen.getByRole('listitem')).getAttribute('aria-current')).toBe('true') // No preview column arrived for the failed selection. expect(columns()).toHaveLength(1) }) @@ -419,7 +424,7 @@ describe('DirectoryBrowser', () => { let resolveSlow!: (value: DirectoryListing) => void const slow = new Promise((settle) => { resolveSlow = settle }) b.listDirectory.mockReturnValueOnce(slow) - fireEvent.click(screen.getByRole('listitem')) + fireEvent.click(rowButton(screen.getByRole('listitem'))) fireEvent.click(screen.getByRole('button', { name: 'browser.home' })) await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledTimes(3) }) await waitFor(() => { expect(columns()).toHaveLength(1) }) @@ -435,7 +440,7 @@ describe('DirectoryBrowser', () => { let rejectSlow!: (reason: unknown) => void const slow = new Promise((_settle, fail) => { rejectSlow = fail }) b.listDirectory.mockReturnValueOnce(slow) - fireEvent.click(screen.getByRole('listitem')) + fireEvent.click(rowButton(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')) @@ -447,14 +452,14 @@ describe('DirectoryBrowser', () => { 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')) + fireEvent.click(rowButton(screen.getByRole('listitem'))) await waitFor(() => { expect(columns()).toHaveLength(2) }) let rejectSlow!: (reason: unknown) => void const slow = new Promise((_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' })) + fireEvent.click(within(screen.getByRole('navigation')).getByRole('button', { name: 'Documents' })) await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledTimes(4) }) rejectSlow(new Error('late nav failure')) await new Promise(settle => setTimeout(settle, 0)) @@ -464,13 +469,13 @@ describe('DirectoryBrowser', () => { 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')) + fireEvent.click(rowButton(screen.getByRole('listitem'))) await waitFor(() => { expect(columns()).toHaveLength(2) }) let resolveSlow!: (value: DirectoryListing) => void const slow = new Promise((settle) => { resolveSlow = settle }) b.listDirectory.mockReturnValueOnce(slow) fireEvent.click(screen.getByRole('button', { name: 'browser.home' })) - fireEvent.click(screen.getByRole('button', { name: 'Documents' })) + fireEvent.click(within(screen.getByRole('navigation')).getByRole('button', { name: 'Documents' })) await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') }) resolveSlow(listingFor(undefined)) await new Promise(settle => setTimeout(settle, 0)) @@ -510,7 +515,7 @@ describe('DirectoryBrowser', () => { it('starts back at home on reopen', async () => { const b = mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) - fireEvent.click(screen.getByRole('listitem')) + fireEvent.click(rowButton(screen.getByRole('listitem'))) await waitFor(() => { expect(columns()).toHaveLength(2) }) b.view.rerender() b.view.rerender() From d2b16380d150a94e029a70b5bcadf47c2d2abbdf Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 00:04:06 +0800 Subject: [PATCH 11/30] fix(host): keep path entry available when the home listing fails With no listed level (an unreadable or missing home directory), the path-edit zone previously disabled forever, stranding the operator on the alert with only Cancel; it now opens with an empty draft so an absolute path remains the way forward. Covered by a recovery test. --- .../src/client/DirectoryBrowser.tsx | 8 +++++--- .../tests/directory-browser.spec.tsx | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 19e7a6f799..5318ad0ea0 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -282,9 +282,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, type="button" className={css.crumbEditZone} aria-label={t('browser.editPath')} - disabled={parent === null || parentInert} - /* v8 ignore next -- narrowing guard: the zone disables while the level is null. */ - onClick={() => { if (parent !== null) setPathDraft(selected?.path ?? parent.path) }} + // Stays available with no listed level: when the home + // listing itself fails, typing an absolute path is the one + // remaining way forward. + disabled={parentInert} + onClick={() => { setPathDraft(selected?.path ?? parent?.path ?? '') }} /> ) diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index 4022d12754..daf795ddbf 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -269,6 +269,21 @@ describe('DirectoryBrowser', () => { expect(screen.getByRole('button', { name: 'browser.newFolder' }).disabled).toBe(false) }) + it('keeps path entry available when the home listing fails', async () => { + const listDirectory = vi.fn(async (): Promise => { + throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'home unreadable', details: { path: HOME } }) + }) + mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('home unreadable') }) + // With no listed level, typing an absolute path is the one way forward. + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: DOCS } }) + listDirectory.mockImplementation(async (path?: string) => listingFor(path)) + fireEvent.keyDown(input, { key: 'Enter' }) + await waitFor(() => { expect(screen.getByText('harness')).toBeTruthy() }) + }) + it('ignores dismissal while adoption is busy', async () => { const b = mount({ busy: true }) await waitFor(() => { expect(screen.getByRole('dialog')).toBeTruthy() }) From f8cd2bf749561ba6ac317652c2d9611055dca207 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 00:15:30 +0800 Subject: [PATCH 12/30] =?UTF-8?q?fix(host):=20review=20round=205=20?= =?UTF-8?q?=E2=80=94=20draft-pending=20action=20gating,=20in-flow=20errors?= =?UTF-8?q?,=20IME=20guards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Open and New folder disable while a path draft is uncommitted: targetPath still names the previous selection/listing, and committing against it while a different path shows in the header adopts the wrong directory. - The Miller columns keep their own row so a status/error line renders below them inside the card instead of competing as a third flex item the dialog clips off-screen. - Both text inputs (path editor, folder name) carry the IME composition guard the workspace-name inputs already had: a composing Enter confirms the candidate, never submits. --- .../src/client/DirectoryBrowser.module.css | 13 +++- .../src/client/DirectoryBrowser.tsx | 59 +++++++++++-------- .../tests/directory-browser.spec.tsx | 53 +++++++++++++++++ 3 files changed, 100 insertions(+), 25 deletions(-) diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css index d9c72ec55b..47564236fe 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -41,6 +41,16 @@ /* Deep chains scroll inside the trail (the effect pins the tail into view) * so the edit zone to the right never leaves the bar. */ +/* The Miller columns keep their own row so a status/error line below never + * competes with the fixed column widths for horizontal space. */ +.millerRow { + display: flex; + align-items: stretch; + flex: 1 1 0; + min-height: 0; + gap: 20px; +} + .crumbTrail { display: flex; align-items: center; @@ -113,10 +123,9 @@ * the hairline divider centered between them; each column scrolls alone. */ .content { display: flex; - align-items: stretch; + flex-direction: column; flex: 1 1 0; min-height: 0; - gap: 20px; padding: 16px 24px 0; } diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 5318ad0ea0..c809ea43b6 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -120,6 +120,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // Deep ancestry overflows the trail; keep its tail (the current directory // and the edit zone beside it) in view whenever the chain changes. const crumbTrailRef = useRef(null) + // IME confirmation (Enter selecting a candidate) must not submit either + // text input; the same guard the workspace-name inputs carry. + const composingRef = useRef(false) /** Replace the whole view with one freshly listed level (no selection). */ const navigate = useCallback((path?: string) => { @@ -242,6 +245,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // focus trap, so every parent control goes inert (Shift-Tab or AT must not // close, adopt, or retarget underneath the child). const parentInert = busy || folderDraft !== null + // An uncommitted path draft makes targetPath stale relative to the header: + // committing actions must not act on the previous selection/listing while + // a different path is displayed. + const draftPending = pathDraft !== null return ( { setPathDraft(event.target.value) }} + onCompositionStart={() => { composingRef.current = true }} + onCompositionEnd={() => { composingRef.current = false }} onKeyDown={(event) => { - if (event.key === 'Enter') { + if (event.key === 'Enter' && !composingRef.current) { event.preventDefault() const target = pathDraft.trim() if (target !== '') navigate(target) @@ -315,25 +324,27 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
- {parent !== null && ( - - )} - {twoPane && } - {twoPane && child !== null && ( - - )} +
+ {parent !== null && ( + + )} + {twoPane && } + {twoPane && child !== null && ( + + )} +
{loading &&
{t('browser.loading')}
} {error !== null &&
{error}
}
@@ -341,7 +352,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,