From 006a6655ee78db607524e1c1f000273f488ff96b Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 17:15:39 +0800 Subject: [PATCH] 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