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 cd1441f46f..bb9425fa64 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: 9f2703b7499870bf2d5ff8735e3c8cb2c351a503 -2026-07-28-directory-picker-capability-seam.zh.md: 946156a5d06cea30c833c768147b36b30f258192 +2026-07-28-directory-picker-capability-seam.md: 7c8f8cb67690cb4c5858cefb52b8cd79e649ec38 +2026-07-28-directory-picker-capability-seam.zh.md: 05545fc3cd758523814b31afa705249972d86464 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 9f2703b749..7c8f8cb676 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 @@ -12,7 +12,7 @@ The web GUI's "Open local folder" flow was hardwired to one interaction: `host.p A three-package capability seam in `packages/host/` — `directory-picker` (interface), `directory-picker-native`, `directory-picker-browse` (backends) — with one contract method: `capability()` returns a **discriminated union**, `{ kind: 'native', pick(signal) }` or `{ kind: 'browse', list(path?), createDirectory(path, name) }`. The gateway (`dsh-host-apiproxy`) injects `directoryPicker`, serves the matching RPCs, and answers `directory-picker-unavailable` for the other kind. The union is discriminated because the backends differ in *interaction shape* — flattening them into one method set would force every backend to fake the other's shape. -**The client side is slot-composed, not advertisement-branched.** ui-workspace's two trigger surfaces each declare a `single` directory-flow hole (`conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`; two keys because a hole has exactly one declaring slot entry — same owner contract, same occupant). Backend packages are **dual-face**: the browser half registers the matching interaction into both holes — `-native` ships here with a renderless occupant driving `host.pickDirectory`; `-browse`'s half (the in-app browsing dialog) lands in the stacked follow-up PR, until which a `-browse` composition shows no picking affordance (the documented empty-hole default). The hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`) carries the whole exchange: ui-workspace keeps the trigger (menu entry rendered only while the hole is occupied) and the adoption (`createWorkspace({path})`, conflict/error dialog, Choose again), the occupant owns everything between `open` and the picked path. One `cordis.yml` row therefore swaps the host capability and the client flow together; a mismatch is impossible by construction, and mounting two flow packages fails at client load (`single` hole). The earlier `host.describe.directoryPicker` advertisement and the client's kind branching are deleted — with composition wiring both sides, a wire fact for the client to branch on had no remaining consumer. The hole registry (`ctx.slots.entries`) replaces it as the per-menu-open occupancy read. +**The client side is slot-composed, not advertisement-branched.** ui-workspace's two trigger surfaces each declare a `single` directory-flow hole (`conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`; two keys because a hole has exactly one declaring slot entry — same owner contract, same occupant). Backend packages are **dual-face**: the browser half registers the matching interaction into both holes — `-native` a renderless occupant driving `host.pickDirectory`, `-browse` the in-app Select Workspace Directory dialog. The hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`) carries the whole exchange: ui-workspace keeps the trigger (menu entry rendered only while the hole is occupied) and the adoption (`createWorkspace({path})`, conflict/error dialog, Choose again), the occupant owns everything between `open` and the picked path. One `cordis.yml` row therefore swaps the host capability and the client flow together; a mismatch is impossible by construction, and mounting two flow packages fails at client load (`single` hole). The earlier `host.describe.directoryPicker` advertisement and the client's kind branching are deleted — with composition wiring both sides, a wire fact for the client to branch on had no remaining consumer. The hole registry (`ctx.slots.entries`) replaces it as the per-menu-open occupancy read. Placement and policy rulings folded into this decision: @@ -33,7 +33,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 946156a5d0..05545fc3cd 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 @@ -12,7 +12,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick 在 `packages/host/` 落一个三包能力 seam——`directory-picker`(接口)、`directory-picker-native`、`directory-picker-browse`(后端)——唯一契约方法 `capability()` 返回**可辨识联合**:`{ kind: 'native', pick(signal) }` 或 `{ kind: 'browse', list(path?), createDirectory(path, name) }`。网关(`dsh-host-apiproxy`)注入 `directoryPicker`,提供对应的 RPC,另一种 kind 的调用以 `directory-picker-unavailable` 应答。联合之所以可辨识,是因为后端差异在**交互形态**——压平成统一方法集会逼每个后端伪装另一方的形态。 -**client 侧靠 slot 组合,而非按广播分支。** ui-workspace 的两个触发表层各自声明一个 `single` 目录流洞(`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`;之所以是两个 key,是因为一个洞只有一个声明它的 slot entry——owner 契约相同、占用者相同)。后端包是**双面包**:browser half 把匹配的交互注册进两个洞——`-native` 在本 PR 随附驱动 `host.pickDirectory` 的无渲染占用者;`-browse` 的那一半(应用内浏览对话框)在栈中的后续 PR 落地,在那之前 `-browse` 组合不显示选目录入口(文档化的空洞默认行为)。洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)承载整个交换:ui-workspace 保留触发(菜单入口仅在洞被占用时渲染)与接纳(`createWorkspace({path})`、冲突/错误对话框、重新选择),占用者持有从 `open` 到所选路径之间的一切。因此一行 `cordis.yml` 同时切换宿主能力与 client 流程;错配在构造上不可能,同时挂两个流程包会在 client 加载期失败(`single` 洞)。早先的 `host.describe.directoryPicker` 广播与客户端 kind 分支被删除——组合已经接好两侧后,供客户端分支用的 wire 事实不再有任何消费者。洞注册表(`ctx.slots.entries`)取而代之,成为每次打开菜单的占用读取。 +**client 侧靠 slot 组合,而非按广播分支。** ui-workspace 的两个触发表层各自声明一个 `single` 目录流洞(`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`;之所以是两个 key,是因为一个洞只有一个声明它的 slot entry——owner 契约相同、占用者相同)。后端包是**双面包**:browser half 把匹配的交互注册进两个洞——`-native` 是驱动 `host.pickDirectory` 的无渲染占用者,`-browse` 是应用内的选择工作区目录对话框。洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)承载整个交换:ui-workspace 保留触发(菜单入口仅在洞被占用时渲染)与接纳(`createWorkspace({path})`、冲突/错误对话框、重新选择),占用者持有从 `open` 到所选路径之间的一切。因此一行 `cordis.yml` 同时切换宿主能力与 client 流程;错配在构造上不可能,同时挂两个流程包会在 client 加载期失败(`single` 洞)。早先的 `host.describe.directoryPicker` 广播与客户端 kind 分支被删除——组合已经接好两侧后,供客户端分支用的 wire 事实不再有任何消费者。洞注册表(`ctx.slots.entries`)取而代之,成为每次打开菜单的占用读取。 并入本决策的位置与策略裁决: @@ -33,7 +33,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 4795fd4511..fed929a466 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -264,12 +264,10 @@ # mapping target (user config overrides these engineering defaults). # 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: '-browse' -# serves remote-capable listing primitives; its in-app dialog (and this -# row's flip) land in the stacked follow-up PR — until then a '-browse' -# composition has no picking affordance. +# slots — one row composes the whole interaction. Swap point: mount +# '-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 8388774103..d683bb4aec 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -53,6 +53,7 @@ "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-session": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", + "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", 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..7fbcd35d75 --- /dev/null +++ b/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md @@ -0,0 +1,23 @@ +- dialog "选择工作区目录": + - heading "选择工作区目录" [level=2] + - navigation: + - button "主目录" + - img + - button "browse-golden" + - button "编辑路径" + - list: + - listitem: + - button "alpha": + - img + - text: alpha + - img + - listitem: + - button "beta": + - 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 ca98e7b176..86a20e73fe 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,20 @@ 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: '选择工作区目录' }, { 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 })) + // Open disables while the selection's child listing is in flight; wait for + // the enabled state or the click lands on a dead button on slow runners. + await waitFor(() => { + expect(within(dialog).getByRole('button', { name: '打开' }).disabled).toBe(false) + }, { timeout: 10_000 }) + fireEvent.click(within(dialog).getByRole('button', { name: '打开' })) await findHeroComposer() await waitFor(() => { expect(visibleText(screen.getByRole('tree', { name: 'Sessions' }))).toContain('project') diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index fd18d89087..ff9d458280 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)', () => { @@ -30,14 +31,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 +164,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 +220,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 +290,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 }, @@ -330,6 +346,42 @@ 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 }) + // homedir() reads HOME on POSIX and USERPROFILE on Windows: root both + // at the scaffold cwd so the golden's ancestry collapses everywhere. + const realHome = process.env.HOME + const realUserProfile = process.env.USERPROFILE + process.env.HOME = scaffold.workspaceCwd + process.env.USERPROFILE = 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 { + if (realHome === undefined) delete process.env.HOME + else process.env.HOME = realHome + if (realUserProfile === undefined) delete process.env.USERPROFILE + else process.env.USERPROFILE = realUserProfile + } + 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 @@ -361,8 +413,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/docs/module-graph.md b/docs/module-graph.md index b4046e361c..703f4c8abf 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -270,7 +270,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 @@ -362,6 +361,12 @@ 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_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 @@ -976,7 +981,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) | @@ -1006,6 +1010,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-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) | diff --git a/packages/client/runtime/src/client/contract/workspaces.ts b/packages/client/runtime/src/client/contract/workspaces.ts index cd32cbf56f..9238ea5fd0 100644 --- a/packages/client/runtime/src/client/contract/workspaces.ts +++ b/packages/client/runtime/src/client/contract/workspaces.ts @@ -6,7 +6,7 @@ * the concrete class. Widening this interface is the explicit act of * widening what features may do to the workspaces domain. */ -import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client' +import type { DirectoryListing, SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client' import type { WorkspaceListState } from '../workspaces/service.ts' import type { ObservableSnapshot } from './store.ts' @@ -37,6 +37,20 @@ export interface IWorkspaces { * @returns the selected path, or null when the user cancelled. */ pickDirectory(): Promise + /** + * List one directory level through the Host's `browse` capability. + * @param path - absolute directory to list; absent lists the Host home directory. + * @param signal - aborts the wire request (and the Host's scan) when the caller supersedes it. + * @returns the level's listing with breadcrumb ancestry. + */ + listDirectory(path?: string, signal?: AbortSignal): Promise + /** + * Create one child directory through the Host's `browse` capability. + * @param path - absolute existing parent directory. + * @param name - single non-blank path segment. + * @returns the created directory's absolute path. + */ + createDirectory(path: string, name: string): Promise /** * Open a filesystem path with the Host operating system's default application. * @param path - absolute or host-resolvable path. diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index acc0d079aa..1dd3319e79 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -195,10 +195,11 @@ export class WorkspacesService implements IWorkspaces { /** * List one directory level through the Host's `browse` capability. * @param path - absolute directory to list; absent lists the Host home directory. + * @param signal - aborts the wire request (and the Host's scan) when the caller supersedes it. * @returns the level's listing with breadcrumb ancestry. */ - async listDirectory(path?: string): Promise { - const response = await this.api.host.listDirectory(path === undefined ? {} : { path }) + async listDirectory(path?: string, signal?: AbortSignal): Promise { + const response = await this.api.host.listDirectory(path === undefined ? {} : { path }, signal) if (!response.result.ok) throw new DirectoryBrowseError(response.result.error) return response.result.value } diff --git a/packages/client/test-runtime/src/workspaces.ts b/packages/client/test-runtime/src/workspaces.ts index 32c9bff36e..6c1a9d0aad 100644 --- a/packages/client/test-runtime/src/workspaces.ts +++ b/packages/client/test-runtime/src/workspaces.ts @@ -1,7 +1,7 @@ /** Test-owned workspaces face: the renderer standard-kit observable plus recorded actions. */ import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { - IWorkspaces, SessionId, SnapshotStore, WorkspaceId, WorkspaceListState, WorkspaceView, + DirectoryListing, IWorkspaces, SessionId, SnapshotStore, WorkspaceId, WorkspaceListState, WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' import { workspaceListState } from './fixtures.ts' import type { Stabilizer } from './fixtures.ts' @@ -109,6 +109,48 @@ export class TestWorkspaces implements IWorkspaces { return null } + /** + * Browse listing (recorded). The default serves an empty home level; stub + * to shape a tree. + * @param path - absolute directory to list; absent lists the home level. + * @returns the level's listing. + */ + async listDirectory(path?: string, signal?: AbortSignal): Promise { + // The signal is recorded and forwarded like the production face passes + // it to the wire, so cancellation integration tests can observe or + // reject on a superseded scan. + this.calls.push({ method: 'listDirectory', args: [path, signal] }) + const stub = this.stubs.get('listDirectory') + if (stub !== undefined) return await (stub(path, signal) as Promise) + // The chain runs root-to-target inclusive, per the DirectoryListing + // contract — a bare root crumb would mislabel the level in browsers + // driven by this double. + return { + path: '/home/test', + home: '/home/test', + crumbs: [ + { name: '/', path: '/', hidden: false }, + { name: 'home', path: '/home', hidden: false }, + { name: 'test', path: '/home/test', hidden: false }, + ], + entries: [], + truncated: false, + } + } + + /** + * Browse child creation (recorded). The default joins parent and name. + * @param path - absolute existing parent directory. + * @param name - single path segment. + * @returns the created directory's absolute path. + */ + async createDirectory(path: string, name: string): Promise { + this.calls.push({ method: 'createDirectory', args: [path, name] }) + const stub = this.stubs.get('createDirectory') + if (stub !== undefined) return await (stub(path, name) as Promise) + return `${path}/${name}` + } + /** * Rename a Workspace (recorded). The default echoes a minimal view. * @param workspaceId - target workspace. diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index e63fcc3821..b170f69ba2 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -322,6 +322,32 @@ describe('workspaces', () => { expect(stub).toHaveBeenCalledOnce() await runtime.dispose() }) + + it('records the browse calls: listDirectory serves an empty home, createDirectory joins, stubs override', async () => { + const runtime = await runtimeWithFrame() + // Defaults: an empty home level and parent/name joining. + await expect(runtime.workspaces.listDirectory()).resolves.toMatchObject({ path: '/home/test', entries: [] }) + await expect(runtime.workspaces.listDirectory('/home/test')).resolves.toMatchObject({ path: '/home/test' }) + await expect(runtime.workspaces.createDirectory('/home/test', 'fresh')).resolves.toBe('/home/test/fresh') + // The recorded signal seat mirrors the production face (undefined here; + // cancellation tests pass and observe a real one). + expect(runtime.workspaces.calls).toEqual([ + { method: 'listDirectory', args: [undefined, undefined] }, + { method: 'listDirectory', args: ['/home/test', undefined] }, + { method: 'createDirectory', args: ['/home/test', 'fresh'] }, + ]) + // Stubs replace the defaults like every sibling method. + const listing = { path: '/x', home: '/x', crumbs: [], entries: [] } + const listStub = vi.fn(() => Promise.resolve(listing as never)) + runtime.workspaces.stub('listDirectory', listStub) + runtime.workspaces.stub('createDirectory', vi.fn(() => Promise.resolve('/x/made' as never))) + const scan = new AbortController() + await expect(runtime.workspaces.listDirectory('/x', scan.signal)).resolves.toBe(listing) + // The stub receives the signal too, like the production face gives the wire. + expect(listStub).toHaveBeenLastCalledWith('/x', scan.signal) + await expect(runtime.workspaces.createDirectory('/x', 'made')).resolves.toBe('/x/made') + await runtime.dispose() + }) }) describe('feature mount and disposal', () => { 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/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/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 96151039b3..4454afde3a 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: 9543fc89f1314d05d02df72e9b5d86af21fdad66 -README.zh.md: dd314c5b5a709b2cf2e6840911fd665ead7d7e87 +README.md: 318380405214d5f25ad77e348c4e134a8981ffb3 +README.zh.md: 2f88f64cc2974b8535e34eb9798f512ea109b754 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 9543fc89f1..3183804052 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. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (a windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated); window insertion is binary with an O(1) full-window tail rejection, and `list` threads the caller's `AbortSignal` so a disconnect or timeout stops the scan instead of letting it outlive the caller. 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. @@ -16,7 +18,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **No client half yet** — the in-app browsing dialog that consumes these primitives lands in the next PR of this stack; until then a `-browse` composition hides the picking affordance entirely (ui-workspace's documented empty-hole default) and the listing/creation RPCs go unconsumed. - **Windows hidden attribute is not read** — Node dirents do not expose `FILE_ATTRIBUTE_HIDDEN`, so `hidden` means dot-prefixed on every platform until a native probe is worth its cost. - **No drive-root enumeration** — on Windows the ancestry stops at the drive root; crossing drives waits for the browser UI's path-entry affordance rather than an enumeration primitive here. - **Whole-filesystem scope** — no per-deployment browse-root restriction; `workspace.create` accepts arbitrary paths today, so a root here would be UX scoping, not a boundary — deferred until a deployment needs it. diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index dd314c5b5a..2f88f64cc2 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 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断);窗口插入为二分查找、满窗尾部单次比较即拒绝,且 `list` 透传调用方的 `AbortSignal`,断连或超时会停止扫描而不是让它在调用方离开后继续。失败抛出 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 宿主的目录选择;这里没有任何内容进入模型请求。 @@ -16,7 +18,6 @@ ## 已知限制与延期工作 -- **尚无 client half**——消费这些原语的应用内浏览对话框在本栈的下一个 PR 落地;在那之前 `-browse` 组合会完全隐藏选目录入口(ui-workspace 文档化的空洞默认行为),列举/创建 RPC 无消费者。 - **不读取 Windows 隐藏属性**——Node 的 dirent 不暴露 `FILE_ATTRIBUTE_HIDDEN`,因此在所有平台上 `hidden` 都意味着点前缀,直到原生探测值回其成本为止。 - **不枚举盘符根**——Windows 上祖先链止于盘符根;跨盘依赖浏览器 UI 的路径输入入口,而不是这里的枚举原语。 - **全盘可浏览**——没有按部署限定的浏览根;`workspace.create` 今天就接受任意路径,这里的根只会是 UX 范围而非边界——等到有部署需要时再做。 diff --git a/packages/host/directory-picker-browse/package.json b/packages/host/directory-picker-browse/package.json index f83b0fcb86..a9f3fc2090 100644 --- a/packages/host/directory-picker-browse/package.json +++ b/packages/host/directory-picker-browse/package.json @@ -15,12 +15,17 @@ "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" @@ -28,14 +33,36 @@ "license": "BSD-3-Clause", "dependencies": { "@deepseek-ai/dsh-host-directory-picker": "workspace:^", + "clsx": "^2.0.0", "schemastery": "^3.18.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..800af854a1 --- /dev/null +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -0,0 +1,306 @@ +/* Directory-browser dialog (figma 813-23126 family). The shared Modal renders + * headless here — mask, card, Escape only — and this module owns the figma + * frame: 600×420 card (viewport-clamped), 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. */ +/* Short viewports clamp the card: header/footer are flex-none and the + * columns scroll, so shrinking the height keeps Open/Cancel reachable + * instead of clipping them below a fixed overlay. */ +.dialog.dialog { + width: min(600px, 100%); + height: min(420px, calc(100dvh - 32px)); + 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; +} + +/* 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. */ +/* A narrow viewport shrinks the dialog below two fixed panes; the row + * scrolls horizontally (the effect pins the child pane into view) so + * descent never hides behind the Modal's clipping. */ +.millerRow { + display: flex; + align-items: stretch; + flex: 1 1 0; + min-height: 0; + gap: 20px; + overflow-x: auto; +} + +.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; + 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 0 34px; + 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; + flex-direction: column; + flex: 1 1 0; + min-height: 0; + 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); +} + +.rowSeat { + display: flex; + flex: none; +} + +.row { + width: 100%; + 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; + /* Narrow viewports wrap the confirm/cancel pair onto their own row + * instead of clipping Open past the card's hidden overflow. */ + flex-wrap: wrap; + 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..f348f1dd8d --- /dev/null +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -0,0 +1,503 @@ +/** + * The in-app workspace-directory browser (figma Harness 813-23126 family): a + * 600×420 dialog (clamped to short/narrow viewports — the Miller row scrolls + * sideways, the columns scroll down) 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); the signal aborts a superseded scan on the wire. */ + listDirectory: (path?: string, signal?: AbortSignal) => 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 ( + // The wrapper carries the list semantics; the row keeps its NATIVE + // button role so assistive technology exposes an actionable control. + + + + ) + })} +
+ ) +} + +/** + * 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) + // The in-flight listing's controller: superseding intent aborts the wire + // request too — the Host stops scanning — instead of only discarding the + // eventual result while the scan keeps consuming host resources. + const scanController = useRef(null) + // Bumped on every open/close edge: settlements from a previous open (a + // pending creation included) must never mutate a reopened dialog. + const openGeneration = 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) + // IME confirmation (Enter selecting a candidate) must not submit either + // text input; the same guard the workspace-name inputs carry, shared by + // the path editor and the folder-name input. + const composingRef = useRef(false) + // HMR/unmount invalidation: a completion from a disposed flow must not + // update state or issue follow-up requests from a dead component. + useEffect(() => () => { + requestSeq.current += 1 + openGeneration.current += 1 + scanController.current?.abort() + }, []) + const compositionGuard = { + onCompositionStart: () => { composingRef.current = true }, + onCompositionEnd: () => { composingRef.current = false }, + } + + /** Newer intent wins: invalidate the pending listing's settlement AND abort its wire request. */ + const supersede = useCallback((): number => { + scanController.current?.abort() + scanController.current = null + return ++requestSeq.current + }, []) + + /** Launch one listing under a fresh controller so a later supersession can abort it. */ + const launchListing = useCallback((path: string | undefined): { seq: number; scan: Promise } => { + const seq = supersede() + const controller = new AbortController() + scanController.current = controller + return { seq, scan: listDirectory(path, controller.signal) } + }, [supersede, listDirectory]) + + /** Replace the whole view with one freshly listed level (no selection). */ + const navigate = useCallback((path?: string) => { + const { seq, scan } = launchListing(path) + setLoading(true) + setError(null) + scan.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)) + }) + }, [launchListing]) + + /** Select a row of the listed level and preview its children on the right. */ + const select = useCallback((entry: DirectoryEntry) => { + const { seq, scan } = launchListing(entry.path) + setSelected(entry) + setChild(null) + setLoading(true) + setError(null) + scan.then((next) => { + if (seq !== requestSeq.current) return + setChild(next) + setLoading(false) + }, (reason: unknown) => { + if (seq !== requestSeq.current) return + setLoading(false) + setError(failureText(reason)) + // An unreadable selection cannot be the committing target while the + // breadcrumb still names the level: fall back to the single pane. + setSelected(null) + }) + }, [launchListing]) + + /** 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(() => { + openGeneration.current += 1 + if (open) { + setParent(null) + setSelected(null) + setChild(null) + setCreatingFolder(false) + navigate() + return + } + supersede() + setError(null) + setPathDraft(null) + setFolderDraft(null) + setCreateError(null) + }, [open, navigate, supersede]) + + /** 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 + // Trim only rejects an all-whitespace draft; the Host gets the original + // spelling — the backend accepts any non-blank single segment verbatim, + // and trimming here would create (and select) a different sibling. + const name = folderDraft + if (name.trim() === '') return + setCreatingFolder(true) + setCreateError(null) + const generation = openGeneration.current + createDirectory(targetPath, name).then((createdPath) => { + // A settlement from a closed (possibly reopened) flow must not touch + // the fresh dialog or issue a relist against the stale target. + if (generation !== openGeneration.current) return + 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, scan } = launchListing(targetPath) + setLoading(true) + scan.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) => { + if (generation !== openGeneration.current) return + setCreatingFolder(false) + setCreateError(failureText(reason)) + }) + } + + // After the hooks: a closed dialog renders nothing and evaluates no copy. + 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]) + // On viewports too narrow for both fixed panes the Miller row scrolls; + // whenever a child preview lands, pin it into view the way the crumb tail + // pins — otherwise descent is unreachable on a phone-width window. + const millerRowRef = useRef(null) + const childPath = child?.path + useEffect(() => { + const row = millerRowRef.current + if (row !== null && childPath !== undefined) row.scrollLeft = row.scrollWidth + }, [childPath]) + + if (!open) return null + const twoPane = selected !== null + // The nested create dialog owns the interaction while open: Modal has no + // 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 ( + { if (folderDraft === null && !busy) onClose() }} + title={t('browser.title')} + className={clsx(css.dialog)} + headless + > +
+

{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')}
} + {/* The backend bounds a level at its complete-result limit; say so + * whenever a visible pane was cut instead of letting the tail of a + * huge directory go silently missing. */} + {(parent?.truncated === true || child?.truncated === true) && !loading + &&
{t('browser.truncated')}
} + {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) }} + {...compositionGuard} + onKeyDown={(event) => { + if (event.key === 'Enter' && !composingRef.current) { + 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/flow.ts b/packages/host/directory-picker-browse/src/client/flow.ts new file mode 100644 index 0000000000..84e49b2c98 --- /dev/null +++ b/packages/host/directory-picker-browse/src/client/flow.ts @@ -0,0 +1,43 @@ +/** + * The browse picking occupant (package-internal; the `./client` surface + * exposes only the Loader exports). Same-package tests exercise it directly + * through this module. + */ +import { createElement } from 'react' +import type { ReactElement } from 'react' +import type { DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client' +import type { Translate } from '@deepseek-ai/dsh-client-locale/client' +// Type-only: the owner contract of the directory-flow holes. +import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client' +import { DirectoryBrowser } from './DirectoryBrowser.tsx' + +/** Injected face: the browse wire calls and copy the dialog drives (bound in apply's closure). */ +export interface BrowseFlowInjected { + /** List one directory level (absent path = the Host home directory); the signal aborts a superseded scan. */ + listDirectory: (path?: string, signal?: AbortSignal) => 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, + }) +} 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..8ec0ffc5f9 --- /dev/null +++ b/packages/host/directory-picker-browse/src/client/index.ts @@ -0,0 +1,91 @@ +/** + * 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 { deferGroupRegistration } from '@deepseek-ai/dsh-client-ui-slots' +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +// Type-only: pulls the SlotMap merge declaring the directory-flow holes. +import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' +import type { BrowseFlowInjected } from './flow.ts' +import { BrowseDirectoryFlow } from './flow.ts' + +/** Locale namespace owning the browser dialog's copy. */ +const LOCALE_NS = 'directory-browser' + +/** 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(() => { + // The two dictionaries land as a unit: if the second registration hits a + // rival owner of the namespace, the first rolls back before the throw — + // a failed activation must not squat the namespace's other locale. + const disposers: (() => void)[] = [] + const dictionaries: [locale: string, dict: Record][] = [ + ['zh', { + 'browser.title': '选择工作区目录', + 'browser.home': '主目录', + 'browser.newFolder': '新建文件夹', + 'browser.folderName': '文件夹名称', + 'browser.createIn': '在"{name}"中新建文件夹', + 'browser.untitledFolder': '未命名文件夹', + 'browser.create': '创建', + 'browser.cancel': '取消', + 'browser.open': '打开', + 'browser.editPath': '编辑路径', + 'browser.loading': '加载中…', + 'browser.truncated': '文件夹过多,仅显示开头部分。', + }], + ['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…', + 'browser.truncated': 'Too many folders to list; only the beginning is shown.', + }], + ] + try { + for (const [locale, dict] of dictionaries) disposers.push(ctx.locale.register(LOCALE_NS, locale, dict)) + } catch (error) { + for (const dispose of disposers.reverse()) dispose() + throw error + } + return () => { for (const dispose of disposers) dispose() } + }, 'directory-picker-browse: dialog dictionaries') + + const injected = (): BrowseFlowInjected => ({ + listDirectory: (path, signal) => ctx.workspaces.listDirectory(path, signal), + createDirectory: (path, name) => ctx.workspaces.createDirectory(path, name), + t: ctx.locale.bind(LOCALE_NS), + }) + ctx.effect(() => { + // One occupant, both holes, as a unit: construction or late conflicts + // (holes declared after rival providers activated) roll the whole pair + // back and fail loud — semantics owned by deferGroupRegistration. + const group = deferGroupRegistration( + ctx.slots, + ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const, + BrowseDirectoryFlow, + name => ctx.slots.register({ name, inject: injected }, BrowseDirectoryFlow), + ) + return () => { group.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..8a9b27b5d7 --- /dev/null +++ b/packages/host/directory-picker-browse/tests/client-flow.spec.tsx @@ -0,0 +1,214 @@ +// @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, inject } from '../src/client/index.ts' +import { BrowseDirectoryFlow } from '../src/client/flow.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 }], + truncated: 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('rolls back the first deferral when the second hole is already occupied', async () => { + const b = await bench() + b.declare() + // Foreign occupant in the SECOND registered hole: the pair construction + // throws after the first deferral installed its subscription. + b.slots.register({ name: HOLES[1] } as never, () => null) + const rejections: unknown[] = [] + const onUnhandled = (reason: unknown): void => { rejections.push(reason) } + process.on('unhandledRejection', onUnhandled) + try { + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await expect(fiber.await()).rejects.toThrow(/already has a registration/) + // A leaked first deferral would now race this probe registration and + // throw from its orphaned subscription against the HERO hole; the + // rollback leaves only the activation failure itself (cordis re-raises + // the apply throw as a late rejection — installFailLoud's contract). + const disposeProbe = b.slots.register({ name: HOLES[0] } as never, () => null) + await new Promise(resolve => setTimeout(resolve, 20)) + expect(rejections.map(String).filter(text => text.includes(HOLES[0]))).toEqual([]) + disposeProbe() + } finally { + process.off('unhandledRejection', onUnhandled) + } + }) + + it('rolls back wholesale and reports loudly when a rival provider wins after deferred activation', async () => { + const b = await bench() + const rejections: unknown[] = [] + const onUnhandled = (reason: unknown): void => { rejections.push(reason) } + process.on('unhandledRejection', onUnhandled) + process.on('uncaughtException', onUnhandled) + try { + // This provider activates BEFORE any hole exists: both deferrals wait. + await b.ctx.plugin({ inject: [...inject], apply }).await() + b.declare() + // A rival occupies both holes ahead of the pending microtask flush. + b.slots.register({ name: HOLES[0] } as never, () => null) + b.slots.register({ name: HOLES[1] } as never, () => null) + await new Promise(resolve => setTimeout(resolve, 20)) + // The rival keeps both holes; this provider rolled back wholesale and + // surfaced the conflict on the fail-loud channel — no partial mix. + for (const hole of HOLES) expect(b.slots.entries(hole)).toHaveLength(1) + expect(rejections.map(String).join('\n')).toContain('already has a registration') + + // Non-Error conflicts wrap before the loud rethrow (same channel). + const c = await bench() + await c.ctx.plugin({ inject: [...inject], apply }).await() + const original = c.slots.register.bind(c.slots) + const slotsAny = c.slots as { register: typeof original } + slotsAny.register = ((options: never, component: never) => { + if ((options as { name?: string }).name === HOLES[0]) throw 'string conflict' + return original(options, component) + }) as typeof original + c.declare() + await new Promise(resolve => setTimeout(resolve, 20)) + expect(rejections.map(String).join('\n')).toContain('string conflict') + } finally { + process.off('unhandledRejection', onUnhandled) + process.off('uncaughtException', onUnhandled) + } + }) + + it('rolls back the zh dictionary when a rival already owns the namespace en slot', async () => { + const b = await bench() + b.declare() + const locale = b.ctx.get('locale') as LocaleService + const disposeRival = locale.register('directory-browser', 'en', { 'browser.title': 'rival' }) + const rejections: unknown[] = [] + const onUnhandled = (reason: unknown): void => { rejections.push(reason) } + // cordis re-raises the apply throw as a late rejection (installFailLoud's contract). + process.on('unhandledRejection', onUnhandled) + try { + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await expect(fiber.await()).rejects.toThrow(/already has locale/) + // The zh registration rolled back with the failure: once the rival + // leaves, a fresh registrant owns the whole namespace again. + disposeRival() + const disposeZh = locale.register('directory-browser', 'zh', { 'browser.title': '空闲' }) + disposeZh() + } finally { + await new Promise(resolve => setTimeout(resolve, 0)) + process.off('unhandledRejection', onUnhandled) + } + }) + + 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..babe7c8bf7 --- /dev/null +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -0,0 +1,792 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, 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 }, + ], + truncated: 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 }], + truncated: 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: [], + truncated: 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, 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') +} + +/** 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() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + expect(b.listDirectory).toHaveBeenCalledWith(undefined, expect.any(AbortSignal)) + 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(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(rowButton(selectedRow).getAttribute('aria-current')).toBe('true') + expect(within(preview!).getByRole('listitem').textContent).toBe('harness') + expect(b.listDirectory).toHaveBeenLastCalledWith(DOCS, expect.any(AbortSignal)) + 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(rowButton(screen.getByRole('listitem'))) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + 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(rowButton(selectedRow).getAttribute('aria-current')).toBe('true') + }) + + it('aborts a superseded listing on the wire, and the in-flight one on close', async () => { + const signals: (AbortSignal | undefined)[] = [] + const gates: (() => void)[] = [] + const listDirectory = vi.fn((path?: string, signal?: AbortSignal) => { + signals.push(signal) + if (signals.length === 1) return Promise.resolve(listingFor(path)) + // Later listings hang until released: supersession must abort them + // on the wire, not merely discard their eventual results. + return new Promise((resolve) => { gates.push(() => { resolve(listingFor(path)) }) }) + }) + const b = mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(rowButton(screen.getByRole('listitem'))) + expect(signals).toHaveLength(2) + // A crumb jump supersedes the hanging preview: its request aborts. + fireEvent.click(screen.getByRole('button', { name: 'browser.home' })) + expect(signals[1]?.aborted).toBe(true) + expect(signals[2]?.aborted).toBe(false) + // Closing the dialog aborts the still-pending navigation too. + b.view.rerender() + expect(signals[2]?.aborted).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(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(rowButton(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(rowButton(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('restarts the home listing when Escape cancels an edit opened before any level listed', async () => { + // The initial home listing hangs; Edit Path supersedes it while parent + // is still null, and Escape must not strand a blank picker. + let settled = false + const gate = new Promise(() => {}) + const listDirectory = vi.fn(async (path?: string) => { + if (!settled) { settled = true; return gate } + return listingFor(path) + }) + mount({ listDirectory }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + expect(input.value).toBe('') + fireEvent.keyDown(input, { key: 'Escape' }) + // Cancellation relaunched the home listing instead of leaving neither + // rows nor status behind. + await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') }) + expect(listDirectory).toHaveBeenCalledTimes(2) + expect(listDirectory).toHaveBeenLastCalledWith(undefined, expect.any(AbortSignal)) + }) + + it('passes the entered path to the Host untrimmed (trim only gates blank drafts)', async () => { + const listDirectory = vi.fn(async (path?: string) => listingFor(path)) + mount({ listDirectory }) + 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: `${DOCS} ` } }) + fireEvent.keyDown(input, { key: 'Enter' }) + // A trailing space may name a real directory; trimming would list its sibling. + await waitFor(() => { expect(listDirectory).toHaveBeenLastCalledWith(`${DOCS} `, expect.any(AbortSignal)) }) + }) + + 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: [], + truncated: false, + } + 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('scopes Escape to the topmost dialog: the nested create closes first, the browser only after', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + expect(screen.getByLabelText('browser.folderName')).toBeTruthy() + fireEvent.keyDown(document, { key: 'Escape' }) + // The nested dialog consumed Escape; the browser stays up. + expect(screen.queryByLabelText('browser.folderName')).toBeNull() + expect(b.onClose).not.toHaveBeenCalled() + fireEvent.keyDown(document, { key: 'Escape' }) + expect(b.onClose).toHaveBeenCalledOnce() + }) + + it('keeps both dialogs open when Escape lands during an in-flight creation', async () => { + let resolve!: (path: string) => void + const createDirectory = vi.fn(() => new Promise((settle) => { resolve = settle })) + const b = mount({ createDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + fireEvent.change(screen.getByLabelText('browser.folderName'), { target: { value: 'pending' } }) + fireEvent.click(screen.getByRole('button', { name: 'browser.create' })) + fireEvent.keyDown(document, { key: 'Escape' }) + // The in-flight fence holds the nested dialog, and the browser must not + // fall out from under it either. + expect(screen.getByLabelText('browser.folderName')).toBeTruthy() + expect(b.onClose).not.toHaveBeenCalled() + await act(async () => { resolve(`${HOME}/pending`) }) + }) + + it('keeps New folder disabled while the post-create relist is still loading', async () => { + const pending: (() => void)[] = [] + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + // Every listing after the create hangs until drained: the button must not + // offer a second create against a target the pending relist/select + // sequence is about to change. + const fresh: DirectoryListing = { + path: `${HOME}/fresh`, home: HOME, + crumbs: [...listingFor(HOME).crumbs, { name: 'fresh', path: `${HOME}/fresh`, hidden: false }], + entries: [], + truncated: false, + } + b.listDirectory.mockImplementation((path?: string) => + new Promise((settle) => { + pending.push(() => { settle(path === `${HOME}/fresh` ? fresh : listingFor(path)) }) + })) + fireEvent.change(screen.getByLabelText('browser.folderName'), { target: { value: 'fresh' } }) + fireEvent.click(screen.getByRole('button', { name: 'browser.create' })) + await waitFor(() => { expect(screen.queryByLabelText('browser.folderName')).toBeNull() }) + expect(screen.getByRole('button', { name: 'browser.newFolder' }).disabled).toBe(true) + // Drain the relist and the follow-up selection listing; only then does + // the affordance return. + await act(async () => { for (const settle of pending.splice(0)) settle() }) + await act(async () => { for (const settle of pending.splice(0)) settle() }) + 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('disables Open and New folder while a path draft is uncommitted', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + // targetPath still names the previous listing; committing actions must + // not act on it while a different path is displayed in the header. + expect(screen.getByRole('button', { name: 'browser.open' }).disabled).toBe(true) + expect(screen.getByRole('button', { name: 'browser.newFolder' }).disabled).toBe(true) + fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Escape' }) + expect(screen.getByRole('button', { name: 'browser.open' }).disabled).toBe(false) + }) + + it('ignores Enter while an IME composition is active in either input', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + // Path editor: a composing Enter confirms the candidate, not the path. + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const pathInput = screen.getByLabelText('browser.editPath') + fireEvent.change(pathInput, { target: { value: DOCS } }) + const listCalls = b.listDirectory.mock.calls.length + fireEvent.compositionStart(pathInput) + fireEvent.keyDown(pathInput, { key: 'Enter' }) + expect(b.listDirectory.mock.calls.length).toBe(listCalls) + fireEvent.compositionEnd(pathInput) + fireEvent.keyDown(pathInput, { key: 'Enter' }) + await waitFor(() => { expect(b.listDirectory).toHaveBeenLastCalledWith(DOCS, expect.any(AbortSignal)) }) + // Create dialog: same guard. + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + const nameInput = screen.getByLabelText('browser.folderName') + fireEvent.change(nameInput, { target: { value: '新建' } }) + fireEvent.compositionStart(nameInput) + fireEvent.keyDown(nameInput, { key: 'Enter' }) + expect(b.createDirectory).not.toHaveBeenCalled() + fireEvent.compositionEnd(nameInput) + fireEvent.keyDown(nameInput, { key: 'Enter' }) + await waitFor(() => { expect(b.createDirectory).toHaveBeenCalledWith(DOCS, '新建') }) + }) + + it('surfaces a two-pane navigation failure as an alert below the columns', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(rowButton(screen.getByRole('listitem'))) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + b.listDirectory.mockImplementation(async () => { + throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'denied', details: { path: HOME } }) + }) + fireEvent.click(within(screen.getByRole('navigation')).getByRole('button', { name: 'browser.home' })) + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') }) + // Both panes survive the failure; the alert renders in the flow, not as a + // third column competing for the fixed widths. + expect(columns()).toHaveLength(2) + }) + + it('keeps the editor open when a pending listing settles right after Edit Path was clicked', async () => { + const pending: ((listing: DirectoryListing) => void)[] = [] + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + // A crumb navigation hangs; the user opens the editor before it settles. + b.listDirectory.mockImplementation(() => + new Promise((settle) => { pending.push(settle) })) + fireEvent.click(within(screen.getByRole('navigation')).getByRole('button', { name: 'browser.home' })) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + expect(screen.getByLabelText('browser.editPath')).toBeTruthy() + await act(async () => { pending.shift()!(listingFor(HOME)) }) + // The superseded settlement must not close the editor underneath the user. + expect(screen.getByLabelText('browser.editPath')).toBeTruthy() + }) + + it('ignores a pending navigation that settles after Escape cancelled the editor', async () => { + const pending: ((listing: DirectoryListing) => void)[] = [] + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + b.listDirectory.mockImplementation(() => + new Promise((settle) => { pending.push(settle) })) + fireEvent.change(input, { target: { value: DOCS } }) + fireEvent.keyDown(input, { key: 'Enter' }) + fireEvent.keyDown(input, { key: 'Escape' }) + // The cancelled navigation settling late must not jump the view to DOCS. + await act(async () => { pending.shift()!(listingFor(DOCS)) }) + expect(screen.queryByText('harness')).toBeNull() + expect(screen.getByText('Documents')).toBeTruthy() + expect(screen.queryByRole('status')).toBeNull() + }) + + it('keeps a newer path edit when an older slow navigation settles', async () => { + const pending: ((listing: DirectoryListing) => void)[] = [] + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + b.listDirectory.mockImplementation(() => + new Promise((settle) => { pending.push(settle) })) + fireEvent.change(input, { target: { value: DOCS } }) + fireEvent.keyDown(input, { key: 'Enter' }) + // The user keeps typing while the lookup hangs; the older completion must + // neither clear this newer draft nor swap the view to the older path. + fireEvent.change(input, { target: { value: `${DOCS}/har` } }) + await act(async () => { pending.shift()!(listingFor(DOCS)) }) + expect(screen.getByLabelText('browser.editPath').value).toBe(`${DOCS}/har`) + expect(screen.queryByText('harness')).toBeNull() + }) + + it('keeps an intact selection preview when a path edit is cancelled', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(rowButton(screen.getByRole('listitem'))) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Escape' }) + // Nothing was superseded: the two-pane view survives the cancel. + expect(columns()).toHaveLength(2) + }) + + it('falls back to the single-pane level when a path edit superseded the preview and was cancelled', async () => { + const pending: ((listing: DirectoryListing) => void)[] = [] + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + // Selection starts a preview that never lands (superseded below). + b.listDirectory.mockImplementation(() => + new Promise((settle) => { pending.push(settle) })) + fireEvent.click(rowButton(screen.getByRole('listitem'))) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: `${DOCS}/x` } }) + fireEvent.keyDown(input, { key: 'Escape' }) + // No half-empty two-pane residue: back to the single wide level. + expect(columns()).toHaveLength(1) + expect(screen.getByRole('button', { name: 'browser.editPath' })).toBeTruthy() + }) + + it('drops a creation that settles after the browser unmounted', async () => { + let settleCreate!: (path: string) => void + const createDirectory = vi.fn(() => new Promise((settle) => { settleCreate = settle })) + const b = mount({ createDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + fireEvent.change(screen.getByLabelText('browser.folderName'), { target: { value: 'slow' } }) + fireEvent.click(screen.getByRole('button', { name: 'browser.create' })) + const listCalls = b.listDirectory.mock.calls.length + b.view.unmount() + // The dead flow must not issue the post-create relist. + await act(async () => { settleCreate(`${HOME}/slow`) }) + expect(b.listDirectory.mock.calls.length).toBe(listCalls) + }) + + it('clears the selection when its preview listing fails', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + b.listDirectory.mockImplementation(async () => { + throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'denied', details: { path: DOCS } }) + }) + fireEvent.click(rowButton(screen.getByRole('listitem'))) + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') }) + // The breadcrumb names the level, so the level must be the committing + // target: no half-selected two-pane state survives the failure. + expect(columns()).toHaveLength(1) + expect(rowButton(screen.getByRole('listitem')).getAttribute('aria-current')).toBeNull() + }) + + it('ignores dismissal while adoption is busy', async () => { + const b = mount({ busy: true }) + await waitFor(() => { expect(screen.getByRole('dialog')).toBeTruthy() }) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(b.onClose).not.toHaveBeenCalled() + }) + + it('makes every parent control inert while the nested create dialog is open', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + // Modal traps no focus: Shift-Tab/AT reach the parent, so closing, + // adopting, and retargeting must all disable underneath the child. Both + // dialogs carry a cancel: the parent's disables, the child's stays live. + const cancels = screen.getAllByRole('button', { name: 'browser.cancel' }) + 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(rowButton(row).disabled).toBe(true) + } + }) + + it('drops a creation failure that lands after the flow closed and reopened', async () => { + let rejectCreate!: (reason: unknown) => void + const createDirectory = vi.fn(() => new Promise((_settle, reject) => { rejectCreate = reject })) + const b = mount({ createDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + fireEvent.change(screen.getByLabelText('browser.folderName'), { target: { value: 'slow' } }) + fireEvent.click(screen.getByRole('button', { name: 'browser.create' })) + b.view.rerender() + b.view.rerender() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + // The stale failure must not surface an alert inside the fresh flow. + await act(async () => { rejectCreate(new Error('too late')) }) + expect(screen.queryByText('too late')).toBeNull() + }) + + it('drops a creation that settles after the flow closed and reopened', async () => { + let settleCreate!: (path: string) => void + const createDirectory = vi.fn(() => new Promise((settle) => { settleCreate = settle })) + const b = mount({ createDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + fireEvent.change(screen.getByLabelText('browser.folderName'), { target: { value: 'slow' } }) + fireEvent.click(screen.getByRole('button', { name: 'browser.create' })) + b.view.rerender() + b.view.rerender() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + const listCallsBefore = b.listDirectory.mock.calls.length + // The stale settlement must not relist the old target or reopen the + // nested dialog's state inside the fresh flow. + await act(async () => { settleCreate(`${HOME}/slow`) }) + expect(b.listDirectory.mock.calls.length).toBe(listCallsBefore) + expect(screen.queryByLabelText('browser.folderName')).toBeNull() + expect(screen.getByText('Documents')).toBeTruthy() + }) + + it('passes the folder name to the Host untrimmed (trim only gates blank drafts)', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + const input = screen.getByLabelText('browser.folderName') + fireEvent.change(input, { target: { value: 'project ' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + // A trailing space may be the wanted spelling; trimming would create a sibling. + await waitFor(() => { expect(b.createDirectory).toHaveBeenCalledWith(HOME, 'project ') }) + }) + + 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(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). + 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: [], + truncated: false, + } + } + 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(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' && rowButton(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 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(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) }) + 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(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')) + 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(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(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)) + 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(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(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)) + // 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: [], truncated: false } + 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('says a level is incomplete when the backend cut it at its bound', async () => { + const cut = { ...listingFor(HOME), truncated: true } + mount({ listDirectory: vi.fn(async () => cut) }) + await screen.findByText('browser.truncated') + }) + + it('flags a truncated child preview under a complete level', async () => { + mount({ + listDirectory: vi.fn(async (path?: string) => + (path === DOCS ? { ...listingFor(DOCS), truncated: true } : listingFor(path))), + }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + expect(screen.queryByText('browser.truncated')).toBeNull() + fireEvent.click(rowButton(screen.getByRole('listitem'))) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + await screen.findByText('browser.truncated') + }) + + it('pins the child pane into view when its preview lands (narrow viewports scroll the miller row)', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + const row = document.querySelector('[class*=millerRow]') as HTMLElement + // jsdom does no layout: stub the overflow width the effect pins against. + Object.defineProperty(row, 'scrollWidth', { value: 640, configurable: true }) + fireEvent.click(rowButton(screen.getByRole('listitem'))) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + await waitFor(() => { expect(row.scrollLeft).toBe(640) }) + }) + + it('starts back at home on reopen', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(rowButton(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, expect.any(AbortSignal)) + }) +}) 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 68d77fe2c4..18cb1ec0d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -227,6 +227,9 @@ importers: '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../../packages/host/apiproxy + '@deepseek-ai/dsh-host-directory-picker-browse': + specifier: workspace:^ + version: link:../../packages/host/directory-picker-browse '@deepseek-ai/dsh-host-directory-picker-native': specifier: workspace:^ version: link:../../packages/host/directory-picker-native @@ -2894,16 +2897,40 @@ importers: '@deepseek-ai/dsh-host-directory-picker': specifier: workspace:^ version: link:../directory-picker + clsx: + specifier: ^2.0.0 + version: 2.1.1 schemastery: specifier: ^3.18.0 version: 3.18.0 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 7890fd3fb4..7ab9e8cd3b 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 a41c4c49a0..e2112b7f6a 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" ], @@ -169,7 +170,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" },