From 4321766ead539d457dbbcad4414ca1c4fba8b525 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:42:19 +0800 Subject: [PATCH 01/11] feat(client): add extensible document previews and sidebar defaults --- ...2026-09-05-client-resource-model.i18n.yaml | 4 +- .../2026-09-05-client-resource-model.md | 22 +- .../2026-09-05-client-resource-model.zh.md | 22 +- ...26-09-05-workspace-files-service.i18n.yaml | 4 +- .../2026-09-05-workspace-files-service.md | 42 +-- .../2026-09-05-workspace-files-service.zh.md | 42 +-- ...9-08-document-preview-operations.i18n.yaml | 6 + .../2026-09-08-document-preview-operations.md | 39 ++ ...26-09-08-document-preview-operations.zh.md | 39 ++ ...t-sidebar-docking-infrastructure.i18n.yaml | 4 +- ...04-right-sidebar-docking-infrastructure.md | 2 + ...right-sidebar-docking-infrastructure.zh.md | 2 + ...debar-text-preview-and-file-tree.i18n.yaml | 4 +- ...9-05-sidebar-text-preview-and-file-tree.md | 2 + ...5-sidebar-text-preview-and-file-tree.zh.md | 2 + ...2026-09-08-sidebar-default-pages.i18n.yaml | 6 + .../2026-09-08-sidebar-default-pages.md | 27 ++ .../2026-09-08-sidebar-default-pages.zh.md | 27 ++ THIRD_PARTY_NOTICES.md | 1 + .../tests/details-session-lifecycle.e2e.ts | 10 +- apps/web/tests/document-preview.e2e.ts | 353 ++++++++++++++++++ apps/web/tests/sidebar-right.e2e.ts | 108 +++--- apps/web/tsconfig.json | 1 + docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 + docs/config-catalog.zh.md | 2 + docs/subsystems/client-resources.i18n.yaml | 4 +- docs/subsystems/client-resources.md | 15 +- docs/subsystems/client-resources.zh.md | 15 +- docs/subsystems/sidebar-right.md | 29 +- docs/subsystems/sidebar-right.zh.md | 29 +- docs/subsystems/workspace.i18n.yaml | 4 +- docs/subsystems/workspace.md | 19 + docs/subsystems/workspace.zh.md | 19 + packages/api/workspace-files/README.i18n.yaml | 4 +- packages/api/workspace-files/README.md | 26 +- packages/api/workspace-files/README.zh.md | 26 +- packages/api/workspace-files/package.json | 2 - .../workspace-files/src/client/change-feed.ts | 51 +-- .../api/workspace-files/src/client/index.ts | 19 +- .../workspace-files/src/client/provider.ts | 75 ++-- .../api/workspace-files/src/client/types.ts | 32 +- packages/api/workspace-files/src/index.ts | 45 +++ .../tests/apply.client.spec.ts | 16 +- .../tests/change-feed.client.spec.ts | 26 +- .../api/workspace-files/tests/changes.spec.ts | 4 +- packages/api/workspace-files/tests/harness.ts | 1 + .../tests/host-path.client.spec.ts | 81 ++-- .../tests/provider.client.spec.ts | 181 +++------ .../workspace-files/tests/read-all.spec.ts | 86 +++++ .../api/workspace-files/tsconfig.client.json | 1 - packages/client/AGENTS.md | 2 +- packages/client/resources/README.i18n.yaml | 4 +- packages/client/resources/README.md | 7 +- packages/client/resources/README.zh.md | 7 +- .../client/resources/src/client/contract.ts | 7 - .../client/resources/src/client/resources.ts | 24 +- .../resources/tests/apply.client.spec.ts | 71 +++- .../resources/tests/resources.client.spec.ts | 112 +++++- packages/client/ui-chat/src/client/apply.ts | 6 +- .../tests/apply-inject.client.spec.tsx | 4 +- .../ui-chat/tests/chat-view.client.spec.tsx | 2 +- .../tests/transcript-view-row.client.spec.tsx | 2 +- .../tests/enter-behavior-row.client.spec.tsx | 2 +- .../tests/input-bar.client.spec.tsx | 2 +- .../tests/input-matrix.client.spec.tsx | 2 +- .../tests/input-scenarios.client.spec.tsx | 2 +- .../tests/queue-dock.client.spec.tsx | 2 +- .../tests/skeleton.client.spec.tsx | 2 +- packages/client/ui-dockkit/README.i18n.yaml | 4 +- packages/client/ui-dockkit/README.md | 2 +- packages/client/ui-dockkit/README.zh.md | 2 +- .../ui-dockkit/src/components/DockSurface.tsx | 5 +- .../ui-dockkit/src/components/FloatLayer.tsx | 28 +- .../ui-dockkit/src/components/TabMenu.tsx | 24 +- .../ui-dockkit/src/components/TabPanel.tsx | 36 +- .../src/components/dockkit.module.css | 4 + .../ui-dockkit/src/components/render.ts | 2 + .../client/ui-dockkit/src/contract/adapter.ts | 2 +- .../tests/components.client.spec.tsx | 117 +++++- .../ui-layout/tests/app-frame.client.spec.tsx | 2 +- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../src/markdown/CodeBlock.module.css | 25 ++ .../ui-primitives/src/markdown/CodeBlock.tsx | 17 +- .../tests/code-block.client.spec.tsx | 43 +++ .../client/ui-sidebar-right/README.i18n.yaml | 4 +- packages/client/ui-sidebar-right/README.md | 8 +- packages/client/ui-sidebar-right/README.zh.md | 8 +- .../src/client/contract/seed.ts | 41 +- .../ui-sidebar-right/src/client/index.ts | 3 +- .../ui-sidebar-right/src/client/service.ts | 6 +- .../src/client/shell/SidebarRight.tsx | 3 + .../ui-sidebar-right/src/client/stores.ts | 116 +++--- .../tests/apply.client.spec.ts | 4 + .../tests/expand-button.client.spec.tsx | 2 +- .../tests/seat.client.spec.tsx | 41 +- .../tests/service.client.spec.ts | 6 +- .../tests/stores.client.spec.ts | 2 +- .../tests/tab-info.client.spec.tsx | 2 +- .../tests/tab-registry.client.spec.ts | 6 + .../ui-sidebar-textpreview/README.i18n.yaml | 4 +- .../client/ui-sidebar-textpreview/README.md | 48 ++- .../ui-sidebar-textpreview/README.zh.md | 48 ++- .../ui-sidebar-textpreview/package.json | 12 +- .../src/client/LoadingIndicator.module.css | 25 ++ .../src/client/LoadingIndicator.tsx | 13 + .../src/client/TextPreview.module.css | 17 +- .../src/client/TextPreview.tsx | 266 ++++++------- .../src/client/code/CodeBody.module.css | 34 ++ .../src/client/code/CodeBody.tsx | 33 ++ .../src/client/code/index.ts | 26 ++ .../src/client/code/languages.ts | 46 +++ .../src/client/code/locales.ts | 23 ++ .../src/client/definition.ts | 8 +- .../src/client/document/contract.ts | 54 +++ .../src/client/document/registry.ts | 103 +++++ .../ui-sidebar-textpreview/src/client/face.ts | 82 +++- .../src/client/html/HtmlBody.module.css | 15 + .../src/client/html/HtmlBody.tsx | 76 ++++ .../src/client/html/bootstrap.ts | 48 +++ .../src/client/html/bytes.ts | 21 ++ .../src/client/html/index.ts | 47 +++ .../src/client/html/locales.ts | 25 ++ .../src/client/html/pack.ts | 78 ++++ .../src/client/html/read-relative.ts | 37 ++ .../src/client/index.ts | 66 +++- .../src/client/locales.ts | 12 +- .../client/markdown/MarkdownBody.module.css | 7 + .../src/client/markdown/MarkdownBody.tsx | 31 ++ .../src/client/markdown/index.ts | 31 ++ .../src/client/markdown/locales.ts | 25 ++ .../src/client/pdf/PdfBody.module.css | 56 +++ .../src/client/pdf/PdfBody.tsx | 132 +++++++ .../src/client/pdf/asset-imports.d.ts | 5 + .../src/client/pdf/assets.ts | 39 ++ .../src/client/pdf/document.ts | 73 ++++ .../src/client/pdf/errors.ts | 11 + .../src/client/pdf/index.ts | 47 +++ .../src/client/pdf/locales.ts | 53 +++ .../src/client/pdf/runtime.ts | 112 ++++++ .../src/client/pdf/store.ts | 54 +++ .../ui-sidebar-textpreview/src/client/rpc.ts | 44 ++- .../src/client/store.ts | 42 ++- .../src/client/text/TextBody.tsx | 31 ++ .../src/client/text/index.ts | 26 ++ .../src/client/text/lines.ts | 48 +++ .../tests/apply.client.spec.ts | 48 ++- .../tests/code-body.client.spec.tsx | 173 +++++++++ .../tests/code-languages.client.spec.ts | 37 ++ .../tests/code-registration.client.spec.tsx | 89 +++++ .../tests/definition.client.spec.ts | 24 +- .../tests/document-registry.client.spec.ts | 73 ++++ .../tests/document-seat.client.spec.tsx | 174 +++++++++ .../tests/document-toolbar.client.spec.tsx | 82 ++++ .../tests/face.client.spec.ts | 318 ++++++++++++++-- .../tests/fixtures.client.ts | 63 ++-- .../tests/html-apply.client.spec.ts | 85 +++++ .../tests/html-body.client.spec.tsx | 162 ++++++++ .../tests/html-bootstrap.client.spec.ts | 60 +++ .../tests/html-pack.client.spec.ts | 90 +++++ .../tests/html-read-relative.client.spec.ts | 57 +++ .../tests/markdown-body.client.spec.tsx | 103 +++++ .../markdown-registration.client.spec.tsx | 77 ++++ .../tests/pdf-assets.client.spec.ts | 39 ++ .../tests/pdf-body.client.spec.tsx | 210 +++++++++++ .../tests/pdf-document.client.spec.ts | 79 ++++ .../tests/pdf-fixture.ts | 25 ++ .../tests/pdf-registration.client.spec.ts | 73 ++++ .../tests/pdf-runtime.client.spec.ts | 234 ++++++++++++ .../tests/pdf-smoke.client.spec.ts | 64 ++++ .../tests/pdf-store.client.spec.ts | 26 ++ .../tests/pdf-worker.fixture.mjs | 19 + .../tests/rpc.client.spec.ts | 49 ++- .../tests/store.client.spec.ts | 22 ++ .../tests/text-preview.client.spec.tsx | 99 ++++- .../ui-sidebar-textpreview/tsconfig.json | 6 + .../ui-sidebar-textpreview/tsdown.config.ts | 55 ++- .../src/client/slot-catalog.ts | 50 +++ .../extensions/tool-cordis/src/api-catalog.ts | 12 + packages/util/workspace-path/README.i18n.yaml | 4 +- packages/util/workspace-path/README.md | 4 +- packages/util/workspace-path/README.zh.md | 4 +- .../util/workspace-path/src/file-address.ts | 21 +- packages/util/workspace-path/src/index.ts | 8 +- .../workspace-path/tests/file-address.spec.ts | 20 +- .../util/workspace-path/tests/index.spec.ts | 10 +- pnpm-lock.yaml | 143 ++++++- scripts/gen-cordis-catalog.ts | 1 + .../sidebar.expected.md | 18 +- .../web/document-preview/document.expected.md | 46 +++ .../web/document-preview/paging.patch.yml | 5 + snapshots/web/document-preview/snapshot.yml | 9 + tsconfig.host.json | 1 + 195 files changed, 6512 insertions(+), 1113 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md create mode 100644 .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md create mode 100644 .agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.md create mode 100644 .agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.zh.md create mode 100644 apps/web/tests/document-preview.e2e.ts create mode 100644 packages/api/workspace-files/tests/read-all.spec.ts create mode 100644 packages/client/ui-sidebar-textpreview/src/client/LoadingIndicator.module.css create mode 100644 packages/client/ui-sidebar-textpreview/src/client/LoadingIndicator.tsx create mode 100644 packages/client/ui-sidebar-textpreview/src/client/code/CodeBody.module.css create mode 100644 packages/client/ui-sidebar-textpreview/src/client/code/CodeBody.tsx create mode 100644 packages/client/ui-sidebar-textpreview/src/client/code/index.ts create mode 100644 packages/client/ui-sidebar-textpreview/src/client/code/languages.ts create mode 100644 packages/client/ui-sidebar-textpreview/src/client/code/locales.ts create mode 100644 packages/client/ui-sidebar-textpreview/src/client/document/contract.ts create mode 100644 packages/client/ui-sidebar-textpreview/src/client/document/registry.ts create mode 100644 packages/client/ui-sidebar-textpreview/src/client/html/HtmlBody.module.css create mode 100644 packages/client/ui-sidebar-textpreview/src/client/html/HtmlBody.tsx create mode 100644 packages/client/ui-sidebar-textpreview/src/client/html/bootstrap.ts create mode 100644 packages/client/ui-sidebar-textpreview/src/client/html/bytes.ts create mode 100644 packages/client/ui-sidebar-textpreview/src/client/html/index.ts create mode 100644 packages/client/ui-sidebar-textpreview/src/client/html/locales.ts create mode 100644 packages/client/ui-sidebar-textpreview/src/client/html/pack.ts create mode 100644 packages/client/ui-sidebar-textpreview/src/client/html/read-relative.ts create mode 100644 packages/client/ui-sidebar-textpreview/src/client/markdown/MarkdownBody.module.css create mode 100644 packages/client/ui-sidebar-textpreview/src/client/markdown/MarkdownBody.tsx create mode 100644 packages/client/ui-sidebar-textpreview/src/client/markdown/index.ts create mode 100644 packages/client/ui-sidebar-textpreview/src/client/markdown/locales.ts create mode 100644 packages/client/ui-sidebar-textpreview/src/client/pdf/PdfBody.module.css create mode 100644 packages/client/ui-sidebar-textpreview/src/client/pdf/PdfBody.tsx create mode 100644 packages/client/ui-sidebar-textpreview/src/client/pdf/asset-imports.d.ts create mode 100644 packages/client/ui-sidebar-textpreview/src/client/pdf/assets.ts create mode 100644 packages/client/ui-sidebar-textpreview/src/client/pdf/document.ts create mode 100644 packages/client/ui-sidebar-textpreview/src/client/pdf/errors.ts create mode 100644 packages/client/ui-sidebar-textpreview/src/client/pdf/index.ts create mode 100644 packages/client/ui-sidebar-textpreview/src/client/pdf/locales.ts create mode 100644 packages/client/ui-sidebar-textpreview/src/client/pdf/runtime.ts create mode 100644 packages/client/ui-sidebar-textpreview/src/client/pdf/store.ts create mode 100644 packages/client/ui-sidebar-textpreview/src/client/text/TextBody.tsx create mode 100644 packages/client/ui-sidebar-textpreview/src/client/text/index.ts create mode 100644 packages/client/ui-sidebar-textpreview/src/client/text/lines.ts create mode 100644 packages/client/ui-sidebar-textpreview/tests/code-body.client.spec.tsx create mode 100644 packages/client/ui-sidebar-textpreview/tests/code-languages.client.spec.ts create mode 100644 packages/client/ui-sidebar-textpreview/tests/code-registration.client.spec.tsx create mode 100644 packages/client/ui-sidebar-textpreview/tests/document-registry.client.spec.ts create mode 100644 packages/client/ui-sidebar-textpreview/tests/document-seat.client.spec.tsx create mode 100644 packages/client/ui-sidebar-textpreview/tests/document-toolbar.client.spec.tsx create mode 100644 packages/client/ui-sidebar-textpreview/tests/html-apply.client.spec.ts create mode 100644 packages/client/ui-sidebar-textpreview/tests/html-body.client.spec.tsx create mode 100644 packages/client/ui-sidebar-textpreview/tests/html-bootstrap.client.spec.ts create mode 100644 packages/client/ui-sidebar-textpreview/tests/html-pack.client.spec.ts create mode 100644 packages/client/ui-sidebar-textpreview/tests/html-read-relative.client.spec.ts create mode 100644 packages/client/ui-sidebar-textpreview/tests/markdown-body.client.spec.tsx create mode 100644 packages/client/ui-sidebar-textpreview/tests/markdown-registration.client.spec.tsx create mode 100644 packages/client/ui-sidebar-textpreview/tests/pdf-assets.client.spec.ts create mode 100644 packages/client/ui-sidebar-textpreview/tests/pdf-body.client.spec.tsx create mode 100644 packages/client/ui-sidebar-textpreview/tests/pdf-document.client.spec.ts create mode 100644 packages/client/ui-sidebar-textpreview/tests/pdf-fixture.ts create mode 100644 packages/client/ui-sidebar-textpreview/tests/pdf-registration.client.spec.ts create mode 100644 packages/client/ui-sidebar-textpreview/tests/pdf-runtime.client.spec.ts create mode 100644 packages/client/ui-sidebar-textpreview/tests/pdf-smoke.client.spec.ts create mode 100644 packages/client/ui-sidebar-textpreview/tests/pdf-store.client.spec.ts create mode 100644 packages/client/ui-sidebar-textpreview/tests/pdf-worker.fixture.mjs create mode 100644 snapshots/web/document-preview/document.expected.md create mode 100644 snapshots/web/document-preview/paging.patch.yml create mode 100644 snapshots/web/document-preview/snapshot.yml diff --git a/.agents/notes/implemented/architecture/2026-09-05-client-resource-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-05-client-resource-model.i18n.yaml index 72cf5954dc..aba709660e 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-client-resource-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-05-client-resource-model.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-09-05-client-resource-model.md -2026-09-05-client-resource-model.md: 75502ffc91af049bf89b7c36ec6ae3dc1339a5f8 -2026-09-05-client-resource-model.zh.md: d1430e88fc16b46a6ad32bbeacb1d59e0a7f6131 +2026-09-05-client-resource-model.md: e6ad8dae331d8a6392585f5dc8eb8fc52150bdc3 +2026-09-05-client-resource-model.zh.md: d716aa8a42f5c3037a329deabd572a56ab1b0537 diff --git a/.agents/notes/implemented/architecture/2026-09-05-client-resource-model.md b/.agents/notes/implemented/architecture/2026-09-05-client-resource-model.md index 75502ffc91..e6ad8dae33 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-client-resource-model.md +++ b/.agents/notes/implemented/architecture/2026-09-05-client-resource-model.md @@ -8,7 +8,7 @@ English | [中文](2026-09-05-client-resource-model.zh.md) A right-Sidebar tab body, a chat card, or any other slot component often needs live data it knows only by address: the file an agent just wrote, later a chat node or a terminal. Before the resource model each consumer fetched for itself — the text preview owned its own Remote call and refresh loop — so every mount re-read, two components showing one file held two copies, switching tabs unmounted the body and lost its content, and each new kind of content meant a new bespoke hook. -The tab record set the constraint. A tab must survive undo, redo, reload, and hot module replacement without the code that opened it, so the record can hold only serializable data: an address and navigation parameters. The opener therefore cannot hand a body its data, and injection is the wrong tool — injection is a registration-time relation between a domain and a seat, while opening is a runtime event. A component has to find its data from the address alone, through something registered once by whoever owns that kind of data. +The tab record set the constraint. A tab must survive undo, redo, body remount, and hot module replacement without the code that opened it, so the record can hold only serializable data: an address and navigation parameters. Browser-page reload resets the memory-only Sidebar state. The opener therefore cannot hand a body its data, and injection is the wrong tool — injection is a registration-time relation between a domain and a seat, while opening is a runtime event. A component has to find its data from the address alone, through something registered once by whoever owns that kind of data. ## Decision @@ -16,7 +16,7 @@ The tab record set the constraint. A tab must survive undo, redo, reload, and ho ### Addresses -A resource address is a `dsh-resource:///…` URL. The host is the protocol key — the key of `ResourceProtocolMap` — and the path belongs to the protocol's owner. `RESOURCE_SCHEME = 'dsh-resource'` is the one scheme constant; `protocolOf(address)` parses the string with `new URL`, requires `protocol === 'dsh-resource:'`, and returns the lower-cased host, or `undefined` for a string the parser rejects, another scheme, or an empty host. `dsh-resource` is not one of the URL specification's special schemes, so the parser keeps the host's case and treats the path as opaque; the lower-casing is explicit, and each path segment is percent-encoded by the protocol that defines it. A protocol that needs a scope encodes it in the path: `dsh-resource://file/session//`, with `session/` naming the session whose root resolves the file, or `dsh-resource://file/absolute/`, which carries no session and is read through the current one ([grammar](../../../../packages/util/workspace-path/README.md)). Any other scheme — `sidebar://guide` — is a navigation address: it names a tab, not data, and the model answers `none` for it ([tab types and navigation](2026-09-05-sidebar-tab-types-and-navigation.md)). +A resource address is a `dsh-resource:///…` URL. The host is the protocol key — the key of `ResourceProtocolMap` — and the path belongs to the protocol's owner. `RESOURCE_SCHEME = 'dsh-resource'` is the one scheme constant; `protocolOf(address)` parses the string with `new URL`, requires `protocol === 'dsh-resource:'`, and returns the lower-cased host, or `undefined` for a string the parser rejects, another scheme, or an empty host. `dsh-resource` is not one of the URL specification's special schemes, so the parser keeps the host's case and treats the path as opaque; the lower-casing is explicit, and each path segment is percent-encoded by the protocol that defines it. A protocol that needs a scope encodes it in the path: `dsh-resource://file/session//`, with `session/` naming the Session whose Host workspace resolves the relative or absolute path ([grammar](../../../../packages/util/workspace-path/README.md)). Any other scheme — `sidebar://guide` — is a navigation address: it names a tab, not data, and the model answers `none` for it ([tab types and navigation](2026-09-05-sidebar-tab-types-and-navigation.md)). ### The service @@ -30,32 +30,30 @@ interface Resources { interface ResourceProvider

{ readonly protocol: P open(address: string, ctx: { readonly signal: AbortSignal }): AsyncIterable> - reload?(address: string): void } interface ResourceSnapshot { readonly status: 'none' | 'loading' | 'live' | 'failed' readonly value: Value | undefined readonly failure: RemoteFailure | undefined - readonly reload: () => void } type UseResource =

(address: string) => ResourceSnapshot ``` -`register` owns exactly one provider per protocol: a second registration for the same protocol throws, and the registration is an effect on the registering plugin's fiber, so a protocol leaves with its plugin and may be registered again afterwards. `pin` holds a resource open without subscribing until the signal aborts; an already-aborted signal pins nothing. `source` is the bare observable behind the hook, reference-stable per address, for callers outside React. The value type is looked up in `ResourceProtocolMap`, declared as an empty interface in `ui-slots` beside `SlotMap` — a module augmentation cannot introduce an export the target module lacks, and every consumer already depends on `ui-slots` — and each protocol's owner declaration-merges its member (`file: WorkspaceFileResource`); the resources package re-exports the type. +`register` owns exactly one provider per protocol: a second registration for the same protocol throws, and the registration is an effect on the registering plugin's fiber, so a protocol leaves with its plugin and may be registered again afterwards. `pin` holds a resource open without subscribing until the signal aborts; an already-aborted signal pins nothing. `source` is the bare observable behind the hook, reference-stable per address, for callers outside React. The value type is looked up in `ResourceProtocolMap`, declared as an empty interface in `ui-slots` beside `SlotMap` — a module augmentation cannot introduce an export the target module lacks, and every consumer already depends on `ui-slots` — and each protocol's owner declaration-merges its member (`file: WorkspaceFileStat`); the resources package re-exports the type. ### The hook -`useResource` is declared on `GlobalStandardProps` in `ui-slots`, so every slot component has it whatever its scope, and the plugin provides it through `ctx.slots.provideRoot({ keyedHooks: { resource: address => resources.source(address) } })`, the same root keyed-hook path `useSessions` uses. It is not a session standard prop: a resource carries its own scope in its address, and components outside any session scope read resources too. `useResource

(address)` returns the snapshot: `none` when the address's protocol has no provider or the address is not a resource address, `loading` between the stream opening and its first frame, `live` with the latest `ok` value, `failed` with the latest frame's failure beside the last value. `reload()` asks the provider for a fresh frame and is a no-op when the protocol has no provider or no `reload`. +`useResource` is declared on `GlobalStandardProps` in `ui-slots`, so every slot component has it whatever its scope, and the plugin provides it through `ctx.slots.provideRoot({ keyedHooks: { resource: address => resources.source(address) } })`, the same root keyed-hook path `useSessions` uses. It is not a session standard prop: a resource carries its own scope in its address, and components outside any session scope read resources too. `useResource

(address)` returns the snapshot: `none` when the address's protocol has no provider or the address is not a resource address, `loading` between the stream opening and its first frame, `live` with the latest `ok` value, `failed` with the latest frame's failure beside the last value. ### Frames -A provider yields `RemoteResult` frames: the current state first, one frame per later change. An `ok` frame makes the resource `live`, replaces the value, and clears the failure; an `ok: false` frame makes it `failed`, records the failure, and keeps the last value. Failure is data, not an exception: the Remote face already folds failures into `ok: false` and never rejects, providers pass those frames on, and the model neither catches nor wraps — a throw inside a provider's stream is a programming error left to surface. A stream that ends on its own keeps its last state; frames a provider yields after the release that aborted it are dropped and the iterator is returned. Streams carry metadata, not payload: the `file` value is `{ absolutePath, version, bytes?, changed }`, and a consumer reads content itself, by page, through the [Workspace Files service](2026-09-05-workspace-files-service.md). +A provider yields `RemoteResult` frames: the current state first, one frame per later change. An `ok` frame makes the resource `live`, replaces the value, and clears the failure; an `ok: false` frame makes it `failed`, records the failure, and keeps the last value. Failure is data, not an exception: the Remote face already folds failures into `ok: false` and never rejects, providers pass those frames on, and the model neither catches nor wraps — a throw inside a provider's stream is a programming error left to surface. A stream that ends on its own keeps its last state; frames a provider yields after the release that aborted it are dropped and the iterator is returned. Streams carry metadata, not payload: the `file` value is `{ absolutePath, version, bytes? }`, and a consumer reads content itself through the [Workspace Files service](2026-09-05-workspace-files-service.md). ### Lifecycle -One record exists per address. Its holders are the hook's subscribers plus pins; the first holder opens the provider's stream under an `AbortController`, later holders share it and read the latest value at once, and the last release aborts the stream and resets the snapshot to idle — `loading` while a provider is registered, `none` otherwise. A provider that arrives while an address is already held opens that address's stream; one that leaves aborts it and the address reads `none`. Records are kept for the page lifetime so `source(address)` stays reference-stable across React's render-then-subscribe window and a StrictMode remount, where a recreated record would resubscribe and restart the stream on every render. +One record exists per address. Holds start and stop observation, not the underlying file or Session. Its holders are the hook's subscribers plus pins; the first holder opens the provider's stream under an `AbortController`, later holders share it and read the latest value at once, and the last release aborts the stream and resets the snapshot to idle — `loading` while a provider is registered, `none` otherwise. A provider that arrives while an address is already held opens that address's stream; one that leaves aborts it and the address reads `none`. Records are kept for the page lifetime so `source(address)` stays reference-stable across React's render-then-subscribe window and a StrictMode remount, where a recreated record would resubscribe and restart the stream on every render. The right Sidebar's Tab domain pins every open tab record's address for the record's life, so switching tabs unmounts a body without closing its stream and switching back reads the latest value; a record restored by undo is a new pin, and a resource the model already let go is read again ([tab types and navigation](2026-09-05-sidebar-tab-types-and-navigation.md)). `openResource(address)` accepts resource addresses only; pages such as the guide and the file tree are opened by kind and never enter the resource model. @@ -63,7 +61,7 @@ The right Sidebar's Tab domain pins every open tab record's address for the reco **Session-bound resources: `useResource` on the session kit and a `(session, address)` identity.** The first form. Rejected because a file is not a session concern — the session is only who authorizes the path — and because the model must serve protocols and components outside any session scope. Identity became the address alone, the scope moved into the address grammar, and the hook moved to the global kit. -**Content in the resource stream.** Rejected: content can be arbitrarily large, and a stream is for pushing change, not payload. The stream carries metadata and the consumer reads content by page, which is also what lets one open tab hold a multi-megabyte file at the cost of one page. +**Content in the resource stream.** Rejected: content can be arbitrarily large, and a stream is for pushing change, not payload. The stream carries metadata and the consumer chooses how much content to read and retain. **Failure as a thrown error, wrapping a non-`RemoteFailure` throw as `gateway/internal`.** Rejected: the Remote face never rejects, so anything a provider throws is a bug, and wrapping it would be a fallback that hides the bug from the developer who caused it. A failure is an `ok: false` frame; a throw surfaces. @@ -71,17 +69,17 @@ The right Sidebar's Tab domain pins every open tab record's address for the reco **A hand-parsed scheme prefix instead of the URL parser.** The first `protocolOf` matched a regular expression for the scheme. Rejected once addresses were URLs: the parser already decides validity and case, and a string it rejects should read as "no protocol" rather than be half-parsed. -**A per-tab stream hook, or a framework-managed `useTabResource(fetch)`.** Rejected in turn: a stream hook on the tab domain asks the wrong owner — `file` data must come from the workspace file service, chat data from the chat domain — and a framework-owned fetch has no good cache key. What remains is owner props on the tab plus one client-wide `useResource` keyed by address. +**A per-tab stream hook, or a framework-managed `useTabResource(fetch)`.** Rejected in turn: a stream hook on the tab domain asks the wrong owner — `file` data must come from the workspace file service, chat data from the chat domain — and a framework-owned fetch has no good cache key. What remains is the framework-bound `useTabInfo` on the tab plus one client-wide `useResource` keyed by address. ## Consequences -Any slot component reads live data by address and nothing else, so an opener passes data only and a body reconstructs itself from its record after undo, reload, or hot replacement. Two components showing one address share one stream, and a pinned address survives its body's unmount. A protocol's transport lives in exactly one provider, and adding a protocol is one declaration-merged type plus one registration. +Any slot component reads live data by address and nothing else, so an opener passes data only and a body reconstructs itself from its record after undo, body remount, or hot replacement. Two components showing one address share one stream, and a pinned address survives its body's unmount. A protocol's transport lives in exactly one provider, and adding a protocol is one declaration-merged type plus one registration. The costs are recorded here so they are not rediscovered. Records are never reclaimed: memory grows with the number of distinct addresses ever read, not with reads. Abort compliance rests with the provider; the model drops what a released stream still yields but cannot stop a provider that ignores the signal before its next frame. The failure type is the Remote face's `RemoteFailure`, so a provider whose source is not a Remote call has to mint one. A navigation address or a malformed string reads as `none` rather than an error, which keeps mixed address lists cheap to render but gives a misspelled protocol no diagnostic beyond the missing value. ## Testing -`packages/client/resources/tests/resources.client.spec.ts` drives the registry with scripted feeds: protocol ownership and disposal, `none` for a protocol without a provider and for a navigation address, a provider arriving after a held address and leaving while it is held, registrations dropped with their fiber, open-on-first-holder and close-on-last, one source per address, pins including an already-aborted signal, a remount reading the latest value without reopening, reopening as a fresh stream, frames after abort dropped with the iterator returned, a stream ending on its own, failure frames beside the last value, and `reload` forwarding. `tests/apply.client.spec.ts` mounts the plugin in `SlotTestRuntime` and checks, through a root-scope probe component, that `useResource` reaches props, that rendering it opens the provider's stream, and that disposing the plugin withdraws both the service and the hook. +`packages/client/resources/tests/resources.client.spec.ts` drives the registry with scripted feeds: protocol ownership and disposal, `none` for a protocol without a provider and for a navigation address, a provider arriving after a held address and leaving while it is held, registrations dropped with their fiber, open-on-first-holder and close-on-last, one source per address, pins including an already-aborted signal, a remount reading the latest value without reopening, reopening as a fresh stream, frames after abort dropped with the iterator returned, a stream ending on its own, and failure frames beside the last value. `tests/apply.client.spec.ts` mounts the plugin in `SlotTestRuntime` and checks, through a root-scope probe component, that `useResource` reaches props, that rendering it opens the provider's stream, and that disposing the plugin withdraws both the service and the hook. ## Deferred diff --git a/.agents/notes/implemented/architecture/2026-09-05-client-resource-model.zh.md b/.agents/notes/implemented/architecture/2026-09-05-client-resource-model.zh.md index d1430e88fc..d716aa8a42 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-client-resource-model.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-05-client-resource-model.zh.md @@ -8,7 +8,7 @@ Status: implemented 右侧 Sidebar 的 tab 正文、聊天卡片或任何别的 slot 组件,常常需要只以地址可知的活数据:agent 刚写的文件,将来的聊天节点或终端。资源模型出现前每个消费方各自取数——文本预览自己持有 Remote 调用与刷新循环——于是每次挂载都重读、两个组件显示同一文件就持有两份、切 tab 卸载正文就丢内容,每种新内容都意味着一个新的专用 hook。 -约束来自 tab 记录。tab 必须在打开它的代码不在场时挺过撤销、重做、刷新与热替换,所以记录只能存可序列化的数据:一个地址与导航参数。因此开启方不能把数据交给正文,注入也不是合适的工具——注入是领域与席位之间注册期的关系,而打开是运行期事件。组件必须只凭地址找到数据,途径是由数据拥有者注册一次的东西。 +约束来自 tab 记录。tab 必须在打开它的代码不在场时挺过撤销、重做、正文重挂载与热替换,所以记录只能存可序列化的数据:一个地址与导航参数。浏览器页面刷新会重置只在内存中的 Sidebar 状态。因此开启方不能把数据交给正文,注入也不是合适的工具——注入是领域与席位之间注册期的关系,而打开是运行期事件。组件必须只凭地址找到数据,途径是由数据拥有者注册一次的东西。 ## Decision @@ -16,7 +16,7 @@ Status: implemented ### 地址 -资源地址是 `dsh-resource:///…` 形式的 URL。host 是协议键——`ResourceProtocolMap` 的键——路径归协议拥有者。`RESOURCE_SCHEME = 'dsh-resource'` 是唯一的 scheme 常量;`protocolOf(address)` 用 `new URL` 解析字串,要求 `protocol === 'dsh-resource:'`,返回小写 host;解析器拒绝的字串、其它 scheme 或空 host 返回 `undefined`。`dsh-resource` 不是 URL 规范里的特殊 scheme,解析器会保留 host 的大小写并把路径当作不透明串,所以小写化是显式做的,每段路径由定义它的协议做百分号编码。需要作用域的协议把作用域编进路径:`dsh-resource://file/session//<相对该会话工作区根的路径>`,`session/` 命名以其根解析该文件的会话;或 `dsh-resource://file/absolute/<绝对路径>`,不带会话、经当前会话读取([语法](../../../../packages/util/workspace-path/README.zh.md))。其它任何 scheme——`sidebar://guide`——是导航地址:它命名一个 tab 而非数据,模型对它回答 `none`([tab 类型与导航](2026-09-05-sidebar-tab-types-and-navigation.zh.md))。 +资源地址是 `dsh-resource:///…` 形式的 URL。host 是协议键——`ResourceProtocolMap` 的键——路径归协议拥有者。`RESOURCE_SCHEME = 'dsh-resource'` 是唯一的 scheme 常量;`protocolOf(address)` 用 `new URL` 解析字串,要求 `protocol === 'dsh-resource:'`,返回小写 host;解析器拒绝的字串、其它 scheme 或空 host 返回 `undefined`。`dsh-resource` 不是 URL 规范里的特殊 scheme,解析器会保留 host 的大小写并把路径当作不透明串,所以小写化是显式做的,每段路径由定义它的协议做百分号编码。需要作用域的协议把作用域编进路径:`dsh-resource://file/session//`,`session/` 命名由其 Host 工作区解析相对或绝对路径的 Session([语法](../../../../packages/util/workspace-path/README.zh.md))。其它任何 scheme——`sidebar://guide`——是导航地址:它命名一个 tab 而非数据,模型对它回答 `none`([tab 类型与导航](2026-09-05-sidebar-tab-types-and-navigation.zh.md))。 ### 服务 @@ -30,32 +30,30 @@ interface Resources { interface ResourceProvider

{ readonly protocol: P open(address: string, ctx: { readonly signal: AbortSignal }): AsyncIterable> - reload?(address: string): void } interface ResourceSnapshot { readonly status: 'none' | 'loading' | 'live' | 'failed' readonly value: Value | undefined readonly failure: RemoteFailure | undefined - readonly reload: () => void } type UseResource =

(address: string) => ResourceSnapshot ``` -`register` 让每个协议恰有一个提供方:同一协议的第二次注册抛错,注册是挂在注册方插件 fiber 上的 effect,所以协议随插件离开、之后可再注册。`pin` 在不订阅的情况下让资源保持打开直到信号中止;已中止的信号什么也不钉。`source` 是 hook 背后的裸 observable,按地址引用稳定,供 React 之外的调用方使用。值类型在 `ResourceProtocolMap` 里查得,它作为空接口声明在 `ui-slots` 里、与 `SlotMap` 并列——模块增强无法给目标模块添加它没有的导出,而每个消费方本来就依赖 `ui-slots`——各协议拥有者声明合并自己的成员(`file: WorkspaceFileResource`);resources 包再导出这个类型。 +`register` 让每个协议恰有一个提供方:同一协议的第二次注册抛错,注册是挂在注册方插件 fiber 上的 effect,所以协议随插件离开、之后可再注册。`pin` 在不订阅的情况下让资源保持打开直到信号中止;已中止的信号什么也不钉。`source` 是 hook 背后的裸 observable,按地址引用稳定,供 React 之外的调用方使用。值类型在 `ResourceProtocolMap` 里查得,它作为空接口声明在 `ui-slots` 里、与 `SlotMap` 并列——模块增强无法给目标模块添加它没有的导出,而每个消费方本来就依赖 `ui-slots`——各协议拥有者声明合并自己的成员(`file: WorkspaceFileStat`);resources 包再导出这个类型。 ### hook -`useResource` 声明在 `ui-slots` 的 `GlobalStandardProps` 上,因此每个 slot 组件不论作用域都有它,插件经 `ctx.slots.provideRoot({ keyedHooks: { resource: address => resources.source(address) } })` 提供,与 `useSessions` 走同一条根 keyed hook 路径。它不是会话标准 prop:资源的作用域随地址携带,会话作用域之外的组件也要读资源。`useResource

(address)` 返回快照:地址协议没有提供方或地址不是资源地址时为 `none`,流已打开、首帧未到时为 `loading`,`live` 携带最新 `ok` 值,`failed` 在最后一个值旁携带最新帧的失败。`reload()` 请提供方给一个新帧,协议没有提供方或提供方没有 `reload` 时是空操作。 +`useResource` 声明在 `ui-slots` 的 `GlobalStandardProps` 上,因此每个 slot 组件不论作用域都有它,插件经 `ctx.slots.provideRoot({ keyedHooks: { resource: address => resources.source(address) } })` 提供,与 `useSessions` 走同一条根 keyed hook 路径。它不是会话标准 prop:资源的作用域随地址携带,会话作用域之外的组件也要读资源。`useResource

(address)` 返回快照:地址协议没有提供方或地址不是资源地址时为 `none`,流已打开、首帧未到时为 `loading`,`live` 携带最新 `ok` 值,`failed` 在最后一个值旁携带最新帧的失败。 ### 帧 -提供方产出 `RemoteResult` 帧:首帧是当前状态,之后每次变化一帧。`ok` 帧使资源 `live`、替换值、清除失败;`ok: false` 帧使其 `failed`、记下失败、保留最后一个值。失败是数据不是异常:Remote 面本来就把失败折进 `ok: false` 且从不 reject,提供方原样转发这些帧,模型既不捕获也不包装——提供方流里抛出是编程错误,任其冒出。自行结束的流保持最后状态;提供方在中止它的那次释放之后产出的帧被丢弃,迭代器被归还。流只推元数据不推载荷:`file` 的值是 `{ absolutePath, version, bytes?, changed }`,消费方自己经 [Workspace Files 服务](2026-09-05-workspace-files-service.zh.md)按页读内容。 +提供方产出 `RemoteResult` 帧:首帧是当前状态,之后每次变化一帧。`ok` 帧使资源 `live`、替换值、清除失败;`ok: false` 帧使其 `failed`、记下失败、保留最后一个值。失败是数据不是异常:Remote 面本来就把失败折进 `ok: false` 且从不 reject,提供方原样转发这些帧,模型既不捕获也不包装——提供方流里抛出是编程错误,任其冒出。自行结束的流保持最后状态;提供方在中止它的那次释放之后产出的帧被丢弃,迭代器被归还。流只推元数据不推载荷:`file` 的值是 `{ absolutePath, version, bytes? }`,消费方自己经 [Workspace Files 服务](2026-09-05-workspace-files-service.zh.md)读内容。 ### 生命周期 -每个地址一条记录。持有者是 hook 的订阅者加 pin;第一个持有者在 `AbortController` 下打开提供方的流,之后的持有者共享它并立刻读到最新值,最后一个释放时中止流并把快照重置为空闲——有提供方注册时为 `loading`,否则为 `none`。地址已被持有时到达的提供方会打开该地址的流;离开的提供方中止它,地址读作 `none`。记录在页面存续期内保留,使 `source(address)` 在 React 渲染到订阅的窗口与 StrictMode 重挂载之间保持引用稳定,否则重建记录会让每次渲染重订阅、重开流。 +每个地址一条记录。持有只控制观察的启停,不控制底层文件或 Session 的生灭。持有者是 hook 的订阅者加 pin;第一个持有者在 `AbortController` 下打开提供方的流,之后的持有者共享它并立刻读到最新值,最后一个释放时中止流并把快照重置为空闲——有提供方注册时为 `loading`,否则为 `none`。地址已被持有时到达的提供方会打开该地址的流;离开的提供方中止它,地址读作 `none`。记录在页面存续期内保留,使 `source(address)` 在 React 渲染到订阅的窗口与 StrictMode 重挂载之间保持引用稳定,否则重建记录会让每次渲染重订阅、重开流。 右侧 Sidebar 的 Tab 域在每条打开的 tab 记录存续期内钉住其地址,所以切 tab 卸载正文不关流、切回读到最新值;撤销恢复的记录是一次新的钉住,模型已放掉的资源会重新读取([tab 类型与导航](2026-09-05-sidebar-tab-types-and-navigation.zh.md))。`openResource(address)` 只收资源地址;引导页与文件树这类页面按 kind 打开,从不进入资源模型。 @@ -63,7 +61,7 @@ type UseResource =

(address: string) => ResourceSnap **会话绑定的资源:`useResource` 挂会话标准件、身份为 `(session, address)`。** 第一版形态。被否,因为文件不是会话的事——会话只是路径的授权者——而且模型必须服务会话作用域之外的协议与组件。身份改为只有地址,作用域进入地址语法,hook 移到全局标准件。 -**内容进资源流。** 被否:内容可能任意大,流是用来推变化的,不是推载荷。流只带元数据,消费方按页读内容,这也是一个打开的 tab 能以一页的代价承载数兆字节文件的原因。 +**内容进资源流。** 被否:内容可能任意大,流是用来推变化的,不是推载荷。流只带元数据,消费方选择读取并保留多少内容。 **以抛错表达失败,并把非 `RemoteFailure` 的抛出包装成 `gateway/internal`。** 被否:Remote 面从不 reject,所以提供方抛出的任何东西都是 bug,包装它就是把 bug 藏起来不让肇事者看见的 fallback。失败是 `ok: false` 帧;抛出就冒出来。 @@ -71,17 +69,17 @@ type UseResource =

(address: string) => ResourceSnap **手写 scheme 前缀解析代替 URL 解析器。** 第一版 `protocolOf` 用正则匹配 scheme。地址成为 URL 后被否:解析器已经决定合法性与大小写,它拒绝的字串应读作「无协议」而不是被解析一半。 -**每 tab 一个流 hook,或框架代管的 `useTabResource(fetch)`。** 依次被否:挂在 tab 域上的流 hook 问错了拥有者——`file` 数据必须来自工作区文件服务,聊天数据来自聊天域——而框架代管的 fetch 没有好的缓存键。留下的是 tab 上的 owner props 加一个按地址的客户端级 `useResource`。 +**每 tab 一个流 hook,或框架代管的 `useTabResource(fetch)`。** 依次被否:挂在 tab 域上的流 hook 问错了拥有者——`file` 数据必须来自工作区文件服务,聊天数据来自聊天域——而框架代管的 fetch 没有好的缓存键。留下的是 tab 上框架绑定的 `useTabInfo` 加一个按地址的客户端级 `useResource`。 ## Consequences -任何 slot 组件只凭地址读活数据,于是开启方只传数据,正文在撤销、刷新或热替换后能从记录重建自己。显示同一地址的两个组件共享一条流,被钉住的地址在正文卸载后仍存活。一个协议的传输只住在一个提供方里,新增协议只是一个声明合并的类型加一次注册。 +任何 slot 组件只凭地址读活数据,于是开启方只传数据,正文在撤销、正文重挂载或热替换后能从记录重建自己。显示同一地址的两个组件共享一条流,被钉住的地址在正文卸载后仍存活。一个协议的传输只住在一个提供方里,新增协议只是一个声明合并的类型加一次注册。 代价记录在此以免被重新发现。记录不回收:内存随读过的不同地址数增长,而非随读取次数增长。中止合规归提供方;模型会丢弃已释放的流仍产出的帧,却阻止不了忽略信号的提供方跑到下一帧。失败类型是 Remote 面的 `RemoteFailure`,来源不是 Remote 调用的提供方得自己铸一个。导航地址或畸形字串读作 `none` 而非报错,这让混合地址列表渲染起来便宜,却让拼错的协议除了缺值之外没有任何诊断。 ## Testing -`packages/client/resources/tests/resources.client.spec.ts` 用脚本化的 feed 驱动注册表:协议归属与注销、无提供方的协议与导航地址都为 `none`、提供方在地址已被持有后到达与在持有中离开、注册随 fiber 消失、首个持有者开流末个关流、一址一源、包括已中止信号在内的 pin、重挂读到最新值且不重开、重开为新流、中止后帧丢弃且迭代器归还、流自行结束、失败帧与最后值并存、`reload` 转发。`tests/apply.client.spec.ts` 在 `SlotTestRuntime` 里挂载插件,经一个根作用域探针组件验证 `useResource` 到达 props、渲染它即打开提供方的流、dispose 插件同时撤走服务与 hook。 +`packages/client/resources/tests/resources.client.spec.ts` 用脚本化的 feed 驱动注册表:协议归属与注销、无提供方的协议与导航地址都为 `none`、提供方在地址已被持有后到达与在持有中离开、注册随 fiber 消失、首个持有者开流末个关流、一址一源、包括已中止信号在内的 pin、重挂读到最新值且不重开、重开为新流、中止后帧丢弃且迭代器归还、流自行结束、失败帧与最后值并存。`tests/apply.client.spec.ts` 在 `SlotTestRuntime` 里挂载插件,经一个根作用域探针组件验证 `useResource` 到达 props、渲染它即打开提供方的流、dispose 插件同时撤走服务与 hook。 ## Deferred diff --git a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml index e8267647e2..7b1fa6e594 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.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-09-05-workspace-files-service.md -2026-09-05-workspace-files-service.md: a95e083f8cea57b957a2c060fe1b7f2b76050153 -2026-09-05-workspace-files-service.zh.md: 59c638157f3e65efe3b89b220401434d1f2370b0 +2026-09-05-workspace-files-service.md: 6ee94522fc75e2a833f8e4d664652baccdc41e31 +2026-09-05-workspace-files-service.zh.md: 42f331c46c2123c7b2354a66d607071dabcc3894 diff --git a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md index a95e083f8c..6ee94522fc 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md +++ b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md @@ -12,7 +12,7 @@ Two constraints frame any answer. Reads through `ctx.fs` are deliberately unconf ## Decision -`packages/api/workspace-files` (`@deepseek-ai/dsh-api-workspace-files`) owns the Host `ctx.workspaceFiles` service, the `workspaceFiles` Remote namespace, and the Client `file` provider that turns `stat` and `changes` into live metadata for the [resource model](2026-09-05-client-resource-model.md); [dual-face packaging](2026-09-07-workspace-files-dual-face-package.md) governs their package organization. Every method confines itself to the workspace root the sandbox policy resolves for the addressed session, names files by their absolute path in the filesystem's execution world, and pages or windows content so that no method ever buffers a whole file. The byte window rides on a new `dsh-fs` seam, `FileSystem.readByteRange`, implemented by every provider. The Session Controller carries no workspace-file code. +`packages/api/workspace-files` (`@deepseek-ai/dsh-api-workspace-files`) owns the Host `ctx.workspaceFiles` service, the `workspaceFiles` Remote namespace, and the Client `file` provider that turns `stat` and `changes` into live metadata for the [resource model](2026-09-05-client-resource-model.md); [dual-face packaging](2026-09-07-workspace-files-dual-face-package.md) governs their package organization. Every method confines itself to the workspace root the sandbox policy resolves for the addressed session, names files by their absolute path in the filesystem's execution world, and bounds content by page, byte window, or complete-file cap. The byte window rides on a new `dsh-fs` seam, `FileSystem.readByteRange`, implemented by every provider. The Session Controller carries no workspace-file code. ### Package topology @@ -23,15 +23,17 @@ Two constraints frame any answer. Reads through `ctx.fs` are deliberately unconf | Host | `api/workspace-files/tsconfig.host.json` | `src/index.ts` (`WorkspaceFiles`, `Config`, gates, pager), `src/changes.ts` (`WorkspaceChangeFeed`), `src/types.ts` (wire types, error codes) | `dsh-fs`, `dsh-sandbox-policy`, `dsh-typert-protocol`, `dsh-agent`, `dsh-session` | | Client | `api/workspace-files/tsconfig.client.json` | `src/client/index.ts` (plugin body), `provider.ts`, `change-feed.ts`, `remote.ts`, `types.ts`, and shared `src/types.ts` | `dsh-api-gateway/client`, `dsh-api-session-controller/client`, `dsh-client-resources`, `dsh-util-workspace-path`, `dsh-typert-protocol`, and the package's generated `./remote` | -`api/remotes` and both root aggregates reference the matching Host/Client leaf. The package exports `.`, `./client`, `./types`, `./typert`, and `./remote`, with one `workspace-files` web-app row supplying both faces. The Client plugin injects `['resources', 'remote', 'remote.workspaceFiles', 'sessions']`; the resource model takes result types directly from the protocol package, and the text preview owns the Sidebar parameter declaration, so the Client compilation graph has no reverse dependency on Remote assembly or Sidebar UI. +`api/remotes` and both root aggregates reference the matching Host/Client leaf. The package exports `.`, `./client`, `./types`, `./typert`, and `./remote`, with one `workspace-files` web-app row supplying both faces. The Client plugin injects `['resources', 'remote', 'remote.workspaceFiles']`; the resource model takes result types directly from the protocol package, and the text preview owns the Sidebar parameter declaration, so the Client compilation graph has no reverse dependency on Remote assembly or Sidebar UI. ### The `workspaceFiles` Remote namespace -Every Host method takes the target `Agent` first, resolved by the Gateway from the Session identity on the wire, so a Client calls `remote.workspaceFiles.stat(sessionId, path, signal)` and never names a root. The five signatures, as `src/index.ts` declares them: +Every Host method takes the target `Agent` first, resolved by the Gateway from the Session identity on the wire, so a Client calls `remote.workspaceFiles.stat(sessionId, path, signal)` and never names a root. The seven signatures, as `src/index.ts` declares them: ```ts ignore-check @Remote async read(agent: Agent, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise @Remote async readBytes(agent: Agent, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise +@Remote async readAll(agent: Agent, path: string, signal: AbortSignal): Promise +@Remote async readRelated(agent: Agent, path: string, relativePath: string, signal: AbortSignal): Promise @Remote async stat(agent: Agent, path: string, signal: AbortSignal): Promise @Remote async list(agent: Agent, path: string, signal: AbortSignal): Promise @Remote({ mode: 'stream' }) changes(agent: Agent, signal: AbortSignal): AsyncIterable @@ -40,22 +42,23 @@ Every Host method takes the target `Agent` first, resolved by the Gateway from t - **`stat`** returns `WorkspaceFileStat { absolutePath, version, bytes? }`: the file's identity, its opaque freshness token, and its size when the backend reports one. It accepts a regular file only. - **`read`** returns one window of lines, `WorkspaceFileText = WorkspaceFileStat & { offset, text, lines, eof }`; `lines` counts the page's lines, so a page holding one empty line (`text: ''`, `lines: 1`) and a page past the end (`lines: 0`) read differently. `range.offset` is the 1-based first line and defaults to 1; `range.limit` is the largest number of lines and defaults to `maxLines`, which it may not exceed. Lines end at `\n` and a final `\n` terminates the last line rather than opening an empty one; `text` joins the page's lines with `\n` and carries no terminator; `eof` is true when the page includes the last line, and an offset past the end returns an empty page with `eof` true. The pager walks `streamText`, counts the lines before the window without keeping them, admits each in-window segment against `maxBytes` before buffering it, and returns at the first character past the window, so a file of any size costs one page of memory. The `version` and `bytes` on a page are the stat's, taken before the stream. - **`readBytes`** returns one window of raw bytes, `WorkspaceFileBytes = WorkspaceFileStat & { offset, data, eof }`. `range.offset` is the 0-based first byte and defaults to 0; `range.length` is the largest byte count and defaults to `maxBytes`, which it may not exceed. `data` is base64, shorter than `length` where the file ends and empty at or past it; `eof` is true when the window includes the last byte. Nothing is decoded and nothing is refused as binary. `read` pages by lines and never by bytes; a byte window is `readBytes`. +- **`readAll` and `readRelated`** return complete `WorkspaceFileBytes` under `maxFileBytes`. `readRelated` resolves a relative filesystem path from the base file's directory; the Host checks both files under the same workspace rules. [Document Preview](2026-09-08-document-preview-operations.md) owns their loading and address semantics. - **`list`** returns `WorkspaceDirectoryListing { path, entries, truncated }`: the listed directory as a workspace path relative to the root (empty for the root), its direct children in the backend's stable name order as `{ name, type, size? }`, and whether `maxEntries` cut the list. `type` is `file`, `directory`, or `other`; a symlink child reports the type of what it points to and a dangling one is `other`, while opening such a child still fails the link gate below. Dotfiles are listed; nothing is filtered. - **`changes`** yields `WorkspaceFileWatchFrame`: `{ kind: 'ready' }` after the observation queue is registered and the workspace root resolves, followed by `{ kind: 'change', change }`. The `WorkspaceFileChange` payload is `{ absolutePath, version }` for a present file or `{ absolutePath, absent: true }` for one observed gone. Its source is `fs/observed` inside the workspace root, never an OS watcher. Observations after the first pull are queued, including during root resolution; cancellation or plugin disposal ends the generation. ### Paths on the wire -Two path vocabularies leave the service, and each method uses exactly one. `read`, `readBytes`, `stat`, and `changes` name a file by `absolutePath`: its absolute path in the filesystem's execution world, symlinks resolved (`ctx.fs.processPath(target)`), so the Client provider matches a change frame to an open address by absolute path: the Client sends the address's path unchanged to the Host and binds the follower only to a successful `stat.absolutePath`, without reading a Session summary's cwd. `list` speaks workspace paths — the same syntax its `path` argument accepts, absolute or relative to the root — because its consumer is a tree rooted there. The field is called `absolutePath` and not `url` because it is not a resource address; the address grammar belongs to `dsh-util-workspace-path` and is described with the resource model. Input paths to `read`, `readBytes`, `stat`, and `list` are absolute or relative to the session's workspace root, never to the backend's own cwd. +Two path vocabularies leave the service, and each method uses exactly one. `read`, `readBytes`, `readAll`, `readRelated`, `stat`, and `changes` name a file by `absolutePath`: its absolute path in the filesystem's execution world, symlinks resolved (`ctx.fs.processPath(target)`), so the Client provider matches a change frame to an open address by absolute path: the Client sends the address's path unchanged to the Host and binds the follower only to a successful `stat.absolutePath`, without reading a Session summary's cwd. `list` speaks workspace paths — the same syntax its `path` argument accepts, absolute or relative to the root — because its consumer is a tree rooted there. The field is called `absolutePath` and not `url` because it is not a resource address; the address grammar belongs to `dsh-util-workspace-path` and is described with the resource model. Input paths to `read`, `readBytes`, `readAll`, `readRelated`, `stat`, and `list` are absolute or relative to the session's workspace root, never to the backend's own cwd. `version` is an opaque string a consumer compares for equality and never parses: the local backend derives it from device, inode, size, and nanosecond mtime and ctime, so a rewrite that leaves the content identical still changes it. `offset` means a line on `read` and a byte on `readBytes`; the two units never mix, and `eof` on either means the window reached the file's end. ### The four gates -Every `read`, `readBytes`, `stat`, and `list` passes four gates in order, and the constraints are the service's own because the filesystem does not confine reads. The path is inspected before containment is decided, so a caller learns whether an outside path exists and what kind it is before `outside-workspace` refuses it; that is accepted because the caller is the Session's own owner, who can already read the Host through the Agent. +Every `read`, `readBytes`, `readAll`, `readRelated`, `stat`, and `list` passes four gates in order, and the constraints are the service's own because the filesystem does not confine reads. The path is inspected before containment is decided, so a caller learns whether an outside path exists and what kind it is before `outside-workspace` refuses it; that is accepted because the caller is the Session's own owner, who can already read the Host through the Agent. 1. **The path itself.** `lstat` inspects the path before anything follows it: a missing path is `not-found`, and a symlink — wherever it points, including back inside the workspace — is `not-regular-file` (kind `symlink`) for the file methods and `not-directory` for `list`. An empty path is a `gateway/bad-request`. 2. **Containment.** The path resolves to a target and `ctx.fs.contains(root, target)` decides, where `root` is `sandboxPolicy.resolve({ session }).workspaceRoot` resolved the same way (the session's cwd, falling back to the policy's configured root). A `..` traversal or an absolute path outside the root is `outside-workspace`. A string-prefix comparison is never used: `resolve` realpaths, so a prefix test cannot see a link that leaves the root. -3. **The caps.** A page or window above `maxBytes`, or a `read` asking for more than `maxLines`, is refused, never shortened, because a silently cut page reads as the whole page; a listing above `maxEntries` is cut and says so. +3. **The caps.** A page or window above `maxBytes`, or a `read` asking for more than `maxLines`, is refused, never shortened, because a silently cut page reads as the whole page; a listing above `maxEntries` is cut and says so. Complete and related-file reads are refused above `maxFileBytes`. 4. **Text.** For `read` only: content that is not UTF-8 up to the end of the page, a NUL byte in the backend's 8 KiB opening sample, or a NUL byte anywhere in the page is `not-text`; bytes past the page are not inspected. After the gates the file methods `stat` the target once more, because the file may have gone or changed kind between the inspection and the read: a vanished file is `not-found` and a replaced one `not-regular-file` with the new kind. The gate order has one visible consequence: an entry outside the root whose type already disqualifies it reports its kind, not its position. @@ -68,19 +71,19 @@ Each failure is one `RemoteError` code with typed details, declared beside the t |---|---|---| | `workspace-file/not-found` | no entry at the path, or the file vanished after the gates | `{ path }` | | `workspace-file/outside-workspace` | the resolved target is not inside the workspace root | `{ path }` | -| `workspace-file/too-large` | a page's text or a requested byte window exceeds `maxBytes` | `{ path, limit }` | +| `workspace-file/too-large` | a page's text or byte window exceeds `maxBytes`, or a complete read exceeds `maxFileBytes` | `{ path, limit }` | | `workspace-file/not-text` | invalid UTF-8 up to the page's end, or a NUL byte in the sample or the page (`read` only) | `{ path }` | -| `workspace-file/not-regular-file` | `read`, `readBytes`, or `stat` on something that is not a regular file | `{ path, kind: 'directory' \| 'symlink' \| 'other' }` | +| `workspace-file/not-regular-file` | `read`, `readBytes`, `readAll`, `readRelated`, or `stat` on something that is not a regular file | `{ path, kind: 'directory' \| 'symlink' \| 'other' }` | | `workspace-file/not-directory` | `list` on something that is not a directory | `{ path, kind: 'file' \| 'symlink' \| 'other' }` | | `workspace-file/unsupported-address` | Client-minted: a resource address this provider cannot serve | `{ address }` | -| `workspace-file/unknown-workspace` | Client-minted: an `absolute` address with no current Session | `{ address }` | +| `workspace-file/unknown-workspace` | Client-minted: an `absolute` address, which carries no Session | `{ address }` | | `gateway/bad-request` | an empty path, or an `offset`, `limit`, or `length` that is not an integer in range | `{}` | The set is append-only: a code may be added, and none is renamed or removed, because consumers branch on these strings across the wire. ### Configuration -Three fields, all validated positive integers changeable from `cordis.yml`, and no other tunables: `maxBytes` (default 2,097,152, 2 MiB) is the inclusive cap on one page's text and on one byte window; `maxLines` (default 5,000) is the default and largest page in lines; `maxEntries` (default 2,000) is the cap on returned directory entries. The file itself has no size cap: a caller pages or windows through it. +Four fields, all validated positive integers changeable from `cordis.yml`, and no other tunables: `maxBytes` (default 2,097,152, 2 MiB) is the inclusive cap on one page's text and on one byte window; `maxLines` (default 5,000) is the default and largest page in lines; `maxEntries` (default 2,000) is the cap on returned directory entries; `maxFileBytes` (default 33,554,432, 32 MiB) caps complete and related-file reads. Paged and windowed reads impose no whole-file size cap. ### The `readByteRange` seam in `dsh-fs` @@ -98,15 +101,15 @@ It returns the bytes at `[offset, offset + length)`, shorter when the file ends The Client export registers one `ResourceProvider<'file'>` into `ctx.resources` for the plugin's lifetime and declares `ResourceProtocolMap.file`. The text-preview package registers this package's exported `WorkspaceFileParams` as `SidebarRightResourceParamsMap.file`. -- **The value is metadata**, `WorkspaceFileResource { absolutePath, version, bytes?, changed }`; content never rides the stream because content can be arbitrarily large and a stream is for pushing change, not payload. A consumer reads pages with `read` (or windows with `readBytes`) and uses `version` and `changed` to know when they are stale. -- **The address names the file; its scope selects the Session.** A `session` address's relative path reaches the Host unchanged for resolution and containment against that Session's workspace root; Client cwd is not a prerequisite. An `absolute` address reads through the current Session, failing with `workspace-file/unknown-workspace` when none is current. Unsupported grammar yields `workspace-file/unsupported-address`. These two Client errors end the stream and make reload a no-op. -- **The frames.** The first frame is a `stat` (`changed: false`) or its failure as an `ok: false` frame; the provider throws and catches nothing, because the Remote face never rejects and a throw inside a provider stream is a programming error left to surface. A Host write carrying a version the value does not hold yields `changed: true` with the byte count kept and no stat; a frame carrying the held version is dropped. A reported disappearance stats again — still there is fresh metadata flagged `changed`, gone is a `not-found` frame with the previous value left for display. `reload(address)` stats again and yields `changed: false`. The follow is on the address, not the file: after a failed stat the stream continues, so the agent creating the file, or a reload, brings the resource live. Aborting the signal ends the stream silently. +- **The value is metadata**, `WorkspaceFileStat { absolutePath, version, bytes? }`; content never rides the stream because content can be arbitrarily large and a stream is for pushing change, not payload. A consumer reads pages with `read` (or windows with `readBytes`) and compares versions to know when they are stale; freshness and refresh belong to each consumer, not the shared observation. +- **The address names the file; its scope selects the Session.** A `session` address's relative or absolute path reaches the Host unchanged for resolution and containment against that Session's workspace root; Client cwd is not a prerequisite. An `absolute` address has no Session and fails with `workspace-file/unknown-workspace`; no current or tab Session is borrowed. Unsupported grammar yields `workspace-file/unsupported-address`. These two Client errors end the stream. +- **The frames.** The first frame is a `stat` or its failure as an `ok: false` frame; the provider throws and catches nothing, because the Remote face never rejects and a throw inside a provider stream is a programming error left to surface. A Host write carrying a version the value does not hold yields that version with the byte count kept and no stat; a frame carrying the held version is dropped. A reported disappearance stats again — still there is fresh metadata, gone is a `not-found` frame with the previous value left for display. The follow is on the address, not the file: after a failed stat the stream continues, so the agent creating the file brings the resource live. Aborting the signal ends the stream silently. - **One `changes` subscription per Session.** The first follower opens `remote.$stream`, the last release disposes it, and successor streams and plugin teardown await pending closes. The Client starts its first `stat` only after accepting Host `ready`; sending a local WebSocket request is not Host acknowledgement. A follower registers by address, queues changes before its path is known, then filters queued and live frames by the successful stat's `absolutePath`, normalizing backslashes to slashes. Any Session write can trigger a re-stat before the first successful binding. Gateway supervision reconnects carrier loss; Host end or terminal failure ends followers and retains their last metadata until reopened. - **Navigation parameters.** `SidebarRightResourceParamsMap.file` is `WorkspaceFileParams { line?: number }`, a 1-based line to reveal. A line travels as a navigation parameter and not as part of the address, because the file is one piece of content whether it opens at the top or at line 400. ### Related notes -The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`, `useResource`, the `dsh-resource:///…` address grammar, and the reasoning for one resource per address; the [text preview and file tree](../feature/2026-09-05-sidebar-text-preview-and-file-tree.md) are the shipped consumers of `read`, `list`, and the `file` provider; the [right Sidebar docking infrastructure](../feature/2026-09-04-right-sidebar-docking-infrastructure.md) is the surface they open into; [workspace file links](../feature/2026-07-31-web-workspace-file-links.md) is where serving files over HTTP was rejected. Anyone extending this system reaches the same five methods through `remote.workspaceFiles` and the same `file` resource through `useResource<'file'>`; the wire types are published as `@deepseek-ai/dsh-api-workspace-files/types`. +The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`, `useResource`, the `dsh-resource:///…` address grammar, and the reasoning for one resource per address; the [text preview and file tree](../feature/2026-09-05-sidebar-text-preview-and-file-tree.md) are the shipped consumers of `read`, `list`, and the `file` provider; the [right Sidebar docking infrastructure](../feature/2026-09-04-right-sidebar-docking-infrastructure.md) is the surface they open into; [workspace file links](../feature/2026-07-31-web-workspace-file-links.md) is where serving files over HTTP was rejected. Anyone extending this system reaches the same seven methods through `remote.workspaceFiles` and the same `file` resource through `useResource<'file'>`; the wire types are published as `@deepseek-ai/dsh-api-workspace-files/types`. [Document Preview](2026-09-08-document-preview-operations.md) owns file-address authorization, content loading, and per-tab freshness. ## Alternatives considered @@ -129,23 +132,20 @@ The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`, ## Consequences - Workspace file access belongs to the Host/Client faces of `api/workspace-files`; the Session Controller carries neither implementation, and compiler and runtime entries stay separate. -- A file of any size opens: text by line page, anything by byte window, each costing one page or window of memory on the Host and never a whole file; the cost is that a consumer assembles pages itself and that a single line above `maxBytes` has no page at all, because pages are cut by lines. +- A file of any size opens: text by line page, anything by byte window, each costing one page or window of memory on the Host; complete reads instead enforce `maxFileBytes`; the cost is that a consumer assembles pages itself and that a single line above `maxBytes` has no page at all, because pages are cut by lines. - Every filesystem provider now offers a windowed raw read. `fs-e2b` pays for it by transferring the skipped prefix, since its SDK cannot seek; `fs-local` seeks. -- Paths on the wire are canonical: `absolutePath` and change frames spell a file with symlinks resolved. An address built from another spelling of the same file — a workspace root reached through a symlink — opens and stats it, but its change frames never match, so `changed` stays false until a reload. +- Paths on the wire are canonical: `absolutePath` and change frames spell a file with symlinks resolved. A follower binds to successful `stat.absolutePath`, so another spelling of the same file — a workspace root reached through a symlink — uses that canonical change key. - Change frames report the agent's own operations only. A file edited by the user's editor, a shell, or a subprocess raises no frame; an agent merely reading a file that something else changed does raise one, because the read observes a new version. - The gate order reports kind before position, a page's `version` may be one write behind its content, and a stalled `changes` consumer grows Host memory, because a generation's queue is unbounded; each is a known trade-off recorded in the package README. - The `file` resource pushes change, not content, so a preview learns a file moved on without a payload and reads the pages it wants; a failed open keeps following the address, so the agent creating the file brings the tab live without user action. -- `readBytes` has no shipped consumer yet: it is the wire form the image and binary previews build on. +- `readBytes` has no shipped consumer yet; Document Preview uses `readAll` for complete-file formats. ## Testing -Host specs in `packages/api/workspace-files/tests` exercise the paged read (whole file, nested path, empty file, multi-byte UTF-8, the line window's edges, defaults and refused limits, carriage returns kept), the byte window (defaults, a middle window with more following, tail windows exact and short, past-end and empty files, NUL and invalid UTF-8 round-tripping through base64, version parity with `stat`, the cap as `too-large`, bad ranges, a window of a file far above the cap, and `eof` inferred without a size), `stat`, `list` with truncation, symlink children, and `not-directory`, the `changes` stream driven by `fs/observed` and filtered by root, and every gate and code against a real local backend, because a fake filesystem would let a prefix test pass the symlink case the gate exists to catch. Client specs in `packages/api/workspace-files/tests` cover the provider's frames (opening stat, failure frames, writes without content, disappearance, reload, recovery, abort), the change feed (one stream per session, fan-out by normalized path, queued frames, ending on signal or Host close), the unsupported-address cases, and registration and disposal with the fiber. `fs/fs`, `fs-local`, and `fs-e2b` specs pin `readByteRange`'s range semantics — a middle window, a tail shorter than asked, past-end and zero-length windows, errors, aborts, and the e2b cancel — and `dsh-util-workspace-path` specs pin the file-address grammar. The connection fixture serves `stat`, paged `read`, `list`, and an opt-in `changes` frame for the web e2e suite. +Host specs in `packages/api/workspace-files/tests` exercise the paged read (whole file, nested path, empty file, multi-byte UTF-8, the line window's edges, defaults and refused limits, carriage returns kept), the byte window (defaults, a middle window with more following, tail windows exact and short, past-end and empty files, NUL and invalid UTF-8 round-tripping through base64, version parity with `stat`, the cap as `too-large`, bad ranges, a window of a file far above the cap, and `eof` inferred without a size), `stat`, `list` with truncation, symlink children, and `not-directory`, the `changes` stream driven by `fs/observed` and filtered by root, and every gate and code against a real local backend, because a fake filesystem would let a prefix test pass the symlink case the gate exists to catch. Client specs in `packages/api/workspace-files/tests` cover the provider's frames (opening stat, failure frames, writes without content, disappearance, recovery, abort), the change feed (one stream per session, fan-out by normalized path, queued frames, ending on signal or Host close), the unsupported-address cases, and registration and disposal with the fiber. `fs/fs`, `fs-local`, and `fs-e2b` specs pin `readByteRange`'s range semantics — a middle window, a tail shorter than asked, past-end and zero-length windows, errors, aborts, and the e2b cancel — and `dsh-util-workspace-path` specs pin the file-address grammar. `readAll` and `readRelated` specs cover complete-read caps and Host path authorization. The connection fixture serves `stat`, paged `read`, `list`, and an opt-in `changes` frame for the web e2e suite. ## Deferred -- A web e2e chain through the Sidebar: open a file, have the agent write it, see `changed`, reload. -- Aliasing a follower under the Host's canonical spelling once the first `stat` reveals it, so a symlinked workspace root still receives change frames. - A bound on a `changes` generation's queue. - The shipped consumer of `readBytes` (image and binary previews) and any write, search, or media route; the service is read-only. - Scopes other than `session` in the file address; the grammar leaves room, the provider serves one. -- Reload delivery per record: today `reload` re-stats every follower of the file's absolute path in the session, so two records naming one file — a `session` and an `absolute` address, or two readers with different addresses — clear each other's `changed` flag. diff --git a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md index 59c638157f..42f331c46c 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md @@ -12,7 +12,7 @@ Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工 ## Decision -`packages/api/workspace-files`(`@deepseek-ai/dsh-api-workspace-files`)同时拥有 Host 服务 `ctx.workspaceFiles`、`workspaceFiles` Remote 命名空间,以及将 `stat` 与 `changes` 转成[资源模型](2026-09-05-client-resource-model.zh.md)实时元数据的 Client `file` 提供者;包组织方式由[双面包组织](2026-09-07-workspace-files-dual-face-package.zh.md)规定。每个方法都把自己限制在沙箱策略为被寻址会话解析出的工作区根内,以文件在文件系统执行环境中的绝对路径命名文件,并对内容分页或开窗,因此没有任何方法会缓冲整个文件。字节窗口依托 `dsh-fs` 新增的 seam `FileSystem.readByteRange`,由每个提供者实现。Session Controller 不再携带任何工作区文件代码。 +`packages/api/workspace-files`(`@deepseek-ai/dsh-api-workspace-files`)同时拥有 Host 服务 `ctx.workspaceFiles`、`workspaceFiles` Remote 命名空间,以及将 `stat` 与 `changes` 转成[资源模型](2026-09-05-client-resource-model.zh.md)实时元数据的 Client `file` 提供者;包组织方式由[双面包组织](2026-09-07-workspace-files-dual-face-package.zh.md)规定。每个方法都把自己限制在沙箱策略为被寻址会话解析出的工作区根内,以文件在文件系统执行环境中的绝对路径命名文件,并按页、字节窗口或整文件上限约束内容读取。字节窗口依托 `dsh-fs` 新增的 seam `FileSystem.readByteRange`,由每个提供者实现。Session Controller 不再携带任何工作区文件代码。 ### 包拓扑 @@ -23,15 +23,17 @@ Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工 | Host | `api/workspace-files/tsconfig.host.json` | `src/index.ts`(`WorkspaceFiles`、`Config`、围栏、切页器)、`src/changes.ts`(`WorkspaceChangeFeed`)、`src/types.ts`(线路类型、错误码) | `dsh-fs`、`dsh-sandbox-policy`、`dsh-typert-protocol`、`dsh-agent`、`dsh-session` | | Client | `api/workspace-files/tsconfig.client.json` | `src/client/index.ts`(插件体)、`provider.ts`、`change-feed.ts`、`remote.ts`、`types.ts`,以及共享的 `src/types.ts` | `dsh-api-gateway/client`、`dsh-api-session-controller/client`、`dsh-client-resources`、`dsh-util-workspace-path`、`dsh-typert-protocol`,以及本包生成的 `./remote` | -`api/remotes` 和两个根聚合分别引用匹配的 Host/Client 叶子。包导出 `.`、`./client`、`./types`、`./typert` 和 `./remote`,web-app 中单个 `workspace-files` 条目供应两面。Client 插件注入 `['resources', 'remote', 'remote.workspaceFiles', 'sessions']`;资源模型直接从协议包取结果类型,Sidebar 参数声明归文本预览,因此 Client 编译图不再反向依赖 Remote 装配或右栏 UI。 +`api/remotes` 和两个根聚合分别引用匹配的 Host/Client 叶子。包导出 `.`、`./client`、`./types`、`./typert` 和 `./remote`,web-app 中单个 `workspace-files` 条目供应两面。Client 插件注入 `['resources', 'remote', 'remote.workspaceFiles']`;资源模型直接从协议包取结果类型,Sidebar 参数声明归文本预览,因此 Client 编译图不再反向依赖 Remote 装配或右栏 UI。 ### `workspaceFiles` Remote 命名空间 -每个 Host 方法首参都是目标 `Agent`,由 Gateway 从线路上的 Session 身份解析而来,因此 Client 调用 `remote.workspaceFiles.stat(sessionId, path, signal)`,从不自行命名根。五个签名照 `src/index.ts` 的声明: +每个 Host 方法首参都是目标 `Agent`,由 Gateway 从线路上的 Session 身份解析而来,因此 Client 调用 `remote.workspaceFiles.stat(sessionId, path, signal)`,从不自行命名根。七个签名照 `src/index.ts` 的声明: ```ts ignore-check @Remote async read(agent: Agent, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise @Remote async readBytes(agent: Agent, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise +@Remote async readAll(agent: Agent, path: string, signal: AbortSignal): Promise +@Remote async readRelated(agent: Agent, path: string, relativePath: string, signal: AbortSignal): Promise @Remote async stat(agent: Agent, path: string, signal: AbortSignal): Promise @Remote async list(agent: Agent, path: string, signal: AbortSignal): Promise @Remote({ mode: 'stream' }) changes(agent: Agent, signal: AbortSignal): AsyncIterable @@ -40,22 +42,23 @@ Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工 - **`stat`** 返回 `WorkspaceFileStat { absolutePath, version, bytes? }`:文件身份、不透明的新鲜度令牌,以及后端报得出时的大小。它只接受普通文件。 - **`read`** 返回一个行窗口 `WorkspaceFileText = WorkspaceFileStat & { offset, text, lines, eof }`;`lines` 计页内行数,使只含一个空行的页(`text: ''`、`lines: 1`)与越过文件末尾的页(`lines: 0`)可区分。`range.offset` 是 1 起算的首行,缺省 1;`range.limit` 是最多行数,缺省 `maxLines` 且不得超过。行以 `\n` 结束,末尾的 `\n` 终止最后一行而不是开启一空行;`text` 以 `\n` 连接本页各行且不带终止符;页含最后一行时 `eof` 为 true,越过末尾的 offset 返回 `eof` 为 true 的空页。切页器沿 `streamText` 前进,数过窗口前的行而不保留,把每个窗内片段先按 `maxBytes` 核准再缓冲,并在越过窗口的第一个字符处返回,因此任意大小的文件只花一页内存。页上的 `version` 与 `bytes` 来自流之前的那次 stat。 - **`readBytes`** 返回一个原始字节窗口 `WorkspaceFileBytes = WorkspaceFileStat & { offset, data, eof }`。`range.offset` 是 0 起算的首字节,缺省 0;`range.length` 是最多字节数,缺省 `maxBytes` 且不得超过。`data` 为 base64,文件在窗内结束则短于 `length`,位于或越过末尾则为空;窗口含最后一个字节时 `eof` 为 true。不做任何解码,也不按二进制拒绝。`read` 按行分页、绝不按字节;字节窗口走 `readBytes`。 +- **`readAll` 与 `readRelated`** 在 `maxFileBytes` 上限内返回完整的 `WorkspaceFileBytes`。`readRelated` 从基准文件所在目录解析相对文件系统路径;Host 对两个文件执行相同的工作区检查。[Document Preview](2026-09-08-document-preview-operations.zh.md) 负责其加载与地址语义。 - **`list`** 返回 `WorkspaceDirectoryListing { path, entries, truncated }`:被列目录相对根的工作区路径(根为空串)、其直接子项按后端的稳定名序以 `{ name, type, size? }` 给出,以及 `maxEntries` 是否截断了列表。`type` 为 `file`、`directory` 或 `other`;符号链接子项报告其指向目标的类型,悬空者为 `other`,而打开这样的子项仍会在下文的链接关被拒。dotfile 照常列出,不做任何过滤。 - **`changes`** 产出 `WorkspaceFileWatchFrame`:在观察队列注册且工作区根解析完成后先发 `{ kind: 'ready' }`,随后为 `{ kind: 'change', change }`。载荷 `WorkspaceFileChange` 对存在的文件为 `{ absolutePath, version }`,对消失的文件为 `{ absolutePath, absent: true }`。来源是工作区根内的 `fs/observed`,不监视操作系统。首次拉取后的观察都会排队,包括根解析期间的观察;取消或插件释放会结束该代流。 ### 线路上的路径 -离开服务的路径词汇有两套,每个方法只用其中一套。`read`、`readBytes`、`stat` 与 `changes` 以 `absolutePath` 命名文件:它在文件系统执行环境中、符号链接已解析的绝对路径(`ctx.fs.processPath(target)`),因此 Client 提供者按绝对路径把变更帧匹配到已打开的地址:Client 把地址路径原样交给 Host,并只按成功的 `stat.absolutePath` 绑定跟随者,不读取会话摘要的 cwd。`list` 说工作区路径——与其 `path` 参数相同的语法,绝对或相对根——因为其消费方是一棵以根为起点的树。该字段叫 `absolutePath` 而不叫 `url`,因为它不是资源地址;地址语法归 `dsh-util-workspace-path` 所有,与资源模型一并描述。`read`、`readBytes`、`stat` 与 `list` 的输入路径是绝对路径或相对会话工作区根的路径,从不相对后端自己的 cwd。 +离开服务的路径词汇有两套,每个方法只用其中一套。`read`、`readBytes`、`readAll`、`readRelated`、`stat` 与 `changes` 以 `absolutePath` 命名文件:它在文件系统执行环境中、符号链接已解析的绝对路径(`ctx.fs.processPath(target)`),因此 Client 提供者按绝对路径把变更帧匹配到已打开的地址:Client 把地址路径原样交给 Host,并只按成功的 `stat.absolutePath` 绑定跟随者,不读取会话摘要的 cwd。`list` 说工作区路径——与其 `path` 参数相同的语法,绝对或相对根——因为其消费方是一棵以根为起点的树。该字段叫 `absolutePath` 而不叫 `url`,因为它不是资源地址;地址语法归 `dsh-util-workspace-path` 所有,与资源模型一并描述。`read`、`readBytes`、`readAll`、`readRelated`、`stat` 与 `list` 的输入路径是绝对路径或相对会话工作区根的路径,从不相对后端自己的 cwd。 `version` 是消费者只比较是否相等、从不解析的不透明字符串:本地后端由设备、inode、大小及纳秒级 mtime 与 ctime 导出,因此内容不变的重写也会改变它。`offset` 在 `read` 上指行、在 `readBytes` 上指字节;两套单位从不混用,二者的 `eof` 都表示窗口到达了文件末尾。 ### 四道关 -每次 `read`、`readBytes`、`stat` 与 `list` 依次过四道关,而这些约束是服务自己的,因为文件系统并不限制读取。路径先被检视再判定是否在工作区内,因此调用方在 `outside-workspace` 拒绝之前就能得知工作区外的路径是否存在、是何种类;这一点被接受,因为调用方就是 Session 的所有者,本来就能经 Agent 读 Host。 +每次 `read`、`readBytes`、`readAll`、`readRelated`、`stat` 与 `list` 依次过四道关,而这些约束是服务自己的,因为文件系统并不限制读取。路径先被检视再判定是否在工作区内,因此调用方在 `outside-workspace` 拒绝之前就能得知工作区外的路径是否存在、是何种类;这一点被接受,因为调用方就是 Session 的所有者,本来就能经 Agent 读 Host。 1. **路径本身。** `lstat` 在跟随任何东西之前检查路径:缺失路径为 `not-found`;符号链接——不论指向哪里,包括指回工作区内——对文件方法为 `not-regular-file`(kind 为 `symlink`),对 `list` 为 `not-directory`。空路径是 `gateway/bad-request`。 2. **包含关系。** 路径解析为目标,由 `ctx.fs.contains(root, target)` 判定,其中 `root` 是以同样方式解析的 `sandboxPolicy.resolve({ session }).workspaceRoot`(会话 cwd,退而取策略配置的根)。`..` 爬出或根外绝对路径为 `outside-workspace`。从不使用字符串前缀比较:`resolve` 会取 realpath,前缀测试看不见离开根的链接。 -3. **上限。** 超过 `maxBytes` 的页或窗口,或 `read` 索要超过 `maxLines` 的行数,一律拒绝、绝不截短,因为悄悄截短的页读起来就像整页;超过 `maxEntries` 的列表被截断并如实报告。 +3. **上限。** 超过 `maxBytes` 的页或窗口,或 `read` 索要超过 `maxLines` 的行数,一律拒绝、绝不截短,因为悄悄截短的页读起来就像整页;超过 `maxEntries` 的列表被截断并如实报告。全文及关联文件读取超过 `maxFileBytes` 时被拒绝。 4. **文本。** 仅限 `read`:到页末为止不是 UTF-8 的内容、后端 8 KiB 开头样本里的 NUL 字节,或页内任何位置的 NUL 字节,都是 `not-text`;页之后的字节不检查。 过关之后文件方法再对目标 `stat` 一次,因为在检查与读取之间文件可能已消失或换了种类:消失者为 `not-found`,被替换者为带新种类的 `not-regular-file`。关的顺序有一个可见后果:根外条目若类型本身已不合格,报告的是其种类而不是其位置。 @@ -68,19 +71,19 @@ Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工 |---|---|---| | `workspace-file/not-found` | 路径处无条目,或文件在过关后消失 | `{ path }` | | `workspace-file/outside-workspace` | 解析出的目标不在工作区根内 | `{ path }` | -| `workspace-file/too-large` | 一页文本或所请求的字节窗口超过 `maxBytes` | `{ path, limit }` | +| `workspace-file/too-large` | 一页文本或字节窗口超过 `maxBytes`,或全文读取超过 `maxFileBytes` | `{ path, limit }` | | `workspace-file/not-text` | 到页末为止的非法 UTF-8,或样本或页内的 NUL 字节(仅 `read`) | `{ path }` | -| `workspace-file/not-regular-file` | 对非普通文件执行 `read`、`readBytes` 或 `stat` | `{ path, kind: 'directory' \| 'symlink' \| 'other' }` | +| `workspace-file/not-regular-file` | 对非普通文件执行 `read`、`readBytes`、`readAll`、`readRelated` 或 `stat` | `{ path, kind: 'directory' \| 'symlink' \| 'other' }` | | `workspace-file/not-directory` | 对非目录执行 `list` | `{ path, kind: 'file' \| 'symlink' \| 'other' }` | | `workspace-file/unsupported-address` | Client 铸出:本提供者无法服务的资源地址 | `{ address }` | -| `workspace-file/unknown-workspace` | Client 铸出:没有当前会话时的 `absolute` 地址 | `{ address }` | +| `workspace-file/unknown-workspace` | Client 铸出:不携带 Session 的 `absolute` 地址 | `{ address }` | | `gateway/bad-request` | 空路径,或不是范围内整数的 `offset`、`limit`、`length` | `{}` | 这个集合只增不改不删:可以新增代码,但不重命名、不移除任何一个,因为消费方跨线路按这些字符串分支。 ### 配置 -三个字段,都是可在 `cordis.yml` 中修改、经校验的正整数,此外没有其他可调项:`maxBytes`(默认 2,097,152,即 2 MiB)是单页文本与单个字节窗口的含上限;`maxLines`(默认 5,000)是页的缺省与最大行数;`maxEntries`(默认 2,000)是返回目录条目数的上限。文件本身没有大小上限:调用方分页或开窗读完它。 +四个字段,都是可在 `cordis.yml` 中修改、经校验的正整数,此外没有其他可调项:`maxBytes`(默认 2,097,152,即 2 MiB)是单页文本与单个字节窗口的含上限;`maxLines`(默认 5,000)是页的缺省与最大行数;`maxEntries`(默认 2,000)是返回目录条目数的上限;`maxFileBytes`(默认 33,554,432,即 32 MiB)限制全文及关联文件读取。分页和开窗读取不限制整个文件的大小。 ### `dsh-fs` 中的 `readByteRange` seam @@ -98,15 +101,15 @@ abstract readByteRange(target: FsTarget, range: { offset: number; length: number Client 导出向 `ctx.resources` 注册一个 `ResourceProvider<'file'>`,存活期与插件相同,并声明 `ResourceProtocolMap.file`。文本预览包把本包导出的 `WorkspaceFileParams` 注册为 `SidebarRightResourceParamsMap.file`。 -- **值是元数据**,`WorkspaceFileResource { absolutePath, version, bytes?, changed }`;内容从不进入流,因为内容可以任意大,而流是用来推送变更而不是载荷的。消费者用 `read` 读页(或用 `readBytes` 开窗),并以 `version` 与 `changed` 得知它们何时过时。 -- **地址命名文件,作用域决定读取会话。** `session` 地址携带的相对路径原样交给 Host,由 Host 按该会话的工作区根解析并检查包含关系,不要求 Client 持有 cwd。`absolute` 地址经当前会话读取,缺少当前会话时产生 `workspace-file/unknown-workspace`。不支持的语法产生 `workspace-file/unsupported-address`。这两种 Client 错误会结束流,刷新无动作。 -- **帧。** 第一帧是 `stat`(`changed: false`)或其失败的 `ok: false` 帧;提供者不抛也不接,因为 Remote 面从不 reject,而提供者流里的抛错只可能是编程错误,任其浮出。携带值尚未持有的版本的 Host 写入产生 `changed: true`、保留字节数、不做 stat;携带已持有版本的帧被丢弃。报告的消失会再 stat 一次——仍在则是标为 `changed` 的新元数据,不在则是保留上一个值供展示的 `not-found` 帧。`reload(address)` 再 stat 一次并产生 `changed: false`。跟随的是地址而不是文件:stat 失败后流继续,因此 agent 创建该文件或一次刷新会让资源恢复正常。中止 signal 则流静默结束。 +- **值是元数据**,`WorkspaceFileStat { absolutePath, version, bytes? }`;内容从不进入流,因为内容可以任意大,而流是用来推送变更而不是载荷的。消费者用 `read` 读页(或用 `readBytes` 开窗),并比较版本判断它们是否过时;新鲜度判断和刷新属于各消费方,不属于共享观察。 +- **地址命名文件,作用域决定读取会话。** `session` 地址携带的相对或绝对路径原样交给 Host,由 Host 按该会话的工作区根解析并检查包含关系,不要求 Client 持有 cwd。`absolute` 地址不带 Session,以 `workspace-file/unknown-workspace` 失败,不借用当前或 tab 所属 Session。不支持的语法产生 `workspace-file/unsupported-address`。这两种 Client 错误会结束流。 +- **帧。** 第一帧是 `stat`或其失败的 `ok: false` 帧;提供者不抛也不接,因为 Remote 面从不 reject,而提供者流里的抛错只可能是编程错误,任其浮出。携带值尚未持有的版本的 Host 写入产生该版本、保留字节数、不做 stat;携带已持有版本的帧被丢弃。报告的消失会再 stat 一次——仍在则是新元数据,不在则是保留上一个值供展示的 `not-found` 帧。跟随的是地址而不是文件:stat 失败后流继续,因此 agent 创建该文件会让资源恢复正常。中止 signal 则流静默结束。 - **每会话一条 `changes` 订阅。** 首位跟随者打开 `remote.$stream`,最后一位离开时释放,后继流和插件拆除等待关闭完成。Client 接受 Host 的 `ready` 后才开始首次 `stat`;本地发出 WebSocket 请求不是 Host 确认。跟随者先按地址注册,缓冲路径未知期间的变更,成功 stat 后按返回的 `absolutePath` 过滤排队与实时帧,反斜杠归一为斜杠。尚未成功绑定时,Session 内任何写入均可触发重新 stat。载体掉线由 Gateway 监督器重连;Host 结束或终态失败会结束跟随者,并保留最近元数据,直到重新打开。 - **导航参数。** `SidebarRightResourceParamsMap.file` 是 `WorkspaceFileParams { line?: number }`,即要显露的 1 起算行号。行号作为导航参数而不是地址的一部分传递,因为不论从顶部还是第 400 行打开,文件都是同一份内容。 ### 相关记录 -[资源模型](2026-09-05-client-resource-model.zh.md)拥有 `ctx.resources`、`useResource`、`dsh-resource:///…` 地址语法以及"每个地址一份资源"的推理;[文本预览与文件树](../feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md)是 `read`、`list` 与 `file` 提供者随包交付的消费方;[右侧 Sidebar 停靠基础设施](../feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md)是它们打开进去的界面;[工作区文件链接](../feature/2026-07-31-web-workspace-file-links.zh.md)是经 HTTP 供文件被否决之处。任何在这套体系上扩展的人都经 `remote.workspaceFiles` 触达同样的五个方法、经 `useResource<'file'>` 触达同样的 `file` 资源;线路类型以 `@deepseek-ai/dsh-api-workspace-files/types` 发布。 +[资源模型](2026-09-05-client-resource-model.zh.md)拥有 `ctx.resources`、`useResource`、`dsh-resource:///…` 地址语法以及"每个地址一份资源"的推理;[文本预览与文件树](../feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md)是 `read`、`list` 与 `file` 提供者随包交付的消费方;[右侧 Sidebar 停靠基础设施](../feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md)是它们打开进去的界面;[工作区文件链接](../feature/2026-07-31-web-workspace-file-links.zh.md)是经 HTTP 供文件被否决之处。任何在这套体系上扩展的人都经 `remote.workspaceFiles` 触达同样的七个方法、经 `useResource<'file'>` 触达同样的 `file` 资源;线路类型以 `@deepseek-ai/dsh-api-workspace-files/types` 发布。[Document Preview](2026-09-08-document-preview-operations.zh.md) 负责文件地址授权、内容加载和逐 tab 新鲜度。 ## Alternatives considered @@ -129,23 +132,20 @@ Client 导出向 `ctx.resources` 注册一个 `ResourceProvider<'file'>`,存 ## Consequences - 工作区文件访问由 `api/workspace-files` 的 Host/Client 两面共同承担;Session Controller 不携带其中任何实现,两面的编译与运行时入口保持独立。 -- 任意大小的文件都能打开:文本按行页、任何文件按字节窗口,在 Host 上各自只花一页或一窗内存、从不整文件;代价是消费者自己拼装页面,且单行超过 `maxBytes` 的行没有任何页,因为页按行切。 +- 任意大小的文件都能打开:文本按行页、任何文件按字节窗口,在 Host 上各自只花一页或一窗内存;全文读取则受 `maxFileBytes` 约束;代价是消费者自己拼装页面,且单行超过 `maxBytes` 的行没有任何页,因为页按行切。 - 每个文件系统提供者现在都提供开窗的原始读取。`fs-e2b` 为此付出传输被跳过前缀的代价,因为其 SDK 不能 seek;`fs-local` 能 seek。 -- 线路上的路径是规范的:`absolutePath` 与变更帧以符号链接已解析的拼法命名文件。由同一文件另一种拼法铸出的地址——经符号链接到达的工作区根——能打开并 stat 它,但其变更帧永不匹配,因此 `changed` 在刷新前保持 false。 +- 线路上的路径是规范的:`absolutePath` 与变更帧以符号链接已解析的拼法命名文件。跟随者绑定到成功的 `stat.absolutePath`,因此同一文件的另一种拼法——经符号链接到达的工作区根——也使用该规范变更键。 - 变更帧只报告 agent 自己的操作。用户编辑器、shell 或子进程改动的文件不产生帧;agent 仅仅读取一个被别处改动的文件却会产生帧,因为读取观察到了新版本。 - 关的顺序先报种类后报位置,页的 `version` 可能落后内容一次写入,停滞的 `changes` 消费者会让 Host 内存增长,因为一代流的队列无界;每一条都是包 README 记录在册的已知取舍。 - `file` 资源推送变更而非内容,因此预览不靠载荷就得知文件已更新并读取它想要的页;失败的打开继续跟随地址,因此 agent 创建该文件时 tab 无需用户动作即恢复正常。 -- `readBytes` 尚无随包交付的消费方:它是图片与二进制预览赖以构建的线路形态。 +- `readBytes` 尚无随包交付的消费方;Document Preview 对整文件格式使用 `readAll`。 ## Testing -`packages/api/workspace-files/tests` 中的 Host spec 覆盖分页读取(整文件、嵌套路径、空文件、多字节 UTF-8、行窗口边界、缺省与被拒的 limit、保留回车)、字节窗口(缺省值、后面还有内容的中段窗口、恰好与变短的尾窗、越界与空文件、NUL 与非法 UTF-8 经 base64 往返、与 `stat` 一致的版本、作为 `too-large` 的上限、坏范围、远超上限的文件的一个窗口、无大小时推断的 `eof`)、`stat`、带截断、符号链接子项与 `not-directory` 的 `list`、由 `fs/observed` 驱动并按根过滤的 `changes` 流,以及针对真实本地后端的每道关与每个代码——因为假文件系统会让前缀测试放过这道关本为捕获的符号链接场景。`packages/api/workspace-files/tests` 中的 Client spec 覆盖提供者的帧(开头 stat、失败帧、不带内容的写入、消失、刷新、恢复、中止)、变更流(每会话一条流、按归一路径扇出、排队的帧、因 signal 或 Host 关闭而结束)、不支持地址的各种情形,以及随 fiber 的注册与释放。`fs/fs`、`fs-local` 与 `fs-e2b` 的 spec 钉住 `readByteRange` 的范围语义——中段窗口、短于所求的尾窗、越界与零长窗口、错误、中止以及 e2b 的取消——`dsh-util-workspace-path` 的 spec 钉住文件地址语法。connection fixture 为 web e2e 套件提供 `stat`、分页 `read`、`list` 与一帧可选启用的 `changes`。 +`packages/api/workspace-files/tests` 中的 Host spec 覆盖分页读取(整文件、嵌套路径、空文件、多字节 UTF-8、行窗口边界、缺省与被拒的 limit、保留回车)、字节窗口(缺省值、后面还有内容的中段窗口、恰好与变短的尾窗、越界与空文件、NUL 与非法 UTF-8 经 base64 往返、与 `stat` 一致的版本、作为 `too-large` 的上限、坏范围、远超上限的文件的一个窗口、无大小时推断的 `eof`)、`stat`、带截断、符号链接子项与 `not-directory` 的 `list`、由 `fs/observed` 驱动并按根过滤的 `changes` 流,以及针对真实本地后端的每道关与每个代码——因为假文件系统会让前缀测试放过这道关本为捕获的符号链接场景。`packages/api/workspace-files/tests` 中的 Client spec 覆盖提供者的帧(开头 stat、失败帧、不带内容的写入、消失、恢复、中止)、变更流(每会话一条流、按归一路径扇出、排队的帧、因 signal 或 Host 关闭而结束)、不支持地址的各种情形,以及随 fiber 的注册与释放。`fs/fs`、`fs-local` 与 `fs-e2b` 的 spec 钉住 `readByteRange` 的范围语义——中段窗口、短于所求的尾窗、越界与零长窗口、错误、中止以及 e2b 的取消——`dsh-util-workspace-path` 的 spec 钉住文件地址语法。`readAll` 与 `readRelated` 的 spec 覆盖全文读取上限和 Host 路径授权。connection fixture 为 web e2e 套件提供 `stat`、分页 `read`、`list` 与一帧可选启用的 `changes`。 ## Deferred -- 一条经 Sidebar 的 web e2e 链:打开文件、让 agent 写它、看到 `changed`、刷新。 -- 在首次 `stat` 揭示 Host 的规范拼法后为跟随者加别名,使经符号链接的工作区根也能收到变更帧。 - 给 `changes` 一代流的队列加上限。 - `readBytes` 随包交付的消费方(图片与二进制预览)以及任何写入、搜索或媒体路由;本服务只读。 - 文件地址中 `session` 之外的作用域;语法留有余地,提供者只服务一个。 -- 按记录投递重载:今天 `reload` 重新 stat 该会话中此文件绝对路径的所有跟随者,因此命名同一文件的两条记录——`session` 与 `absolute` 地址,或地址不同的两个读者——会互相清掉 `changed` 标记。 diff --git a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml new file mode 100644 index 0000000000..5d5fe71939 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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-09-08-document-preview-operations.md +2026-09-08-document-preview-operations.md: 213534e36d1080463086cf0c78c03c12b30e3613 +2026-09-08-document-preview-operations.zh.md: ff4e8efcfb90395b87fe925eafe7b0331097ecef diff --git a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md new file mode 100644 index 0000000000..213534e36d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md @@ -0,0 +1,39 @@ +# Agent Note: Document preview and file addresses + +Status: implemented + +English | [中文](2026-09-08-document-preview-operations.zh.md) + +## Problem + +File viewers need different loading policies and may offer several implementations for one extension. A change stream cannot also express an on-demand read without mixing live data with callable capabilities. HTML dependencies additionally need the Host's filesystem authorization and path resolution, not the browser's current directory. + +## Decision + +Document Preview separates resource observation from content reads. The [resource model](2026-09-05-client-resource-model.md) shares observations by address alone: `source(address)`, `pin(address, signal)`, and provider `open(address, { signal })` carry no consuming Session. Providers return `AsyncIterable>`; `useResource` exposes only `{ status, value, failure }`. Holds start and stop observation, not the underlying file or Session. Content reads use ordinary injected Preview callbacks. + +[Workspace Files](../../../../packages/api/workspace-files/README.md) retains Host line reads, byte windows, bounded complete reads, and bounded reads relative to another file's directory. Its Client `file` provider observes only `stat` and `changes`, with `ResourceProtocolMap.file` directly naming `WorkspaceFileStat`. The Host continues to resolve and confine every path under the [file-service decision](2026-09-05-workspace-files-service.md). + +Readable files use `dsh-resource://file/session//`. The path may be workspace-relative or absolute; an encoded absolute path retains its leading slash. `fileAddressFor` always emits this Session-address form. The provider and Preview RPC take the Session only from that address, never from the current selection, first holder, or owning tab. A Session-less `absolute` URI cannot be read; the provider reports `workspace-file/unknown-workspace`. Session authorization is a file-protocol rule, not an additional Resource identity. + +[Document Preview](../../../../packages/client/ui-sidebar-textpreview/README.md) owns format selection and loading policy. Metadata registers with `ctx.documentPreviews`; components register separately into the keyed `sidebar.right.tab.document` Slot. Extension registrations precede builtins, then longer suffixes and registration order decide. The toolbar lists matching alternatives and remembers a manual choice per tab; plain text is the fallback. The child receives accumulated text or complete native bytes, the original resource address, and the standard `useResource` and `useTabInfo` hooks. Preview calls existing `read`, `readAll`, and `readRelated` through ordinary injection and decodes bytes in its own `rpc.ts`. Refresh remains per tab, with no resource reload, shared `changed` acknowledgement, extra resource wrapper, or content Session. + +Markdown and code reuse the incremental primitives with cumulative paged text. HTML and PDF read complete `Uint8Array` data; Host transport remains base64. Published buffers are borrowed read-only and never persist into layout or Session JSON. PDF.js runs in an owned Worker with version-matched bundled font and decoder data, and copies input before transfer to preserve Preview's retained buffer. HTML runs in a Blob iframe with `sandbox="allow-scripts"`, without same-origin, popup, form, download, or top-navigation privileges. The browser retains its normal external-network rules. Bounded static local JS/CSS reads stay in the parent; the opaque frame creates its own asset Blobs, because it cannot load parent-origin Blobs. Replacing the document replaces the browsing context and revokes its root Blob. + +## Alternatives considered + +**Methods attached to an Iterator or its values.** This conflates observation with commands and repeats capability identity in data frames. Frames carry data and failures; explicit Preview RPC callbacks perform reads. + +**A core public-projection factory, or the same assembly inside `open`.** Separate stream values, operations bundles, and public interfaces add assembly without another current consumer that needs it. Preview's shared RPC adapter already keeps Session decoding and base64 out of renderers. Resource offers no provider-agnostic command interface or opening-bound command lifetime; adding either needs consumer evidence beyond file preview. + +**UI Session as extra Resource identity, or authorization from the first holder or current selection.** A retained tab can belong to a different Session from the selected one, and the UI location does not identify the addressed file. Encoding the required Session in the file address preserves Host authorization while letting all readers of one address share observation. + +**File-reading methods on every resource.** Chat and terminal resources have independent data and operation semantics; only observation registration and lifetime are common. + +**A preview resource wrapper, content Session, or second resource Hook.** These duplicate addressing, cancellation, subscriptions, and ownership already provided by Resource and Workspace Files. Loading policy belongs to the preview owner. + +**A local server, virtual host, or `file:` iframe.** These require extra hosting or filesystem authority. The preview is for static generated pages, not a complete application runtime; modules, dynamic filesystem requests, and arbitrary nested asset graphs are outside its support. + +## Consequences + +Renderers can be replaced without changing the tab or file protocol. Full-file formats pay bounded whole-file memory and PDF adds bundled Worker/font/decoder bytes. Format selection and view state are page-local, not durable Session data. Preview owns RPC cancellation and native buffers independently of metadata observation. A tab retains its read version and the observation version captured at read start; refreshing it neither discards another tab's content nor clears its change notice. File reads remain non-transactional, and opaque versions are compared for equality, not ordering. The [recorded browser scenario](../../../../apps/web/tests/document-preview.e2e.ts) exercises the shared toolbar, incremental text, isolated HTML dependencies, and PDF Worker rendering with retained page state. diff --git a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md new file mode 100644 index 0000000000..ff4e8efcfb --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md @@ -0,0 +1,39 @@ +# Agent Note: 文档预览与文件地址 + +Status: implemented + +[English](2026-09-08-document-preview-operations.md) | 中文 + +## 问题 + +文件预览器需要不同的加载策略,同一扩展名也可能对应多种实现。变更流无法同时表达按需读取而又不把实时数据与可调用能力混在一起。HTML 依赖还需要 Host 的文件系统授权和路径解析,而非浏览器的当前目录。 + +## 决策 + +Document Preview 将资源观察与内容读取分开。[资源模型](2026-09-05-client-resource-model.zh.md)只按地址共享观察:`source(address)`、`pin(address, signal)` 和提供方的 `open(address, { signal })` 均不携带消费 Session。提供方返回 `AsyncIterable>`;`useResource` 只暴露 `{ status, value, failure }`。持有只控制观察的启停,不控制底层文件或 Session 的生灭。内容通过普通注入的 Preview 回调读取。 + +[Workspace Files](../../../../packages/api/workspace-files/README.zh.md) 保留 Host 的行读取、字节窗口、有上限的全文读取和相对另一文件目录的有界读取。Client `file` 提供方只观察 `stat` 与 `changes`,`ResourceProtocolMap.file` 直接为 `WorkspaceFileStat`。Host 继续按[文件服务决议](2026-09-05-workspace-files-service.zh.md)解析并限制每条路径。 + +可读取的文件使用 `dsh-resource://file/session//`。路径可以相对工作区,也可以是绝对路径;编码后的绝对路径保留前导斜杠。`fileAddressFor` 始终生成这种 Session 地址。提供方与 Preview RPC 只从该地址取 Session,不取当前选择、首个持有者或 tab 所属 Session。不带 Session 的 `absolute` URI 无法读取;提供方报告 `workspace-file/unknown-workspace`。Session 授权是文件协议规则,不是额外的 Resource 身份。 + +[Document Preview](../../../../packages/client/ui-sidebar-textpreview/README.zh.md) 负责格式选择和加载策略。元数据通过 `ctx.documentPreviews` 注册;组件单独注册到 keyed `sidebar.right.tab.document` Slot。扩展注册优先于内置注册,其次比较后缀长度和注册顺序。工具栏列出匹配候选,按 tab 记住手动选择;纯文本是兜底。子组件收到累积文本或完整原生字节、原始资源地址,以及标准 `useResource` 和 `useTabInfo` 钩子。Preview 经普通注入调用既有 `read`、`readAll` 与 `readRelated`,在自己的 `rpc.ts` 解码字节。刷新仍按 tab 独立进行,不引入资源 reload、共享 `changed` 确认、额外资源包装层或内容 Session。 + +Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML 和 PDF 读取完整 `Uint8Array` 数据;Host 传输保持 base64。发布后的缓冲区只读借用,绝不持久化进布局或 Session JSON。PDF.js 在自有 Worker 中运行,字体和解码数据以相同版本随包发布,转移输入前先复制,以保留 Preview 的缓冲区。HTML 在 Blob iframe 中运行,设置 `sandbox="allow-scripts"`,不授予同源、弹窗、表单、下载或顶层导航权限。浏览器保持正常的外部网络规则。有上限的静态本地 JS/CSS 读取由父页面负责;不透明源 iframe 创建自己的资源 Blob,因为它不能加载父源创建的 Blob。替换文档会替换浏览上下文,并撤销其根 Blob。 + +## 考虑过的替代方案 + +**把方法挂到 Iterator 或其值上。** 这会混淆观察与命令,并在数据帧中重复能力身份。帧携带数据和失败;显式 Preview RPC 回调负责读取。 + +**核心公开投影工厂,或在 `open` 内做同样的组装。** 分开的流值、operations 组合与公开接口增加了组装步骤,没有另一个当前消费方需要它。Preview 的共享 RPC 适配已让渲染器无需解码 Session 和 base64。Resource 不提供与提供方无关的命令接口,也不提供绑定于打开实例的命令生命周期;增加任一种都需要文件预览之外的消费方证据。 + +**把 UI Session 作为额外 Resource 身份,或由首个持有者、当前选择决定授权。** 保留的 tab 可以属于不同于当前选择的 Session,UI 所在位置也不能标识地址指向的文件。将所需 Session 编入文件地址,既保留 Host 授权,也让同地址的所有读者共享观察。 + +**让所有资源提供文件读取方法。** Chat 与终端资源的数据和操作语义各自独立,只有观察的注册和生命周期是共用机制。 + +**预览资源包装层、内容 Session 或第二个资源 Hook。** 这些方案重复了 Resource 和 Workspace Files 已提供的寻址、取消、订阅和归属。加载策略属于预览所有者。 + +**本地服务器、虚拟主机或 `file:` iframe。** 这些方案需要额外托管或文件系统权限。预览面向静态生成页面,而非完整应用运行时;模块、动态文件系统请求和任意嵌套资源图不在支持范围内。 + +## 影响 + +替换渲染器不需要改变 Tab 或文件协议。全文格式承担有上限的整文件内存成本,PDF 增加随包发布的 Worker、字体和解码器字节。格式选择和查看状态仅属于当前页面,不是持久 Session 数据。Preview 独立于元数据观察,拥有 RPC 取消和原生缓冲区。tab 保留读取版本及读取开始时捕获的观察版本;刷新它既不丢弃其他 tab 的内容,也不清除其变更提示。文件读取仍非事务,不透明版本只比较相等性、不排序。[录制的浏览器场景](../../../../apps/web/tests/document-preview.e2e.ts) 覆盖共用工具栏、增量文本、隔离的 HTML 依赖,以及保留页码状态的 PDF Worker 渲染。 diff --git a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml index a9da3ca3f4..f9094ecb23 100644 --- a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.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/feature/2026-09-04-right-sidebar-docking-infrastructure.md -2026-09-04-right-sidebar-docking-infrastructure.md: 8e1d1b5518c3b80a05be877ee9796883b6eb9a60 -2026-09-04-right-sidebar-docking-infrastructure.zh.md: 6830bf74734bbc8072b201ded6194e1d20ddbc88 +2026-09-04-right-sidebar-docking-infrastructure.md: 3600a17fdd0659c4f235922fa1bc905f03f5841e +2026-09-04-right-sidebar-docking-infrastructure.zh.md: 2a9ddfb7ec1fa34cefe434692829939767a6b710 diff --git a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md index 8e1d1b5518..3600a17fdd 100644 --- a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md +++ b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md @@ -37,6 +37,8 @@ The right Sidebar uses one mounted content tree in normal and fullscreen modes; ### State +[Default pages and close protection](2026-09-08-sidebar-default-pages.md) supersedes explicit last-tab closing and default-guide reseeding here; moving tabs still settles emptied panes. + `ui-sidebar-right` keeps one `SurfaceState` per session id — the layout, its history, and the mint counter — in a store declared at the seat registration. Every action mints the ids its intent needs, asks a kit planner for the operations, runs the settle planner over the result, and records the whole intent as one history entry before assigning the session's surface back; no action edits a layout in place. The settle step is the product's rule: a docked pane whose last tab is closed, moved out, or floated is merged away, and when only the root pane remains and it is empty, the guide tab is reseeded — there is always at least one tab and never an empty pane, so no pane-closing gesture exists. State is memory-only: a reload returns every session to the collapsed default, and switching sessions keeps each surface where it was. Layout is presentation state and never enters the session log. ### Beyond the surface diff --git a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md index 6830bf7473..2a9ddfb7ec 100644 --- a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md +++ b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md @@ -37,6 +37,8 @@ Agent 产出的文件是最尖锐的案例。产出文件 chip 或 `read` 行的 ### 状态 +[默认页与关闭保护](2026-09-08-sidebar-default-pages.zh.md)取代此处的显式关闭最后一个 tab 和默认补入引导页;移动 tab 仍会处理被清空的格。 + `ui-sidebar-right` 为每个会话 id 保存一份 `SurfaceState`——布局、历史与铸造计数——住在坑位注册时声明的 store 里。每个 action 先铸造意图所需的 id,向库的 planner 索取操作,对结果跑一遍 settle planner,把整个意图记为一条历史账,再把该会话的 surface 整体赋回;没有 action 就地改布局。settle 是产品规则:最后一个 tab 被关闭、拖走或悬浮出去的停靠 pane 会被合并掉;只剩根 pane 且为空时重新种上引导 tab——永远至少有一个 tab、永远没有空 pane,所以不存在"关闭 pane"手势。状态仅在内存:刷新使所有会话回到折叠默认态,切换会话时各 surface 保持原样。布局是呈现状态,永不进入会话日志。 ### 面之外 diff --git a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml index e0a9906fd6..bbc30b63d9 100644 --- a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.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/feature/2026-09-05-sidebar-text-preview-and-file-tree.md -2026-09-05-sidebar-text-preview-and-file-tree.md: a21946c73302561a2cb8539ca314e0a5d4fb25b8 -2026-09-05-sidebar-text-preview-and-file-tree.zh.md: dc02c159e0e052a21f2f1c80ac2e6f30bf5cf416 +2026-09-05-sidebar-text-preview-and-file-tree.md: 0afc029298292493429d8648976e46901c411e2e +2026-09-05-sidebar-text-preview-and-file-tree.zh.md: ac076db2d5797fe1cc58f54d8daa1972ac6a85a2 diff --git a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md index a21946c733..0afc029298 100644 --- a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md +++ b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md @@ -16,6 +16,8 @@ Three tab types ship with the Sidebar: the **guide** (`ui-sidebar-right`), the * ### The guide +[Default pages and close protection](2026-09-08-sidebar-default-pages.md) supersedes this section's default-guide selection; guide registration, replacement and uniqueness remain unchanged. + The guide is what a pane shows before it holds content. Its registration is `{ id: '@deepseek-ai/dsh-client-ui-sidebar-right/guide', kind: 'guide', priority: 'builtin', title }` with no `patterns`: a guide views nothing, so it is opened by kind through `openTab` and recorded under the page address `sidebar://guide`, which is the registry's bookkeeping and never composed by a caller. The tab's title is `开始` / `Start`, captured into the layout record when the pane is seeded, so a later language change relabels the type and not tabs already open. The body is a centred column — a lead line (`侧栏用来放你想一直看着的东西。` / `The sidebar holds what you want to keep looking at.`), one line of copy (`会话里的文件和产物会开在这一栏,也可以从下面的入口打开。` / `Files and artifacts from the conversation open in this column; the entries below open more.`), and a grid of entry boxes at most 480px wide, each box at least 160px, filling as many columns as fit. The boxes are projected from every registered type's `guide[]` in `order`, through the registry's observable `guide()` list, so a type registering later appears without the guide knowing it. A box shows the contributing type's glyph, title, and description, and picking it calls `tabActions.openTab(entry.kind, { replaceTab: true })`: the picked type opens in the guide's own tab, and the guide is gone. The guide is a doorway, not a page that stays open beside what it opened. diff --git a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md index dc02c159e0..ac076db2d5 100644 --- a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md +++ b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md @@ -16,6 +16,8 @@ Sidebar 随包交付三个 tab 类型:**引导页**(`ui-sidebar-right`)、 ### 引导页 +[默认页与关闭保护](2026-09-08-sidebar-default-pages.zh.md)取代本节的默认引导选择;引导页注册、替换和唯一性保持不变。 + 引导页是 pane 承载内容之前显示的东西。它的注册定义是 `{ id: '@deepseek-ai/dsh-client-ui-sidebar-right/guide', kind: 'guide', priority: 'builtin', title }`,没有 `patterns`:引导页不查看任何东西,所以经 `openTab` 按 kind 打开,并记在页地址 `sidebar://guide` 之下——那是注册表自己的记账,调用方从不拼它。tab 标题是 `开始` / `Start`,在 pane 播种时捕获进布局记录,于是之后切换语言只重标类型,不改已开着的 tab。 体是一根居中的列——一句引导语(`侧栏用来放你想一直看着的东西。` / `The sidebar holds what you want to keep looking at.`)、一行文案(`会话里的文件和产物会开在这一栏,也可以从下面的入口打开。` / `Files and artifacts from the conversation open in this column; the entries below open more.`),以及一组最宽 480px 的入口框栅格,每框至少 160px,能放几列放几列。入口框按 `order` 从每个已注册类型的 `guide[]` 投影而来,经注册表可观察的 `guide()` 列表,因此后注册的类型不用引导页知道就能出现。一个框显示贡献类型的图标、标题与说明;点选它调用 `tabActions.openTab(entry.kind, { replaceTab: true })`:被选的类型在引导页自己的 tab 里打开,引导页随之消失。引导页是一扇门,不是留在被打开者旁边的一页。 diff --git a/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.i18n.yaml new file mode 100644 index 0000000000..2d8d26b371 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/feature/2026-09-08-sidebar-default-pages.md +2026-09-08-sidebar-default-pages.md: c78d6a3af2c88c61ee5ba8fe4319d9b3f59ae7b4 +2026-09-08-sidebar-default-pages.zh.md: 8e0a081cb25862bffc20fdcac2b97c57b0080c36 diff --git a/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.md b/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.md new file mode 100644 index 0000000000..c78d6a3af2 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.md @@ -0,0 +1,27 @@ +# Agent Note: Sidebar default pages and close protection + +Status: implemented + +English | [中文](2026-09-08-sidebar-default-pages.zh.md) + +## Problem + +A guide with one registered entry adds a click without offering a choice. Hiding only the last tab's close button would let an added guide make the default file browser closable again. + +## Decision + +The Sidebar selects each default page from the registered guide-entry list. Exactly one entry opens that entry's page; zero or multiple entries open the guide. Resource viewers without guide entries do not affect this count. Explicitly adding a guide always opens a guide, and each pane holds at most one. + +A single-entry default is protected from explicit close for its record lifetime. Every pane's final tab is also protected; other tabs can close. The Sidebar stores protected record IDs and shares one close predicate between its store actions and docking controls. The generic docking kit accepts a presentation callback and has no file-browser or guide policy. Moving tabs still settles empty panes, and layout state remains memory-only. + +This replaces default-guide selection in [the shipped types](2026-09-05-sidebar-text-preview-and-file-tree.md) and explicit last-tab closing in [docking infrastructure](2026-09-04-right-sidebar-docking-infrastructure.md). Their registration, content-state, engine and layout ownership decisions remain active. + +## Alternatives considered + +**Count all registered tab types or currently open tabs.** Neither counts choices available on the guide; resource viewers need not contribute an entry. + +**Protect only the final tab.** Adding a guide would expose a close control on the single-entry default, violating its retained-entry behavior. + +## Consequences + +One-entry compositions open directly into their registered page without hardcoding Files. Guide selection can still replace its own tab, while ordinary close cannot empty a pane. Store and component tests cover registration counts and close protection; the assembled browser scenarios cover default Files, explicit guide creation, and returning to Files after closing the guide. diff --git a/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.zh.md b/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.zh.md new file mode 100644 index 0000000000..8e0a081cb2 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.zh.md @@ -0,0 +1,27 @@ +# Agent Note: Sidebar 默认页与关闭保护 + +Status: implemented + +[English](2026-09-08-sidebar-default-pages.md) | 中文 + +## 问题 + +只有一个注册入口的引导页增加一次点击,却不提供选择。若只隐藏最后一个 tab 的关闭按钮,新增引导页后,默认文件浏览页又会变得可关闭。 + +## 决策 + +Sidebar 从已注册的引导入口列表选择每个默认页。恰好一个入口时打开对应页面;没有入口或有多个入口时打开引导页。没有引导入口的资源查看器不影响计数。显式添加引导页始终打开引导,每个格最多持有一个。 + +单入口默认页在记录生命周期内受到显式关闭保护。每个格的最后一个 tab 也受保护;其他 tab 可以关闭。Sidebar 保存受保护的记录 ID,store 动作与停靠控件共享一个关闭判定。通用停靠套件接收呈现回调,不拥有文件浏览器或引导页策略。移动 tab 仍会处理空格,布局状态仅存于内存。 + +本决策取代[随包类型](2026-09-05-sidebar-text-preview-and-file-tree.zh.md)中的默认引导选择,以及[停靠基础设施](2026-09-04-right-sidebar-docking-infrastructure.zh.md)中的显式关闭最后一个 tab。它们的注册、内容状态、引擎与布局所有权决策继续有效。 + +## 考虑过的替代方案 + +**统计所有已注册 tab 类型或已打开的 tab。** 两者都不代表引导页提供的选择;资源查看器不一定贡献入口。 + +**只保护最后一个 tab。** 新增引导页后,单入口默认页会出现关闭控件,违反保留该入口的行为要求。 + +## 后果 + +单入口组合直接打开已注册页面,不写死 Files。引导页选择仍可替换自身 tab,普通关闭则不能清空一个格。store 与组件测试覆盖注册数量和关闭保护;组装后的浏览器场景覆盖默认 Files、显式新增引导及关闭引导后返回 Files。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 9004b1906f..31eebdab8d 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -92,6 +92,7 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT | | [`node-pty`](https://github.com/microsoft/node-pty) | MIT | | [`open`](https://github.com/sindresorhus/open) | MIT | +| [`pdfjs-dist`](https://github.com/mozilla/pdf.js) | Apache-2.0 | | [`picomatch`](https://github.com/micromatch/picomatch) | MIT | | [`react`](https://github.com/facebook/react) | MIT | | [`react-dom`](https://github.com/facebook/react) | MIT | diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index ea3b6969a8..05fb15dc51 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -243,8 +243,10 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S // CSS width assigned by the grid solver. await expect.poll(() => sidebarSnapshot(page), { timeout: 5_000 }) .toMatchObject({ mode: 'push', panelContentWidth: normalWidth, panelOuterWidth: normalWidth + 1, resizeHandleWidth: 8 }) - await column.locator('[data-sidebar-right-guide-entry="files"]').click() - await column.locator('[data-files-state="tree"]').waitFor({ timeout: 15_000 }) + await expect.poll(async () => ({ + filesVisible: await column.locator('[data-files-state="tree"]').isVisible(), + errors: tripwire.pageErrors, + })).toEqual({ filesVisible: true, errors: [] }) await column.locator('[data-dockkit-add-tab]').click() const split = column.locator('[data-dockkit-split-button]').first() await expect.poll(() => split.isDisabled()).toBe(false) @@ -253,7 +255,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S await panes.first().locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).click() await expect.poll(() => panes.first().locator('[data-files-state="tree"]').count()).toBe(1) const retainedA = await paneSnapshot(page) - expect(retainedA.map(pane => pane.tabs.map(tab => tab.title))).toEqual([['Files', 'Start'], ['Start']]) + expect(retainedA.map(pane => pane.tabs.map(tab => tab.title))).toEqual([['Files', 'Start'], ['Files']]) await checkpoint('A normal: two panes') await column.locator('[data-sidebar-right-mode="fullscreen"]').click() @@ -272,7 +274,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S await expect.poll(() => detailsTrack(page)).toBe(0) await open() expect(await panel.getAttribute('data-sidebar-right-panel')).toBe('push') - await column.locator('[data-sidebar-right-guide-entry="files"]').click() + await column.locator('[data-files-state="tree"]').waitFor({ timeout: 15_000 }) const workspaceDirectory = column.locator('[data-files-entry="directory"] > button').filter({ hasText: /^workspace$/ }) await workspaceDirectory.waitFor({ timeout: 15_000 }) await workspaceDirectory.click() diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts new file mode 100644 index 0000000000..4f47b317dd --- /dev/null +++ b/apps/web/tests/document-preview.e2e.ts @@ -0,0 +1,353 @@ +/** Keyless document-preview smoke through a real Session, Files tab, and shipped renderers. */ +import { mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Browser, Locator, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { pdfFixture } from '../../../packages/client/ui-sidebar-textpreview/tests/pdf-fixture.ts' +import { assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v2.jsonl', import.meta.url)) +const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/document-preview', import.meta.url)) +const EXPECTED = join(SNAPSHOT_DIR, 'document.expected.md') +const PAGING_PATCH = join(SNAPSHOT_DIR, 'paging.patch.yml') +const PAGE_LINES = 64 +const SHOT_DIR = fileURLToPath(new URL('../../../.artifacts/screenshots/0908-document-preview', import.meta.url)) +const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.' +const MODE = webSnapshotMode() + +/** Successful render evidence stays outside the committed snapshot inventory. */ +async function successShot(page: Page, name: string): Promise { + await mkdir(SHOT_DIR, { recursive: true }) + await page.screenshot({ path: join(SHOT_DIR, `${name}-${MODE}-${process.pid}.png`), fullPage: true }) +} + +/** Trigger the document owner's native scroll handler after a real first page overflows. */ +async function scrollForNextPage(body: Locator): Promise { + await body.evaluate((node) => { + if (node.scrollHeight <= node.clientHeight) throw new Error('paging fixture does not overflow the document body') + node.scrollTop = node.scrollHeight + }) +} + +/** Read the solid vector fill away from antialiased page edges. */ +async function canvasColor(canvas: Locator): Promise { + return await canvas.evaluate((node) => { + const surface = node as HTMLCanvasElement + const context = surface.getContext('2d') + if (context === null) throw new Error('PDF canvas has no 2D context') + const pixel = context.getImageData(Math.floor(surface.width / 2), Math.floor(surface.height / 2), 1, 1).data + if (Number(pixel[3]) !== 255) return 'transparent' + if (Number(pixel[0]) - Number(pixel[2]) > 150) return 'red' + if (Number(pixel[2]) - Number(pixel[0]) > 150) return 'blue' + return 'other' + }) +} + +describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false, extraOverlayPath: PAGING_PATCH }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + }) + + afterAll(async () => { + try { + await browser?.close() + } finally { + await scaffold?.close() + } + }) + + it('opens Markdown, isolated HTML, and a rendered PDF from the Session workspace', async () => { + onTestFailed(async () => { + await mkdir(SHOT_DIR, { recursive: true }) + await saveFailureShot(page, `screenshots/0908-document-preview/smoke-${process.pid}`) + }) + const settled = scaffold.whenTurnSettled() + const input = page.locator('[data-composer-input]').first() + await input.fill(PROMPT) + await input.press('Enter') + const sessionId = await settled + await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) + const cwd = scaffold.ctx.agents.get(sessionId)?.session.header.cwd + if (cwd === undefined) throw new Error('settled Session has no workspace cwd') + const markdownText = [ + '# Markdown smoke', '', 'Rendered from the workspace.', '', + ...Array.from({ length: (PAGE_LINES - 4) / 2 }, (_, index) => [`Paragraph ${index + 1}: ${'visible prefix '.repeat(20)}`, '']).flat(), + '# Markdown tail', + ].join('\n') + const codeLines = [ + ...Array.from({ length: PAGE_LINES }, (_, index) => index === 0 ? 'const prefix = "CODE_PREFIX";' : `// prefix line ${index + 1}`), + 'const tail = "CODE_TAIL";', + ] + await Promise.all([ + writeFile(join(cwd, 'smoke.md'), markdownText), + writeFile(join(cwd, 'pages.ts'), codeLines.join('\n')), + writeFile(join(cwd, 'notes.unknown'), 'UNKNOWN_SUFFIX\nPlain fallback.'), + writeFile(join(cwd, 'smoke.html'), [ + '', + '

HTML smoke

pending

pending

pending

', + '', + '', + ].join('\n')), + writeFile(join(cwd, 'local.js'), 'document.getElementById("local-result").textContent="LOCAL_JS_OK";'), + writeFile(join(cwd, 'local.css'), '#local-result { color: rgb(12, 34, 56); }'), + writeFile(join(cwd, 'smoke.pdf'), pdfFixture()), + ]) + + const column = page.locator('[data-rightbar-col]') + await page.locator('[data-sidebar-right-expand]').click() + await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' }) + await column.locator('[data-files-reload]').click() + const filesTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('Files', { exact: true }) }) + const addTab = column.locator('[data-dockkit-add-tab]') + expect(await column.locator('[data-dockkit-tab]').count()).toBe(1) + const defaultTitle = await filesTab.locator('[data-dockkit-tab-title]').innerText() + const initialFilesClose = await filesTab.locator('[data-dockkit-tab-close]').count() + expect(initialFilesClose).toBe(0) + await filesTab.click({ button: 'right' }) + expect(await page.locator('[data-dockkit-tab-menu]:visible').count()).toBe(0) + await addTab.waitFor({ state: 'visible' }) + const initialAdd = await addTab.count() + expect(initialAdd).toBe(1) + await addTab.click() + await column.locator('[data-sidebar-right-guide]').waitFor({ state: 'visible' }) + await expect.poll(() => column.locator('[data-dockkit-tab]').count()).toBe(2) + const guideTab = column.locator('[data-dockkit-tab]').filter({ hasNot: page.getByText('Files', { exact: true }) }) + const guideClose = await guideTab.locator('[data-dockkit-tab-close]').count() + const filesCloseWithGuide = await filesTab.locator('[data-dockkit-tab-close]').count() + expect(guideClose).toBe(1) + expect(filesCloseWithGuide).toBe(0) + await expect.poll(() => addTab.count()).toBe(0) + const addWithGuide = await addTab.count() + await guideTab.locator('[data-dockkit-tab-close]').click() + await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' }) + await expect.poll(() => column.locator('[data-sidebar-right-guide]').count()).toBe(0) + await expect.poll(() => column.locator('[data-dockkit-tab]').count()).toBe(1) + await addTab.waitFor({ state: 'visible' }) + const restoredFilesClose = await filesTab.locator('[data-dockkit-tab-close]').count() + const restoredAdd = await addTab.count() + expect(restoredFilesClose).toBe(0) + expect(restoredAdd).toBe(1) + const preview = column.locator('[data-document-preview]') + const openFile = async (name: string): Promise => { + await filesTab.click() + await column.locator('[data-files-entry="file"]').getByRole('button', { name, exact: true }).click() + await expect.poll(async () => (await preview.getAttribute('data-textpreview-url'))?.endsWith(`/${name}`)).toBe(true) + } + const viewer = preview.locator('[data-document-viewer-menu]') + const body = preview.locator('[data-textpreview-body]') + const sections = ['# Document preview'] + sections.push([ + '## Sidebar tabs', '', + `- Default tab: ${defaultTitle}`, + `- Files close buttons (alone -> with guide -> restored): ${[initialFilesClose, filesCloseWithGuide, restoredFilesClose].join(' -> ')}`, + `- Manual guide close buttons: ${guideClose}`, + `- Add buttons (Files -> guide -> Files): ${[initialAdd, addWithGuide, restoredAdd].join(' -> ')}`, + ].join('\n')) + + await openFile('smoke.md') + await expect.poll(() => viewer.innerText()).toBe('Markdown') + await preview.getByRole('heading', { name: 'Markdown smoke', exact: true }).waitFor({ timeout: 15_000 }) + expect(await preview.getByText('Rendered from the workspace.', { exact: true }).isVisible()).toBe(true) + const heading = await preview.getByRole('heading', { name: 'Markdown smoke', exact: true }).innerText() + const markdownTail = preview.getByRole('heading', { name: 'Markdown tail', exact: true }) + await expect.poll(() => preview.locator('[data-textpreview-more]').isEnabled()).toBe(true) + expect(await markdownTail.count()).toBe(0) + await scrollForNextPage(body) + await markdownTail.waitFor({ timeout: 15_000 }) + await expect.poll(() => preview.locator('[data-textpreview-more]').count()).toBe(0) + expect(await preview.getByRole('heading', { name: heading, exact: true }).count()).toBe(1) + expect(await preview.getByText('Rendered from the workspace.', { exact: true }).count()).toBe(1) + const tailHeading = await markdownTail.innerText() + await preview.getByRole('heading', { name: heading, exact: true }).scrollIntoViewIfNeeded() + await successShot(page, 'markdown') + const markdownTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('smoke.md', { exact: true }) }) + const markdownTabId = await markdownTab.getAttribute('data-dockkit-tab') + expect(markdownTabId).not.toBeNull() + const tabCount = await column.locator('[data-dockkit-tab]').count() + const markdownViewers = [await viewer.innerText()] + const sourceFonts: Array<{ fontSize: string; lineHeight: string }> = [] + for (const label of ['Code', 'Plain text']) { + await viewer.click() + await page.getByRole('menuitem', { name: label, exact: true }).click() + await expect.poll(() => viewer.innerText()).toBe(label) + if (label === 'Code') { + await preview.locator('.shiki').waitFor({ timeout: 15_000 }) + expect(await preview.locator('.shiki').textContent()).toBe(markdownText) + } else { + await expect.poll(async () => (await preview.locator('[data-textpreview-line]').first().textContent())?.trim()).toBe('# Markdown smoke') + } + const source = label === 'Code' ? preview.locator('.shiki') : preview.locator('[data-textpreview-line]').first() + sourceFonts.push(await source.evaluate((node) => { + const style = getComputedStyle(node) + return { fontSize: style.fontSize, lineHeight: style.lineHeight } + })) + expect(await markdownTab.getAttribute('data-dockkit-tab')).toBe(markdownTabId) + expect(await column.locator('[data-dockkit-tab]').count()).toBe(tabCount) + markdownViewers.push(await viewer.innerText()) + } + expect(sourceFonts[1]).toEqual(sourceFonts[0]) + sections.push([ + '## Markdown', '', + `- Heading: ${heading}`, + `- Tail loaded by scrolling: ${tailHeading}`, + `- Viewers: ${markdownViewers.join(' -> ')}`, + `- Same tab: ${String(await markdownTab.getAttribute('data-dockkit-tab') === markdownTabId)}`, + ].join('\n')) + + await openFile('smoke.html') + await expect.poll(() => viewer.innerText()).toBe('HTML') + const iframe = preview.locator('[data-html-preview]') + await iframe.waitFor({ timeout: 15_000 }) + expect(await iframe.getAttribute('sandbox')).toBe('allow-scripts') + const html = page.frameLocator('[data-html-preview]') + await html.getByRole('heading', { name: 'HTML smoke', exact: true }).waitFor({ timeout: 15_000 }) + await expect.poll(() => html.locator('#result').innerText()).toBe('INLINE_OK') + await expect.poll(() => html.locator('#local-result').innerText()).toBe('LOCAL_JS_OK') + await expect.poll(() => html.locator('#local-result').evaluate(node => getComputedStyle(node).color)).toBe('rgb(12, 34, 56)') + await expect.poll(() => html.locator('#parent-result').innerText()).toBe('parent-blocked') + expect(await html.locator('#parent-result').getAttribute('data-error')).toBe('SecurityError') + expect(await page.locator('html').getAttribute('data-document-preview-escape')).toBeNull() + await successShot(page, 'html') + sections.push([ + '## HTML', '', + `- Viewer: ${await viewer.innerText()}`, + `- Sandbox: ${await iframe.getAttribute('sandbox')}`, + `- Inline script: ${await html.locator('#result').innerText()}`, + `- Local script: ${await html.locator('#local-result').innerText()}`, + `- Local stylesheet: ${await html.locator('#local-result').evaluate(node => getComputedStyle(node).color)}`, + `- Parent access: ${await html.locator('#parent-result').innerText()} (${await html.locator('#parent-result').getAttribute('data-error')})`, + `- Parent unchanged: ${String(await page.locator('html').getAttribute('data-document-preview-escape') === null)}`, + ].join('\n')) + + await openFile('smoke.pdf') + await expect.poll(() => viewer.innerText()).toBe('PDF') + const canvas = preview.getByRole('img', { name: 'PDF page 1', exact: true }) + await canvas.waitFor({ state: 'visible', timeout: 30_000 }) + expect(await preview.getByRole('spinbutton', { name: 'Page', exact: true }).inputValue()).toBe('1') + expect(await preview.getByText('of 2', { exact: true }).isVisible()).toBe(true) + await expect.poll(() => canvasColor(canvas), { timeout: 30_000 }).toBe('red') + const firstColor = await canvasColor(canvas) + expect(firstColor).toBe('red') + const workerNames = await Promise.all(page.workers().map(worker => worker.evaluate(() => self.name))) + expect(workerNames).toContain('dsh-pdf') + await preview.getByRole('button', { name: 'Next page', exact: true }).click() + const secondPage = preview.getByRole('img', { name: 'PDF page 2', exact: true }) + await secondPage.waitFor({ state: 'visible', timeout: 30_000 }) + await expect.poll(() => canvasColor(secondPage), { timeout: 30_000 }).toBe('blue') + const secondColor = await canvasColor(secondPage) + expect(secondColor).toBe('blue') + expect(await preview.getByRole('spinbutton', { name: 'Page', exact: true }).inputValue()).toBe('2') + const pdfTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('smoke.pdf', { exact: true }) }) + const pdfTabId = await pdfTab.getAttribute('data-dockkit-tab') + expect(pdfTabId).not.toBeNull() + await filesTab.click() + await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' }) + await pdfTab.click() + await secondPage.waitFor({ state: 'visible', timeout: 30_000 }) + const restoredPage = await preview.getByRole('spinbutton', { name: 'Page', exact: true }).inputValue() + expect(restoredPage).toBe('2') + await expect.poll(() => canvasColor(secondPage), { timeout: 30_000 }).toBe('blue') + const restoredColor = await canvasColor(secondPage) + expect(restoredColor).toBe('blue') + expect(await pdfTab.getAttribute('data-dockkit-tab')).toBe(pdfTabId) + await successShot(page, 'pdf') + sections.push([ + '## PDF', '', + `- Viewer: ${await viewer.innerText()}`, + `- Worker: ${workerNames.find(name => name === 'dsh-pdf')}`, + `- Page count: ${await preview.getByText('of 2', { exact: true }).innerText()}`, + `- Canvas fills: ${[firstColor, secondColor, restoredColor].join(' -> ')}`, + `- Page after Files round trip: ${restoredPage}`, + `- Same tab: ${String(await pdfTab.getAttribute('data-dockkit-tab') === pdfTabId)}`, + ].join('\n')) + + await openFile('pages.ts') + await expect.poll(() => viewer.innerText()).toBe('Code') + const highlightedLines = preview.locator('.shiki .line') + await expect.poll(() => highlightedLines.count(), { timeout: 15_000 }).toBe(PAGE_LINES) + const codeBlock = preview.locator('.md-code-block') + expect(await codeBlock.getAttribute('data-line-numbers')).toBe('true') + await expect.poll(() => highlightedLines.first().evaluate(node => getComputedStyle(node, '::before').content)) + .not.toMatch(/^(?:none|normal)$/u) + const numbering = await highlightedLines.first().evaluate((node) => { + const line = getComputedStyle(node) + const before = getComputedStyle(node, '::before') + return { + counterIncrement: line.counterIncrement, + gutterWidth: Number.parseFloat(before.width), + sourceInset: Number.parseFloat(line.paddingInlineStart), + } + }) + expect(numbering.counterIncrement).toBe('source-line 1') + expect(numbering.gutterWidth).toBeGreaterThan(0) + expect(numbering.sourceInset).toBeGreaterThan(numbering.gutterWidth) + const prefix = await highlightedLines.allTextContents() + expect(prefix).toEqual(codeLines.slice(0, PAGE_LINES)) + await expect.poll(() => preview.locator('[data-textpreview-more]').isEnabled()).toBe(true) + await scrollForNextPage(body) + await expect.poll(() => highlightedLines.count(), { timeout: 15_000 }).toBe(codeLines.length) + const completed = await highlightedLines.allTextContents() + expect(completed).toEqual(codeLines) + await expect.poll(() => preview.locator('[data-textpreview-more]').count()).toBe(0) + const scrollTop = await body.evaluate((node) => { + const target = Math.floor((node.scrollHeight - node.clientHeight) / 2) + if (target <= 0) throw new Error('code fixture does not overflow the document body') + node.scrollTop = target + return target + }) + await expect.poll(() => body.evaluate((node) => { + const banner = node.querySelector('.md-code-block')?.firstElementChild + const firstLine = node.querySelector('.shiki .line') + if (!(banner instanceof HTMLElement) || firstLine === null) throw new Error('missing rendered code banner or source line') + const bounds = node.getBoundingClientRect() + const clipTop = bounds.top + node.clientTop + const bannerBounds = banner.getBoundingClientRect() + const hit = document.elementFromPoint(bounds.left + node.clientLeft + node.clientWidth / 2, clipTop + 1) + return { + scrollTop: node.scrollTop, + position: getComputedStyle(banner).position, + topGap: bannerBounds.top - clipTop, + firstLineAbove: firstLine.getBoundingClientRect().top < clipTop, + topCoveredByBanner: hit !== null && banner.contains(hit), + } + })).toEqual({ scrollTop, position: 'sticky', topGap: 0, firstLineAbove: true, topCoveredByBanner: true }) + await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin }) + await page.evaluate(() => navigator.clipboard.writeText('')) + await codeBlock.getByRole('button', { name: 'Copy', exact: true }).click() + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(codeLines.join('\n')) + sections.push([ + '## Code paging', '', + `- Viewer: ${await viewer.innerText()}`, + `- Lines: ${prefix.length} -> ${completed.length}`, + `- Prefix retained: ${String(JSON.stringify(completed.slice(0, prefix.length)) === JSON.stringify(prefix))}`, + `- Tail: ${completed.at(-1)}`, + ].join('\n')) + + await openFile('notes.unknown') + await expect.poll(() => viewer.innerText()).toBe('Plain text') + const plainLines = preview.locator('[data-textpreview-line]') + await expect.poll(() => plainLines.count()).toBe(2) + const fallback = (await plainLines.allTextContents()).map(line => line.trim()) + expect(fallback).toEqual(['UNKNOWN_SUFFIX', 'Plain fallback.']) + sections.push(['## Unknown suffix', '', `- Viewer: ${await viewer.innerText()}`, `- Text: ${fallback.join(' | ')}`].join('\n')) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await compareOrRefreshGolden(EXPECTED, sections.join('\n\n'), MODE) + await assertFixtureInventory(SNAPSHOT_DIR, ['document.expected.md', 'paging.patch.yml']) + }) +}) diff --git a/apps/web/tests/sidebar-right.e2e.ts b/apps/web/tests/sidebar-right.e2e.ts index 69196119d3..257e0c9f6e 100644 --- a/apps/web/tests/sidebar-right.e2e.ts +++ b/apps/web/tests/sidebar-right.e2e.ts @@ -132,7 +132,7 @@ async function resetSidebar(page: Page): Promise { const column = page.locator('[data-rightbar-col]') await expandOf(page).waitFor({ timeout: 15_000 }) await ensureExpanded(page, column) - await expect.poll(async () => await tabTitles(column)).toEqual(['Start']) + await expect.poll(async () => await tabTitles(column)).toEqual(['Files']) await width(column) return column } @@ -370,7 +370,7 @@ describe('web e2e: shipped right Sidebar', () => { expect(Math.round(gapAfter)).toBe(Math.round(gapBefore)) // The panel is in the column, not over it, and carries the seeded tab — - // whose body arrives through the guide type's keyed registration, not from + // whose body arrives through the Files type's keyed registration, not from // any dispatch inside the seat. Its two controls sit at the end of the // top-right pane's strip: the panel has no header row of its own. expect(await column.locator('[data-sidebar-right-panel="push"]').count()).toBe(1) @@ -380,8 +380,7 @@ describe('web e2e: shipped right Sidebar', () => { expect(await chrome.locator('[data-sidebar-right-toggle]').count()).toBe(1) // One centre line across the strip: chip text, split, and the two panel - // controls all sit at the same height. The add control joins the check - // below, once the strip draws it. + // controls all sit at the same height. The add control joins the check below. const centreY = async (selector: string): Promise => { const box = await column.locator(selector).first().boundingBox() if (box === null) throw new Error(`${selector} is not rendered`) @@ -392,24 +391,26 @@ describe('web e2e: shipped right Sidebar', () => { expect(await centreY(selector), selector).toBe(textLine) } - // The guide is unique per pane, so while this pane holds one its strip - // offers no add control. Closing it brings the control back, and the - // control opens the guide again in that pane. + // Files is permanent. A manual guide is closable and suppresses another + // add control in its pane until it is closed. const addTab = column.locator('[data-dockkit-add-tab]') - expect(await addTab.count()).toBe(0) - await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click() - await expect.poll(async () => await tabTitles(column)).toEqual(['Start', SAMPLE_NAME]) - await column.locator('[data-dockkit-tab-close]').first().click() - await expect.poll(async () => await tabTitles(column)).toEqual([SAMPLE_NAME]) + const filesTab = column.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }) + await expect.poll(async () => await tabTitles(column)).toEqual(['Files']) + await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' }) + expect(await filesTab.locator('[data-dockkit-tab-close]').count()).toBe(0) await expect.poll(async () => await addTab.count()).toBe(1) expect(await centreY('[data-dockkit-add-tab]')).toBe(textLine) await addTab.click() - await expect.poll(async () => await tabTitles(column)).toEqual([SAMPLE_NAME, 'Start']) + await expect.poll(async () => await tabTitles(column)).toEqual(['Files', 'Start']) await expect.poll(async () => await column.locator('[data-sidebar-right-guide]').count()).toBe(1) await expect.poll(async () => await addTab.count()).toBe(0) + expect(await filesTab.locator('[data-dockkit-tab-close]').count()).toBe(0) + const guideTab = column.locator('[data-dockkit-tab]').filter({ hasText: 'Start' }) + expect(await guideTab.locator('[data-dockkit-tab-close]').count()).toBe(1) // Back to the seeded shape the cases below start from. - await column.locator('[data-dockkit-tab-close]').first().click() - await expect.poll(async () => await tabTitles(column)).toEqual(['Start']) + await guideTab.locator('[data-dockkit-tab-close]').click() + await expect.poll(async () => await tabTitles(column)).toEqual(['Files']) + await expect.poll(async () => await addTab.count()).toBe(1) await shot(page, '02-squeezed-panel') expect(tripwire.pageErrors).toEqual([]) @@ -648,12 +649,12 @@ describe('web e2e: shipped right Sidebar', () => { // text type claims the address. const chip = page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }) await chip.click() - await expect.poll(async () => await tabTitles(column)).toEqual(['Start', SAMPLE_NAME]) + await expect.poll(async () => await tabTitles(column)).toEqual(['Files', SAMPLE_NAME]) // Opening the same content again focuses rather than duplicating. await panes.first().locator('[data-dockkit-tab]').first().click() await chip.click() - await expect.poll(async () => await tabTitles(column)).toEqual(['Start', SAMPLE_NAME]) + await expect.poll(async () => await tabTitles(column)).toEqual(['Files', SAMPLE_NAME]) // The body arrives through the text type's keyed registration, and its // content came over the wire from the real file. @@ -684,15 +685,15 @@ describe('web e2e: shipped right Sidebar', () => { ) await expect.poll(async () => await panes.count()).toBe(2) - await panes.nth(1).locator('[data-dockkit-tab]').filter({ hasText: 'Start' }) - .locator('[data-dockkit-tab-close]').click() + const splitFiles = panes.nth(1).locator('[data-dockkit-tab]').filter({ hasText: 'Files' }) + expect(await splitFiles.locator('[data-dockkit-tab-close]').count()).toBe(0) + await dragTo(page, splitFiles, await pointIn(panes.first(), 0.5, 0.5)) await expect.poll(async () => await tabTitles(panes.nth(1))).toEqual([SAMPLE_NAME]) - // The guide is unique per pane: panes seeded with one offer no - // add control; the pane holding only the file is the one that does. + // Neither pane holds a guide, so both offer an add control. const filePane = panes.filter({ has: page.locator('[data-dockkit-tab-title]', { hasText: SAMPLE_NAME }) }) await expect.poll(async () => await filePane.locator('[data-dockkit-add-tab]').count()).toBe(1) - expect(await column.locator('[data-dockkit-add-tab]').count()).toBe(1) + expect(await column.locator('[data-dockkit-add-tab]').count()).toBe(2) // Floating leaves the column entirely, and survives collapsing it. The // pane the tab was alone in goes with it: an emptied pane never stays. @@ -780,7 +781,7 @@ describe('web e2e: shipped right Sidebar', () => { const first = panes.first() const strip = first.locator('[data-dockkit-strip]') await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click() - await expect.poll(async () => await tabTitles(first)).toEqual(['Start', SAMPLE_NAME]) + await expect.poll(async () => await tabTitles(first)).toEqual(['Files', SAMPLE_NAME]) const order = await tabTitles(first) // The insertion index is measured against chip midpoints, not strip width. await dragTo(page, first.locator('[data-dockkit-tab]').last(), @@ -824,10 +825,8 @@ describe('web e2e: shipped right Sidebar', () => { expect(await panes.count()).toBe(2) expect(await splitButtons.count()).toBe(0) - // 5. Two floats coexist, and one of them moves. Both leave the widest - // pane; a pane emptied by the first float is merged away, so the - // second one comes from whichever pane is widest by then. - const floatOne = panes.last().locator('[data-dockkit-tab]').first() + // 5. A manual guide and the document float while Files stays docked. + const floatOne = panes.last().locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }) await floatByDrag(page, floatOne) await expect.poll(async () => await floats.count()).toBe(1) const box = await floats.first().boundingBox() @@ -835,7 +834,9 @@ describe('web e2e: shipped right Sidebar', () => { await dragElement(page, floats.first().locator('[data-dockkit-float-grip]'), { x: box.x + 140, y: box.y + 90 }) await expect.poll(async () => (await floats.first().boundingBox())?.x ?? box.x).not.toBe(box.x) - const second = panes.last().locator('[data-dockkit-tab]').first() + await panes.last().locator('[data-dockkit-add-tab]').click() + await expect.poll(async () => await tabTitles(panes.last())).toEqual(['Files', 'Start']) + const second = panes.last().locator('[data-dockkit-tab]').filter({ hasText: 'Start' }) await floatByDrag(page, second) await expect.poll(async () => await floats.count()).toBe(2) @@ -849,34 +850,33 @@ describe('web e2e: shipped right Sidebar', () => { expect(tripwire.warnings).toEqual([]) }, 90_000) - it('drops a pane whose last tab closes, and reseeds the guide when none is left', async () => { + it('keeps the last tab unclosable and drops a pane emptied by a drag', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-settle')) const column = await resetSidebar(page) const panes = column.locator('[data-dockkit-pane]') + expect(await column.locator('[data-dockkit-tab-close]').count()).toBe(0) + await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click() + await expect.poll(async () => await tabTitles(panes.first())).toEqual(['Files', SAMPLE_NAME]) await panes.first().locator('[data-dockkit-split-button]').click() await expect.poll(async () => await panes.count()).toBe(2) - const closeAllIn = async (pane: Locator): Promise => { - const tabs = await pane.locator('[data-dockkit-tab]').count() - for (let i = 0; i < tabs; i += 1) { - await pane.locator('[data-dockkit-tab-close]').first().click() - } - } + await dragTo(page, panes.first().locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }), + await pointIn(panes.nth(1), 0.5, 0.5)) + await expect.poll(async () => await tabTitles(panes.nth(1))).toEqual(['Files', SAMPLE_NAME]) + const splitFiles = panes.nth(1).locator('[data-dockkit-tab]').filter({ hasText: 'Files' }) + expect(await splitFiles.locator('[data-dockkit-tab-close]').count()).toBe(0) + await dragTo(page, splitFiles, await pointIn(panes.first(), 0.5, 0.5)) + await expect.poll(async () => await tabTitles(panes.nth(1))).toEqual([SAMPLE_NAME]) - // Closing a pane's last tab drops the pane: there is no separate - // "close pane" gesture, and none is needed. - let count = await panes.count() - expect(count).toBeGreaterThan(1) - while (count > 1) { - await closeAllIn(panes.nth(count - 1)) - await expect.poll(async () => await panes.count()).toBe(count - 1) - count -= 1 - } - - // The last pane cannot be dropped, so closing everything in it reseeds - // the guide: the surface always has one tab to look at. - await closeAllIn(panes.first()) - await expect.poll(async () => await tabTitles(column)).toEqual(['Start']) - expect(await column.locator('[data-sidebar-right-guide]').count()).toBe(1) + // The remaining ordinary document is also unclosable while alone. + expect(await panes.nth(1).locator('[data-dockkit-tab-close]').count()).toBe(0) + await dragTo(page, panes.nth(1).locator('[data-dockkit-tab]'), await pointIn(panes.first(), 0.5, 0.5)) + await expect.poll(async () => await panes.count()).toBe(1) + const documentTab = panes.first().locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }) + await expect.poll(async () => await documentTab.locator('[data-dockkit-tab-close]').count()).toBe(1) + await documentTab.locator('[data-dockkit-tab-close]').click() + await expect.poll(async () => await documentTab.count()).toBe(0) + expect(await panes.count()).toBe(1) + expect(await column.locator('[data-dockkit-tab-close]').count()).toBe(0) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) @@ -902,10 +902,12 @@ describe('web e2e: shipped right Sidebar', () => { await ensureExpanded(page, column) await expect.poll(async () => await column.locator('[data-dockkit-tab]').count()).toBeGreaterThan(0) + await column.locator('[data-dockkit-add-tab]').click() + await expect.poll(async () => await tabTitles(column)).toEqual(['Files', 'Start']) // No "more" control on the chip: the chip carries its close, and the menu // is the secondary press. expect(await column.locator('[data-dockkit-tab-more]').count()).toBe(0) - await column.locator('[data-dockkit-tab]').first().click({ button: 'right' }) + await column.locator('[data-dockkit-tab]').filter({ hasText: 'Start' }).click({ button: 'right' }) const menu = page.locator('[data-dockkit-tab-menu]') await expect.poll(async () => await menu.count()).toBe(1) @@ -947,6 +949,8 @@ describe('web e2e: shipped right Sidebar', () => { const column = zhPage.locator('[data-rightbar-col]') await expandOf(zhPage).waitFor({ timeout: 20_000 }) await expandOf(zhPage).click() + await expect.poll(async () => await tabTitles(column)).toEqual(['文件']) + await column.locator('[data-dockkit-add-tab]').click() const guide = column.locator('[data-sidebar-right-guide]') await expect.poll(async () => await guide.count()).toBe(1) @@ -954,7 +958,7 @@ describe('web e2e: shipped right Sidebar', () => { // the column has the width, and a screenshot taken mid-transition reads // as a layout defect that is not there. expect(await width(column)).toBeGreaterThan(300) - await expect.poll(async () => await tabTitles(column)).toEqual(['开始']) + await expect.poll(async () => await tabTitles(column)).toEqual(['文件', '开始']) await expect.poll(async () => await guide.locator('p').first().innerText()) .toBe('侧栏用来放你想一直看着的东西。') await shot(zhPage, '05-guide-copy-zh') diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 2a25226d5f..8001c0a9c3 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -40,6 +40,7 @@ "tests/trajectory-virtualization.e2e.ts", "tests/lifecycle-chrome.e2e.ts", "tests/details-session-lifecycle.e2e.ts", + "tests/document-preview.e2e.ts", "tests/plugin-config.e2e.ts", "tests/settings-chrome.e2e.ts", "tests/models-settings.e2e.ts", diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 2c01491512..54a8fa5c51 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: a1cc7d72898367cb4a752c10355d5c4808d1c6c7 -config-catalog.zh.md: 88e656f0ac0a8132638259e0ab089888b633d446 +config-catalog.md: 01aecfe6037ae7125771046329cf758473245f13 +config-catalog.zh.md: 277f66830f94e5d12e1f532755956442ebd69c07 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a1cc7d7289..01aecfe603 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -246,6 +246,8 @@ export interface Config { * way. The file itself has no size cap: a caller pages through it. */ readonly maxBytes: number + /** Inclusive byte cap on a complete-file read; larger files are refused, never truncated. */ + readonly maxFileBytes: number /** Default and largest page size in lines; a request asking for more is refused. */ readonly maxLines: number /** Cap on returned directory entries; the rest is dropped and reported cut. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 88e656f0ac..277f66830f 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -248,6 +248,8 @@ export interface Config { * way. The file itself has no size cap: a caller pages through it. */ readonly maxBytes: number + /** Inclusive byte cap on a complete-file read; larger files are refused, never truncated. */ + readonly maxFileBytes: number /** Default and largest page size in lines; a request asking for more is refused. */ readonly maxLines: number /** Cap on returned directory entries; the rest is dropped and reported cut. */ diff --git a/docs/subsystems/client-resources.i18n.yaml b/docs/subsystems/client-resources.i18n.yaml index e1267b8d56..9957789d79 100644 --- a/docs/subsystems/client-resources.i18n.yaml +++ b/docs/subsystems/client-resources.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 docs/subsystems/client-resources.md -client-resources.md: a1643e61779e5c19450c7e1688b0ce7fcfbee786 -client-resources.zh.md: 418c9aef91a58a85ff8102617074a38813c5f94b +client-resources.md: d89ba6af433cd41066835be85d348b8fdf2afef0 +client-resources.zh.md: 6bfbb3e4470f89c1a90853427ca6f3a684120b3e diff --git a/docs/subsystems/client-resources.md b/docs/subsystems/client-resources.md index a1643e6177..d89ba6af43 100644 --- a/docs/subsystems/client-resources.md +++ b/docs/subsystems/client-resources.md @@ -8,19 +8,19 @@ This page is the developer reference: how to write an address, how to register a ## Addresses -A resource address is a `dsh-resource:///…` URL. The host names the protocol and must be a key of `ResourceProtocolMap`; the path is the protocol's own, and its owner percent-encodes each segment. A protocol that needs a scope puts it in the path: the `file` protocol's addresses read `dsh-resource://file/session//` or `dsh-resource://file/absolute/`, built with `fileAddressFor(sessionId, cwd, path)` and read back with `parseFileAddress(address)` from [`dsh-util-workspace-path`](../../packages/util/workspace-path/README.md). The model itself reads only the scheme and the host: `protocolOf(address)` returns the lower-cased host of a `dsh-resource://` URL and `undefined` for anything else. Addresses under any other scheme — the Sidebar's `sidebar://guide` — name no resource and read as `none`. +A resource address is a `dsh-resource:///…` URL. The host names the protocol and must be a key of `ResourceProtocolMap`; the path is the protocol's own, and its owner percent-encodes each segment. A protocol that needs a scope puts it in the path: the `file` protocol's addresses read `dsh-resource://file/session//`, where path is workspace-relative or absolute with its leading slashes preserved, built with `fileAddressFor(sessionId, cwd, path)` and read back with `parseFileAddress(address)` from [`dsh-util-workspace-path`](../../packages/util/workspace-path/README.md). The model itself reads only the scheme and the host: `protocolOf(address)` returns the lower-cased host of a `dsh-resource://` URL and `undefined` for anything else. Addresses under any other scheme — the Sidebar's `sidebar://guide` — name no resource and read as `none`. | Address | Protocol key | Reads as | |---|---|---| | `dsh-resource://file/session/s1/notes/a.md` | `file` | the metadata of `notes/a.md` under session `s1`'s workspace root, when the `file` provider is registered | -| `dsh-resource://file/absolute/home/me/notes.md` | `file` | the metadata of that absolute path, read through the current session and confined to its workspace | +| `dsh-resource://file/absolute/home/me/notes.md` | `file` | parseable but fails with `workspace-file/unknown-workspace`: no authorizing Session, and neither current nor Tab Session is borrowed | | `DSH-RESOURCE://File/session/s1/a` | `file` | a distinct record: addresses compare as strings, and `openResource` accepts only the canonical lower-case spelling that `fileAddressFor` emits | | `sidebar://guide` | — | `none`: a navigation address | | `/home/me/notes.md` | — | `none`: not a URL | ## Registering a provider -The owner of a protocol declares its value type on `ResourceProtocolMap` and registers one provider inside its own `ctx.effect`, so the protocol lives exactly as long as the plugin ([provide a protocol](../../packages/client/resources/README.md#provide-a-protocol)). `open(address, { signal })` returns a stream of `RemoteResult` frames — the current state first, then one frame per change — and must stop when `signal` aborts. A failure is an `ok: false` frame carrying a `RemoteFailure`; a throw inside the stream is a programming error and is not caught. `reload(address)` is optional and asks the open stream for a fresh frame. +The owner of a protocol declares its value type on `ResourceProtocolMap` and registers one provider inside its own `ctx.effect`, so the protocol lives exactly as long as the plugin ([provide a protocol](../../packages/client/resources/README.md#provide-a-protocol)). `open(address, { signal })` returns a stream of `RemoteResult` frames — the current state first, then one frame per change — and must stop when `signal` aborts. A failure is an `ok: false` frame carrying a `RemoteFailure`; a throw inside the stream is a programming error and is not caught. ```ts ignore-check import type { Context } from '@deepseek-ai/cordis' @@ -43,7 +43,6 @@ export function apply(ctx: Context): void { yield await ctx.remote.notes.read(id, signal) for await (const change of ctx.remote.notes.follow(id, signal)) yield change }, - reload(address) { ctx.remote.notes.requestReread(new URL(address).pathname.slice(1)) }, }), 'my-notes: note resource provider') } ``` @@ -61,21 +60,19 @@ Every slot component receives `useResource` in its props, whatever its scope ([S | `live` | The latest frame succeeded | the latest `ok` value | `undefined` | | `failed` | The latest frame reported a failure | the last `ok` value, kept | the frame's `RemoteFailure` | -`reload()` asks the provider for a fresh frame and is a no-op when the protocol has no provider or the provider has no `reload`; the function is reference-stable per address, so a body may hold it. - ```tsx ignore-check import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-api-workspace-files/client' type Props = PropsRuntime<'sidebar.right.pane.tab'> -export function FileHeader({ tab, useResource, t }: Props) { +export function FileHeader({ useTabInfo, useResource, t }: Props) { + const { tab } = useTabInfo() const meta = useResource<'file'>(tab.contentId) if (meta.status === 'failed') return

{t('failed', { code: meta.failure.code })}

return (
{tab.title} - {meta.value?.changed && }
) } @@ -87,7 +84,7 @@ A consumer presents `failed` itself: the model keeps the last value beside the f A resource is alive while it has a holder: a subscribed `useResource`, or a pin. `ctx.resources.pin(address, signal)` keeps a resource open without subscribing until `signal` aborts, and an already-aborted signal pins nothing; the right Sidebar pins every open tab record's address for the record's life, so switching tabs unmounts a body without closing its stream. The first holder opens the provider's stream; the last release aborts it, discards the value, and returns the snapshot to `loading` (provider present) or `none` (absent). A frame the provider yields after that release is dropped, and the iterator is returned. `ctx.resources.source(address)` is the bare observable behind the hook, reference-stable per address, for callers outside React; reading its snapshot does not hold the resource ([lifecycle](../../packages/client/resources/README.md#lifecycle)). -Streams carry metadata, not content. The `file` provider's value is `{ absolutePath, version, bytes?, changed }`: `absolutePath`, `version`, and `bytes` from the Host's `stat`, `changed` raised when the Host reports an agent write and cleared by `reload`. A consumer reads the file's text itself, by page, through the Workspace Files Remote namespace ([`dsh-api-workspace-files`](../../packages/api/workspace-files/README.md)). +Streams carry metadata, not content. The `file` provider's value is `WorkspaceFileStat { absolutePath, version, bytes? }`: the first frame comes from Host `stat`, and later observations update the version. A consumer reads content through the Workspace Files Remote namespace; Preview owns refresh independently per tab ([`dsh-api-workspace-files`](../../packages/api/workspace-files/README.md)). ## Limits diff --git a/docs/subsystems/client-resources.zh.md b/docs/subsystems/client-resources.zh.md index 418c9aef91..6bfbb3e447 100644 --- a/docs/subsystems/client-resources.zh.md +++ b/docs/subsystems/client-resources.zh.md @@ -8,19 +8,19 @@ ## 地址 -资源地址是 `dsh-resource:///…` 形式的 URL。host 命名协议,必须是 `ResourceProtocolMap` 的键;路径归协议自己,由其拥有者逐段做百分号编码。需要作用域的协议把作用域放进路径:`file` 协议的地址形如 `dsh-resource://file/session//<相对该会话工作区根的路径>` 或 `dsh-resource://file/absolute/<去掉前导 / 的绝对路径>`,用 [`dsh-util-workspace-path`](../../packages/util/workspace-path/README.zh.md) 的 `fileAddressFor(sessionId, cwd, path)` 构造、`parseFileAddress(address)` 读回。模型本身只读 scheme 与 host:`protocolOf(address)` 对 `dsh-resource://` URL 返回小写 host,对其它任何字串返回 `undefined`。其它 scheme 下的地址——Sidebar 的 `sidebar://guide`——不指向资源,读作 `none`。 +资源地址是 `dsh-resource:///…` 形式的 URL。host 命名协议,必须是 `ResourceProtocolMap` 的键;路径归协议自己,由其拥有者逐段做百分号编码。需要作用域的协议把作用域放进路径:`file` 协议的地址形如 `dsh-resource://file/session//`,其中 path 可以相对工作区根,也可以是保留前导斜杠的绝对路径,用 [`dsh-util-workspace-path`](../../packages/util/workspace-path/README.zh.md) 的 `fileAddressFor(sessionId, cwd, path)` 构造、`parseFileAddress(address)` 读回。模型本身只读 scheme 与 host:`protocolOf(address)` 对 `dsh-resource://` URL 返回小写 host,对其它任何字串返回 `undefined`。其它 scheme 下的地址——Sidebar 的 `sidebar://guide`——不指向资源,读作 `none`。 | 地址 | 协议键 | 读作 | |---|---|---| | `dsh-resource://file/session/s1/notes/a.md` | `file` | 会话 `s1` 工作区根下 `notes/a.md` 的元数据(`file` 提供方已注册时) | -| `dsh-resource://file/absolute/home/me/notes.md` | `file` | 该绝对路径的元数据,经当前会话读取、受其工作区限制 | +| `dsh-resource://file/absolute/home/me/notes.md` | `file` | 可解析,但没有授权 Session,以 `workspace-file/unknown-workspace` 失败;不借用当前或 Tab Session | | `DSH-RESOURCE://File/session/s1/a` | `file` | 另一份记录:地址按字符串比较,`openResource` 只接受 `fileAddressFor` 生成的规范小写拼写 | | `sidebar://guide` | — | `none`:导航地址 | | `/home/me/notes.md` | — | `none`:不是 URL | ## 注册提供方 -协议拥有者在 `ResourceProtocolMap` 上声明其值类型,并在自己的 `ctx.effect` 里注册一个提供方,使协议与插件同寿([提供协议](../../packages/client/resources/README.zh.md#provide-a-protocol))。`open(address, { signal })` 返回一条 `RemoteResult` 帧流——首帧是当前状态,之后每次变化一帧——并且必须在 `signal` 中止时停下。失败是携带 `RemoteFailure` 的 `ok: false` 帧;流里抛出是编程错误,不会被捕获。`reload(address)` 可选,请已打开的流给一个新帧。 +协议拥有者在 `ResourceProtocolMap` 上声明其值类型,并在自己的 `ctx.effect` 里注册一个提供方,使协议与插件同寿([提供协议](../../packages/client/resources/README.zh.md#provide-a-protocol))。`open(address, { signal })` 返回一条 `RemoteResult` 帧流——首帧是当前状态,之后每次变化一帧——并且必须在 `signal` 中止时停下。失败是携带 `RemoteFailure` 的 `ok: false` 帧;流里抛出是编程错误,不会被捕获。 ```ts ignore-check import type { Context } from '@deepseek-ai/cordis' @@ -43,7 +43,6 @@ export function apply(ctx: Context): void { yield await ctx.remote.notes.read(id, signal) for await (const change of ctx.remote.notes.follow(id, signal)) yield change }, - reload(address) { ctx.remote.notes.requestReread(new URL(address).pathname.slice(1)) }, }), 'my-notes: note resource provider') } ``` @@ -61,21 +60,19 @@ export function apply(ctx: Context): void { | `live` | 最新一帧成功 | 最新的 `ok` 值 | `undefined` | | `failed` | 最新一帧报告了失败 | 保留的上一个 `ok` 值 | 该帧的 `RemoteFailure` | -`reload()` 请提供方给一个新帧,协议没有提供方或提供方没有 `reload` 时是空操作;该函数按地址引用稳定,正文可以长期持有。 - ```tsx ignore-check import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-api-workspace-files/client' type Props = PropsRuntime<'sidebar.right.pane.tab'> -export function FileHeader({ tab, useResource, t }: Props) { +export function FileHeader({ useTabInfo, useResource, t }: Props) { + const { tab } = useTabInfo() const meta = useResource<'file'>(tab.contentId) if (meta.status === 'failed') return

{t('failed', { code: meta.failure.code })}

return (
{tab.title} - {meta.value?.changed && }
) } @@ -87,7 +84,7 @@ export function FileHeader({ tab, useResource, t }: Props) { 资源有持有者就存活:一个订阅中的 `useResource`,或一次钉住。`ctx.resources.pin(address, signal)` 在不订阅的情况下让资源保持打开直到 `signal` 中止,已中止的信号什么也不钉;右侧 Sidebar 在每条打开的 tab 记录存续期内钉住其地址,因此切 tab 卸载正文不关流。第一个持有者打开提供方的流;最后一个释放时中止它、丢弃值,并把快照回到 `loading`(有提供方)或 `none`(没有)。提供方在这次释放之后产出的帧被丢弃,迭代器被归还。`ctx.resources.source(address)` 是 hook 背后的裸 observable,按地址引用稳定,供 React 之外的调用方使用;只读它的快照不算持有([生命周期](../../packages/client/resources/README.zh.md#lifecycle))。 -流只推元数据不推内容。`file` 提供方的值是 `{ absolutePath, version, bytes?, changed }`:`absolutePath`、`version` 与 `bytes` 来自 Host 的 `stat`,`changed` 在 Host 报告 agent 写入时置起、由 `reload` 清除。消费方自己经 Workspace Files Remote 命名空间按页读文件文本([`dsh-api-workspace-files`](../../packages/api/workspace-files/README.zh.md))。 +流只推元数据不推内容。`file` 提供方的值是 `WorkspaceFileStat { absolutePath, version, bytes? }`:首帧来自 Host 的 `stat`,后续观察更新版本。消费方自己经 Workspace Files Remote 命名空间读取内容;Preview 按 tab 独立刷新([`dsh-api-workspace-files`](../../packages/api/workspace-files/README.zh.md))。 ## 限制 diff --git a/docs/subsystems/sidebar-right.md b/docs/subsystems/sidebar-right.md index 59eda91862..eacb6cdd67 100644 --- a/docs/subsystems/sidebar-right.md +++ b/docs/subsystems/sidebar-right.md @@ -10,7 +10,7 @@ This page is the reference for the subsystem's contracts: addresses, tab-type re One docking surface exists per Session, held in a session-scoped slot store and drawn by `rightbar.session`. The root-scoped `rightbar` controller mounts that seat only while Conversation is selected; a reload returns every session to the collapsed default, and switching sessions keeps each surface where it was ([state](../../packages/client/ui-sidebar-right/README.md#state)). The surface's every change is one recorded history entry computed by the kit's pure planners; a docked pane never stays empty, and the last pane reseeds the guide tab. -A tab type is two registrations that share one `kind`: a static definition in `ctx.sidebarRightTabs` saying which addresses the type opens, and a keyed slot registration supplying its body. The framework injects `useTabInfo()` for live Sidebar, pane and tab information; each type keeps its own state in its slot store. Packages import each other's declarations only as types. +A tab type is two registrations that share the definition's `id`: a static definition in `ctx.sidebarRightTabs` saying which addresses its `kind` opens, and a keyed slot registration supplying its body. The framework injects `useTabInfo()` for live Sidebar, pane and tab information; each type keeps its own state in its slot store. Packages import each other's declarations only as types. | Package | Role | |---|---| @@ -90,7 +90,7 @@ Placement is the caller's option, never a type's property. The conversation call ## Slots and owner props -The subsystem declares four slots; a tab type registers into the first, optionally the second, and any package into the others ([hierarchy](slots.md)). +The Sidebar declares four extension slots; its document tab declares the additional keyed document body below ([hierarchy](slots.md)). | Slot | Cardinality | Purpose | |---|---|---| @@ -98,29 +98,40 @@ The subsystem declares four slots; a tab type registers into the first, optional | `sidebar.right.pane.tab.title` | keyed by the definition's `id`, Session scope | The chip's title, with the same owner share as the body. Optional: without an entry the chip shows the `title(address)` text captured at open time; a type with a live title reads its own store here. | | `sidebar.right.tab.guide` | chain, Session scope | Replaces the guide tab's contents without replacing the tab; the first non-declining entry takes the body, otherwise the shipped guide renders. | | `sidebar.right.tab.menu.item` | list, Session scope | Content-level actions appended after the kit's own layout actions. An item that acts must call the owner's `dismiss()`. | +| `sidebar.right.tab.document` | keyed by the document implementation's `id`, Session scope | The selected file renderer inside the document tab; the parent owns shared loading and toolbar controls. | A body, title and guide replacement receive the framework-injected `useTabInfo()`. It returns `{ sidebar, panel, tab }`: `sidebar` holds `expanded` and `fullscreen`, `panel.id` names the containing pane, and `tab` contains its record fields plus `visible`, `navigation`, `signal`, and `actions`. Docked bodies are visible only while expanded and active; docked titles need only expansion; floats stay visible. `signal` aborts when the record disappears or the plugin unloads, not on hiding or Session switching. `tab.actions` provides `openResource`, `openTab`, and `close`, bound to the tab's own Session. Open placement defaults to its current pane; `revealIfOpened` defaults to `true`, and `replaceTab: true` replaces this record in the same history entry. Menu entries retain plain `tab` and `dismiss` owner parameters. `navigation.revision` increments on every navigation to the tab whether or not `params` changed, so a body can act on "navigated again" alone; it is `1` for a tab opened by address and `0` for a record nobody opened by address — a seeded guide, or a tab restored by undo. The Tab domain holds one occurrence per open record: a record that appears is pinned in the resource model, so switching tabs unmounts a body without dropping its content; a record that vanishes is aborted and dropped; a record restored by undo is a new occurrence ([Tab domain](../../packages/client/ui-sidebar-right/README.md#the-tab-domain)). +## Document renderers + +The `text` tab is the shared Document Preview owner. Its [root registration](../../packages/client/ui-sidebar-textpreview/src/client/index.ts) declares `sidebar.right.tab.document` and provides `ctx.documentPreviews`. A renderer registers `DocumentPreviewDefinition` metadata in its own effect, then waits through `ctx.slots.inject('sidebar.right.tab.document', ...)` and registers its component with `key: definition.id` and its locale namespace. Changing the renderer does not change the tab or resource address; the [extension decision](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md) separates preview policy from resource ownership. + +The [registry](../../packages/client/ui-sidebar-textpreview/src/client/document/registry.ts) records unique `id`, `extensions`, localized `title()`, `loading`, optional `priority`, and optional `wrap`. Case-insensitive suffix matching ranks `extension` (the default) before `builtin`, then longer suffixes before shorter ones, then registration order. Unlike tab-kind replacement, the registry keeps all implementations available; the toolbar lists matching alternatives and remembers the selection per tab. Unknown extensions use plain text. `loading` is `text-pages` or `bytes-complete`; `wrap` advertises support for the shared source-wrap control. + +[`DocumentPreviewProps`](../../packages/client/ui-sidebar-textpreview/src/client/document/contract.ts) derives from `PropsRuntime<'sidebar.right.tab.document'>`. The owner supplies the original `resourceAddress`, `content`, and current `wrap`: text content is `{ kind: 'text', text, pages: [{ offset, text, lines }], eof }`, with cumulative `text`; complete bytes are `{ kind: 'bytes', data }`, with `Uint8Array` data. These transient buffers are borrowed read-only and must not enter durable layout or Session JSON. PDF copies the bytes before Worker transfer, preserving the owner's buffer. The child receives the same framework-bound `useTabInfo` and the global metadata-only `useResource`. The parent reads through ordinary inject callbacks to `remote.workspaceFiles.read`/`readAll` and owns page appends, per-tab refresh, and loading status. HTML's own inject callback uses `readRelated`; Host code resolves paths. Markdown and code retain one incremental renderer across appends and settle at EOF; HTML and PDF receive complete bytes. + +Preview records its loaded version and the version observed when a read starts. Refresh rereads only that tab, without changing shared metadata or another tab's content. Reads are non-transactional; versions are opaque equality tokens, not ordered timestamps ([resource observation and Preview RPC](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md)). + ## Resource model -The model is documented in [Client Resources](client-resources.md); this section states what the Sidebar relies on. A resource is one address, and a resource address is a `dsh-resource:///…` URL whose lower-cased host is the protocol key. The protocol's owning client package registers one provider with `ctx.resources.register(provider)` for its own lifetime; a second provider for the same protocol throws ([provide a protocol](../../packages/client/resources/README.md#provide-a-protocol)). A provider is `{ protocol, open(address, { signal }), reload?(address) }`: `open` yields `RemoteResult` frames — the current state first, one frame per later change — and stops when `signal` aborts; a failure is an `{ ok: false, error }` frame, never a throw, and a throw inside the stream is a programming error the model does not catch. +The model is documented in [Client Resources](client-resources.md); this section states what the Sidebar relies on. A resource is one address, and a resource address is a `dsh-resource:///…` URL whose lower-cased host is the protocol key. The protocol's owning client package registers one provider with `ctx.resources.register(provider)` for its own lifetime; a second provider for the same protocol throws ([provide a protocol](../../packages/client/resources/README.md#provide-a-protocol)). A provider is `{ protocol, open(address, { signal }) }`: `open` yields `RemoteResult` frames — the current state first, one frame per later change — and stops when `signal` aborts; a failure is an `{ ok: false, error }` frame, never a throw, and a throw inside the stream is a programming error the model does not catch. -`useResource

(address)` is a global standard prop on every slot component, whatever its scope. It returns `{ status, value, failure, reload }`: `none` when the address's protocol has no provider or the address is not a resource address (`sidebar://guide` names no resource), `loading` until the first frame, `live` with the latest `ok` value, `failed` with the latest frame's failure beside the last value. `reload()` asks the provider for a fresh frame and is a no-op without one ([read a resource](../../packages/client/resources/README.md#read-a-resource)). +`useResource

(address)` is a global standard prop on every slot component, whatever its scope. It returns `{ status, value, failure }`: `none` when the address's protocol has no provider or the address is not a resource address (`sidebar://guide` names no resource), `loading` until the first frame, `live` with the latest `ok` value, `failed` with the latest frame's failure beside the last value. ([read a resource](../../packages/client/resources/README.md#read-a-resource)). -A resource stays open while it has a holder — a subscribed `useResource` or a `ctx.resources.pin(address, signal)`; the first holder opens the provider's stream, later holders share it and read the latest value at once, and the last release aborts the stream and discards the value. Streams carry metadata, not content: the `file` value is `{ absolutePath, version, bytes?, changed }`, and a consumer reads file text itself, by page, through the Workspace Files service ([lifecycle](../../packages/client/resources/README.md#lifecycle)). +A resource stays open while it has a holder — a subscribed `useResource` or a `ctx.resources.pin(address, signal)`; the first holder opens the provider's stream, later holders share it and read the latest value at once, and the last release aborts the stream and discards the value. Streams carry metadata, not content: the `file` value is `{ absolutePath, version, bytes? }`, and a consumer reads file text itself, by page, through the Workspace Files service ([lifecycle](../../packages/client/resources/README.md#lifecycle)). ## Workspace Files The Host `ctx.workspaceFiles` service and the generated `workspaceFiles` Remote namespace answer for files inside the addressed Session's workspace root: `stat(path)` returns `{ absolutePath, version, bytes? }`; `read(path, { offset?, limit? })` returns one page of lines (`offset` 1-based, `limit` capped by the configured page size) as `{ …stat, offset, text, eof }`; `readBytes(path, { offset?, length? })` returns one raw byte window (`offset` 0-based, `length` capped by the configured byte limit) as base64 `{ …stat, offset, data, eof }` with no text decoding; `list(path)` returns a directory's direct children (`name`, `type: 'file' | 'directory' | 'other'`, `size?`) cut to the configured cap with `truncated` set; `changes()` yields `{ kind: 'ready' }` once subscribed, then `{ kind: 'change', change }` frames whose payload is `{ absolutePath, version }` or `{ absolutePath, absent: true }` ([README](../../packages/api/workspace-files/README.md#use-this-package)). Every call passes the same four gates — the path is inside the workspace root, symlinks are refused, page, window, and entry caps hold, `read`'s text is UTF-8 — and fails with a `workspace-file/*` error code otherwise ([failures](../../packages/api/workspace-files/README.md)). -[`dsh-api-workspace-files`](../../packages/api/workspace-files/README.md) registers the `file` provider and declares `ResourceProtocolMap.file`. It sends a Session address's relative path unchanged to the Host and binds change filtering to the first successful `stat.absolutePath`. It waits for the Host's `ready` frame before stat, retaining changes delivered during that read. An absolute address uses the current Session; only its absence produces Client `workspace-file/unknown-workspace`. No Client `cwd` is required. +[`dsh-api-workspace-files`](../../packages/api/workspace-files/README.md) registers the `file` provider, with `ResourceProtocolMap.file` directly naming `WorkspaceFileStat`. A Session address carries the authorizing Session and a relative or absolute path, passed unchanged to the Host for resolution. The provider waits for Host `ready` before stat and filters changes by `stat.absolutePath`. Bare `absolute` addresses have no authorizing Session and fail with `workspace-file/unknown-workspace`, without borrowing current or Tab Session. Any UI, including Global components, shares the observation for the same complete address. Preview's ordinary Remote callbacks use the Session in that address; Host `readAll` and `readRelated` remain, and Preview's `rpc.ts` decodes byte results. ## Shipped types - **`guide`** — `builtin`, opened as `openTab('guide')`. A centred title, one line, and one entry box per `guide` entry the registered types contributed, in `order`; picking a box opens the contributing type as a page in the guide tab's place. A pane holds at most one guide tab, every new pane is seeded with one, and the strip's add control appears only while its pane has none ([guide](../../packages/client/ui-sidebar-right/README.md#the-guide)). -- **`text`** — `fallback`, `dsh-resource://file/**`. Reads metadata through `useResource<'file'>` and the file's lines by page through `read`; honours `params.line` on every navigation; keeps pages, scroll, and wrap in its own store ([README](../../packages/client/ui-sidebar-textpreview/README.md)). +- **`text`** — `fallback`, `dsh-resource://file/**`, claiming Session addresses only. Document Preview observes metadata through `useResource<'file'>`, loads content through Remote callbacks, and owns renderer selection, the toolbar, per-tab refresh, scroll, and source navigation; unknown extensions render as plain text ([README](../../packages/client/ui-sidebar-textpreview/README.md)). - **`files`** — `builtin`, opened as `openTab('files')`. The workspace directory tree, listed lazily through `list`, opening a file with `tab.actions.openResource(fileAddressFor(sessionId, root, path))` into its own pane ([README](../../packages/client/ui-sidebar-files/README.md)). @@ -129,9 +140,9 @@ The Host `ctx.workspaceFiles` service and the generated `workspaceFiles` Remote - Persistence: layout state is memory-only; a reload starts every session collapsed, and no session's tabs are visible from another. - A read-only layout snapshot or subscription on `ctx.sidebarRight`: the service exposes operations only, and dockkit's `LayoutState`/`LayoutOp` are internal. - A capability-discovery array (`features`) on the service. -- An `option` priority band: nothing lists a type without letting it claim. +- An `option` priority band for tab types: nothing lists a tab type without letting it claim. - Retitling a record: `title(address)` is captured once; a live chip comes from the title slot, not from the record. -- Naming an implementation when opening: `openResource` names a kind at most, and the kind's implementation in force answers. +- Naming a tab implementation when opening: `openResource` names a kind at most; document-renderer selection belongs to the file tab's toolbar. - An address lookup on the service (`find`): a caller opens with `revealIfOpened` and lets the surface de-duplicate. - Navigation addresses beyond the Sidebar's own `sidebar://` bookkeeping; their grammar waits for the navigation controller as a whole. - A user-facing undo, a content navigation stack, tab icons, and closing restrictions ([deferred](../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md#deferred)). diff --git a/docs/subsystems/sidebar-right.zh.md b/docs/subsystems/sidebar-right.zh.md index 3ecb5e9ef4..2e0f484244 100644 --- a/docs/subsystems/sidebar-right.zh.md +++ b/docs/subsystems/sidebar-right.zh.md @@ -10,7 +10,7 @@ 每个会话恰有一个停靠面,保存在会话作用域的 slot store 里、由 `rightbar.session` 绘制。root 作用域的 `rightbar` 控制器仅在选中 Conversation 时挂载该席位;刷新页面后每个会话回到折叠的默认态,切换会话时各自的面保持原状([状态](../../packages/client/ui-sidebar-right/README.zh.md#state))。面的每一次变化都是 kit 纯规划器算出的一条历史记录;停靠的 pane 从不空着,最后一个 pane 会重新种入引导 tab。 -一个 tab 类型是共用一个 `kind` 的两次注册:在 `ctx.sidebarRightTabs` 里的静态定义说明该类型打开哪些地址,一次 keyed slot 注册提供它的正文。框架注入 `useTabInfo()` 以读取 Sidebar、窗格和标签的实时信息;各类型把自身状态放在 slot store 里。各包之间只以类型形式引用彼此的声明。 +一个 tab 类型是共用定义 `id` 的两次注册:在 `ctx.sidebarRightTabs` 里的静态定义说明其 `kind` 打开哪些地址,一次 keyed slot 注册提供它的正文。框架注入 `useTabInfo()` 以读取 Sidebar、窗格和标签的实时信息;各类型把自身状态放在 slot store 里。各包之间只以类型形式引用彼此的声明。 | 包 | 职责 | |---|---| @@ -90,7 +90,7 @@ export function apply(ctx: Context): void { ## Slot 与 owner props -本子系统声明四个 slot;tab 类型注册进第一个,可选地注册第二个,任何包都可注册进其余两个([层级](slots.zh.md))。 +Sidebar 声明四个扩展 slot;其文档 tab 另行声明下表中的 keyed 文档正文 slot([层级](slots.zh.md))。 | Slot | Cardinality | 用途 | |---|---|---| @@ -98,29 +98,40 @@ export function apply(ctx: Context): void { | `sidebar.right.pane.tab.title` | 按定义的 `id` keyed,会话作用域 | chip 的标题,owner share 与正文相同。可选:没有条目时 chip 显示打开时捕获的 `title(address)` 文本;有活标题的类型在此读自己的 store。 | | `sidebar.right.tab.guide` | chain,会话作用域 | 替换引导 tab 的内容而不替换 tab;第一个不拒绝的条目接管正文,否则渲染自带引导。 | | `sidebar.right.tab.menu.item` | list,会话作用域 | 追加在 kit 自身布局动作之后的内容级动作。执行了动作的条目必须调用 owner 的 `dismiss()`。 | +| `sidebar.right.tab.document` | 按文档实现的 `id` keyed,会话作用域 | 文档 tab 内选中的文件渲染器;父组件拥有共享加载与工具栏控件。 | 正文、标题与引导页替换项接收框架注入的 `useTabInfo()`。它返回 `{ sidebar, panel, tab }`:`sidebar` 包含 `expanded` 与 `fullscreen`,`panel.id` 标识所属窗格,`tab` 包含记录字段以及 `visible`、`navigation`、`signal` 和 `actions`。停靠正文仅在展开且活跃时可见;停靠标题只要求展开;浮窗保持可见。`signal` 在记录消失或插件卸载时中止,不因隐藏或切换 Session 而中止。`tab.actions` 提供绑定到标签所属 Session 的 `openResource`、`openTab` 与 `close`。打开位置缺省为当前所属窗格;`revealIfOpened` 缺省为 `true`,`replaceTab: true` 在同一历史项中替换本记录。菜单项保留普通的 `tab` 与 `dismiss` owner 参数。 `navigation.revision` 在每次导航到该 tab 时递增,`params` 不变也递增,正文可仅凭「又被导航了」行动;按地址打开的 tab 为 `1`,没有人按地址打开的记录——种入的引导、撤销恢复的 tab——为 `0`。Tab 域为每条打开的记录保有一个 occurrence:记录出现即在资源模型里钉住,因此切换 tab 卸载正文也不丢内容;记录消失即中止并丢弃;撤销恢复的记录是新的 occurrence([Tab 域](../../packages/client/ui-sidebar-right/README.zh.md#the-tab-domain))。 +## 文档渲染器 + +`text` tab 是共享的 Document Preview 所有者。其[根注册](../../packages/client/ui-sidebar-textpreview/src/client/index.ts)声明 `sidebar.right.tab.document` 并提供 `ctx.documentPreviews`。渲染器在自己的 effect 中注册 `DocumentPreviewDefinition` 元数据,再通过 `ctx.slots.inject('sidebar.right.tab.document', ...)` 等待 slot,以 `key: definition.id` 和自己的 locale 命名空间注册组件。切换渲染器不改变 tab 或资源地址;[扩展决议](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md)将预览策略与资源归属分开。 + +[注册表](../../packages/client/ui-sidebar-textpreview/src/client/document/registry.ts)记录唯一的 `id`、`extensions`、本地化 `title()`、`loading`,以及可选的 `priority` 和 `wrap`。后缀匹配不区分大小写,先排 `extension`(缺省值)、再排 `builtin`,随后比较后缀长度(长者优先)与注册顺序。与 tab kind 替换不同,注册表保留所有实现;工具栏列出匹配的候选,按 tab 记住选择。未知扩展名使用纯文本。`loading` 为 `text-pages` 或 `bytes-complete`;`wrap` 声明是否支持共享的源码换行控件。 + +[`DocumentPreviewProps`](../../packages/client/ui-sidebar-textpreview/src/client/document/contract.ts) 派生自 `PropsRuntime<'sidebar.right.tab.document'>`。owner 提供原始 `resourceAddress`、`content` 与当前 `wrap`:文本内容为 `{ kind: 'text', text, pages: [{ offset, text, lines }], eof }`,其中 `text` 为累积文本;完整字节为 `{ kind: 'bytes', data }`,其中 `data` 为 `Uint8Array`。这些瞬时缓冲区按只读方式借用,不得进入持久布局或 Session JSON。PDF 在转移到 Worker 前复制字节,以保留 owner 的缓冲区。子组件收到同一个框架绑定的 `useTabInfo`,以及全局共享、仅提供元数据的 `useResource`。父组件通过普通 inject 回调调用 `remote.workspaceFiles.read`/`readAll`,拥有追加分页、逐 tab 刷新与加载状态。HTML 自己的 inject 回调使用 `readRelated`;路径由 Host 代码解析。Markdown 和代码在追加期间保留同一个增量渲染器,到 EOF 完成最终解析;HTML 和 PDF 接收完整字节。 + +Preview 记录已载入版本和读取开始时的观察版本。刷新只重读当前 tab,不改变共享元数据或其他 tab 的内容。读取不具备事务性;版本是不透明的相等性令牌,不是可排序的时间戳([资源观察与 Preview RPC](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md))。 + ## 资源模型 -模型本身见[客户端资源](client-resources.zh.md);本节只写 Sidebar 依赖的部分。一份资源是一个地址,资源地址是 `dsh-resource:///…` 形式的 URL,小写 host 即协议键。协议所属的客户端包用 `ctx.resources.register(provider)` 在自身生命周期内注册唯一的提供方;同一协议的第二个提供方抛错([提供协议](../../packages/client/resources/README.zh.md#provide-a-protocol))。提供方是 `{ protocol, open(address, { signal }), reload?(address) }`:`open` 产出 `RemoteResult` 帧——首帧是当前状态,之后每次变化一帧——并在 `signal` 中止时停下;失败是 `{ ok: false, error }` 帧而不是抛错,流里抛出的东西是编程错误,模型不捕获。 +模型本身见[客户端资源](client-resources.zh.md);本节只写 Sidebar 依赖的部分。一份资源是一个地址,资源地址是 `dsh-resource:///…` 形式的 URL,小写 host 即协议键。协议所属的客户端包用 `ctx.resources.register(provider)` 在自身生命周期内注册唯一的提供方;同一协议的第二个提供方抛错([提供协议](../../packages/client/resources/README.zh.md#provide-a-protocol))。提供方是 `{ protocol, open(address, { signal }) }`:`open` 产出 `RemoteResult` 帧——首帧是当前状态,之后每次变化一帧——并在 `signal` 中止时停下;失败是 `{ ok: false, error }` 帧而不是抛错,流里抛出的东西是编程错误,模型不捕获。 -`useResource

(address)` 是每个 slot 组件都有的全局标准 prop,不论作用域。它返回 `{ status, value, failure, reload }`:地址协议没有提供方或地址不是资源地址(`sidebar://guide` 不指向资源)时为 `none`,首帧之前为 `loading`,`live` 携带最新 `ok` 值,`failed` 在最后一个值旁携带最新帧的失败。`reload()` 请提供方给一个新帧,没有提供方时是空操作([读取资源](../../packages/client/resources/README.zh.md#read-a-resource))。 +`useResource

(address)` 是每个 slot 组件都有的全局标准 prop,不论作用域。它返回 `{ status, value, failure }`:地址协议没有提供方或地址不是资源地址(`sidebar://guide` 不指向资源)时为 `none`,首帧之前为 `loading`,`live` 携带最新 `ok` 值,`failed` 在最后一个值旁携带最新帧的失败。([读取资源](../../packages/client/resources/README.zh.md#read-a-resource))。 -资源有持有者就保持打开——订阅中的 `useResource` 或一次 `ctx.resources.pin(address, signal)`;第一个持有者打开提供方的流,之后的持有者共享它并立刻读到最新值,最后一个释放时中止流并丢弃值。流只推元数据不推内容:`file` 的值是 `{ absolutePath, version, bytes?, changed }`,消费方自己经 Workspace Files 服务按页读文件文本([生命周期](../../packages/client/resources/README.zh.md#lifecycle))。 +资源有持有者就保持打开——订阅中的 `useResource` 或一次 `ctx.resources.pin(address, signal)`;第一个持有者打开提供方的流,之后的持有者共享它并立刻读到最新值,最后一个释放时中止流并丢弃值。流只推元数据不推内容:`file` 的值是 `{ absolutePath, version, bytes? }`,消费方自己经 Workspace Files 服务按页读文件文本([生命周期](../../packages/client/resources/README.zh.md#lifecycle))。 ## Workspace Files Host 的 `ctx.workspaceFiles` 服务与生成的 `workspaceFiles` Remote 命名空间负责所寻址会话工作区根之内的文件:`stat(path)` 返回 `{ absolutePath, version, bytes? }`;`read(path, { offset?, limit? })` 返回一页行(`offset` 1 起,`limit` 受配置页长限制),形如 `{ …stat, offset, text, eof }`;`readBytes(path, { offset?, length? })` 返回一个原始字节窗口(`offset` 0 起,`length` 受配置字节上限限制),形如 base64 的 `{ …stat, offset, data, eof }`、不做文本解码;`list(path)` 返回目录的直接子项(`name`、`type: 'file' | 'directory' | 'other'`、`size?`),按配置上限截断并置 `truncated`;`changes()` 在订阅就绪后产出 `{ kind: 'ready' }`,随后产出 `{ kind: 'change', change }` 帧,其载荷为 `{ absolutePath, version }` 或 `{ absolutePath, absent: true }`([README](../../packages/api/workspace-files/README.zh.md#use-this-package))。每次调用都过同样四关——路径在工作区根内、拒绝符号链接、页、窗口与条目上限、`read` 的 UTF-8 文本——否则以 `workspace-file/*` 错误码失败([失败](../../packages/api/workspace-files/README.zh.md))。 -[`dsh-api-workspace-files`](../../packages/api/workspace-files/README.zh.md) 注册 `file` 提供方并声明 `ResourceProtocolMap.file`。它把 Session 地址的相对路径原样发送给 Host,按首次成功的 `stat.absolutePath` 绑定变更过滤。它在 stat 前等待 Host 的 `ready` 帧,并保留读取期间到达的变更。绝对地址使用当前 Session;只有缺少当前 Session 时才产生 Client `workspace-file/unknown-workspace`。Client 不需要 `cwd`。 +[`dsh-api-workspace-files`](../../packages/api/workspace-files/README.zh.md) 注册 `file` 提供方,`ResourceProtocolMap.file` 直接是 `WorkspaceFileStat`。Session 地址携带授权 Session 与相对或绝对路径,Host 原样接收并解析。提供方在 stat 前等待 Host 的 `ready` 帧,并按 `stat.absolutePath` 过滤变更。裸 `absolute` 地址没有授权 Session,以 `workspace-file/unknown-workspace` 失败,不借用当前或 Tab Session。任何 UI(包括 Global)访问同一完整地址都共享观察。Preview 的普通 Remote 回调使用地址中的 Session;Host `readAll` 和 `readRelated` 保留,字节结果由 Preview 的 `rpc.ts` 解码。 ## 内置类型 - **`guide`**——`builtin`,以 `openTab('guide')` 打开。居中标题、一行说明,以及已注册类型贡献的每个 `guide` 入口一框、按 `order` 排列;点一框即在引导 tab 的位置把贡献它的类型作为页面打开。每个 pane 最多一个引导 tab,每个新 pane 都种入一个,tab 条的新增控件只在本 pane 没有引导时出现([引导](../../packages/client/ui-sidebar-right/README.zh.md#the-guide))。 -- **`text`**——`fallback`,`dsh-resource://file/**`。经 `useResource<'file'>` 读元数据、经 `read` 按页读文件行;每次导航都响应 `params.line`;页、滚动与换行放在自己的 store 里([README](../../packages/client/ui-sidebar-textpreview/README.zh.md))。 +- **`text`**——`fallback`,`dsh-resource://file/**`,只认领 Session 地址。Document Preview 通过 `useResource<'file'>` 观察元数据,经 Remote 回调加载内容,并拥有渲染器选择、工具栏、逐 tab 刷新、滚动与源码定位;未知扩展名按纯文本渲染([README](../../packages/client/ui-sidebar-textpreview/README.zh.md))。 - **`files`**——`builtin`,以 `openTab('files')` 打开。工作区目录树,经 `list` 懒加载,用 `tab.actions.openResource(fileAddressFor(sessionId, root, path))` 在自己所在 pane 打开文件([README](../../packages/client/ui-sidebar-files/README.zh.md))。 @@ -129,9 +140,9 @@ Host 的 `ctx.workspaceFiles` 服务与生成的 `workspaceFiles` Remote 命名 - 持久化:布局状态只在内存里;刷新后每个会话从折叠开始,任何会话的 tab 都不会出现在另一个会话里。 - `ctx.sidebarRight` 上的只读布局快照或订阅:服务只暴露操作,dockkit 的 `LayoutState`/`LayoutOp` 是内部的。 - 服务上的能力探测数组(`features`)。 -- `option` 优先级档:没有「只列出、不许认领」的类型。 +- tab 类型的 `option` 优先级档:没有「只列出、不许认领」的 tab 类型。 - 改写记录的标题:`title(address)` 只捕获一次;活的 chip 来自标题 slot,而不是记录。 -- 打开时点名某个实现:`openResource` 最多点名一个 kind,由该 kind 的生效实现应答。 +- 打开时点名某个 tab 实现:`openResource` 最多点名一个 kind;文档渲染器由文件 tab 的工具栏选择。 - 服务上的地址查找(`find`):调用方用 `revealIfOpened` 打开,由停靠面去重。 - Sidebar 自身 `sidebar://` 记账之外的导航地址;其语法等导航控制器整体做时再定。 - 面向用户的撤销、内容导航栈、tab 图标与关闭限制([暂缓](../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md#deferred))。 diff --git a/docs/subsystems/workspace.i18n.yaml b/docs/subsystems/workspace.i18n.yaml index 77fbb01e39..97ecae89e0 100644 --- a/docs/subsystems/workspace.i18n.yaml +++ b/docs/subsystems/workspace.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 docs/subsystems/workspace.md -workspace.md: 3e54ef3d0a2339d50fbc8a4132b91d7f82701321 -workspace.zh.md: a9b8375f388538a3588a84f2d71e47b89ddc679a +workspace.md: d2327ce5616cc2aa4845ac2965cebea8c70424f3 +workspace.zh.md: 159f6cf0df4684d5080ee0db7d0197b6ec96e887 diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index 3e54ef3d0a..d2327ce561 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -270,6 +270,25 @@ Host Remote service over the composed filesystem, confined to one workspace. */ @Remote async readBytes(agent: Agent, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise +/** + * Read a complete regular file as bytes, subject to the configured full-file cap. + * @param agent - target Agent whose workspace confines the read. + * @param path - absolute or workspace-relative file path. + * @param signal - caller cancellation. + * @returns one complete base64 window with offset zero and eof true; oversized files fail with too-large. + */ +@Remote async readAll(agent: Agent, path: string, signal: AbortSignal): Promise + +/** + * Read a complete file relative to another file's directory within the same workspace. + * @param agent - Agent whose workspace confines both files. + * @param path - base file, absolute or workspace-relative. + * @param relativePath - relative filesystem path, not a URL or absolute path. + * @param signal - caller cancellation. + * @returns the complete related file using the ordinary file-size and access checks. + */ +@Remote async readRelated(agent: Agent, path: string, relativePath: string, signal: AbortSignal): Promise + /** * Report one regular file's identity, version, and size without its content. * @param agent - target Agent resolved from the Session identity on the wire. diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index a9b8375f38..159f6cf0df 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -270,6 +270,25 @@ Host Remote service over the composed filesystem, confined to one workspace. */ @Remote async readBytes(agent: Agent, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise +/** + * Read a complete regular file as bytes, subject to the configured full-file cap. + * @param agent - target Agent whose workspace confines the read. + * @param path - absolute or workspace-relative file path. + * @param signal - caller cancellation. + * @returns one complete base64 window with offset zero and eof true; oversized files fail with too-large. + */ +@Remote async readAll(agent: Agent, path: string, signal: AbortSignal): Promise + +/** + * Read a complete file relative to another file's directory within the same workspace. + * @param agent - Agent whose workspace confines both files. + * @param path - base file, absolute or workspace-relative. + * @param relativePath - relative filesystem path, not a URL or absolute path. + * @param signal - caller cancellation. + * @returns the complete related file using the ordinary file-size and access checks. + */ +@Remote async readRelated(agent: Agent, path: string, relativePath: string, signal: AbortSignal): Promise + /** * Report one regular file's identity, version, and size without its content. * @param agent - target Agent resolved from the Session identity on the wire. diff --git a/packages/api/workspace-files/README.i18n.yaml b/packages/api/workspace-files/README.i18n.yaml index 3d4b3525e8..1d8a12afc8 100644 --- a/packages/api/workspace-files/README.i18n.yaml +++ b/packages/api/workspace-files/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/api/workspace-files/README.md -README.md: ec15f52bf17fffca2b225fa427a7405922aaf2b3 -README.zh.md: 7e45d5c9a6073435e26e1237791a328db654d839 +README.md: 117926ab8c78cdfff79010d8e5d6db39e2c93b8d +README.zh.md: 51c5fcd7fc3e53f54a56ae378b4f73109657317c diff --git a/packages/api/workspace-files/README.md b/packages/api/workspace-files/README.md index ec15f52bf1..117926ab8c 100644 --- a/packages/api/workspace-files/README.md +++ b/packages/api/workspace-files/README.md @@ -1,5 +1,5 @@ --- -description: "Workspace file service for the web GUI: paged read, byte windows, stat, directory listing, and the Agent-write change feed inside the Session workspace root, exposed as the workspaceFiles Remote namespace." +description: "Workspace file service for the web GUI: paged read, byte windows, complete and related-file reads, stat, directory listing, and the Agent-write change feed inside the Session workspace root, exposed as the workspaceFiles Remote namespace." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use this package to browse and inspect files within a Session's workspace from the web client. It reads UTF-8 text one page of lines at a time, reads raw bytes in bounded windows, reports file versions and sizes, lists direct directory children, and streams changes caused by Agent file operations. Every operation stays within the workspace root selected for the addressed Session, independent of the filesystem backend's working directory. Client components can also follow live file metadata and build the Sidebar file tree through the shared Remote API. +Use this package to browse and inspect files within a Session's workspace from the web client. It reads UTF-8 text one page of lines at a time, reads raw bytes in bounded windows or complete files, resolves related files from a base file's directory, reports file versions and sizes, lists direct directory children, and streams changes caused by Agent file operations. Every operation stays within the workspace root selected for the addressed Session, independent of the filesystem backend's working directory. Client components can also follow live file metadata and build the Sidebar file tree through the shared Remote API. ## Table of Contents @@ -32,12 +32,14 @@ Mount the package beside `dsh-fs`, `dsh-sandbox-policy`, and the Typert Gateway; | `stat(path)` | `WorkspaceFileStat { absolutePath, version, bytes? }` | Identity, version, and size of one regular file, without content | | `read(path, { offset?, limit? })` | `WorkspaceFileText` = stat + `{ offset, text, lines, eof }` | One window of lines from a UTF-8 text file; `lines` counts them, so one empty line and a page past the end read differently | | `readBytes(path, { offset?, length? })` | `WorkspaceFileBytes` = stat + `{ offset, data, eof }` | One window of raw bytes from any regular file, base64-encoded | +| `readAll(path)` | `WorkspaceFileBytes` with `offset: 0`, `eof: true` | Complete raw bytes under `maxFileBytes`; oversized files fail instead of being truncated | +| `readRelated(path, relativePath)` | `WorkspaceFileBytes` | Complete bytes of a file resolved from the base file's directory on the Host | | `list(path)` | `WorkspaceDirectoryListing { path, entries, truncated }` | Direct children of one directory | | `changes()` | stream of `WorkspaceFileWatchFrame` | Subscription readiness, then Agent observations inside the workspace root | ### Addressing and paths -`read`, `stat`, and `list` accept a workspace path that is absolute or relative to the Session's workspace root. Two path vocabularies leave the service, and each method uses exactly one: `read`, `stat`, and `changes` report a file as its absolute path in the filesystem's execution world, symlinks resolved (`WorkspaceFileStat.absolutePath`, `WorkspaceFileChange.absolutePath`), because their consumer is the Client resource system, which follows changes by that path; `list` reports the listed directory as a workspace path relative to the root — empty for the root itself — because its consumer is a tree rooted there, and a child's path is that value joined with the entry name by `/`. +`read`, `stat`, and `list` accept a workspace path that is absolute or relative to the Session's workspace root. Two path vocabularies leave the service, and each method uses exactly one: `read`, `stat`, and `changes` report a file as its absolute path in the filesystem's execution world, symlinks resolved (`WorkspaceFileStat.absolutePath`, `WorkspaceFileChange.absolutePath`), because their consumer is the Client resource system, which follows changes by that path; `list` reports the listed directory as a workspace path relative to the root — empty for the root itself — because its consumer is a tree rooted there, and a child's path is that value joined with the entry name by `/`. `readRelated` resolves a relative filesystem path from the base file's directory, not a URL or absolute path; both files pass Host access checks. ### Pages @@ -49,7 +51,7 @@ Mount the package beside `dsh-fs`, `dsh-sandbox-policy`, and the Typert Gateway; ### The four gates -Every read, stat, and listing passes four gates in this order. First, `lstat` inspects the path itself before anything follows it: a symlink, wherever it points, fails `read` and `stat` with `not-regular-file` and `list` with `not-directory`, each carrying the entry's `kind`. Second, containment: the path resolves to a target and `ctx.fs.contains(root, target)` decides, so a `..` traversal or an absolute path outside the root fails with `outside-workspace` — never a string-prefix comparison, which cannot see a realpath that leaves the root. Third, the caps: a page whose text exceeds `maxBytes` fails with `too-large` instead of arriving shortened — the file itself has no size cap — while `maxEntries` cuts a listing and sets `truncated`. Fourth, text: content that is not UTF-8 up to the end of the page, or a page that carries a NUL byte, fails with `not-text`; bytes past the page are not inspected. A missing path fails with `not-found`; an empty path is a `gateway/bad-request`. +Every read, stat, and listing passes four gates in this order. First, `lstat` inspects the path itself before anything follows it: a symlink, wherever it points, fails `read` and `stat` with `not-regular-file` and `list` with `not-directory`, each carrying the entry's `kind`. Second, containment: the path resolves to a target and `ctx.fs.contains(root, target)` decides, so a `..` traversal or an absolute path outside the root fails with `outside-workspace` — never a string-prefix comparison, which cannot see a realpath that leaves the root. Third, the caps: a page whose text exceeds `maxBytes` fails with `too-large` instead of arriving shortened — paged reads have no total file cap, while complete reads use `maxFileBytes` — while `maxEntries` cuts a listing and sets `truncated`. Fourth, text: content that is not UTF-8 up to the end of the page, or a page that carries a NUL byte, fails with `not-text`; bytes past the page are not inspected. A missing path fails with `not-found`; an empty path is a `gateway/bad-request`. ### The change feed @@ -60,6 +62,7 @@ Every read, stat, and listing passes four gates in this order. First, `lstat` in | Field | Default | Meaning | |---|---|---| | `maxBytes` | `2097152` (2 MiB) | Inclusive byte cap on one page's text and on one byte window; a larger page or window fails | +| `maxFileBytes` | `33554432` (32 MiB) | Inclusive complete-file cap for `readAll` and `readRelated`; larger files fail with `too-large` | | `maxLines` | `5000` | Default and largest page size in lines; a larger `limit` is refused | | `maxEntries` | `2000` | Cap on returned directory entries; the rest is dropped and reported cut | @@ -67,15 +70,15 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a ### Failures -Each failure is one `RemoteError` code with typed details, declared in [`src/types.ts`](src/types.ts): `workspace-file/not-found`, `workspace-file/outside-workspace`, `workspace-file/too-large` (with `limit`, the page and window cap), `workspace-file/not-text`, `workspace-file/not-regular-file` (`kind`: `directory`, `symlink`, or `other`), and `workspace-file/not-directory` (`kind`: `file`, `symlink`, or `other`). Callers branch on the code, never on message text. +Each failure is one `RemoteError` code with typed details, declared in [`src/types.ts`](src/types.ts): `workspace-file/not-found`, `workspace-file/outside-workspace`, `workspace-file/too-large` (with `limit`, the applicable page, window, or complete-file cap), `workspace-file/not-text`, `workspace-file/not-regular-file` (`kind`: `directory`, `symlink`, or `other`), and `workspace-file/not-directory` (`kind`: `file`, `symlink`, or `other`). Callers branch on the code, never on message text. ### Client file resources -The browser export registers the `file` provider into `ctx.resources` and requires `resources`, `remote`, `remote.workspaceFiles`, and `sessions`. The bundle's single `workspace-files` row supplies both faces; the Client has no separate configuration. A component follows a file through its standard `useResource<'file'>(address)` prop and reads `{ absolutePath, version, bytes?, changed }`; content is fetched separately through the paged methods. +The browser export registers the `file` provider into `ctx.resources` and requires `resources`, `remote`, and `remote.workspaceFiles`. The bundle's single `workspace-files` row supplies both faces; the Client has no separate configuration. A component reads `WorkspaceFileStat { absolutePath, version, bytes? }` metadata through `useResource<'file'>(address)` and fetches content separately through Remote reads. Any UI, including Global components, shares the observation for the same complete address. -A `session//` resource address sends its relative path unchanged to the Host, which resolves and confines it against that Session's workspace root; the Client needs no Session `cwd`. An `absolute/` address reads through the current Session. Both use the `dsh-resource://file/` grammar in [workspace-path](../../util/workspace-path/README.md). An absolute address without a current Session produces `workspace-file/unknown-workspace`; an unsupported address produces `workspace-file/unsupported-address`. These Client failures end the stream and make reload a no-op. +A `session//` address carries the authorizing Session and a relative or absolute path; leading slashes are preserved, as in `dsh-resource://file/session/s//etc/hosts`. The Host receives the path unchanged and owns resolution and access checks; the Client needs no Session `cwd`. `absolute/` remains parseable but has no authorizing Session and fails with `workspace-file/unknown-workspace`, without borrowing current or Tab Session. Unsupported addresses fail with `workspace-file/unsupported-address`. [Workspace-path](../../util/workspace-path/README.md) owns the grammar; the generic Resource layer knows only the address and `signal`. -The provider waits for the Host's `ready` frame before its first `stat`, queues changes during the read, then binds the follower to `stat.absolutePath`. Both queued and live changes match that Host-returned path. A new write version raises `changed` while retaining the last byte size; duplicate versions are ignored. An absent notice or reload re-stats the file. A failed stat keeps the address followed; a later write or reload can recover it, and any Session write can trigger a retry before the first successful path binding. Reload clears `changed`; a Host-triggered re-stat keeps it raised. Frames are `RemoteResult` values, and programming exceptions remain uncaught. +The provider waits for the Host's `ready` frame before its first `stat`, queues changes during the read, then binds the follower to `stat.absolutePath`. Both queued and live changes match that Host-returned path. A new write version updates metadata while retaining the last byte size; duplicate versions are ignored. An absent notice re-stats the file. A failed stat keeps the address followed; a later write can recover it, and any Session write can trigger a retry before the first successful path binding. Frames are `RemoteResult` values, and programming exceptions remain uncaught. One supervised `changes` stream serves every followed file in a Session. Followers match absolute paths with backslashes normalized to slashes. Carrier loss reconnects through the Gateway supervisor; a Host-ended or terminally failed feed ends its followers and leaves their last metadata readable until reopened. The last follower leaving disposes the stream, a successor waits for that disposal, and plugin teardown awaits all pending closes. The provider declares `ResourceProtocolMap.file`; the text preview declares its Sidebar line-navigation parameters. @@ -95,7 +98,7 @@ Reads through `ctx.fs` are deliberately unconfined — the sandboxing backend fe | File | Role | |---|---| -| [`src/index.ts`](src/index.ts) | `WorkspaceFiles`: the `workspaceFiles` service and Remote namespace, `Config`, the gates, the page cutter, `read`, `readBytes`, `stat`, `list` | +| [`src/index.ts`](src/index.ts) | `WorkspaceFiles`: the `workspaceFiles` service and Remote namespace, `Config`, the gates, the page cutter, `read`, `readBytes`, `readAll`, `readRelated`, `stat`, `list` | | [`src/changes.ts`](src/changes.ts) | `WorkspaceChangeFeed`: `fs/observed` subscription and one queue per open `changes` generation | | [`src/types.ts`](src/types.ts) | Wire types and the `RemoteErrorDetailsMap` codes, published as `./types` for Client packages | | [`src/client/index.ts`](src/client/index.ts), [`provider.ts`](src/client/provider.ts), [`change-feed.ts`](src/client/change-feed.ts) | Browser plugin, file metadata, and per-Session change feed | @@ -137,11 +140,10 @@ None; this package neither assembles nor sends a provider request. - **Kind before position** — an entry outside the workspace whose type already disqualifies it reports `not-regular-file` or `not-directory`, not `outside-workspace`, because the path gate precedes containment. - **No total line count** — a page reports `eof`, not how many lines follow; a consumer that needs the total pages to the end or estimates from `bytes`. - **One giant line has no page** — a single line above `maxBytes` fails `too-large` at every window that includes it, because pages are cut by lines, not bytes. -- **Version precedes content** — the `version` on a page is the stat's, taken before the stream; a write landing between the two leaves the page one version behind, which the next `changes` frame reports. +- **Reads are not transactional** — result metadata comes from stat before content is read; a concurrent write can make the reported version and returned contents differ. - **Unbounded generation queue** — a `changes` generation buffers every contained observation until its consumer pulls; a stalled consumer grows Host memory for the life of the stream. - **`maxEntries` bounds the answer, not the listing** — `list` asks `ctx.fs.listDir` for every child and cuts the array afterwards, so a directory far above the cap still costs the Host the whole listing (on `fs-local`, one stat per child); bounding that work needs a limit on the filesystem seam's `listDir`. -- **Dead feeds retain metadata** — after the Host ends `changes` or the stream fails terminally, open values retain their last state until reopened; reload does not reopen the stream. -- **Reload is shared by path** — a reload re-stats every follower of that absolute path in the Session and clears their `changed` flags, including readers that did not reload their content. Per-record reload delivery remains deferred. +- **Dead feeds retain metadata** — after the Host ends `changes` or the stream fails terminally, open values retain their last state until reopened. ### Dev Note diff --git a/packages/api/workspace-files/README.zh.md b/packages/api/workspace-files/README.zh.md index 7e45d5c9a6..51c5fcd7fc 100644 --- a/packages/api/workspace-files/README.zh.md +++ b/packages/api/workspace-files/README.zh.md @@ -1,5 +1,5 @@ --- -description: "面向 Web GUI 的工作区文件服务:在 Session 工作区根内做分页读取、字节窗口、stat、目录列举与 Agent 写入变更流,以 workspaceFiles Remote 命名空间暴露。" +description: "面向 Web GUI 的工作区文件服务:在 Session 工作区根内做分页读取、字节窗口、完整读取、关联文件读取、stat、目录列举与 Agent 写入变更流,以 workspaceFiles Remote 命名空间暴露。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -使用本包可从 Web Client 浏览和检查 Session 工作区内的文件。它按行分页读取 UTF-8 文本、按有界窗口读取原始字节、报告文件版本与大小、列举目录的直接子项,并流式推送 Agent 文件操作造成的变更。每项操作都限定在为被寻址 Session 选择的工作区根内,不受文件系统后端工作目录影响。Client 组件还可经共享 Remote API 跟随实时文件元数据并构建 Sidebar 文件树。 +使用本包可从 Web Client 浏览和检查 Session 工作区内的文件。它按行分页读取 UTF-8 文本、按有界窗口或完整文件读取原始字节、从基文件目录解析关联文件、报告文件版本与大小、列举目录的直接子项,并流式推送 Agent 文件操作造成的变更。每项操作都限定在为被寻址 Session 选择的工作区根内,不受文件系统后端工作目录影响。Client 组件还可经共享 Remote API 跟随实时文件元数据并构建 Sidebar 文件树。 ## 目录 @@ -32,12 +32,14 @@ kind: "package-reference" | `stat(path)` | `WorkspaceFileStat { absolutePath, version, bytes? }` | 一个普通文件的身份、版本与大小,不含内容 | | `read(path, { offset?, limit? })` | `WorkspaceFileText` = stat + `{ offset, text, lines, eof }` | UTF-8 文本文件的一个行窗口;`lines` 计行数,使单个空行与越过文件末尾的页可区分 | | `readBytes(path, { offset?, length? })` | `WorkspaceFileBytes` = stat + `{ offset, data, eof }` | 任意普通文件的一个原始字节窗口,base64 编码 | +| `readAll(path)` | `WorkspaceFileBytes`,其中 `offset: 0`、`eof: true` | `maxFileBytes` 内的完整原始字节;超大文件失败,不截断 | +| `readRelated(path, relativePath)` | `WorkspaceFileBytes` | Host 从基文件目录解析出的文件的完整字节 | | `list(path)` | `WorkspaceDirectoryListing { path, entries, truncated }` | 一个目录的直接子项 | | `changes()` | `WorkspaceFileWatchFrame` 流 | 订阅就绪确认,随后为工作区根内的 Agent 观察 | ### 寻址与路径 -`read`、`stat` 与 `list` 接受工作区路径,可以是绝对路径,也可以是相对于 Session 工作区根的路径。离开服务的路径词汇有两套,每个方法只用其中一套:`read`、`stat` 与 `changes` 以文件系统执行环境中的绝对路径报告文件,符号链接已解析(`WorkspaceFileStat.absolutePath`、`WorkspaceFileChange.absolutePath`),因为其消费方是 Client 资源系统,它按这条路径跟随变更;`list` 以相对于根的工作区路径报告被列举目录——根自身为空串——因为其消费方是一棵以根为起点的树,子项路径就是该值与条目名以 `/` 连接。 +`read`、`stat` 与 `list` 接受工作区路径,可以是绝对路径,也可以是相对于 Session 工作区根的路径。离开服务的路径词汇有两套,每个方法只用其中一套:`read`、`stat` 与 `changes` 以文件系统执行环境中的绝对路径报告文件,符号链接已解析(`WorkspaceFileStat.absolutePath`、`WorkspaceFileChange.absolutePath`),因为其消费方是 Client 资源系统,它按这条路径跟随变更;`list` 以相对于根的工作区路径报告被列举目录——根自身为空串——因为其消费方是一棵以根为起点的树,子项路径就是该值与条目名以 `/` 连接。 `readRelated` 从基文件所在目录解析相对文件系统路径,不接受 URL 或绝对路径;基文件与目标均经过 Host 访问检查。 ### 分页 @@ -49,7 +51,7 @@ kind: "package-reference" ### 四道关 -每次读取、stat 与列举依次过四道关。第一,`lstat` 在跟随任何东西之前检查路径本身:符号链接不论指向哪里,`read` 与 `stat` 都以 `not-regular-file`、`list` 都以 `not-directory` 拒绝,并带上条目的 `kind`。第二,包含判定:路径解析为目标后由 `ctx.fs.contains(root, target)` 裁决,所以 `..` 上溯或根外绝对路径都以 `outside-workspace` 失败——绝不做字符串前缀比较,那看不见离开根的 realpath。第三,上限:文本超过 `maxBytes` 的页以 `too-large` 失败而不是被截短送达——文件本身没有大小上限——`maxEntries` 则截断列举并置 `truncated`。第四,文本:到该页末尾为止非 UTF-8 的内容,或含 NUL 字节的页,以 `not-text` 失败;页之后的字节不检查。路径不存在以 `not-found` 失败;空路径是 `gateway/bad-request`。 +每次读取、stat 与列举依次过四道关。第一,`lstat` 在跟随任何东西之前检查路径本身:符号链接不论指向哪里,`read` 与 `stat` 都以 `not-regular-file`、`list` 都以 `not-directory` 拒绝,并带上条目的 `kind`。第二,包含判定:路径解析为目标后由 `ctx.fs.contains(root, target)` 裁决,所以 `..` 上溯或根外绝对路径都以 `outside-workspace` 失败——绝不做字符串前缀比较,那看不见离开根的 realpath。第三,上限:文本超过 `maxBytes` 的页以 `too-large` 失败而不是被截短送达——分页读取不限制文件总大小,完整读取则受 `maxFileBytes` 限制——`maxEntries` 则截断列举并置 `truncated`。第四,文本:到该页末尾为止非 UTF-8 的内容,或含 NUL 字节的页,以 `not-text` 失败;页之后的字节不检查。路径不存在以 `not-found` 失败;空路径是 `gateway/bad-request`。 ### 变更流 @@ -60,6 +62,7 @@ kind: "package-reference" | 字段 | 默认值 | 含义 | |---|---|---| | `maxBytes` | `2097152`(2 MiB) | 单页文本与单个字节窗口的字节上限(含);更大的页或窗口失败 | +| `maxFileBytes` | `33554432`(32 MiB) | `readAll` 和 `readRelated` 的完整文件字节上限(含);更大文件以 `too-large` 失败 | | `maxLines` | `5000` | 页大小的缺省值与上限(行);更大的 `limit` 被拒绝 | | `maxEntries` | `2000` | 返回目录条目数上限;其余丢弃并报告截断 | @@ -67,15 +70,15 @@ kind: "package-reference" ### 失败 -每种失败都是一个带类型化 details 的 `RemoteError` 代码,声明于 [`src/types.ts`](src/types.ts):`workspace-file/not-found`、`workspace-file/outside-workspace`、`workspace-file/too-large`(带 `limit`,即页与窗口上限)、`workspace-file/not-text`、`workspace-file/not-regular-file`(`kind` 为 `directory`、`symlink` 或 `other`)以及 `workspace-file/not-directory`(`kind` 为 `file`、`symlink` 或 `other`)。调用方按代码分支,绝不按消息文本。 +每种失败都是一个带类型化 details 的 `RemoteError` 代码,声明于 [`src/types.ts`](src/types.ts):`workspace-file/not-found`、`workspace-file/outside-workspace`、`workspace-file/too-large`(带 `limit`,即适用的页、窗口或完整文件上限)、`workspace-file/not-text`、`workspace-file/not-regular-file`(`kind` 为 `directory`、`symlink` 或 `other`)以及 `workspace-file/not-directory`(`kind` 为 `file`、`symlink` 或 `other`)。调用方按代码分支,绝不按消息文本。 ### Client 文件资源 -浏览器导出向 `ctx.resources` 注册 `file` 提供者,要求 `resources`、`remote`、`remote.workspaceFiles` 和 `sessions` 在场。bundle 中单个 `workspace-files` 条目供应两面;Client 没有单独配置。组件经标准 prop `useResource<'file'>(address)` 跟随文件,读取 `{ absolutePath, version, bytes?, changed }`;内容通过分页方法另行获取。 +浏览器导出向 `ctx.resources` 注册 `file` 提供方,要求 `resources`、`remote` 和 `remote.workspaceFiles` 在场。bundle 中单个 `workspace-files` 条目供应两面;Client 没有单独配置。组件通过 `useResource<'file'>(address)` 读取 `WorkspaceFileStat { absolutePath, version, bytes? }` 元数据,内容另经 Remote 读取。任何 UI(包括 Global)访问同一完整地址都共享观察。 -`session//` 资源地址把相对路径原样发送给 Host,由 Host 按该 Session 的工作区根解析并检查包含关系;Client 不需要 Session `cwd`。`absolute/` 地址经当前 Session 读取。两者都使用[workspace-path](../../util/workspace-path/README.zh.md)规定的 `dsh-resource://file/` 语法。没有当前 Session 的绝对地址产生 `workspace-file/unknown-workspace`;不支持的地址产生 `workspace-file/unsupported-address`。这些 Client 失败会结束流,并使刷新无动作。 +`session//` 地址携带授权 Session,以及相对或绝对路径;前导斜杠保留,例如 `dsh-resource://file/session/s//etc/hosts`。Host 原样接收路径,负责解析与权限检查;Client 不需要 Session `cwd`。`absolute/` 仍可解析,但没有授权 Session,以 `workspace-file/unknown-workspace` 失败,不借用当前或 Tab Session。不支持的地址以 `workspace-file/unsupported-address` 失败。语法由 [workspace-path](../../util/workspace-path/README.zh.md) 定义;Resource 泛型层只认地址和 `signal`。 -提供者等到 Host 的 `ready` 帧后才发首次 `stat`,读取期间将变更排队,随后将跟随者绑定到 `stat.absolutePath`。排队与实时变更都按该 Host 返回路径匹配。新的写入版本置 `changed`,并保留最近的字节大小;重复版本被忽略。消失通知或刷新会重新 stat 文件。stat 失败后仍跟随地址,后续写入或刷新可使其恢复;首次成功绑定路径前,Session 内任何写入都可触发重试。刷新清除 `changed`,由 Host 触发的重新 stat 保留标记。帧是 `RemoteResult` 值,编程异常不被捕获。 +提供者等到 Host 的 `ready` 帧后才发首次 `stat`,读取期间将变更排队,随后将跟随者绑定到 `stat.absolutePath`。排队与实时变更都按该 Host 返回路径匹配。新的写入版本更新元数据并保留最近的字节大小;重复版本被忽略。消失通知会重新 stat 文件。stat 失败后仍跟随地址,后续写入可使其恢复;首次成功绑定路径前,Session 内任何写入都可触发重试。帧是 `RemoteResult` 值,编程异常不被捕获。 每个 Session 的所有被跟随文件共用一条受监督的 `changes` 流。跟随者按反斜杠归一为斜杠的绝对路径匹配。载体掉线由 Gateway 监督器重连;Host 结束或终态失败的流会结束其跟随者,最后的元数据仍可读取,直到重新打开。最后一个跟随者离开时释放流,后继流等待该释放完成,插件拆除等待所有在途关闭。提供者声明 `ResourceProtocolMap.file`;文本预览声明其 Sidebar 行号导航参数。 @@ -95,7 +98,7 @@ kind: "package-reference" | 文件 | 职责 | |---|---| -| [`src/index.ts`](src/index.ts) | `WorkspaceFiles`:`workspaceFiles` 服务与 Remote 命名空间、`Config`、四道关、切页器、`read`、`readBytes`、`stat`、`list` | +| [`src/index.ts`](src/index.ts) | `WorkspaceFiles`:`workspaceFiles` 服务与 Remote 命名空间、`Config`、四道关、切页器、`read`、`readBytes`、`readAll`、`readRelated`、`stat`、`list` | | [`src/changes.ts`](src/changes.ts) | `WorkspaceChangeFeed`:`fs/observed` 订阅与每个打开的 `changes` generation 各一条队列 | | [`src/types.ts`](src/types.ts) | 线路类型与 `RemoteErrorDetailsMap` 错误码,以 `./types` 发布给 Client 包 | | [`src/client/index.ts`](src/client/index.ts)、[`provider.ts`](src/client/provider.ts)、[`change-feed.ts`](src/client/change-feed.ts) | 浏览器插件、文件元数据与每 Session 变更流 | @@ -137,11 +140,10 @@ Typert 生成 `./typert` 与 `./remote` 暴露的 Host 与 Client Remote 产物 - **类型先于位置**——根外条目若类型本身就不合格,报告的是 `not-regular-file` 或 `not-directory` 而非 `outside-workspace`,因为路径关先于包含判定。 - **没有总行数**——页只报告 `eof`,不报告后面还有多少行;需要总数的消费方要翻到末尾或按 `bytes` 估算。 - **超长单行没有页**——超过 `maxBytes` 的单行在包含它的每个窗口都以 `too-large` 失败,因为页按行而非按字节切。 -- **版本先于内容**——页上的 `version` 来自流之前的 stat;两者之间落地的写入会让该页落后一个版本,下一帧 `changes` 会报告它。 +- **读取不具备事务性**——结果元数据来自内容读取之前的 stat;并发写入可能使报告版本与返回内容不一致。 - **generation 队列无界**——一个 `changes` generation 会缓冲每一条被包含的观察直到消费方 pull;停滞的消费方会在流的生命期内持续增长 Host 内存。 - **`maxEntries` 限制的是答案,不是列举**——`list` 让 `ctx.fs.listDir` 列出全部子项后再截断数组,远超上限的目录仍让 Host 付出整个列举的代价(`fs-local` 上每个子项一次 stat);要限制这份工作,需要文件系统 seam 的 `listDir` 支持上限。 -- **失效流保留元数据**——Host 结束 `changes` 或流终态失败后,已打开的值保持最后已知状态,直到重新打开;刷新不会重开流。 -- **刷新按路径共享**——同一会话中,一次刷新会重新 stat 此绝对路径的全部跟随者并清除其 `changed` 标记,包括没有重读内容的其它读者。按记录投递刷新仍是延期工作。 +- **失效流保留元数据**——Host 结束 `changes` 或流终态失败后,已打开的值保持最后已知状态,直到重新打开。 ### 开发备注 diff --git a/packages/api/workspace-files/package.json b/packages/api/workspace-files/package.json index a548e9a2d0..ab886d9e57 100644 --- a/packages/api/workspace-files/package.json +++ b/packages/api/workspace-files/package.json @@ -41,7 +41,6 @@ "client": { "inject": [ "@deepseek-ai/dsh-api-gateway", - "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-resources" ], "platform": "web" @@ -65,7 +64,6 @@ "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-resources": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", diff --git a/packages/api/workspace-files/src/client/change-feed.ts b/packages/api/workspace-files/src/client/change-feed.ts index cc587d5f1f..39f43c3940 100644 --- a/packages/api/workspace-files/src/client/change-feed.ts +++ b/packages/api/workspace-files/src/client/change-feed.ts @@ -7,8 +7,7 @@ * arrives, hands each frame to the followers of its path, and disposes the * stream when the last follower leaves. A follower buffers session changes * until `stat` supplies its Host absolute path, then filters queued and live - * frames by that path, with `\\` normalized to `/`. Resource addresses identify - * reload requests; they never determine a notification path. + * frames by that path, with `\\` normalized to `/`. */ import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { WorkspaceFileChange, WorkspaceFileWatchFrame } from '../types.ts' @@ -26,7 +25,7 @@ function keyOf(path: string): string { /** Notices of one follower, delivered in order and pulled by its consumer. */ class Follower implements AsyncIterable { - private readonly pending: Array<{ readonly key: string | undefined; readonly notice: WorkspaceFileNotice }> = [] + private readonly pending: Array<{ readonly key: string; readonly notice: WorkspaceFileNotice }> = [] private readonly started = Promise.withResolvers() private wake: (() => void) | undefined private ended = false @@ -39,15 +38,9 @@ class Follower implements AsyncIterable { readonly ready = this.started.promise /** - * @param address - resource address used for reload lookup. * @param leave - unregisters this follower and its abort listener. */ - constructor(readonly address: string, private readonly leave: () => void) {} - - /** The normalized Host path, absent until a successful stat. */ - get key(): string | undefined { - return this.hostKey - } + constructor(private readonly leave: () => void) {} /** * Select the Host path for queued and future changes. @@ -65,9 +58,9 @@ class Follower implements AsyncIterable { /** * Queue one notice. * @param notice - what the consumer receives next. - * @param key - normalized Host path for a change; absent for a reload. + * @param key - normalized Host path for the change. */ - push(notice: WorkspaceFileNotice, key?: string): void { + push(notice: WorkspaceFileNotice, key: string): void { this.pending.push({ key, notice }) this.wake?.() } @@ -90,7 +83,7 @@ class Follower implements AsyncIterable { while (true) { const next = this.pending.shift() if (next !== undefined) { - if (next.key === undefined || this.hostKey === undefined || next.key === this.hostKey) yield next.notice + if (this.hostKey === undefined || next.key === this.hostKey) yield next.notice continue } if (this.ended) return @@ -155,22 +148,6 @@ class SessionFeed { if (this.followers.size === 0) this.close() } - /** - * Reload an address and every follower bound to the same Host path. - * @param address - the resource address requesting a reload. - */ - requestRestat(address: string): void { - const keys = new Set() - for (const follower of this.followers) { - if (follower.address === address && follower.key !== undefined) keys.add(follower.key) - } - for (const follower of this.followers) { - if (follower.address === address || (follower.key !== undefined && keys.has(follower.key))) { - follower.push({ kind: 'restat' }) - } - } - } - private async pump(): Promise { try { for await (const item of this.stream) { @@ -250,7 +227,7 @@ export class ChangeFeed { constructor(private readonly remote: WorkspaceFilesRemote) {} /** - * Follow one resource address in one session before its Host path is known. + * Follow one resource in one session before its Host path is known. * * The follower is registered on call, not on first pull. Changes delivered * to this Client are queued while stat is pending. The first follower starts @@ -262,18 +239,17 @@ export class ChangeFeed { * any session write can trigger a retry; after binding, only matching queued * and live changes pass. * @param sessionId - the session whose workspace holds the file. - * @param address - the resource address, used only for reload lookup. * @param signal - ends the follow. * @returns a single-consumer subscription with Host-path binding and explicit disposal. */ - follow(sessionId: SessionId, address: string, signal: AbortSignal): Follower { + follow(sessionId: SessionId, signal: AbortSignal): Follower { const feed = signal.aborted ? undefined : this.feedOf(sessionId) const leave = (): void => { signal.removeEventListener('abort', leave) follower.end() feed?.remove(follower) } - const follower = new Follower(address, leave) + const follower = new Follower(leave) if (feed === undefined) { follower.end() } else { @@ -283,15 +259,6 @@ export class ChangeFeed { return follower } - /** - * Ask an address and its same-session Host-path peers to `stat` again. - * @param sessionId - the session whose workspace holds the file. - * @param address - the resource address requesting a reload. - */ - requestRestat(sessionId: SessionId, address: string): void { - this.sessions.get(sessionId)?.requestRestat(address) - } - /** * Wait for every stream that is still closing, so an owner tearing down * leaves no Host stream behind. diff --git a/packages/api/workspace-files/src/client/index.ts b/packages/api/workspace-files/src/client/index.ts index 418cd5fcf5..687580a7a3 100644 --- a/packages/api/workspace-files/src/client/index.ts +++ b/packages/api/workspace-files/src/client/index.ts @@ -7,29 +7,22 @@ */ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-gateway/client' -// The `sessions` service face supplies the current Session for absolute addresses. -import type {} from '@deepseek-ai/dsh-api-session-controller/client' import type {} from '@deepseek-ai/dsh-client-resources/client' import { ChangeFeed } from './change-feed.ts' -import { createFileResourceProvider, type SessionLookup } from './provider.ts' +import { createFileResourceProvider } from './provider.ts' -export type { SessionLookup } from './provider.ts' -export type { WorkspaceFileParams, WorkspaceFileResource } from './types.ts' +export type { WorkspaceFileParams } from './types.ts' -/** Required browser services: the resource model, the Remote carrier and its namespace, and the Session list. */ -export const inject = ['resources', 'remote', 'remote.workspaceFiles', 'sessions'] +/** Required browser services: the resource model, the Remote carrier and its namespace. */ +export const inject = ['resources', 'remote', 'remote.workspaceFiles'] /** * Client plugin body: register the `file` provider for this plugin's lifetime. - * @param ctx - client root context carrying `resources`, the Remote face, and `sessions`. + * @param ctx - client root context carrying `resources` and the Remote face. */ export function apply(ctx: ClientContext): void { - // The current Session changes with navigation; absolute addresses read it on demand. - const sessions: SessionLookup = { - current: () => ctx.sessions.list.getSnapshot().current, - } const changes = new ChangeFeed(ctx.remote) - const provider = createFileResourceProvider(ctx.remote, changes, sessions) + const provider = createFileResourceProvider(ctx.remote, changes) ctx.effect(() => { const release = ctx.resources.register(provider) // Teardown waits for every session stream still closing, so the plugin diff --git a/packages/api/workspace-files/src/client/provider.ts b/packages/api/workspace-files/src/client/provider.ts index 0010ad9d3c..7816332d2e 100644 --- a/packages/api/workspace-files/src/client/provider.ts +++ b/packages/api/workspace-files/src/client/provider.ts @@ -3,23 +3,22 @@ * `RemoteResult` frames. * * An address names the file in one of two scopes. A `session` address, - * `dsh-resource://file/session//`, carries a path relative to - * that Session's workspace root: the Host receives the relative path as-is and + * `dsh-resource://file/session//`, carries an absolute path or one + * relative to that Session's workspace root: the Host receives the path as-is and * resolves it against the root it holds. Only the Host's `stat.absolutePath` * selects the change-feed key; no Client Session summary is needed. * An `absolute` address, `dsh-resource://file/absolute/`, carries no - * Session and is read through the Session on screen. An address neither scope + * Session and cannot authorize a Host call. An address neither scope * resolves yields one failure frame — `workspace-file/unsupported-address` for * a string outside the grammar, `workspace-file/unknown-workspace` when the - * absolute address has no current Session — and ends. + * address carries no Session — and ends. * * The first frame is the file's `stat`; every Host-reported write yields the - * metadata flagged `changed`; a reported disappearance, or a write while the - * last stat had failed, runs `stat` again and flags what it finds; a reload - * runs `stat` again and clears the flag. Failures travel as `ok: false` frames, never as thrown errors: the + * metadata with its reported version; a reported disappearance, or a write while the + * last stat had failed, runs `stat` again. Failures travel as `ok: false` frames, never as thrown errors: the * Remote face does not reject, and anything thrown inside the stream is a * programming error the resource model lets surface. A failed stat does not end - * the stream: the next write or reload stats again. One {@link ChangeFeed} + * the stream: the next write stats again. One {@link ChangeFeed} * serves every open file of the Client. */ import type { ResourceProvider } from '@deepseek-ai/dsh-client-resources/client' @@ -30,61 +29,49 @@ import { parseFileAddress } from '@deepseek-ai/dsh-util-workspace-path' import type { WorkspaceFileStat } from '../types.ts' import type { ChangeFeed } from './change-feed.ts' import type { WorkspaceFilesRemote } from './remote.ts' -import type { WorkspaceFileResource } from './types.ts' - -/** The current Session used to authorize an absolute address. */ -export interface SessionLookup { - /** - * The Session on screen, which an `absolute` address is read through. - * @returns its id, or `undefined` while no Session is current. - */ - current(): SessionId | undefined -} /** The Session and unmodified path submitted to the Host. */ interface HostFile { readonly sessionId: SessionId - /** The path the Host receives: workspace-relative for a `session` address, absolute for an `absolute` one. */ + /** The path the Host receives: absolute or workspace-relative. */ readonly path: string } /** - * Build the `file` provider over one Remote face, one change feed, and the Client's Session list. + * Build the `file` provider over one Remote face and one change feed. * @param remote - the Remote face carrying `workspaceFiles.stat`. * @param changes - the per-session change fan-out. - * @param sessions - the current Session, read for absolute addresses on every open and reload. * @returns the provider to register into `ctx.resources`. */ export function createFileResourceProvider( remote: WorkspaceFilesRemote, changes: ChangeFeed, - sessions: SessionLookup, ): ResourceProvider<'file'> { return { protocol: 'file', - async *open(address, { signal }): AsyncIterable> { - const resolved = resolve(address, sessions) + async *open(address, { signal }): AsyncIterable> { + const resolved = resolve(address) if (!resolved.ok) { yield resolved return } const { sessionId, path } = resolved.value // Queue changes delivered to this Client while stat is pending. - const notices = changes.follow(sessionId, address, signal) + const notices = changes.follow(sessionId, signal) const stat = (): Promise> => remote.workspaceFiles.stat(sessionId, path, signal) // Read through a call: a plain `signal.aborted` is narrowed to `false` by // the first check and would read as always-false after the later awaits. const aborted = (): boolean => signal.aborted // Undefined while the last stat failed: the follow is on the address, not - // on the file, so a write or a reload can still bring the file live. - let current: WorkspaceFileResource | undefined + // on the file, so a write can still bring the file live. + let current: WorkspaceFileStat | undefined try { if (!await notices.ready || aborted()) return const first = await stat() if (aborted()) return if (first.ok) { notices.bind(first.value.absolutePath) - current = metadataOf(first.value, false) + current = first.value yield { ok: true, value: current } } else { yield first @@ -97,11 +84,11 @@ export function createFileResourceProvider( // Frames report observations: holding this version already means the // consumer learns nothing new. if (notice.version === current.version) continue - current = { ...current, version: notice.version, changed: true } + current = { ...current, version: notice.version } yield { ok: true, value: current } continue } - // A Host notice may mean stale content; only a reload clears the flag. + // A Host notice may mean stale content. const again = await stat() if (aborted()) return if (!again.ok) { @@ -110,27 +97,22 @@ export function createFileResourceProvider( continue } notices.bind(again.value.absolutePath) - current = metadataOf(again.value, notice.kind !== 'restat') + current = again.value yield { ok: true, value: current } } } finally { notices.dispose() } }, - reload(address) { - const resolved = resolve(address, sessions) - if (resolved.ok) changes.requestRestat(resolved.value.sessionId, address) - }, } } /** * Resolve one address to the Host call it stands for, or to the failure frame it earns. * @param address - the full address, scheme included. - * @param sessions - the Client's Session list. * @returns the Host file, or the `unsupported-address` / `unknown-workspace` failure. */ -function resolve(address: string, sessions: SessionLookup): RemoteResult { +function resolve(address: string): RemoteResult { const parsed = parseFileAddress(address) if (parsed === undefined) return { ok: false, error: unsupportedAddress(address) } if (parsed.scope === 'session') { @@ -138,9 +120,7 @@ function resolve(address: string, sessions: SessionLookup): RemoteResult { return new RemoteError( 'workspace-file/unknown-workspace', - `${address} requires a current Session`, + `${address} requires a dsh-resource://file/session// address`, { address }, ) } - -/** - * The resource value one `stat` result amounts to. - * @param stat - what the Host reported. - * @param changed - whether the consumer's content may be stale: `true` after a - * Host notice prompted the stat, `false` for the opening stat and a reload's. - * @returns the metadata frame value. - */ -function metadataOf(stat: WorkspaceFileStat, changed: boolean): WorkspaceFileResource { - return { absolutePath: stat.absolutePath, version: stat.version, changed, ...(stat.bytes === undefined ? {} : { bytes: stat.bytes }) } -} diff --git a/packages/api/workspace-files/src/client/types.ts b/packages/api/workspace-files/src/client/types.ts index 606e1164b3..dcd6e74e31 100644 --- a/packages/api/workspace-files/src/client/types.ts +++ b/packages/api/workspace-files/src/client/types.ts @@ -5,15 +5,15 @@ // Bring the base `ResourceProtocolMap` declaration into this program so the // augmentation below merges into it instead of declaring a second interface. import type {} from '@deepseek-ai/dsh-client-resources/client' +import type { WorkspaceFileStat } from '../types.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface ResourceProtocolMap { /** * One workspace file's metadata, addressed as - * `dsh-resource://file/session//` or - * `dsh-resource://file/absolute/`. + * `dsh-resource://file/session//` (absolute or workspace-relative). */ - file: WorkspaceFileResource + file: WorkspaceFileStat } } @@ -27,13 +27,13 @@ declare module '@deepseek-ai/dsh-typert-protocol' { interface RemoteErrorDetailsMap { /** * The address is not a `dsh-resource://file/` address in a scope the - * provider serves: `session//` or + * provider recognizes: `session//` or * `absolute/`. Raised by the Client provider; the Host never * emits it. */ 'workspace-file/unsupported-address': { readonly address: string } /** - * An `absolute` address has no current Session to authorize its Host call. + * An `absolute` address has no Session to authorize its Host call. * Raised by the Client provider; the Host never emits it. Session addresses * are resolved by the Host without a Client Session summary. */ @@ -41,28 +41,10 @@ declare module '@deepseek-ai/dsh-typert-protocol' { } } -/** - * One workspace file as the resource model carries it: metadata only. - * - * The stream reports that the file moved on; it never carries content. A - * consumer reads the text itself, by page, and uses `version` and `changed` to - * know when its pages are stale. - */ -export interface WorkspaceFileResource { - /** Absolute path in the Host filesystem, as returned by the last successful stat. */ - readonly absolutePath: string - /** The Host's latest report of the file's version: from `stat` first, then from each reported write. */ - readonly version: string - /** Byte size as of the last `stat`, when the backend reports it. */ - readonly bytes?: number - /** The Host reported a write after the last `stat`; a reload (`stat` again) clears it. */ - readonly changed: boolean -} - /** One Host-reported write inside the session's workspace. */ export type WorkspaceFileEdit = | { readonly kind: 'changed'; readonly version: string } | { readonly kind: 'absent' } -/** What one follower of a path receives: a Host write, or a local request to `stat` again. */ -export type WorkspaceFileNotice = WorkspaceFileEdit | { readonly kind: 'restat' } +/** What one follower of a path receives: a Host write. */ +export type WorkspaceFileNotice = WorkspaceFileEdit diff --git a/packages/api/workspace-files/src/index.ts b/packages/api/workspace-files/src/index.ts index 42dfb13bc1..7462f2dfe1 100644 --- a/packages/api/workspace-files/src/index.ts +++ b/packages/api/workspace-files/src/index.ts @@ -63,6 +63,8 @@ export interface Config { * way. The file itself has no size cap: a caller pages through it. */ readonly maxBytes: number + /** Inclusive byte cap on a complete-file read; larger files are refused, never truncated. */ + readonly maxFileBytes: number /** Default and largest page size in lines; a request asking for more is refused. */ readonly maxLines: number /** Cap on returned directory entries; the rest is dropped and reported cut. */ @@ -170,6 +172,7 @@ export class WorkspaceFiles extends TypertRemoteService { static Config: z = z.object({ maxBytes: z.number().step(1).min(1).default(2 * 1024 * 1024), + maxFileBytes: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER - 1).default(32 * 1024 * 1024), maxLines: z.number().step(1).min(1).default(5000), maxEntries: z.number().step(1).min(1).default(2000), }) @@ -222,6 +225,48 @@ export class WorkspaceFiles extends TypertRemoteService { return { ...this.statOf(target, info), offset, data: Buffer.from(data).toString('base64'), eof } } + /** + * Read a complete regular file as bytes, subject to the configured full-file cap. + * @param agent - target Agent whose workspace confines the read. + * @param path - absolute or workspace-relative file path. + * @param signal - caller cancellation. + * @returns one complete base64 window with offset zero and eof true; oversized files fail with too-large. + */ + @Remote + async readAll(agent: Agent, path: string, signal: AbortSignal): Promise { + const { target, info } = await this.locateFile(agent, path, signal) + const limit = this.config.maxFileBytes + if (info.size !== undefined && info.size > limit) { + throw new RemoteError('workspace-file/too-large', `"${path}" exceeds the ${limit} byte full-file cap`, { path, limit }) + } + const data = await this.ctx.fs.readByteRange(target, { offset: 0, length: limit + 1 }, signal) + if (data.length > limit) { + throw new RemoteError('workspace-file/too-large', `"${path}" exceeds the ${limit} byte full-file cap`, { path, limit }) + } + return { ...this.statOf(target, info), offset: 0, data: Buffer.from(data).toString('base64'), eof: true } + } + + /** + * Read a complete file relative to another file's directory within the same workspace. + * @param agent - Agent whose workspace confines both files. + * @param path - base file, absolute or workspace-relative. + * @param relativePath - relative filesystem path, not a URL or absolute path. + * @param signal - caller cancellation. + * @returns the complete related file using the ordinary file-size and access checks. + */ + @Remote + async readRelated(agent: Agent, path: string, relativePath: string, signal: AbortSignal): Promise { + const relative = relativePath.replace(/\\/g, '/') + if (relative.length === 0 || relative.startsWith('/') || /^[a-z][a-z\d+.-]*:/iu.test(relative) || relative.includes(NUL)) { + throw new RemoteError('gateway/bad-request', 'relativePath must be a relative filesystem path', {}) + } + const { target } = await this.locateFile(agent, path, signal) + const root = await this.ctx.fs.resolve(this.workspaceRootOf(agent), { signal }) + const fromRoot = workspacePathOf(this.ctx.fs.fileUrl(root), this.ctx.fs.fileUrl(target)) + const directory = fromRoot.slice(0, fromRoot.lastIndexOf('/') + 1) + return this.readAll(agent, directory + relative, signal) + } + /** * Report one regular file's identity, version, and size without its content. * @param agent - target Agent resolved from the Session identity on the wire. diff --git a/packages/api/workspace-files/tests/apply.client.spec.ts b/packages/api/workspace-files/tests/apply.client.spec.ts index 5153814358..00001f9fea 100644 --- a/packages/api/workspace-files/tests/apply.client.spec.ts +++ b/packages/api/workspace-files/tests/apply.client.spec.ts @@ -1,27 +1,22 @@ /** * The plugin body: one `file` provider registered into `ctx.resources` for the - * fiber's lifetime, reading through `ctx.remote` and resolving addresses - * authorizing absolute addresses through the current `ctx.sessions` entry. + * fiber's lifetime, reading through `ctx.remote` in the Session each address names. */ import { Context } from '@deepseek-ai/cordis' import type { ResourceProvider } from '@deepseek-ai/dsh-client-resources/client' -import { absoluteFileAddress, sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path' +import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path' import { describe, expect, it, onTestFinished } from 'vitest' import { apply, inject } from '../src/client/index.ts' import { FakeRemote, settle } from './fake-remote.client.ts' describe('workspace-files client apply', () => { - it('registers the file provider over ctx.remote and ctx.sessions, and unregisters it with the fiber', async () => { + it('registers the file provider over ctx.remote, and unregisters it with the fiber', async () => { const ctx = new Context() const remote = new FakeRemote() const controller = new AbortController() const pulls: Array> = [] ctx.provide('remote', remote as never) ctx.provide('remote.workspaceFiles', remote.workspaceFiles as never) - // No Session summary or cwd is available; only absolute addresses need current. - ctx.provide('sessions', { - list: { getSnapshot: () => ({ byId: {}, current: 's1' }) }, - } as never) const registered: Array> = [] let released = 0 ctx.provide('resources', { @@ -45,10 +40,9 @@ describe('workspace-files client apply', () => { expect(registered.map(provider => provider.protocol)).toEqual(['file']) const signal = controller.signal - // A session address reaches the Host as its relative path; an absolute one - // is read through the current Session with its absolute path. + // A session address reaches the Host with its relative or absolute path. pulls.push(registered[0]!.open(sessionFileAddress('s1', 'a.txt'), { signal })[Symbol.asyncIterator]().next()) - pulls.push(registered[0]!.open(absoluteFileAddress('/etc/hosts'), { signal })[Symbol.asyncIterator]().next()) + pulls.push(registered[0]!.open(sessionFileAddress('s1', '/etc/hosts'), { signal })[Symbol.asyncIterator]().next()) await settle() expect(remote.stats.map(pending => [pending.sessionId, pending.path])).toEqual([['s1', 'a.txt'], ['s1', '/etc/hosts']]) expect(remote.opened).toHaveLength(1) diff --git a/packages/api/workspace-files/tests/change-feed.client.spec.ts b/packages/api/workspace-files/tests/change-feed.client.spec.ts index 9fe3ed4c73..3bae81a440 100644 --- a/packages/api/workspace-files/tests/change-feed.client.spec.ts +++ b/packages/api/workspace-files/tests/change-feed.client.spec.ts @@ -1,6 +1,6 @@ /** * The change feed's promises: one Host stream per session, delivery by absolute - * path, local stat requests, and a follower's life bounded by its signal or by + * path, and a follower's life bounded by its signal or by * the stream's end. */ import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -16,7 +16,7 @@ function harness() { const remote = new FakeRemote() const feed = new ChangeFeed(remote) const follow = (sessionId: SessionId, path: string, controller = new AbortController()) => { - const follower = feed.follow(sessionId, path, controller.signal) + const follower = feed.follow(sessionId, controller.signal) follower.bind(path) return { it: follower[Symbol.asyncIterator](), controller } } @@ -27,10 +27,10 @@ describe('ChangeFeed — one Host stream per session', () => { it('starts a later follower from the existing session acknowledgement without opening another stream', async () => { const { remote, feed } = harness() const controller = new AbortController() - const first = feed.follow(S1, 'first-resource', controller.signal) + const first = feed.follow(S1, controller.signal) try { await expect(first.ready).resolves.toBe(true) - const second = feed.follow(S1, 'second-resource', controller.signal) + const second = feed.follow(S1, controller.signal) await expect(second.ready).resolves.toBe(true) expect(remote.calls).toEqual(['changes', 'accept']) expect(remote.opened).toHaveLength(1) @@ -189,26 +189,14 @@ describe('ChangeFeed — delivery', () => { await expect(two.it.next()).resolves.toEqual({ done: false, value: { kind: 'changed', version: 'v2' } }) await expect(peek(one.it)).resolves.toBe('silent') }) - - it('hands a stat request to the followers of that path only', async () => { - const { feed, follow } = harness() - const mine = follow(S1, '/w/a.txt') - const other = follow(S1, '/w/b.txt') - await settle() - feed.requestRestat(S1, '/w/a.txt') - feed.requestRestat(S2, '/w/a.txt') - feed.requestRestat(S1, '/w/nobody.txt') - await expect(mine.it.next()).resolves.toEqual({ done: false, value: { kind: 'restat' } }) - await expect(peek(other.it)).resolves.toBe('silent') - }) }) describe('ChangeFeed — a follower ends', () => { it('ends every follower and disposes the session stream for an unknown wire frame kind', async () => { const { remote, feed } = harness() const controller = new AbortController() - const first = feed.follow(S1, 'first-resource', controller.signal) - const second = feed.follow(S1, 'second-resource', controller.signal) + const first = feed.follow(S1, controller.signal) + const second = feed.follow(S1, controller.signal) const firstIterator = first[Symbol.asyncIterator]() const secondIterator = second[Symbol.asyncIterator]() try { @@ -248,7 +236,7 @@ describe('ChangeFeed — a follower ends', () => { const { remote, feed } = harness() const controller = new AbortController() controller.abort() - const it = feed.follow(S1, '/w/a.txt', controller.signal)[Symbol.asyncIterator]() + const it = feed.follow(S1, controller.signal)[Symbol.asyncIterator]() await expect(it.next()).resolves.toEqual({ done: true, value: undefined }) await settle() expect(remote.opened).toEqual([]) diff --git a/packages/api/workspace-files/tests/changes.spec.ts b/packages/api/workspace-files/tests/changes.spec.ts index 452dbaabee..b21b92e8db 100644 --- a/packages/api/workspace-files/tests/changes.spec.ts +++ b/packages/api/workspace-files/tests/changes.spec.ts @@ -149,7 +149,7 @@ describe('workspaceFiles.changes — ending', () => { }) let service: WorkspaceFiles | undefined const fiber = await harness.ctx.plugin(Object.assign((ctx: Context) => { - service = new WorkspaceFiles(ctx, { maxBytes: 1, maxLines: 1, maxEntries: 1 }) + service = new WorkspaceFiles(ctx, { maxBytes: 1, maxFileBytes: 1, maxLines: 1, maxEntries: 1 }) }, { inject: ['fs', 'sandboxPolicy'] })) try { if (service === undefined) throw new Error('plugin body did not run') @@ -267,7 +267,7 @@ describe('workspaceFiles.changes — ending', () => { it('ends every open generation when the owning fiber is disposed', async () => { let service: WorkspaceFiles | undefined const fiber = await harness.ctx.plugin(Object.assign((ctx: Context) => { - service = new WorkspaceFiles(ctx, { maxBytes: 1, maxLines: 1, maxEntries: 1 }) + service = new WorkspaceFiles(ctx, { maxBytes: 1, maxFileBytes: 1, maxLines: 1, maxEntries: 1 }) }, { inject: ['fs', 'sandboxPolicy'] })) if (service === undefined) throw new Error('plugin body did not run') const stream = open(service) diff --git a/packages/api/workspace-files/tests/harness.ts b/packages/api/workspace-files/tests/harness.ts index 2b1a5ece77..17872a7875 100644 --- a/packages/api/workspace-files/tests/harness.ts +++ b/packages/api/workspace-files/tests/harness.ts @@ -64,6 +64,7 @@ export async function openWorkspace(prefix: string): Promise { } service = new WorkspaceFiles(ctx, { maxBytes: caps?.maxBytes ?? 1024 * 1024, + maxFileBytes: caps?.maxFileBytes ?? 1024 * 1024, maxLines: caps?.maxLines ?? 5000, maxEntries: caps?.maxEntries ?? 2000, }) diff --git a/packages/api/workspace-files/tests/host-path.client.spec.ts b/packages/api/workspace-files/tests/host-path.client.spec.ts index 306222f6d7..c1a4328bf4 100644 --- a/packages/api/workspace-files/tests/host-path.client.spec.ts +++ b/packages/api/workspace-files/tests/host-path.client.spec.ts @@ -1,10 +1,10 @@ -/** Host-resolved file identities across pending stats, retries, reloads, and disposal. */ +/** Host-resolved file identities across pending stats, retries, and disposal. */ import type { SessionId } from '@deepseek-ai/dsh-session/types' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' -import { absoluteFileAddress, sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path' +import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path' import { describe, expect, it, onTestFinished } from 'vitest' import { ChangeFeed } from '../src/client/change-feed.ts' -import { createFileResourceProvider, type SessionLookup } from '../src/client/provider.ts' +import { createFileResourceProvider } from '../src/client/provider.ts' import { FakeRemote, settle } from './fake-remote.client.ts' const SESSION = 'host-only' as SessionId @@ -12,10 +12,10 @@ const RELATIVE = 'linked/a b.txt' const ADDRESS = sessionFileAddress(SESSION, RELATIVE) const CANONICAL = '/host/canonical/a b.txt' -function harness(sessions: SessionLookup = { current: () => SESSION }) { +function harness() { const remote = new FakeRemote() const changes = new ChangeFeed(remote) - const provider = createFileResourceProvider(remote, changes, sessions) + const provider = createFileResourceProvider(remote, changes) const open = (address = ADDRESS) => { const controller = new AbortController() const iterator = provider.open(address, { signal: controller.signal })[Symbol.asyncIterator]() @@ -27,21 +27,21 @@ function harness(sessions: SessionLookup = { current: () => SESSION }) { await iterator.return?.() await changes.settle() }) - return { iterator, controller, reload: () => { provider.reload!(address) } } + return { iterator, controller } } return { remote, changes, open } } describe('Host-resolved file paths', () => { it('accepts Host ready before submitting the unmodified relative path without a Client Session summary', async () => { - const { remote, open } = harness({ current: () => { throw new Error('session addresses do not read current') } }) + const { remote, open } = harness() const { iterator } = open() const first = iterator.next() const request = await remote.waitForStat(0) expect(remote.calls).toEqual(['changes', 'accept', 'stat']) expect(request).toMatchObject({ sessionId: SESSION, path: RELATIVE }) request.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v0', bytes: 3 } }) - await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v0', bytes: 3, changed: false } } }) + await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v0', bytes: 3 } } }) }) it('does not stat an opened changes stream until the Host acknowledges its subscription', async () => { @@ -58,7 +58,7 @@ describe('Host-resolved file paths', () => { const request = await remote.waitForStat(0) expect(remote.calls).toEqual(['changes', 'accept', 'stat']) request.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v0' } }) - await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v0', changed: false } } }) + await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v0' } } }) }) it.each(['abort', 'end', 'failure'] as const)('settles %s before Host ready without sending a stat or leaving a stream', async (ending) => { @@ -94,12 +94,12 @@ describe('Host-resolved file paths', () => { await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'v1' } }) expect(remote.stats).toHaveLength(1) expect(remote.calls).toEqual(['changes', 'accept', 'stat', 'accept']) - await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v1', bytes: 3, changed: true } } }) + await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v1', bytes: 3 } } }) }) it.each([ - ['session', ADDRESS, RELATIVE], - ['absolute', absoluteFileAddress('/shortcut/a.txt'), '/shortcut/a.txt'], + ['relative', ADDRESS, RELATIVE], + ['absolute', sessionFileAddress(SESSION, '/shortcut/a.txt'), '/shortcut/a.txt'], ])('filters queued and live %s changes using the Host canonical path, not the input path', async (_, address, path) => { const { remote, open } = harness() const { iterator } = open(address) @@ -111,18 +111,18 @@ describe('Host-resolved file paths', () => { await source.deliver({ kind: 'change', change: { absolutePath: '/other/file.txt', version: 'other-before-stat' } }) await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'v1' } }) request.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v0', bytes: 3 } }) - await expect(first).resolves.toMatchObject({ value: { ok: true, value: { version: 'v0', changed: false } } }) - await expect(iterator.next()).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v1', bytes: 3, changed: true } } }) + await expect(first).resolves.toMatchObject({ value: { ok: true, value: { version: 'v0' } } }) + await expect(iterator.next()).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v1', bytes: 3 } } }) await source.deliver({ kind: 'change', change: { absolutePath: '/other/file.txt', version: 'other-after-stat' } }) await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'v2' } }) - await expect(iterator.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v2', changed: true } } }) + await expect(iterator.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v2' } } }) expect(remote.stats).toHaveLength(1) }) - it.each(['reload', 'write'] as const)('recovers an initial failed stat through %s and filters the retry backlog after binding', async (trigger) => { + it.each(['write'] as const)('recovers an initial failed stat through %s and filters the retry backlog after binding', async () => { const { remote, open } = harness() - const { iterator, reload } = open() + const { iterator } = open() const first = iterator.next() const request = await remote.waitForStat(0) const error = new RemoteError('workspace-file/not-found', 'missing', { path: RELATIVE }) @@ -130,66 +130,35 @@ describe('Host-resolved file paths', () => { await expect(first).resolves.toEqual({ done: false, value: { ok: false, error } }) const source = remote.opened[0]!.source const retried = iterator.next() - if (trigger === 'reload') reload() - else await source.deliver({ kind: 'change', change: { absolutePath: '/unknown-key-trigger.txt', version: 'trigger' } }) + await source.deliver({ kind: 'change', change: { absolutePath: '/unknown-key-trigger.txt', version: 'trigger' } }) const retry = await remote.waitForStat(1) expect(retry).toMatchObject({ sessionId: SESSION, path: RELATIVE }) await source.deliver({ kind: 'change', change: { absolutePath: '/other/file.txt', version: 'other' } }) await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'v3' } }) retry.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v2', bytes: 5 } }) await expect(retried).resolves.toEqual({ - done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v2', bytes: 5, changed: trigger === 'write' } }, + done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v2', bytes: 5 } }, }) - await expect(iterator.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v3', changed: true } } }) + await expect(iterator.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v3' } } }) expect(remote.stats).toHaveLength(2) }) - it('reloads session and absolute addresses bound to the same Host key without re-statting other files', async () => { + it('binds a changed canonical path after disappearance before filtering writes received during that stat', async () => { const { remote, open } = harness() - const session = open() - const absolute = open(absoluteFileAddress('/shortcut/a.txt')) - const other = open(sessionFileAddress(SESSION, 'other.txt')) - const firsts = Promise.all([session.iterator.next(), absolute.iterator.next(), other.iterator.next()]) - await remote.waitForStat(2) - for (const request of remote.stats) { - request.resolve({ ok: true, value: { - absolutePath: request.path === 'other.txt' ? '/host/other.txt' : CANONICAL, - version: 'v0', bytes: 3, - } }) - } - await firsts - const reloaded = Promise.all([session.iterator.next(), absolute.iterator.next()]) - const otherChange = other.iterator.next() - session.reload() - await remote.waitForStat(4) - expect(remote.stats.slice(3).map(request => request.path)).toEqual([RELATIVE, '/shortcut/a.txt']) - for (const request of remote.stats.slice(3)) { - request.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v1', bytes: 4 } }) - } - const value = { done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v1', bytes: 4, changed: false } } } - await expect(reloaded).resolves.toEqual([value, value]) - await remote.opened[0]!.source.deliver({ kind: 'change', change: { absolutePath: '/host/other.txt', version: 'other-v1' } }) - await expect(otherChange).resolves.toMatchObject({ value: { ok: true, value: { version: 'other-v1', changed: true } } }) - expect(remote.stats).toHaveLength(5) - expect(remote.opened).toHaveLength(1) - }) - - it('binds a changed canonical path on reload before filtering writes received during that stat', async () => { - const { remote, open } = harness() - const { iterator, reload } = open() + const { iterator } = open() const first = iterator.next() const request = await remote.waitForStat(0) request.resolve({ ok: true, value: { absolutePath: '/host/old-target.txt', version: 'v0' } }) await first - reload() const reloaded = iterator.next() + await remote.opened[0]!.source.deliver({ kind: 'change', change: { absolutePath: '/host/old-target.txt', absent: true } }) const retry = await remote.waitForStat(1) const source = remote.opened[0]!.source await source.deliver({ kind: 'change', change: { absolutePath: '/host/old-target.txt', version: 'old-target-write' } }) await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'v2' } }) retry.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v1' } }) - await expect(reloaded).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1', changed: false } } }) - await expect(iterator.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v2', changed: true } } }) + await expect(reloaded).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1' } } }) + await expect(iterator.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v2' } } }) expect(remote.stats).toHaveLength(2) }) diff --git a/packages/api/workspace-files/tests/provider.client.spec.ts b/packages/api/workspace-files/tests/provider.client.spec.ts index 539a3888af..5a7550db60 100644 --- a/packages/api/workspace-files/tests/provider.client.spec.ts +++ b/packages/api/workspace-files/tests/provider.client.spec.ts @@ -1,7 +1,7 @@ /** * The `file` provider's frame stream: how the two address scopes resolve to a - * Host call and a change-feed key, the opening stat, the write flag that - * carries no content, the reload and disappearance that stat again, failures + * Host call and a change-feed key, the opening stat, the write version that + * carries no content, the disappearance that stats again, failures * as frames, and the life bounded by the signal. */ import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' @@ -12,8 +12,7 @@ import type { WorkspaceFileStat } from '../src/types.ts' import { describe, expect, it, onTestFinished } from 'vitest' import { ChangeFeed } from '../src/client/change-feed.ts' import { createFileResourceProvider } from '../src/client/provider.ts' -import type { SessionLookup } from '../src/client/provider.ts' -import { FakeRemote, peek, settle } from './fake-remote.client.ts' +import { FakeRemote, settle } from './fake-remote.client.ts' const S1 = 's1' as SessionId const S2 = 's2' as SessionId @@ -23,23 +22,17 @@ const HOST_PATH = '/w/a b.txt' const ADDRESS = sessionFileAddress(S1, REL_PATH) /** A file outside every workspace root, addressed absolutely. */ const ABS_PATH = '/etc/hosts' -const ABS_ADDRESS = absoluteFileAddress(ABS_PATH) - -/** Only the current Session is available to the provider; there are no Client roots. */ -function sessionsWith(current: SessionId | undefined): SessionLookup { - return { current: () => current } -} +const ABS_ADDRESS = sessionFileAddress(S1, ABS_PATH) const stat = (version: string, bytes: number): WorkspaceFileStat => ({ absolutePath: HOST_PATH, version, bytes }) const notFound = (): RemoteFailure => new RemoteError('workspace-file/not-found', 'no such file', { path: REL_PATH }) -function opened(address = ADDRESS, sessions = sessionsWith(S1)) { +function opened(address = ADDRESS) { const remote = new FakeRemote() const changes = new ChangeFeed(remote) - const provider = createFileResourceProvider(remote, changes, sessions) + const provider = createFileResourceProvider(remote, changes) const controller = new AbortController() const it = provider.open(address, { signal: controller.signal })[Symbol.asyncIterator]() - const reload = (): void => { provider.reload!(address) } onTestFinished(async () => { controller.abort() for (const request of remote.stats) { @@ -48,7 +41,7 @@ function opened(address = ADDRESS, sessions = sessionsWith(S1)) { await it.return?.() await changes.settle() }) - return { remote, provider, changes, controller, it, reload } + return { remote, provider, changes, controller, it } } /** Open, answer the opening stat, and hand back the bench once the first frame is out. */ @@ -80,9 +73,9 @@ describe('file provider — the address', () => { expect(remote.opened).toEqual([]) }) - it('rejects an absolute address with no current Session without touching the Remote', async () => { - const address = ABS_ADDRESS - const { remote, it } = opened(address, sessionsWith(undefined)) + it('rejects an absolute address with no Session without touching the Remote', async () => { + const address = absoluteFileAddress(ABS_PATH) + const { remote, it } = opened(address) const first = await it.next() expect(first.done).toBe(false) expect(first.value).toMatchObject({ ok: false, error: { code: 'workspace-file/unknown-workspace', details: { address } } }) @@ -91,14 +84,6 @@ describe('file provider — the address', () => { expect(remote.opened).toEqual([]) }) - it('reloads nothing for an address it does not resolve', async () => { - for (const bench of [opened('dsh-resource://file/shared/x/w/a.txt'), opened(ABS_ADDRESS, sessionsWith(undefined))]) { - bench.reload() - await settle() - expect(bench.remote.stats).toEqual([]) - } - }) - it('hands the Host a session address\'s relative path and follows the stat absolute path', async () => { const { remote, it } = opened() const first = it.next() @@ -108,31 +93,24 @@ describe('file provider — the address', () => { await first // The Host's frame names the file absolutely; the follower keyed by the resolved path receives it. remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } }) - await expect(it.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1', changed: true } } }) + await expect(it.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1' } } }) }) - it('reads an absolute address through the Session on screen, with the absolute path as both Host path and follow key', async () => { - const { remote, it, reload } = opened(ABS_ADDRESS) + it('reads an absolute path through the Session in its address, with the absolute path as both Host path and follow key', async () => { + const { remote, it } = opened(ABS_ADDRESS) const first = it.next() await settle() expect(remote.opened.map(o => o.sessionId)).toEqual([S1]) expect(remote.stats[0]).toMatchObject({ sessionId: S1, path: ABS_PATH }) remote.stats[0]!.resolve({ ok: true, value: { absolutePath: ABS_PATH, version: 'v0', bytes: 3 } }) - await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: ABS_PATH, version: 'v0', bytes: 3, changed: false } } }) + await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: ABS_PATH, version: 'v0', bytes: 3 } } }) remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: ABS_PATH, version: 'v1' } }) - await expect(it.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1', changed: true } } }) - // A reload stats the same absolute path again. - reload() - const next = it.next() - await settle() - expect(remote.stats[1]).toMatchObject({ sessionId: S1, path: ABS_PATH }) - remote.stats[1]!.resolve({ ok: true, value: { absolutePath: ABS_PATH, version: 'v1', bytes: 4 } }) - await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: ABS_PATH, version: 'v1', bytes: 4, changed: false } } }) + await expect(it.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1' } } }) }) it('opens one change stream per session named by the addresses', async () => { const remote = new FakeRemote() - const provider = createFileResourceProvider(remote, new ChangeFeed(remote), sessionsWith(S1)) + const provider = createFileResourceProvider(remote, new ChangeFeed(remote)) const signal = new AbortController().signal void provider.open(sessionFileAddress(S1, 'a.txt'), { signal })[Symbol.asyncIterator]().next() void provider.open(sessionFileAddress(S2, 'a.txt'), { signal })[Symbol.asyncIterator]().next() @@ -143,14 +121,14 @@ describe('file provider — the address', () => { }) describe('file provider — the opening stat', () => { - it('stats the decoded relative path in the session the address names and yields its metadata unflagged', async () => { + it('stats the decoded relative path in the session the address names and yields its metadata', async () => { const { remote, it, controller } = opened() const first = it.next() await settle() expect(remote.stats).toHaveLength(1) expect(remote.stats[0]).toMatchObject({ sessionId: S1, path: REL_PATH, signal: controller.signal }) remote.stats[0]!.resolve({ ok: true, value: stat('v0', 3) }) - await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v0', bytes: 3, changed: false } } }) + await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v0', bytes: 3 } } }) }) it('omits bytes when the backend reports none', async () => { @@ -158,7 +136,7 @@ describe('file provider — the opening stat', () => { const first = it.next() await settle() remote.stats[0]!.resolve({ ok: true, value: { absolutePath: HOST_PATH, version: 'v0' } }) - await expect(first).resolves.toStrictEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v0', changed: false } } }) + await expect(first).resolves.toStrictEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v0' } } }) }) it('yields the Host failure as a frame and keeps following the address', async () => { @@ -174,24 +152,11 @@ describe('file provider — the opening stat', () => { const pending = it.next() await expect(Promise.race([pending, settle().then(() => 'silent' as const)])).resolves.toBe('silent') expect(remote.stats).toHaveLength(1) - // The agent creates the file: the write stats again and the value goes live, flagged as changed. + // The agent creates the file: the write stats again and the value goes live. source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } }) await settle() remote.stats[1]!.resolve({ ok: true, value: stat('v1', 5) }) - await expect(pending).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v1', bytes: 5, changed: true } } }) - }) - - it('lets a reload retry a failed opening stat', async () => { - const { remote, it, reload } = opened() - const first = it.next() - await settle() - remote.stats[0]!.resolve({ ok: false, error: notFound() }) - await first - reload() - const next = it.next() - await settle() - remote.stats[1]!.resolve({ ok: true, value: stat('v0', 3) }) - await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v0', bytes: 3, changed: false } } }) + await expect(pending).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v1', bytes: 5 } } }) }) it('ends without a frame when aborted during the stat', async () => { @@ -205,7 +170,7 @@ describe('file provider — the opening stat', () => { it('shares one change stream between two files of a session', async () => { const remote = new FakeRemote() - const provider = createFileResourceProvider(remote, new ChangeFeed(remote), sessionsWith(S1)) + const provider = createFileResourceProvider(remote, new ChangeFeed(remote)) const signal = new AbortController().signal void provider.open(sessionFileAddress(S1, 'a.txt'), { signal })[Symbol.asyncIterator]().next() void provider.open(sessionFileAddress(S1, 'b.txt'), { signal })[Symbol.asyncIterator]().next() @@ -216,10 +181,10 @@ describe('file provider — the opening stat', () => { }) describe('file provider — Host writes', () => { - it('flags a write with its version and keeps the byte count', async () => { + it('reports a write with its version and keeps the byte count', async () => { const { remote, it } = await live() remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } }) - await expect(it.next()).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v1', bytes: 3, changed: true } } }) + await expect(it.next()).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v1', bytes: 3 } } }) expect(remote.stats).toHaveLength(1) }) @@ -231,7 +196,7 @@ describe('file provider — Host writes', () => { const pending = it.next() await expect(Promise.race([pending, settle().then(() => 'silent' as const)])).resolves.toBe('silent') source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } }) - await expect(pending).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1', changed: true } } }) + await expect(pending).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1' } } }) }) it('does not lose a write reported during the opening stat', async () => { @@ -241,23 +206,35 @@ describe('file provider — Host writes', () => { remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } }) await settle() remote.stats[0]!.resolve({ ok: true, value: stat('v0', 3) }) - await expect(first).resolves.toMatchObject({ value: { ok: true, value: { version: 'v0', changed: false } } }) - await expect(it.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1', changed: true } } }) + await expect(first).resolves.toMatchObject({ value: { ok: true, value: { version: 'v0' } } }) + await expect(it.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1' } } }) }) }) describe('file provider — a reported disappearance', () => { - it('stats again and, when the file is still there, yields its fresh metadata flagged', async () => { + it('ends quietly when aborted during the stat', async () => { + const { remote, it, controller, changes } = await live() + const next = it.next() + await remote.opened[0]!.source.deliver({ kind: 'change', change: { absolutePath: HOST_PATH, absent: true } }) + const request = await remote.waitForStat(1) + controller.abort() + request.resolve({ ok: true, value: stat('late', 9) }) + await expect(next).resolves.toEqual({ done: true, value: undefined }) + await changes.settle() + expect(remote.disposed).toEqual(['workspace file changes of s1']) + }) + + it('stats again and, when the file is still there, yields its fresh metadata', async () => { const { remote, it } = await live('v0', 3) remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: HOST_PATH, absent: true } }) const next = it.next() await settle() expect(remote.stats).toHaveLength(2) remote.stats[1]!.resolve({ ok: true, value: stat('v2', 9) }) - await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v2', bytes: 9, changed: true } } }) + await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v2', bytes: 9 } } }) }) - it('yields the not-found frame and keeps following, so a later write stats again and brings the file back flagged', async () => { + it('yields the not-found frame and keeps following, so a later write stats again and brings the file back', async () => { const { remote, it } = await live('v0', 3) const source = remote.opened[0]!.source source.push({ kind: 'change', change: { absolutePath: HOST_PATH, absent: true } }) @@ -270,79 +247,7 @@ describe('file provider — a reported disappearance', () => { const back = it.next() await settle() remote.stats[2]!.resolve({ ok: true, value: stat('v3', 8) }) - await expect(back).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v3', bytes: 8, changed: true } } }) - }) -}) - -describe('file provider — reload', () => { - it('stats again and clears the flag', async () => { - const { remote, it, reload } = await live('v0', 3) - remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } }) - await it.next() - reload() - const next = it.next() - await settle() - expect(remote.stats).toHaveLength(2) - expect(remote.stats[1]).toMatchObject({ sessionId: S1, path: REL_PATH }) - remote.stats[1]!.resolve({ ok: true, value: stat('v1', 7) }) - await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v1', bytes: 7, changed: false } } }) - }) - - it('yields a failed stat as a frame and keeps the stream open', async () => { - const { remote, it, reload } = await live() - reload() - const next = it.next() - await settle() - const error = notFound() - remote.stats[1]!.resolve({ ok: false, error }) - await expect(next).resolves.toEqual({ done: false, value: { ok: false, error } }) - await expect(peek(it)).resolves.toBe('silent') - }) - - it('ends quietly when aborted during the stat', async () => { - const { remote, it, reload, controller } = await live() - reload() - const next = it.next() - await settle() - controller.abort() - remote.stats[1]!.resolve({ ok: false, error: new RemoteError('gateway/internal', 'aborted', {}) }) - await expect(next).resolves.toEqual({ done: true, value: undefined }) - }) - - it('re-stats every record of the path on one record\'s reload: a session record and an absolute record of one file share it', async () => { - const remote = new FakeRemote() - const provider = createFileResourceProvider(remote, new ChangeFeed(remote), sessionsWith(S1)) - const signal = new AbortController().signal - const session = provider.open(ADDRESS, { signal })[Symbol.asyncIterator]() - const absolute = provider.open(absoluteFileAddress(HOST_PATH), { signal })[Symbol.asyncIterator]() - const firsts = Promise.all([session.next(), absolute.next()]) - await settle() - expect(remote.stats.map(pending => [pending.sessionId, pending.path])).toEqual([[S1, REL_PATH], [S1, HOST_PATH]]) - remote.stats[0]!.resolve({ ok: true, value: stat('v0', 3) }) - remote.stats[1]!.resolve({ ok: true, value: stat('v0', 3) }) - await firsts - expect(remote.opened).toHaveLength(1) - // One Host write flags both records: they follow one path. - remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } }) - await expect(session.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1', changed: true } } }) - await expect(absolute.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1', changed: true } } }) - // A reload on the session record re-stats both and clears both flags: - // delivery is per path, not per record. - provider.reload!(ADDRESS) - const nexts = Promise.all([session.next(), absolute.next()]) - await settle() - expect(remote.stats.slice(2).map(pending => pending.path)).toEqual([REL_PATH, HOST_PATH]) - remote.stats[2]!.resolve({ ok: true, value: stat('v1', 3) }) - remote.stats[3]!.resolve({ ok: true, value: stat('v1', 3) }) - const cleared = { done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v1', bytes: 3, changed: false } } } - await expect(nexts).resolves.toEqual([cleared, cleared]) - }) - - it('is a no-op for a file nobody has open', async () => { - const { remote, reload } = opened() - reload() - await settle() - expect(remote.stats).toHaveLength(0) + await expect(back).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v3', bytes: 8 } } }) }) }) diff --git a/packages/api/workspace-files/tests/read-all.spec.ts b/packages/api/workspace-files/tests/read-all.spec.ts new file mode 100644 index 0000000000..b12d63d71f --- /dev/null +++ b/packages/api/workspace-files/tests/read-all.spec.ts @@ -0,0 +1,86 @@ +/** Full-file reads retain the ordinary file gates and never return a silently truncated result. */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdir, symlink, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { FsVersion } from '@deepseek-ai/dsh-fs' +import { agent, failureOf, openWorkspace, signal, type Harness } from './harness.ts' + +let harness: Harness + +beforeEach(async () => { harness = await openWorkspace('dsh-workspace-files-read-all-') }) +afterEach(async () => { await harness.dispose() }) + +describe('workspaceFiles.readAll', () => { + it('reads the complete bytes independently of the window cap', async () => { + const bytes = Buffer.from([0, 255, 1, 2]) + await writeFile(join(harness.workspace, 'file.bin'), bytes) + const result = await harness.endpoint({ maxBytes: 1, maxFileBytes: 4 }).readAll(agent, 'file.bin', signal()) + expect(Buffer.from(result.data, 'base64')).toEqual(bytes) + expect(result).toMatchObject({ offset: 0, eof: true, bytes: 4 }) + }) + + it('returns an empty complete file', async () => { + await writeFile(join(harness.workspace, 'empty'), '') + expect(await harness.endpoint().readAll(agent, 'empty', signal())).toMatchObject({ data: '', offset: 0, eof: true, bytes: 0 }) + }) + + it('rejects a known oversized file before reading bytes', async () => { + await writeFile(join(harness.workspace, 'large'), 'abcde') + const read = vi.spyOn(harness.ctx.fs, 'readByteRange') + expect(await failureOf(harness.endpoint({ maxFileBytes: 4 }).readAll(agent, 'large', signal()))) + .toEqual({ code: 'workspace-file/too-large', details: { path: 'large', limit: 4 } }) + expect(read).not.toHaveBeenCalled() + }) + + it.each([undefined, 1])('checks the actual bytes when stat reports %s', async (size) => { + await writeFile(join(harness.workspace, 'growing'), 'abcde') + vi.spyOn(harness.ctx.fs, 'stat').mockResolvedValue({ type: 'file', version: FsVersion('v'), ...size === undefined ? {} : { size } }) + expect((await failureOf(harness.endpoint({ maxFileBytes: 4 }).readAll(agent, 'growing', signal()))).code) + .toBe('workspace-file/too-large') + }) + + it('retains missing-file, kind and workspace confinement failures', async () => { + await mkdir(join(harness.workspace, 'directory')) + await writeFile(join(harness.outside, 'outside'), 'outside') + const files = harness.endpoint() + expect((await failureOf(files.readAll(agent, 'missing', signal()))).code).toBe('workspace-file/not-found') + expect((await failureOf(files.readAll(agent, 'directory', signal()))).code).toBe('workspace-file/not-regular-file') + expect((await failureOf(files.readAll(agent, join(harness.outside, 'outside'), signal()))).code).toBe('workspace-file/outside-workspace') + }) +}) + +describe('workspaceFiles.readRelated', () => { + it('resolves relative paths from the base file directory on the Host', async () => { + await mkdir(join(harness.workspace, 'nested')) + await writeFile(join(harness.workspace, 'nested/base.txt'), 'base') + await writeFile(join(harness.workspace, 'nested/near.txt'), 'near') + await writeFile(join(harness.workspace, 'root.txt'), 'root') + const files = harness.endpoint() + const near = await files.readRelated(agent, 'nested/base.txt', './near.txt', signal()) + const root = await files.readRelated(agent, 'nested/base.txt', '../root.txt', signal()) + expect(Buffer.from(near.data, 'base64').toString()).toBe('near') + expect(Buffer.from(root.data, 'base64').toString()).toBe('root') + const fromRoot = await files.readRelated(agent, 'root.txt', 'nested\\near.txt', signal()) + expect(Buffer.from(fromRoot.data, 'base64').toString()).toBe('near') + }) + + it.each(['', '/outside', 'C:\\outside', '\\\\host\\share', 'https://example.test/a.js', 'bad\0path'])('rejects non-relative path %j', async (path) => { + expect((await failureOf(harness.endpoint().readRelated(agent, 'base', path, signal()))).code).toBe('gateway/bad-request') + }) + + it('keeps base-file and target-file access checks', async () => { + await writeFile(join(harness.workspace, 'base'), 'base') + await writeFile(join(harness.outside, 'secret'), 'secret') + const files = harness.endpoint() + expect((await failureOf(files.readRelated(agent, 'missing', 'file', signal()))).code).toBe('workspace-file/not-found') + expect((await failureOf(files.readRelated(agent, join(harness.outside, 'secret'), '../file', signal()))).code).toBe('workspace-file/outside-workspace') + expect((await failureOf(files.readRelated(agent, 'base', '../outside/secret', signal()))).code).toBe('workspace-file/outside-workspace') + }) + + it('rejects a related symlink rather than following it', async () => { + await writeFile(join(harness.workspace, 'base'), 'base') + await writeFile(join(harness.workspace, 'target'), 'target') + await symlink('target', join(harness.workspace, 'link')) + expect((await failureOf(harness.endpoint().readRelated(agent, 'base', 'link', signal()))).code).toBe('workspace-file/not-regular-file') + }) +}) diff --git a/packages/api/workspace-files/tsconfig.client.json b/packages/api/workspace-files/tsconfig.client.json index 43e935dc8f..70f97e7e8e 100644 --- a/packages/api/workspace-files/tsconfig.client.json +++ b/packages/api/workspace-files/tsconfig.client.json @@ -16,7 +16,6 @@ "references": [ { "path": "../../../vendor/cordis" }, { "path": "../gateway/tsconfig.client.json" }, - { "path": "../session-controller/tsconfig.client.json" }, { "path": "../../core/session" }, { "path": "../../client/resources" }, { "path": "../../client/ui-slots" }, diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 91e57ed5e9..6c8b1a54ac 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -23,7 +23,7 @@ How live data reaches render code, and what UI domains may share: 1. **Everything a render reads that can change outside React arrives through a framework hook** (rule 4 above). Event-handler code may read live snapshots (e.g. `keyboard.snapshot`); render code subscribes. 2. **Business components contain no subscription machinery** — no `useSyncExternalStore`, no manual subscribe wiring, no mirroring an external snapshot into local state or a second store. Give each reactive fact its owning channel instead: registrant-private → the inject `hooks` compartment; cross-entry or remount-surviving → a declared store; per-session standard → `sessions.provide`. 3. **Data-access ladder** — resolve needs in this order: framework hooks (standing seats + provide/inject-bound `use`) → a declared store (`useStore`/`actions`) → inject callbacks → anything else is a new framework extension point and needs main-thread arbitration. -4. **UI domains share only JSON-compatible data and callbacks.** Owner props, injected values, store state, and provide contributions are plain serializable data or callbacks over such data. The injected `hooks` compartment is the only place for bare observables, and components never receive those sources directly. Route ReactNode content through a slot; do not add ReactNode-valued owner props or injected members (the composer's existing `accessory`/`overlay`/`leftItems`/`rightItems` fields remain until they move to slots). +4. **UI domains share JSON-compatible data and callbacks.** Owner props, injected values, store state, and provide contributions use these values. Transient file-read and document-preview content may also carry `Uint8Array`: published buffers are borrowed read-only, copied before Worker transfer, and never persisted in layout or Session JSON. The injected `hooks` compartment is the only place for bare observables, and components never receive those sources directly. Route ReactNode content through a slot; do not add ReactNode-valued owner props or injected members (the composer's existing `accessory`/`overlay`/`leftItems`/`rightItems` fields remain until they move to slots). 5. **An observable source keeps two identities stable**: the source object itself (hook binding is cached per source), and its snapshot between changes (`getSnapshot` returns the same reference until the fact moves). 6. **Whoever rebuilds a published value republishes it through the same source in the same step**, and a registration path that can run after consumers exist notifies the live consumers as part of registering. diff --git a/packages/client/resources/README.i18n.yaml b/packages/client/resources/README.i18n.yaml index 34a20c84f9..235d02c651 100644 --- a/packages/client/resources/README.i18n.yaml +++ b/packages/client/resources/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/resources/README.md -README.md: 7c32293c8d4250b11f8beaaf473a62145c915bce -README.zh.md: b53089a2449f379f43156f610b587656a998ece3 +README.md: 6b5afc82230be6930c41a01a584aeed39f596c37 +README.zh.md: 6b928883bb54cd4663efa7a20a7bc768d4f8399e diff --git a/packages/client/resources/README.md b/packages/client/resources/README.md index 7c32293c8d..6b5afc8223 100644 --- a/packages/client/resources/README.md +++ b/packages/client/resources/README.md @@ -33,12 +33,12 @@ Nothing needs configuration to mount: the plugin provides `ctx.resources` and co ### Read a resource -Every slot component receives `useResource` in its props. `useResource

(address)` names the protocol as the type argument and returns `{ status, value, failure, reload }`: `none` when no provider is registered for the address's protocol (or the address is not a `dsh-resource://` URL), `loading` while the provider has not yielded, `live` with the latest `ok` frame's value, and `failed` when the latest frame reported a failure, with that failure beside the last value. `reload()` asks the provider for a fresh value and is a no-op without one. Subscribing through the hook is what holds the resource open; a component that mounts while another holder keeps the resource alive reads the latest value at once. +Every slot component receives `useResource` in its props. `useResource

(address)` names the protocol as the type argument and returns `{ status, value, failure }`: `none` when no provider is registered for the address's protocol (or the address is not a `dsh-resource://` URL), `loading` while the provider has not yielded, `live` with the latest `ok` frame's value, and `failed` when the latest frame reported a failure, with that failure beside the last value. Subscribing through the hook is what holds the resource open; a component that mounts while another holder keeps the resource alive reads the latest value at once. ### Provide a protocol -The protocol's owning client package declares its value type in `ResourceProtocolMap` and registers one provider as an owned effect. `open` yields `RemoteResult` frames: the current content first and one frame per later change, with a failure as an `ok: false` frame rather than a throw; it must stop when `signal` aborts. `reload` is optional: +The protocol's owning client package declares its value type in `ResourceProtocolMap` and registers one provider as an owned effect. `open` yields `RemoteResult` frames: the current content first and one frame per later change, with a failure as an `ok: false` frame rather than a throw; it must stop when `signal` aborts: ```ts ignore-check declare module '@deepseek-ai/dsh-client-ui-slots' { @@ -54,7 +54,6 @@ export function apply(ctx) { yield await readNote(address, signal) for await (const change of followNote(address, signal)) yield change }, - reload(address) { requestReread(address) }, }), 'my-notes: note resource provider') } ``` @@ -72,7 +71,7 @@ A protocol has exactly one provider; a second registration throws. Registering a ### Lifecycle -One record per address holds a snapshot store, a holder count (hook subscribers plus pins), and the running stream's `AbortController`. The first holder opens the provider's stream; every later holder shares it; the last holder's release aborts the stream and resets the snapshot to idle (`loading` with a provider, `none` without). Records are kept for the page lifetime so `source()` stays reference-stable across React's render-then-subscribe window and a StrictMode remount. `reload` is one function per record and never changes. +One record per address holds a snapshot store, a holder count (hook subscribers plus pins), and the running stream's `AbortController`. The first holder opens the provider's stream; every later holder shares it; the last holder's release aborts the stream and resets the snapshot to idle (`loading` with a provider, `none` without). Records are kept for the page lifetime so `source()` stays reference-stable across React's render-then-subscribe window and a StrictMode remount. ### Failures diff --git a/packages/client/resources/README.zh.md b/packages/client/resources/README.zh.md index b53089a244..6b928883bb 100644 --- a/packages/client/resources/README.zh.md +++ b/packages/client/resources/README.zh.md @@ -33,12 +33,12 @@ kind: "package-reference" ### 读取资源 -每个 slot 组件都在 props 上收到 `useResource`。`useResource

(address)` 以类型参数命名协议,返回 `{ status, value, failure, reload }`:地址协议没有提供方(或地址不是 `dsh-resource://` URL)时为 `none`,提供方尚未产出值时为 `loading`,`live` 携带最新一个 `ok` 帧的值,`failed` 表示最新一帧报告了失败,失败放在最后一个值旁。`reload()` 请提供方给一个新值,没有提供方时是空操作。通过 hook 订阅就是钉住资源的方式;另一个持有者让资源保持存活时,新挂载的组件立刻读到最新值。 +每个 slot 组件都在 props 上收到 `useResource`。`useResource

(address)` 以类型参数命名协议,返回 `{ status, value, failure }`:地址协议没有提供方(或地址不是 `dsh-resource://` URL)时为 `none`,提供方尚未产出值时为 `loading`,`live` 携带最新一个 `ok` 帧的值,`failed` 表示最新一帧报告了失败,失败放在最后一个值旁。通过 hook 订阅就是钉住资源的方式;另一个持有者让资源保持存活时,新挂载的组件立刻读到最新值。 ### 提供协议 -协议所属的客户端包在 `ResourceProtocolMap` 声明其值类型,并以自有 effect 注册一个提供方。`open` 产出 `RemoteResult` 帧:先是当前内容,之后每次变化一帧,失败以 `ok: false` 帧而非抛错表达;必须在 `signal` 中止时停止。`reload` 可选: +协议所属的客户端包在 `ResourceProtocolMap` 声明其值类型,并以自有 effect 注册一个提供方。`open` 产出 `RemoteResult` 帧:先是当前内容,之后每次变化一帧,失败以 `ok: false` 帧而非抛错表达;必须在 `signal` 中止时停止: ```ts ignore-check declare module '@deepseek-ai/dsh-client-ui-slots' { @@ -54,7 +54,6 @@ export function apply(ctx) { yield await readNote(address, signal) for await (const change of followNote(address, signal)) yield change }, - reload(address) { requestReread(address) }, }), 'my-notes: note resource provider') } ``` @@ -72,7 +71,7 @@ export function apply(ctx) { ### 生命周期 -每个地址一条记录,持有一个快照 store、一个持有者计数(hook 订阅者加 pin)与运行中流的 `AbortController`。第一个持有者打开提供方的流;之后的持有者共享它;最后一个持有者释放时中止流并把快照重置为空闲(有提供方为 `loading`,没有为 `none`)。记录在页面存续期内保留,使 `source()` 在 React 渲染到订阅的窗口与 StrictMode 重挂载之间保持引用稳定。`reload` 每条记录一个函数,永不变化。 +每个地址一条记录,持有一个快照 store、一个持有者计数(hook 订阅者加 pin)与运行中流的 `AbortController`。第一个持有者打开提供方的流;之后的持有者共享它;最后一个持有者释放时中止流并把快照重置为空闲(有提供方为 `loading`,没有为 `none`)。记录在页面存续期内保留,使 `source()` 在 React 渲染到订阅的窗口与 StrictMode 重挂载之间保持引用稳定。 ### 失败 diff --git a/packages/client/resources/src/client/contract.ts b/packages/client/resources/src/client/contract.ts index c297c172b0..dd5b7bef6b 100644 --- a/packages/client/resources/src/client/contract.ts +++ b/packages/client/resources/src/client/contract.ts @@ -50,8 +50,6 @@ export interface ResourceSnapshot { readonly value: Value | undefined /** The latest frame's failure; present only while `status` is `failed`. */ readonly failure: RemoteFailure | undefined - /** Ask the provider for a fresh frame; a no-op when its protocol has no provider or no `reload`. */ - readonly reload: () => void } /** @@ -84,11 +82,6 @@ export interface ResourceProvider

{ * @returns the frame stream; it must stop once `ctx.signal` aborts. */ open(address: string, ctx: ResourceOpenContext): AsyncIterable> - /** - * Produce a fresh frame on the open stream. Absent when the protocol has no refresh. - * @param address - the full address, a `dsh-resource:///…` URL. - */ - reload?(address: string): void } /** diff --git a/packages/client/resources/src/client/resources.ts b/packages/client/resources/src/client/resources.ts index 5b9a75cabe..614387cfd7 100644 --- a/packages/client/resources/src/client/resources.ts +++ b/packages/client/resources/src/client/resources.ts @@ -23,7 +23,6 @@ import type { interface RuntimeProvider { readonly protocol: string open(address: string, ctx: ResourceOpenContext): AsyncIterable> - reload?(address: string): void } /** One address: its state, its holders, and the running stream. */ @@ -33,7 +32,6 @@ interface ResourceRecord { readonly protocol: string | undefined readonly store: SnapshotStore> readonly source: ObservableSnapshot> - readonly reload: () => void /** Subscribers plus pins; the stream runs while this is positive. */ holders: number /** Present while the provider's stream runs; aborting it ends the stream. */ @@ -69,8 +67,8 @@ export function protocolOf(address: string): string | undefined { return parsed.hostname === '' ? undefined : parsed.hostname.toLowerCase() } -function idle(status: 'none' | 'loading', reload: () => void): ResourceSnapshot { - return { status, value: undefined, failure: undefined, reload } +function idle(status: 'none' | 'loading'): ResourceSnapshot { + return { status, value: undefined, failure: undefined } } /** The `ctx.resources` implementation. */ @@ -120,17 +118,13 @@ export class ResourceRegistry implements Resources { private create(address: string): ResourceRecord { const protocol = protocolOf(address) - const reload = (): void => { - this.providerOf(protocol)?.reload?.(address) - } const store = createSnapshotStore>( - idle(this.providerOf(protocol) === undefined ? 'none' : 'loading', reload), + idle(this.providerOf(protocol) === undefined ? 'none' : 'loading'), ) const record: ResourceRecord = { address, protocol, store, - reload, holders: 0, controller: undefined, source: { @@ -170,7 +164,7 @@ export class ResourceRegistry implements Resources { record.holders -= 1 if (record.holders > 0) return this.stop(record) - record.store.set(idle(this.providerOf(record.protocol) === undefined ? 'none' : 'loading', record.reload)) + record.store.set(idle(this.providerOf(record.protocol) === undefined ? 'none' : 'loading')) } /** The provider arrived: a held record opens its stream, an idle one turns `loading`. */ @@ -179,13 +173,13 @@ export class ResourceRegistry implements Resources { this.start(record) return } - record.store.set(idle('loading', record.reload)) + record.store.set(idle('loading')) } /** The provider left: the stream ends and the record reports `none`. */ private detach(record: ResourceRecord): void { this.stop(record) - record.store.set(idle('none', record.reload)) + record.store.set(idle('none')) } private start(record: ResourceRecord): void { @@ -193,7 +187,7 @@ export class ResourceRegistry implements Resources { if (provider === undefined) return const controller = new AbortController() record.controller = controller - if (record.store.getSnapshot().status !== 'loading') record.store.set(idle('loading', record.reload)) + if (record.store.getSnapshot().status !== 'loading') record.store.set(idle('loading')) void this.consume(record, provider, controller.signal) } @@ -210,8 +204,8 @@ export class ResourceRegistry implements Resources { // to nobody; ending the loop also returns the iterator. if (signal.aborted) break record.store.set(frame.ok - ? { status: 'live', value: frame.value, failure: undefined, reload: record.reload } - : { status: 'failed', value: record.store.getSnapshot().value, failure: frame.error, reload: record.reload }) + ? { status: 'live', value: frame.value, failure: undefined } + : { status: 'failed', value: record.store.getSnapshot().value, failure: frame.error }) } } } diff --git a/packages/client/resources/tests/apply.client.spec.ts b/packages/client/resources/tests/apply.client.spec.ts index 810ba7dbc1..6431d0485f 100644 --- a/packages/client/resources/tests/apply.client.spec.ts +++ b/packages/client/resources/tests/apply.client.spec.ts @@ -4,17 +4,21 @@ * hook reaches every slot component as `useResource`, and both leave with the * fiber so a reload of the plugin registers cleanly again. */ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { act } from '@testing-library/react' import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import { apply, inject, type ResourceSnapshot, type UseResource } from '../src/client/index.ts' import { apply as hostApply } from '../src/index.ts' import { ResourceRegistry } from '../src/client/resources.ts' +import type { ResourceProvider } from '../src/client/contract.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { 'resources.probe': { kind: 'single'; scope: 'root' } + 'resources.sessionProbe': { kind: 'single'; scope: 'session' } + 'resources.sessionPeer': { kind: 'single'; scope: 'session' } } interface ResourceProtocolMap { feed: string @@ -33,7 +37,11 @@ const settle = (): Promise => new Promise((resolve) => { setTimeout(resolv async function boot(): Promise { const rt = await SlotTestRuntime.create() - await rt.declare({ 'resources.probe': { kind: 'single', scope: 'root' } }) + await rt.declare({ + 'resources.probe': { kind: 'single', scope: 'root' }, + 'resources.sessionProbe': { kind: 'single', scope: 'session' }, + 'resources.sessionPeer': { kind: 'single', scope: 'session' }, + }) return rt } @@ -95,4 +103,63 @@ describe('client-resources apply', () => { await runtime.mount({ inject: [...inject], apply }) expect(hook).toBeTypeOf('function') }) + + it('shares one address across Root and Session components without reopening on selection', async () => { + runtime = await boot() + const rt = runtime + const firstId = await rt.sessions.add({ id: 'first-session' }) + const secondId = await rt.sessions.add({ id: 'second-session' }, { current: false }) + await rt.mount({ inject: [...inject], apply }) + const opened = Promise.withResolvers() + const open = vi.fn['open']>(async function* () { + try { yield { ok: true as const, value: 'shared data' } } finally { opened.resolve(undefined) } + }) + await act(async () => { + rt.ctx.effect(() => rt.ctx.resources.register({ protocol: 'feed', open }), 'spec: shared address') + }) + const source = rt.ctx.resources.source(A) + const seen: { + root?: ResourceSnapshot + first?: ResourceSnapshot + second?: ResourceSnapshot + firstSession?: string + secondSession?: string + } = {} + rt.slots.register({ name: 'resources.probe' }, ({ useResource }: PropsRuntime<'resources.probe'>) => { + seen.root = useResource<'feed'>(A) + return null + }) + rt.slots.register({ name: 'resources.sessionProbe' }, ({ sessionId, useResource }: PropsRuntime<'resources.sessionProbe'>) => { + seen.firstSession = sessionId + seen.first = useResource<'feed'>(A) + return null + }) + rt.slots.register({ name: 'resources.sessionPeer' }, ({ sessionId, useResource }: PropsRuntime<'resources.sessionPeer'>) => { + seen.secondSession = sessionId + seen.second = useResource<'feed'>(A) + return null + }) + rt.renderSlot('resources.probe', {}) + rt.renderSlot('resources.sessionProbe', {}) + rt.renderSlot('resources.sessionPeer', {}) + await act(async () => { await opened.promise }) + const snapshot = source.getSnapshot() + expect(snapshot).toEqual({ status: 'live', value: 'shared data', failure: undefined }) + expect(seen.root).toBe(snapshot) + expect(seen.first).toBe(snapshot) + expect(seen.second).toBe(snapshot) + expect([seen.firstSession, seen.secondSession]).toEqual([firstId, firstId]) + expect(open).toHaveBeenCalledTimes(1) + expect(open.mock.calls[0]![0]).toBe(A) + expect(open.mock.calls[0]![1]).toStrictEqual({ signal: expect.any(AbortSignal) as AbortSignal }) + + await rt.sessions.setCurrent(secondId) + expect([seen.firstSession, seen.secondSession]).toEqual([secondId, secondId]) + expect(rt.ctx.resources.source(A)).toBe(source) + expect(seen.root).toBe(snapshot) + expect(seen.first).toBe(snapshot) + expect(seen.second).toBe(snapshot) + expect(open).toHaveBeenCalledTimes(1) + expect(open.mock.calls[0]![1].signal.aborted).toBe(false) + }) }) diff --git a/packages/client/resources/tests/resources.client.spec.ts b/packages/client/resources/tests/resources.client.spec.ts index bc04ca2d7a..1fee63fe9d 100644 --- a/packages/client/resources/tests/resources.client.spec.ts +++ b/packages/client/resources/tests/resources.client.spec.ts @@ -4,7 +4,7 @@ * Providers are scripted feeds so every transition is driven by the spec, * never by timing. */ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' @@ -14,6 +14,7 @@ import type { ResourceOpenContext, ResourceProvider } from '../src/client/contra declare module '@deepseek-ai/dsh-client-ui-slots' { interface ResourceProtocolMap { feed: string + counter: number } } @@ -27,6 +28,7 @@ interface Feed { end(): void /** Whether the consumer returned the iterator (its `finally` ran). */ readonly returned: boolean + readonly closed: Promise } type Step = { readonly kind: 'frame'; readonly frame: RemoteResult } | { readonly kind: 'end' } @@ -35,6 +37,7 @@ function createFeed(ctx: ResourceOpenContext): { feed: Feed; stream: AsyncIterab const steps: Step[] = [] let wake: (() => void) | undefined let returned = false + const closed = Promise.withResolvers() const notify = (): void => { wake?.(); wake = undefined } async function* stream(): AsyncGenerator> { try { @@ -47,10 +50,12 @@ function createFeed(ctx: ResourceOpenContext): { feed: Feed; stream: AsyncIterab } } finally { returned = true + closed.resolve(undefined) } } const feed: Feed = { ctx, + closed: closed.promise, push: (value) => { steps.push({ kind: 'frame', frame: { ok: true, value } }); notify() }, fail: (error) => { steps.push({ kind: 'frame', frame: { ok: false, error } }); notify() }, end: () => { steps.push({ kind: 'end' }); notify() }, @@ -62,6 +67,10 @@ function createFeed(ctx: ResourceOpenContext): { feed: Feed; stream: AsyncIterab /** A `feed` provider whose every `open` is recorded and spec-driven. */ function scriptedProvider() { const opens: Feed[] = [] + onTestFinished(async () => { + for (const feed of opens) feed.end() + await Promise.all(opens.map(feed => feed.closed)) + }) const provider = { protocol: 'feed' as const, open: vi.fn((_address: string, ctx: ResourceOpenContext) => { @@ -69,7 +78,6 @@ function scriptedProvider() { opens.push(feed) return stream }), - reload: vi.fn(), } satisfies ResourceProvider<'feed'> return { provider, opens, last: () => opens[opens.length - 1]! } } @@ -333,27 +341,97 @@ describe('ResourceRegistry streams', () => { }) }) -describe('ResourceRegistry reload', () => { - it('forwards reload to the protocol\'s provider, and stays a no-op without one', () => { +describe('ResourceRegistry addresses', () => { + it('opens different complete addresses independently and supplies only the lifetime signal', async () => { const b = bench() - b.snapshot().reload() b.registry.register(b.provider) - b.snapshot().reload() - expect(b.provider.reload).toHaveBeenCalledWith(A) - - const bare = bench() - bare.registry.register({ protocol: 'feed', open: bare.provider.open }) - expect(() => { bare.snapshot().reload() }).not.toThrow() + const other = A + '?variant=second' + const first = b.registry.source(A) + const second = b.registry.source(other) + const releaseFirst = first.subscribe(() => {}) + const firstFeed = b.last() + const releaseSecond = second.subscribe(() => {}) + const secondFeed = b.last() + expect(first).not.toBe(second) + expect(b.provider.open.mock.calls).toEqual([[A, firstFeed.ctx], [other, secondFeed.ctx]]) + expect(firstFeed.ctx).toStrictEqual({ signal: expect.any(AbortSignal) as AbortSignal }) + expect(secondFeed.ctx).toStrictEqual({ signal: expect.any(AbortSignal) as AbortSignal }) + firstFeed.push('first data') + secondFeed.push('second data') + await vi.waitFor(() => { expect(first.getSnapshot().value).toBe('first data') }) + await vi.waitFor(() => { expect(second.getSnapshot().value).toBe('second data') }) + releaseFirst() + expect(firstFeed.ctx.signal.aborted).toBe(true) + expect(secondFeed.ctx.signal.aborted).toBe(false) + expect(second.getSnapshot().value).toBe('second data') + releaseSecond() + expect(secondFeed.ctx.signal.aborted).toBe(true) }) +}) - it('keeps one reload function per address across state changes', async () => { +describe('ResourceRegistry stream generations', () => { + it.each(['value', 'failure'] as const)('drops late %s frames after a released resource reopens', async (kind) => { const b = bench() b.registry.register(b.provider) const source = b.registry.source(A) - const { reload } = source.getSnapshot() - source.subscribe(() => {}) - b.last().push('v1') - await vi.waitFor(() => { expect(source.getSnapshot().value).toBe('v1') }) - expect(source.getSnapshot().reload).toBe(reload) + const releaseFirst = source.subscribe(() => {}) + const oldFeed = b.last() + oldFeed.push('old data') + await vi.waitFor(() => { expect(source.getSnapshot().value).toBe('old data') }) + releaseFirst() + const releaseCurrent = source.subscribe(() => {}) + const currentFeed = b.last() + expect(currentFeed).not.toBe(oldFeed) + currentFeed.push('current data') + await vi.waitFor(() => { expect(source.getSnapshot().value).toBe('current data') }) + const current = source.getSnapshot() + if (kind === 'value') oldFeed.push('late old data') + else oldFeed.fail(new RemoteError('gateway/internal', 'late old failure', {})) + await oldFeed.closed + expect(oldFeed.returned).toBe(true) + expect(source.getSnapshot()).toBe(current) + releaseCurrent() + }) + + it.each(['value', 'failure'] as const)('drops old-provider %s frames after replacement', async (kind) => { + const b = bench() + const releaseProvider = b.registry.register(b.provider) + const source = b.registry.source(A) + const unsubscribe = source.subscribe(() => {}) + const oldFeed = b.last() + oldFeed.push('old data') + await vi.waitFor(() => { expect(source.getSnapshot().value).toBe('old data') }) + releaseProvider() + expect(oldFeed.ctx.signal.aborted).toBe(true) + expect(source.getSnapshot()).toEqual({ status: 'none', value: undefined, failure: undefined }) + const replacement = scriptedProvider() + b.registry.register(replacement.provider) + expect(source.getSnapshot()).toEqual({ status: 'loading', value: undefined, failure: undefined }) + replacement.last().push('replacement data') + await vi.waitFor(() => { expect(source.getSnapshot().value).toBe('replacement data') }) + const current = source.getSnapshot() + if (kind === 'value') oldFeed.push('late old data') + else oldFeed.fail(new RemoteError('gateway/internal', 'late old failure', {})) + await oldFeed.closed + expect(oldFeed.returned).toBe(true) + expect(source.getSnapshot()).toBe(current) + unsubscribe() + }) + + it('streams another protocol as plain numbers', async () => { + const b = bench() + const closed = Promise.withResolvers() + const dispose = b.registry.register({ + protocol: 'counter', + async *open() { + try { yield { ok: true as const, value: 1 } } finally { closed.resolve(undefined) } + }, + }) + onTestFinished(dispose) + const source = b.registry.source('dsh-resource://counter/one') + const unsubscribe = source.subscribe(() => {}) + await closed.promise + expect(source.getSnapshot()).toEqual({ status: 'live', value: 1, failure: undefined }) + unsubscribe() }) }) diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index 0fc9e82a22..360c5a8fe3 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -121,9 +121,9 @@ export function apply(ctx: Context): void { // content stays in the product, beside the conversation that produced // it. A relative path, or an absolute one inside the session's // workspace, is addressed under this session's scope, - // `dsh-resource://file/session//`; an absolute path - // elsewhere is addressed as `dsh-resource://file/absolute/` and - // read through the session on screen. Which tab type claims the + // `dsh-resource://file/session//`; an absolute path + // elsewhere keeps its absolute spelling in the same Session's address. + // Which tab type claims the // address is the Sidebar's decision, not this call site's. // A line travels as a navigation parameter, not as part of the // address: the file is one piece of content whether it is opened at diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx index 71a7b5afe9..1e8f4e12f7 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -154,9 +154,9 @@ describe('Chat inject API', () => { // Session; the Client need not know it. await injected.openFile('src/a.ts') expect(b.sidebarRight.openResource).toHaveBeenCalledWith('dsh-resource://file/session/root-2/src/a.ts') - // An absolute path outside every known root carries no Session in its address. + // An absolute path outside every known root still names its Session. await injected.openFile('/abs/a.ts') - expect(b.sidebarRight.openResource).toHaveBeenLastCalledWith('dsh-resource://file/absolute/abs/a.ts') + expect(b.sidebarRight.openResource).toHaveBeenLastCalledWith('dsh-resource://file/session/root-2//abs/a.ts') await b.runtime.dispose() }) diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index fc8fd9c15e..5e9033bdd0 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -45,7 +45,7 @@ import type { TurnProcessSpec } from '../src/client/contract/turn-process.ts' import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts' // Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps. -const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource'] afterEach(() => { cleanup() diff --git a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx index 7004e75a37..b75f62a33b 100644 --- a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx @@ -29,7 +29,7 @@ function noPendingInteraction() { } // The resource hook the resources plugin merges into GlobalStandardProps; this row reads no address. -const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource'] function mount(mode: 'normal' | 'compact' = 'compact') { const source = createSnapshotStore(mode) diff --git a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx index 4af4d1dbd6..66011fc2b0 100644 --- a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx +++ b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx @@ -13,7 +13,7 @@ import { ComposerSubmissionPolicy } from '../src/client/input/submission-policy. import { en } from '../src/client/locales.ts' // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. -const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource'] afterEach(() => { cleanup() diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index 75c30b117d..81d25084a9 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -33,7 +33,7 @@ import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx' import { zh } from '../src/client/locales.ts' // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. -const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource'] afterEach(cleanup) diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index 57319d7087..de0e3aa196 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -26,7 +26,7 @@ import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx' import { zh } from '../src/client/locales.ts' // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. -const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource'] // jsdom implements no Range geometry (Lexical's scroll-into-view measures the // caret with one once the surface is genuinely contenteditable). diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index 88ab3a809f..4571ebce58 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -33,7 +33,7 @@ import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx' import { zh } from '../src/client/locales.ts' // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps. -const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource'] // jsdom implements no Range geometry (Lexical's scroll-into-view measures the // caret with one once the surface is genuinely contenteditable). diff --git a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx index 08db337005..0f5942a6bc 100644 --- a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx @@ -24,7 +24,7 @@ import { zh } from '../src/client/locales.ts' import { QueueDock, queueDockEntry, type QueueDockInjected, type QueueDockProps } from '../src/client/queue/QueueDock.tsx' // Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps. -const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource'] afterEach(cleanup) diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index 9ffecfdfaa..f515f20ce1 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -34,7 +34,7 @@ import type { import type { ViewTab } from '../src/client/contract/views.ts' // Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps. -const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource'] // jsdom implements no Range geometry (Lexical's scroll-into-view measures the // caret with one once the surface is genuinely contenteditable). diff --git a/packages/client/ui-dockkit/README.i18n.yaml b/packages/client/ui-dockkit/README.i18n.yaml index 19c343ed54..f28d39fc71 100644 --- a/packages/client/ui-dockkit/README.i18n.yaml +++ b/packages/client/ui-dockkit/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-dockkit/README.md -README.md: 330bff66577464a26eac50d92ee54cc0cdefc8df -README.zh.md: f5259400055a28ea99f2205f9ba0a28d2cc13bd2 +README.md: f4f76072bd16cd75bff74715f8f9437348ee036a +README.zh.md: 39e20b42bfabb49630b1c9e108b43010d6e7b199 diff --git a/packages/client/ui-dockkit/README.md b/packages/client/ui-dockkit/README.md index 330bff6657..f4f76072bd 100644 --- a/packages/client/ui-dockkit/README.md +++ b/packages/client/ui-dockkit/README.md @@ -37,7 +37,7 @@ A docking layout kit: a split tree of tabbed panes with invertible operations, a - `planSettle` is the opt-in rule that keeps every docked pane populated after an intent: panes an intent emptied are merged away, and an emptied root pane is reseeded through the embedder's factory. An embedder that wants empty panes simply does not call it. - `DockController` is the intent layer and an observable source (`subscribe` + `getSnapshot`, whose reference only changes when the layout does). -**The components** render a layout snapshot and report settled intents — one per gesture, never a drag frame. A drag previews in local state while the gesture's own facts stay in its closure; on release the net result leaves through one `DockIntents` call — a strip release reports the caret slot as drawn, the dragged chip counted, and `planPlaceTab` turns that into the reorder or the move. That is what lets an embedder record exactly one history entry per gesture. The strip follows the WAI-ARIA tabs pattern with manual activation: the selected chip is in the tab order; Left and Right (wrapping), Home, and End move focus between chips without selecting; Enter or Space selects the focused chip through the same intent as a click. A chip is a capsule carrying one control, its close; the context menu (a secondary press on the chip) carries the same close plus the embedder's items, and renders in a portal positioned against the chip because the chip box clips its overflow on purpose (see below). After the chips sits the add control, which asks the embedder (`DockIntents.addTab`) to seat its seeded tab; the embedder's `canAddTab(paneId)` decides per pane whether the control is drawn at all. Copying a tab has no kit control — it is the embedder's API — and floating is the drag released clear of the surface. +**The components** render a layout snapshot and report settled intents — one per gesture, never a drag frame. A drag previews in local state while the gesture's own facts stay in its closure; on release the net result leaves through one `DockIntents` call — a strip release reports the caret slot as drawn, the dragged chip counted, and `planPlaceTab` turns that into the reorder or the move. That is what lets an embedder record exactly one history entry per gesture. The strip follows the WAI-ARIA tabs pattern with manual activation: the selected chip is in the tab order; Left and Right (wrapping), Home, and End move focus between chips without selecting; Enter or Space selects the focused chip through the same intent as a click. The optional `canCloseTab(tabId)` hides the chip, menu, and floating close controls; the embedder enforces closing in its intent handler. A chip is a capsule carrying its close control when allowed; the context menu (a secondary press on the chip) carries the same close plus the embedder's items, and renders in a portal positioned against the chip because the chip box clips its overflow on purpose (see below). After the chips sits the add control, which asks the embedder (`DockIntents.addTab`) to seat its seeded tab; the embedder's `canAddTab(paneId)` decides per pane whether the control is drawn at all. Copying a tab has no kit control — it is the embedder's API — and floating is the drag released clear of the surface. ## Embedding it diff --git a/packages/client/ui-dockkit/README.zh.md b/packages/client/ui-dockkit/README.zh.md index f525940005..39e20b42bf 100644 --- a/packages/client/ui-dockkit/README.zh.md +++ b/packages/client/ui-dockkit/README.zh.md @@ -37,7 +37,7 @@ kind: "package-reference" - `planSettle` 是可选加入的规则,保证意图之后每个停靠格都有内容:被意图清空的格会被并掉,被清空的根格通过嵌入方的工厂重新播种。想要空格的嵌入方只需不调用它。 - `DockController` 是意图层,也是一个可观察源(`subscribe` + `getSnapshot`,其引用只在布局变化时才变)。 -**组件**渲染布局快照并上报已落定的意图——每次手势一条,绝不上报拖动帧。拖动过程中在本地状态里预览,手势自身的事实留在它的闭包里;松手时净结果通过一次 `DockIntents` 调用离开——在标签条上松手上报的是按绘制顺序数出的插入槽位(被拖的 chip 也计入),由 `planPlaceTab` 换算成重排或移动。正是这一点让嵌入方能为每次手势记录恰好一条历史。标签条遵循 WAI-ARIA tabs 模式的手动激活:选中的 chip 在 Tab 键序里;左右方向键(循环)、Home、End 只在 chip 之间移动焦点而不选中;Enter 或空格选中当前聚焦的 chip,走与点击相同的意图。chip 是一个胶囊,携带唯一的控件——它的关闭按钮;上下文菜单(在 chip 上的次键按下)携带同样的关闭项加上嵌入方的条目,并渲染在按 chip 定位的 portal 里,因为 chip 盒会故意裁掉溢出(见下文)。chip 之后是添加控件,它请嵌入方(`DockIntents.addTab`)安放其种子 tab;嵌入方的 `canAddTab(paneId)` 按格决定是否绘制该控件。复制 tab 没有套件控件——那是嵌入方的 API——而浮出就是把拖动松手在停靠区之外。 +**组件**渲染布局快照并上报已落定的意图——每次手势一条,绝不上报拖动帧。拖动过程中在本地状态里预览,手势自身的事实留在它的闭包里;松手时净结果通过一次 `DockIntents` 调用离开——在标签条上松手上报的是按绘制顺序数出的插入槽位(被拖的 chip 也计入),由 `planPlaceTab` 换算成重排或移动。正是这一点让嵌入方能为每次手势记录恰好一条历史。标签条遵循 WAI-ARIA tabs 模式的手动激活:选中的 chip 在 Tab 键序里;左右方向键(循环)、Home、End 只在 chip 之间移动焦点而不选中;Enter 或空格选中当前聚焦的 chip,走与点击相同的意图。可选的 `canCloseTab(tabId)` 隐藏 chip、菜单和浮窗的关闭控件;嵌入方在意图处理器中执行关闭限制。chip 是一个胶囊,在允许关闭时携带关闭按钮;上下文菜单(在 chip 上的次键按下)携带同样的关闭项加上嵌入方的条目,并渲染在按 chip 定位的 portal 里,因为 chip 盒会故意裁掉溢出(见下文)。chip 之后是添加控件,它请嵌入方(`DockIntents.addTab`)安放其种子 tab;嵌入方的 `canAddTab(paneId)` 按格决定是否绘制该控件。复制 tab 没有套件控件——那是嵌入方的 API——而浮出就是把拖动松手在停靠区之外。 ## 如何嵌入 diff --git a/packages/client/ui-dockkit/src/components/DockSurface.tsx b/packages/client/ui-dockkit/src/components/DockSurface.tsx index 4ff0d3789d..6c074c86ca 100644 --- a/packages/client/ui-dockkit/src/components/DockSurface.tsx +++ b/packages/client/ui-dockkit/src/components/DockSurface.tsx @@ -45,6 +45,8 @@ export interface DockSurfaceProps { * end controls where they are and the chips as the only shrinking part. */ readonly canAddTab?: (paneId: PaneId) => boolean + /** Whether a tab offers close controls; defaults to true. Called per tab on every render. */ + readonly canCloseTab?: (tabId: TabId) => boolean readonly intents: DockIntents readonly labels: DockLabels readonly renderTab: TabRenderer @@ -154,7 +156,7 @@ function sameSizes(a: readonly number[], b: readonly number[]): boolean { /** The split tree and the gestures over it. */ export function DockSurface({ - state, canSplit, canAddTab, intents, labels, renderTab, renderTabTitle, renderTabMenuItems, chrome, onRoom, + state, canSplit, canAddTab, canCloseTab, intents, labels, renderTab, renderTabTitle, renderTabMenuItems, chrome, onRoom, dropZones = 'edges', minPaneFraction = MIN_PANE_FRACTION, hideSplitAtCapacity = false, }: DockSurfaceProps): ReactNode { const surface = useRef(null) @@ -267,6 +269,7 @@ export function DockSurface({ splitBlock, hideSplitAtCapacity, canAddTab: canAddTab ?? ALWAYS, + canCloseTab: canCloseTab ?? ALWAYS, dropTarget: preview.dropTarget, horizontalDrops: dropZones === 'horizontal', draggingTabId: preview.draggingTabId, diff --git a/packages/client/ui-dockkit/src/components/FloatLayer.tsx b/packages/client/ui-dockkit/src/components/FloatLayer.tsx index 3c7fa9c1d9..4247928b21 100644 --- a/packages/client/ui-dockkit/src/components/FloatLayer.tsx +++ b/packages/client/ui-dockkit/src/components/FloatLayer.tsx @@ -15,7 +15,7 @@ import { useState } from 'react' import type { PointerEvent as ReactPointerEvent, ReactNode } from 'react' import type { DockIntents, DockLabels, TabRenderer } from '../contract/adapter.ts' -import type { FloatRect, LayoutState, PaneId } from '../contract/types.ts' +import type { FloatRect, LayoutState, PaneId, TabId } from '../contract/types.ts' import { FLOAT_MIN_SIZE } from '../engine/constraints.ts' import { movedRect, resizedRect } from '../engine/geometry.ts' import { floatRect, getPane, getTab, onlyTabId } from '../engine/tree.ts' @@ -28,6 +28,8 @@ export interface FloatLayerProps { readonly intents: DockIntents readonly labels: DockLabels readonly renderTab: TabRenderer + /** Whether a floating tab offers its close control; defaults to true. Called per tab on every render. */ + readonly canCloseTab?: (tabId: TabId) => boolean /** The panel header's title content; omit to show the record's `title` text (see `DockSurfaceProps`). */ readonly renderTabTitle?: TabRenderer } @@ -60,7 +62,7 @@ function raised(state: LayoutState, paneId: PaneId): boolean { } /** Every floating panel, in z order. */ -export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle }: FloatLayerProps): ReactNode { +export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle, canCloseTab }: FloatLayerProps): ReactNode { const [preview, setPreview] = useState<{ paneId: PaneId; rect: FloatRect } | undefined>(undefined) const begin = useGesture(() => { setPreview(undefined) }) @@ -127,16 +129,18 @@ export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle } > ⇤ - + {(canCloseTab?.(tab.id) ?? true) && ( + + )}

{renderTab(tab)}
void /** Dismiss without acting. */ readonly onDismiss: () => void - /** Embedder items, rendered after the kit's own; absent means none. */ + /** Embedder ARIA menu items, rendered after the kit's own; absent means none. */ readonly extras: ReactNode } @@ -47,19 +49,18 @@ function placeMenu(anchor: HTMLElement, menu: HTMLElement): CSSProperties { } /** The actions menu body, anchored to the control that opened it. */ -export function TabMenu({ labels, anchor, onClose, onDismiss, extras }: TabMenuProps): ReactNode { +export function TabMenu({ labels, anchor, canCloseTab, onClose, onDismiss, extras }: TabMenuProps): ReactNode { const self = useRef(null) const [position, setPosition] = useState(undefined) + const hasItems = canCloseTab || Children.toArray(extras).some(item => item !== '') useLayoutEffect(() => { - /* v8 ignore next -- the ref is attached by effect time: the menu renders unconditionally. */ if (self.current === null) return setPosition(placeMenu(anchor, self.current)) - }, [anchor]) + }, [anchor, canCloseTab, hasItems]) useEffect(() => { const menu = self.current - /* v8 ignore next -- the ref is attached by effect time: the menu renders unconditionally. */ if (menu === null) return undefined // A press anywhere but inside the menu dismisses it; one with no element // target (dispatched to the window itself) counts as outside. @@ -71,8 +72,9 @@ export function TabMenu({ labels, anchor, onClose, onDismiss, extras }: TabMenuP // so the menu must be gone before that handler runs. window.addEventListener('pointerdown', onPointerDown, true) return () => { window.removeEventListener('pointerdown', onPointerDown, true) } - }, [onDismiss]) + }, [onDismiss, hasItems]) + if (!hasItems) return null return createPortal(
{ event.stopPropagation() }} onClick={(event) => { event.stopPropagation() }} > - + {canCloseTab && ( + + )} {/* Embedder items last: the kit's own item is the same in every menu, so a reader looks for it in the same place every time. */} {extras} diff --git a/packages/client/ui-dockkit/src/components/TabPanel.tsx b/packages/client/ui-dockkit/src/components/TabPanel.tsx index a4a9cdc6f1..1258002996 100644 --- a/packages/client/ui-dockkit/src/components/TabPanel.tsx +++ b/packages/client/ui-dockkit/src/components/TabPanel.tsx @@ -3,7 +3,7 @@ * active tab's body with the dock preview overlay. Presentational; every gesture * leaves through `PaneCallbacks`, and the body itself comes from `renderTab`. * - * A chip is a capsule carrying one control, its close, at its right end; the + * A chip is a capsule carrying an optional close control at its right end; the * context menu (secondary press) carries the same close plus whatever the * embedder appends. The chips sit in their own box, the strip's one shrinking * part: in a narrow pane they ellipsize and then clip there, so the add @@ -136,6 +136,7 @@ export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode { {pane.tabs.map((tabId, index) => { const tab = getTab(state, tabId) const selected = tabId === pane.activeTabId + const canClose = callbacks.canCloseTab(tabId) return ( {stripIndex === index &&
} @@ -188,25 +189,28 @@ export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode { }} > {callbacks.renderTabTitle?.(tab) ?? tab.title} - + {canClose && ( + + )} {menu?.tabId === tabId && ( { setMenu(undefined); callbacks.onCloseTab(tabId) }} onDismiss={() => { setMenu(undefined) }} extras={callbacks.renderTabMenuItems?.(tab, () => { setMenu(undefined) })} diff --git a/packages/client/ui-dockkit/src/components/dockkit.module.css b/packages/client/ui-dockkit/src/components/dockkit.module.css index 2e995a2700..58907277e8 100644 --- a/packages/client/ui-dockkit/src/components/dockkit.module.css +++ b/packages/client/ui-dockkit/src/components/dockkit.module.css @@ -269,6 +269,10 @@ border-radius: 6px; } +.menu:not(:has([role='menuitem'], [role='menuitemcheckbox'], [role='menuitemradio'])) { + display: none; +} + .menuItem { padding: 5px 8px; color: var(--dsw-alias-label-primary); diff --git a/packages/client/ui-dockkit/src/components/render.ts b/packages/client/ui-dockkit/src/components/render.ts index 1bc3c703f6..71d0b1ec9e 100644 --- a/packages/client/ui-dockkit/src/components/render.ts +++ b/packages/client/ui-dockkit/src/components/render.ts @@ -27,6 +27,8 @@ export interface PaneCallbacks { readonly hideSplitAtCapacity?: boolean /** Whether a pane's strip draws the add control. */ readonly canAddTab: (paneId: PaneId) => boolean + /** Whether a tab's chip and menu offer close. */ + readonly canCloseTab: (tabId: TabId) => boolean /** Live drop preview, or `undefined` while nothing is being dragged. */ readonly dropTarget: DropTarget | undefined /** Show both horizontal landing regions while a body split is being targeted. */ diff --git a/packages/client/ui-dockkit/src/contract/adapter.ts b/packages/client/ui-dockkit/src/contract/adapter.ts index eb1abf8d5f..3ca282dd3e 100644 --- a/packages/client/ui-dockkit/src/contract/adapter.ts +++ b/packages/client/ui-dockkit/src/contract/adapter.ts @@ -48,7 +48,7 @@ export type TabRenderer = (tab: TabRecord) => ReactNode * because the menu closes on its own items only. * @param tab - the tab whose menu is open. * @param dismiss - close the menu without acting. - * @returns the extra items, or nothing. + * @returns extra actions with ARIA menuitem, menuitemcheckbox, or menuitemradio roles, or nothing. */ export type TabMenuExtras = (tab: TabRecord, dismiss: () => void) => ReactNode diff --git a/packages/client/ui-dockkit/tests/components.client.spec.tsx b/packages/client/ui-dockkit/tests/components.client.spec.tsx index 0ff855f694..23b49d2237 100644 --- a/packages/client/ui-dockkit/tests/components.client.spec.tsx +++ b/packages/client/ui-dockkit/tests/components.client.spec.tsx @@ -8,8 +8,10 @@ * jsdom lays nothing out, so the gesture specs hand the surface a layout: panes * of one width side by side, each 600px tall with a 36px strip and 100px chips. */ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import type { DockIntents } from '../src/contract/adapter.ts' import type { PaneId, TabId } from '../src/contract/types.ts' import { DockController } from '../src/engine/controller.ts' @@ -18,7 +20,7 @@ import { FLOAT_DEFAULT_SIZE, FLOAT_MIN_SIZE } from '../src/engine/constraints.ts import { floatRectAt } from '../src/engine/geometry.ts' import { DockSurface, type DockSurfaceProps } from '../src/components/DockSurface.tsx' import type { TabMenuExtras } from '../src/contract/adapter.ts' -import { FloatLayer } from '../src/components/FloatLayer.tsx' +import { FloatLayer, type FloatLayerProps } from '../src/components/FloatLayer.tsx' import { dockPaneIds, getPane } from '../src/engine/tree.ts' import { TEST_LABELS, asPane, asTab, fileTab, seededController } from './fixtures.client.ts' @@ -53,7 +55,7 @@ function renderSurface( intents: DockIntents, canSplit = true, renderTabMenuItems?: TabMenuExtras, - options: Pick = {}, + options: Pick = {}, ) { const snapshot = controller.getSnapshot() return render( @@ -456,6 +458,83 @@ describe('DockSurface', () => { expect(screen.queryByRole('menu')).toBeNull() }) + it('offers close only for tabs the embedder allows', () => { + const controller = seededController() + const allowed = controller.openContent({ contentId: 'resource:a', title: 'A', kind: 'test' }) + const intents = spyIntents() + renderSurface(controller, intents, true, undefined, { canCloseTab: tabId => tabId === allowed }) + expect(screen.getByRole('button', { name: TEST_LABELS.closeTab }).getAttribute('data-dockkit-tab-close')).toBe(allowed) + fireEvent.contextMenu(screen.getByRole('tab', { name: 'Start' })) + expect(screen.queryByRole('menu')).toBeNull() + fireEvent.contextMenu(screen.getByRole('tab', { name: /A/u })) + fireEvent.click(screen.getByRole('menuitem', { name: TEST_LABELS.closeTab })) + expect(intents.closeTab).toHaveBeenCalledExactlyOnceWith(allowed) + }) + + it.each([ + ['absent', undefined], + ['null', null], + ['conditional', false], + ['empty array', []], + ['empty text', ''], + ])('hides close and creates no popup when extras are %s', (_, extras) => { + const controller = seededController() + const intents = spyIntents() + renderSurface(controller, intents, true, extras === undefined ? undefined : () => extras, { canCloseTab: () => false }) + expect(screen.queryByRole('button', { name: TEST_LABELS.closeTab })).toBeNull() + fireEvent.contextMenu(screen.getByRole('tab')) + expect(screen.queryByRole('menuitem', { name: TEST_LABELS.closeTab })).toBeNull() + expect(screen.queryByRole('menu')).toBeNull() + expect(intents.closeTab).not.toHaveBeenCalled() + }) + + it('updates chip and open-menu close controls when eligibility props change', () => { + const controller = seededController() + const intents = spyIntents() + const props: DockSurfaceProps = { + state: controller.getSnapshot().state, + canSplit: true, + intents, + labels: TEST_LABELS, + renderTab: () => null, + canCloseTab: () => false, + } + const { rerender } = render() + expect(screen.queryByRole('button', { name: TEST_LABELS.closeTab })).toBeNull() + rerender( true} />) + expect(screen.getByRole('button', { name: TEST_LABELS.closeTab })).toBeDefined() + fireEvent.contextMenu(screen.getByRole('tab')) + expect(screen.getByRole('menuitem', { name: TEST_LABELS.closeTab })).toBeDefined() + rerender() + expect(screen.queryByRole('button', { name: TEST_LABELS.closeTab })).toBeNull() + expect(screen.queryByRole('menu')).toBeNull() + rerender( true} />) + fireEvent.click(screen.getByRole('menuitem', { name: TEST_LABELS.closeTab })) + expect(intents.closeTab).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('menu')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.closeTab })) + expect(intents.closeTab).toHaveBeenCalledTimes(2) + }) + + it.each([false, true])('hides the popup when an extras component renders no content inside a wrapper: %s', (wrapped) => { + const EmptyExtras = () => null + renderSurface(seededController(), spyIntents(), true, + () => wrapped ?
: , + { canCloseTab: () => false }) + fireEvent.contextMenu(screen.getByRole('tab')) + const menu = document.querySelector('[data-dockkit-tab-menu]') + if (menu === null) throw new Error('expected the extras component menu') + // Vitest stubs CSS Modules; bind the real stylesheet's menu selector to its generated class. + const style = document.createElement('style') + style.textContent = readFileSync(resolve(import.meta.dirname, '../src/components/dockkit.module.css'), 'utf8') + .replaceAll(/\.menu(?=[:\s{])/g, `.${menu.className}`) + document.head.append(style) + onTestFinished(() => { style.remove() }) + expect(menu.querySelectorAll('[role^="menuitem"]')).toHaveLength(0) + expect(getComputedStyle(menu).display).toBe('none') + expect(screen.queryByRole('menu')).toBeNull() + }) + it('toggles the menu closed on a second secondary press, and dismisses it on a press anywhere else', () => { const controller = seededController() renderSurface(controller, spyIntents()) @@ -501,7 +580,7 @@ describe('DockSurface', () => { expect(document.querySelector('[data-dockkit-tab-more]')).toBeNull() }) - it('appends embedder menu items after its own, and hands them the tab and a dismiss', () => { + it.each([true, false])('keeps embedder menu items and their tab and dismiss when close is allowed: %s', (canClose) => { const controller = seededController() const acted = vi.fn<(contentId: string) => void>() const extras: TabMenuExtras = (tab, dismiss) => ( @@ -514,17 +593,20 @@ describe('DockSurface', () => { embedder item ) - renderSurface(controller, spyIntents(), true, extras) + const intents = spyIntents() + renderSurface(controller, intents, true, extras, { canCloseTab: () => canClose }) fireEvent.contextMenu(screen.getByRole('tab')) // Order is contract: the kit's own item stays in the same place in every // menu, so an embedder item cannot displace it. - expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([ - TEST_LABELS.closeTab, 'embedder item', - ]) + expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual( + canClose ? [TEST_LABELS.closeTab, 'embedder item'] : ['embedder item'], + ) + expect(screen.queryByRole('button', { name: TEST_LABELS.closeTab }) !== null).toBe(canClose) fireEvent.click(screen.getByTestId('extra')) expect(acted).toHaveBeenCalledWith('seed:start') + expect(intents.closeTab).not.toHaveBeenCalled() expect(screen.queryByRole('menu')).toBeNull() }) @@ -895,7 +977,12 @@ describe('FloatLayer', () => { } /** One floating panel over a spied intent set. */ - function floating(): { intents: ReturnType; paneId: PaneId; tabId: TabId; panel: HTMLElement } { + function floating(canCloseTab?: FloatLayerProps['canCloseTab']): { + intents: ReturnType + paneId: PaneId + tabId: TabId + panel: HTMLElement + } { const controller = seededController() const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' }) const paneId = controller.floatTab(tabId, { x: 100, y: 80, width: 300, height: 200 }) @@ -906,6 +993,7 @@ describe('FloatLayer', () => { intents={intents} labels={TEST_LABELS} renderTab={tab =>

{tab.title}

} + {...canCloseTab === undefined ? {} : { canCloseTab }} />, ) const panel = document.querySelector(`[data-dockkit-float="${paneId}"]`) @@ -927,6 +1015,17 @@ describe('FloatLayer', () => { expect(intents.closeTab).toHaveBeenCalledWith(tabId) }) + it('hides a floating tab close control when the embedder disallows it', () => { + const canCloseTab = vi.fn(() => false) + const { intents, tabId } = floating(canCloseTab) + expect(canCloseTab).toHaveBeenCalledWith(tabId) + expect(screen.queryByRole('button', { name: TEST_LABELS.closeFloat })).toBeNull() + expect(screen.getByTestId('float-body').textContent).toBe('a.txt') + fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.dockFloat })) + expect(intents.unfloatPane).toHaveBeenCalledTimes(1) + expect(intents.closeTab).not.toHaveBeenCalled() + }) + it('raises a panel on a press on its body, but not from a press on its controls, grip, or corner', () => { const { intents, lower, panel, part } = twoFloats() fireEvent.pointerDown(panel(lower)) diff --git a/packages/client/ui-layout/tests/app-frame.client.spec.tsx b/packages/client/ui-layout/tests/app-frame.client.spec.tsx index 55e51bdeac..2e525ae028 100644 --- a/packages/client/ui-layout/tests/app-frame.client.spec.tsx +++ b/packages/client/ui-layout/tests/app-frame.client.spec.tsx @@ -11,7 +11,7 @@ import { createLayoutStore } from '../src/client/stores.ts' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' -const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource'] +const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource'] let selectedSession: SessionId | undefined let selectedSessionTitle: string | undefined let workspacesReady = true diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 6dd7e78078..4f9173f729 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 3e0922690cc68a477e4b1d5ca1ed142d713369f0 -README.zh.md: de8487499ad31affc95986125c910b368ae39b95 +README.md: 7b670394f6ca70ea369fc79766b26119f670f753 +README.zh.md: 8ddefde928a723992945f801fb7407a2d22f5f27 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 3e0922690c..7b670394f6 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -52,7 +52,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `HoverCard` | Hover preview the pointer can rest on and select from; optional copy button. | | `Toast` | Transient top-center banner held for the owner's `holdMs`. | | `JsonTree`, `JsonBlock` | Read-only JSON inspection. | -| `MarkdownText`, `CodeBlock` | Untrusted GFM with TeX math, and highlighted code. | +| `MarkdownText`, `CodeBlock` | Untrusted GFM with TeX math, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter. | | `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. | | `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIcon`, `LinkIcon`, `DocumentFileIcon` | Glyphs and brand marks, all riding `currentColor`. | diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index de8487499a..8ddefde928 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -52,7 +52,7 @@ kind: "package-library" | `HoverCard` | 指针可停留、可选中的悬停预览;可选带复制按钮。 | | `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 | | `JsonTree`、`JsonBlock` | 只读 JSON 查看。 | -| `MarkdownText`、`CodeBlock` | 不可信 GFM 与 TeX 数学,以及高亮代码。 | +| `MarkdownText`、`CodeBlock` | 不可信 GFM 与 TeX 数学,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏。 | | `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 | | `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIcon`、`LinkIcon`、`DocumentFileIcon` | 字形与品牌标识,全部随 `currentColor`。 | diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.module.css b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css index f1beebe5f8..5f726e8474 100644 --- a/packages/client/ui-primitives/src/markdown/CodeBlock.module.css +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css @@ -96,3 +96,28 @@ .plain { color: var(--dsw-alias-label-primary); } + +.numbered :where(pre) code { + display: block; + counter-reset: source-line; + white-space: normal; +} + +.numbered :where(pre) code > :global(.line) { + position: relative; + display: block; + min-height: 1lh; + padding-inline-start: calc(var(--dsl-code-block-line-number-width) + 12px); + counter-increment: source-line; + white-space: var(--dsl-code-block-line-white-space, pre-wrap); +} + +.numbered :where(pre) code > :global(.line)::before { + position: absolute; + inset-inline-start: 0; + width: var(--dsl-code-block-line-number-width); + color: var(--dsw-alias-label-tertiary); + text-align: end; + content: counter(source-line); + user-select: none; +} diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx index 5ba93069dd..fff6a00a45 100644 --- a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx @@ -1,5 +1,5 @@ import { Fragment, useCallback, useMemo, useRef, useState, useSyncExternalStore } from 'react' -import type { ReactNode } from 'react' +import type { CSSProperties, ReactNode } from 'react' import clsx from 'clsx' import { writeClipboard } from '../clipboard.ts' import { @@ -25,6 +25,8 @@ export interface CodeBlockProps { streaming?: boolean | undefined /** Extra class merged onto the wrapper (callers position; this component draws). */ className?: string | undefined + /** Show a numbered gutter without adding numbers to copied source. Defaults to false. */ + lineNumbers?: boolean | undefined /** Copy-button idle label; the owner passes localized copy (this package is cordis-free, so copy arrives via props). */ copyLabel: string /** Copy-button label during the post-copy confirmation window. */ @@ -57,8 +59,9 @@ function renderLine(line: readonly HighlightSpan[], index: number): ReactNode { ) } -export function CodeBlock({ code, lang, streaming, className, copyLabel, copiedLabel }: CodeBlockProps) { +export function CodeBlock({ code, lang, streaming, className, lineNumbers = false, copyLabel, copiedLabel }: CodeBlockProps) { const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code + const sourceLines = lineNumbers ? trimmed.split('\n') : undefined const rootRef = useRef(null) const highlighting = useViewportHighlighting(rootRef, lang) // Re-render when a lazy grammar finishes loading, so a fence that showed plain @@ -160,14 +163,20 @@ export function CodeBlock({ code, lang, streaming, className, copyLabel, copiedL ? streamedBody : html === undefined ? ( -
{trimmed}
+
{sourceLines === undefined ? trimmed : sourceLines.map((line, index) => (
+          {index > 0 && '\n'}{line}
+        ))}
) : (
) return ( -
+
{lang ?? ''}
diff --git a/packages/client/ui-primitives/tests/code-block.client.spec.tsx b/packages/client/ui-primitives/tests/code-block.client.spec.tsx index 2f49687c53..4413cf46d2 100644 --- a/packages/client/ui-primitives/tests/code-block.client.spec.tsx +++ b/packages/client/ui-primitives/tests/code-block.client.spec.tsx @@ -65,6 +65,49 @@ describe('CodeBlock', () => { expect(pre).not.toBeNull() expect(pre!.textContent).toBe('const a = 1') expect(pre!.querySelectorAll('span[style]').length).toBeGreaterThan(1) + expect(view.container.querySelector('[data-line-numbers]')).toBeNull() + }) + + it.each(['ts', 'unregistered'])('numbers %s source lines without copying the gutter', async (lang) => { + const clipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard') + const writeText = vi.fn().mockResolvedValue(undefined) + vi.useFakeTimers() + try { + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) + const code = 'const first = 1\n\nconst last = 3' + const view = render() + expect(view.container.querySelector('[data-line-numbers]')).not.toBeNull() + expect([...view.container.querySelectorAll('code > .line')].map(line => line.textContent)) + .toEqual(['const first = 1', '', 'const last = 3']) + expect(view.container.querySelector('pre')!.textContent).toBe(code) + await act(async () => { fireEvent.click(view.getByRole('button', { name: '复制' })) }) + expect(writeText).toHaveBeenCalledWith(code) + await act(async () => { await vi.runOnlyPendingTimersAsync() }) + } finally { + cleanup() + vi.useRealTimers() + if (clipboard === undefined) Reflect.deleteProperty(navigator, 'clipboard') + else Object.defineProperty(navigator, 'clipboard', clipboard) + } + }) + + it('keeps an empty numbered line and widens the gutter as streaming content grows', () => { + const view = render() + expect(view.container.querySelectorAll('code > .line')).toHaveLength(1) + expect(view.container.querySelector('pre')!.textContent).toBe('') + const gutter = () => view.container.querySelector('[data-line-numbers]')! + .style.getPropertyValue('--dsl-code-block-line-number-width') + expect(gutter()).toBe('2ch') + view.rerender() + const code = ['const first = 1', ...Array.from({ length: 99 }, (_, index) => `const line${index} = 0`)].join('\n') + view.rerender() + expect(view.container.querySelectorAll('code > .line')).toHaveLength(100) + const firstLine = view.container.querySelector('code > .line') + expect(view.container.querySelector('pre')!.textContent).toBe(code) + expect(gutter()).toBe('3ch') + view.rerender() + expect(view.container.querySelector('code > .line')).toBe(firstLine) + expect(gutter()).toBe('3ch') }) it('renders the plain arm for an unknown language with the text verbatim', () => { diff --git a/packages/client/ui-sidebar-right/README.i18n.yaml b/packages/client/ui-sidebar-right/README.i18n.yaml index eff8b1d7a3..ac1571779a 100644 --- a/packages/client/ui-sidebar-right/README.i18n.yaml +++ b/packages/client/ui-sidebar-right/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-right/README.md -README.md: 7520a927f8e77b4fca1bbb7e61fe415dda814b4b -README.zh.md: 648a2c20d8e55a48a684a92a351b7900f3a8d517 +README.md: 15ea04fd3bb23fe1e556c4d7b669b0bb890b004b +README.zh.md: 7eb22f0f22f2d2f4f0cdac8c6cb231af89f36abe diff --git a/packages/client/ui-sidebar-right/README.md b/packages/client/ui-sidebar-right/README.md index 7520a927f8..15ea04fd3b 100644 --- a/packages/client/ui-sidebar-right/README.md +++ b/packages/client/ui-sidebar-right/README.md @@ -45,7 +45,7 @@ Normal and fullscreen presentations share the same content tree, so switching do The seat reports presentation through `ctx.layout.openRightbar(track, fullscreen)` / `closeRightbar()`; the frame does not inject this package. Switching fullscreen on a wide viewport leaves the center width unchanged, and the width handle appears only in expanded normal mode. Independent floating panels and `float`/`dock` operations remain available. -The panel has no header row. Its two controls — the presentation switch and the collapse button — ride the kit's chrome seat at the far end of the top-right pane's tab strip, so the strip is the panel's whole top edge. Each strip reads, left to right: the tabs as capsules with their own close, the add control (drawn only while that pane holds no guide tab; it opens the guide there through `ctx.sidebarRight.openTab`), the pane's split control, and in the top-right pane the two panel controls. Only the chips give way in a narrow pane; the controls after them never shrink or clip. +The panel has no header row. Its two controls — the presentation switch and the collapse button — ride the kit's chrome seat at the far end of the top-right pane's tab strip, so the strip is the panel's whole top edge. Each strip reads, left to right: the tab capsules with close controls where allowed, the add control (drawn only while that pane holds no guide tab; it opens the guide there through `ctx.sidebarRight.openTab`), the pane's split control, and in the top-right pane the two panel controls. Only the chips give way in a narrow pane; the controls after them never shrink or clip. ## The expand button @@ -63,7 +63,7 @@ One `SurfaceState` per session id — the layout, its recorded sequence, and how Carrying the mint counter in the surface is what makes a recorded sequence replayable: operations embed the ids they create, so replaying from the same initial state reproduces the same tree. Every action records one history entry, however many operations it needed. Expanding, collapsing, and switching presentation are recorded too. -After every action the kit's settle planner keeps the surface populated: a docked pane whose last tab was closed, moved out, or floated is merged away, and when only the root pane is left and it is empty, the guide tab is reseeded. There is always at least one tab, and never an empty pane — so there is no separate "close pane" gesture. +After every action the kit's settle planner keeps the surface populated: a docked pane whose last tab was moved out or floated is merged away, and an emptied root pane receives the default page. Explicit closing follows [the default-page and close rules](#the-guide). There is always at least one tab, and never an empty pane — so there is no separate "close pane" gesture. State is memory-only. A reload returns every session to the collapsed default; switching sessions keeps each surface where it was. @@ -94,7 +94,9 @@ The Tab domain retains navigation, an abort signal, and bound actions per (Sessi ## The guide -The guide tab is a centred title, one line under it, and one entry box per `guide` entry the registered types contributed, in `order`. Picking a box calls `tab.actions.openTab(entry.kind, { replaceTab: true })`, so the guide gives way to the page it opened. A pane holds at most one guide tab. The strip's add control is drawn only while its pane holds none and opens one there with `openTab('guide', { paneId, revealIfOpened: false })`, so a guide in another pane does not capture the click; opening the guide into a pane that already has one focuses it instead; a guide dragged, dropped, or docked into such a pane merges into it — the arriving guide closes and the pane's own is focused; `duplicateTab` on the guide records nothing. A split or an emptied root pane seeds a guide through the kit's factory, one per new pane. A plain `openTab('guide')` keeps the tree-wide reveal every open has. The product allows two horizontal panes, initially equal, with divider ratios limited to 20%–80%. Insufficient width blocks a new split; with two panes already present, a body drop moves the tab between panes instead of creating a third. At the two-pane limit, split controls are hidden; closing back to one pane restores them. +Default pages depend on the number of registered guide entries, not the number of tab types or open tabs. Exactly one entry opens its page directly (Files in the shipped composition); zero or multiple entries open the guide. Each single-entry default remains non-closable when other tabs are added. Any pane's last tab is also non-closable; other tabs can close. The chip, context menu, and `close` API apply the same rule. Explicitly adding a guide still opens the guide, even with one entry. + +The guide tab is a centred title, one line under it, and one entry box per `guide` entry the registered types contributed, in `order`. Picking a box calls `tab.actions.openTab(entry.kind, { replaceTab: true })`, so the guide gives way to the page it opened. A pane holds at most one guide tab. The strip's add control is drawn only while its pane holds none and opens one there with `openTab('guide', { paneId, revealIfOpened: false })`, so a guide in another pane does not capture the click; opening the guide into a pane that already has one focuses it instead; a guide dragged, dropped, or docked into such a pane merges into it — the arriving guide closes and the pane's own is focused; `duplicateTab` on the guide records nothing. A split or an emptied root pane uses the same default-page rule, one tab per new pane. A plain `openTab('guide')` keeps the tree-wide reveal every open has. The product allows two horizontal panes, initially equal, with divider ratios limited to 20%–80%. Insufficient width blocks a new split; with two panes already present, a body drop moves the tab between panes instead of creating a third. At the two-pane limit, split controls are hidden; closing back to one pane restores them. ## Copy diff --git a/packages/client/ui-sidebar-right/README.zh.md b/packages/client/ui-sidebar-right/README.zh.md index 648a2c20d8..7eb22f0f22 100644 --- a/packages/client/ui-sidebar-right/README.zh.md +++ b/packages/client/ui-sidebar-right/README.zh.md @@ -45,7 +45,7 @@ kind: "package-reference" 席位通过 `ctx.layout.openRightbar(track, fullscreen)` / `closeRightbar()` 报告呈现,框架不注入本包。宽屏切换全屏不改变中栏宽度;宽度拖拽区只在普通展开态显示。独立浮窗及 `float`/`dock` 操作保持可用。 -面板没有标题行。它的两个控件——形态切换与折叠按钮——搭在套件 chrome 席位上,位于右上格 tab 条的最末端,因此 tab 条就是面板的整条上边。每条 tab 条从左到右读作:作为胶囊、各带关闭按钮的 tab,添加控件(只在该格没有引导 tab 时绘制;它通过 `ctx.sidebarRight.openTab` 在该格打开引导页),该格的分栏控件,以及右上格里的两个面板控件。窄格里只有 chip 让位;其后的控件从不收缩或被裁切。 +面板没有标题行。它的两个控件——形态切换与折叠按钮——搭在套件 chrome 席位上,位于右上格 tab 条的最末端,因此 tab 条就是面板的整条上边。每条 tab 条从左到右读作:在允许时带关闭按钮的 tab 胶囊,添加控件(只在该格没有引导 tab 时绘制;它通过 `ctx.sidebarRight.openTab` 在该格打开引导页),该格的分栏控件,以及右上格里的两个面板控件。窄格里只有 chip 让位;其后的控件从不收缩或被裁切。 ## 展开按钮 @@ -63,7 +63,7 @@ kind: "package-reference" 把铸造计数器带在停靠面里,是记录的序列可回放的原因:操作内嵌它们创建的 id,因此从同一初始状态回放能复现同一棵树。每个动作记录一条历史,无论它需要多少操作。展开、折叠与切换形态也都被记录。 -每个动作之后,套件的 settle planner 保证停靠面有内容:最后一个 tab 被关闭、搬走或浮出的停靠格会被并掉;只剩根格且它为空时,重新播种引导 tab。永远至少有一个 tab,永远没有空格——因此没有单独的「关闭格」手势。 +每个动作之后,套件的 settle planner 保证停靠面有内容:最后一个 tab 被搬走或浮出的停靠格会被并掉;根格被清空时填入默认页。显式关闭遵循[默认页与关闭规则](#the-guide)。永远至少有一个 tab,永远没有空格——因此没有单独的「关闭格」手势。 状态只在内存中。刷新会让每个会话回到折叠的默认态;切换会话则让每个停靠面留在原处。 @@ -94,7 +94,9 @@ Tab域按(Session,Tab id)保留导航、中止信号与绑定动作;私 ## 引导页 -引导 tab 是一个居中标题、其下一行说明,以及各已注册类型贡献的每个 `guide` 条目一个入口框,按 `order` 排列。选中一个框会调用 `tab.actions.openTab(entry.kind, { replaceTab: true })`,于是引导页让位给它打开的页。一个格最多持有一个引导 tab。tab 条的添加控件只在该格没有引导 tab 时绘制,并以 `openTab('guide', { paneId, revealIfOpened: false })` 在该格打开一个,这样别的格里的引导页不会截走这次点击;把引导页开进已有引导页的格则改为聚焦它;把引导页拖入、放入或收回到这样的格会合并进去——来者关闭,该格自己的被聚焦;对引导页 `duplicateTab` 不记录任何东西。分栏或被清空的根格通过套件的工厂播种一个引导页,每个新格一个。普通的 `openTab('guide')` 保留每次打开都有的整树聚焦。产品最多保留左右两格,默认均分,分隔条限定20%~80%。宽度不足以容纳两格时不允许新分栏;已有两格时,正文拖放用于跨格移动,不再创建第三格。 达到两格上限时隐藏分栏控件;关闭回单格后恢复。 +默认页取决于已注册的引导入口数,不取决于 tab 类型数或已打开的 tab 数。恰好一个入口时直接打开对应页面(随包组合中为 Files);没有入口或有多个入口时打开引导页。单入口生成的默认页在增加其他 tab 后仍不可关闭。任何格的最后一个 tab 也不可关闭;其他 tab 可以关闭。chip、上下文菜单与 `close` API 使用同一规则。显式添加引导页始终打开引导,即使只有一个入口。 + +引导 tab 是一个居中标题、其下一行说明,以及各已注册类型贡献的每个 `guide` 条目一个入口框,按 `order` 排列。选中一个框会调用 `tab.actions.openTab(entry.kind, { replaceTab: true })`,于是引导页让位给它打开的页。一个格最多持有一个引导 tab。tab 条的添加控件只在该格没有引导 tab 时绘制,并以 `openTab('guide', { paneId, revealIfOpened: false })` 在该格打开一个,这样别的格里的引导页不会截走这次点击;把引导页开进已有引导页的格则改为聚焦它;把引导页拖入、放入或收回到这样的格会合并进去——来者关闭,该格自己的被聚焦;对引导页 `duplicateTab` 不记录任何东西。分栏或被清空的根格使用相同的默认页规则,每个新格一个 tab。普通的 `openTab('guide')` 保留每次打开都有的整树聚焦。产品最多保留左右两格,默认均分,分隔条限定20%~80%。宽度不足以容纳两格时不允许新分栏;已有两格时,正文拖放用于跨格移动,不再创建第三格。 达到两格上限时隐藏分栏控件;关闭回单格后恢复。 ## 文案 diff --git a/packages/client/ui-sidebar-right/src/client/contract/seed.ts b/packages/client/ui-sidebar-right/src/client/contract/seed.ts index 638801a87e..07578f071a 100644 --- a/packages/client/ui-sidebar-right/src/client/contract/seed.ts +++ b/packages/client/ui-sidebar-right/src/client/contract/seed.ts @@ -1,8 +1,8 @@ /** - * The guide tab's identity, the page-address scheme, and the seed factory. + * The guide tab's identity, the page-address scheme, and default page selection. * * These live in the contract because two sides need them and neither may read - * the other: the store seeds every new pane with a guide tab, and the guide + * the other: the store seeds new panes from the registry, and the guide * domain registers the type under the same kind. * * The docking kit treats `kind` as opaque, so these strings mean something only @@ -10,7 +10,28 @@ * type would use — the guide is not special in the machinery, only in being * always available. */ -import type { TabId, TabRecord } from '@deepseek-ai/dsh-client-ui-dockkit' +import type { SidebarRightTabRegistry } from '../tab-registry.ts' + +/** One pane's initial page, resolved from the current registered guide entries. */ +export interface SidebarRightSeed { + readonly kind: string + readonly title: string + readonly permanent: boolean +} + +/** + * Resolve the default page from the registered entry count. + * @param tabs - current tab registry. + * @returns the sole entry, or the guide when there are zero or multiple entries. + */ +export function defaultSeed(tabs: SidebarRightTabRegistry): SidebarRightSeed { + const [only, ...others] = tabs.guide() + const single = only !== undefined && others.length === 0 + const kind = single ? only.kind : GUIDE_KIND + const definition = tabs.get(kind) + if (definition === undefined) throw new Error(`sidebarRight: default tab kind "${kind}" is not registered`) + return { kind, title: definition.title(pageAddress(kind)), permanent: single } +} /** The guide tab's kind. */ export const GUIDE_KIND = 'guide' @@ -25,17 +46,3 @@ export const GUIDE_KIND = 'guide' export function pageAddress(kind: string): string { return `sidebar://${kind}` } - -/** - * Build the guide tab a new pane is seeded with. - * - * The title is captured at mint time because it goes into the surface's - * operation sequence, which records what happened and must not change meaning - * later. A language change relabels the type, not tabs already open. - * @param id - tab id minted by the caller. - * @param title - the guide type's display name at mint time. - * @returns the guide tab record. - */ -export function makeGuideTab(id: TabId, title: string): TabRecord { - return { id, kind: GUIDE_KIND, contentId: pageAddress(GUIDE_KIND), title } -} diff --git a/packages/client/ui-sidebar-right/src/client/index.ts b/packages/client/ui-sidebar-right/src/client/index.ts index 0fc4a4d2e2..8c5301b095 100644 --- a/packages/client/ui-sidebar-right/src/client/index.ts +++ b/packages/client/ui-sidebar-right/src/client/index.ts @@ -40,6 +40,7 @@ import { en, zh } from './locales.ts' import { GUIDE_ID, guideDefinition } from './tabs/guide/definition.ts' import { guideTabInfoFactory, tabInfoFactory } from './tab-info.ts' import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' +import { defaultSeed } from './contract/seed.ts' export type { RightbarSeatProps, SidebarRightInjected, SidebarRightPresentation } from './shell/SidebarRight.tsx' export type { GuideBodyProps, GuideInjected } from './tabs/guide/GuideBody.tsx' @@ -118,7 +119,7 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-sidebar-right: dictionaries') ctx.effect(() => { - const handle = createSidebarRightStore(() => t('tab.guide.title')) + const handle = createSidebarRightStore(() => defaultSeed(tabs)) // The runtime mints one instance of this handle per session (the scope key // is the session id) and caches it per key. Each is adopted as it is minted, // so a tab's own action reaches its session's store while another session diff --git a/packages/client/ui-sidebar-right/src/client/service.ts b/packages/client/ui-sidebar-right/src/client/service.ts index ca5ad487a3..768fc33364 100644 --- a/packages/client/ui-sidebar-right/src/client/service.ts +++ b/packages/client/ui-sidebar-right/src/client/service.ts @@ -157,7 +157,7 @@ export interface ISidebarRight { */ openTab(kind: K, options?: SidebarRightOpenTabOptions): void /** - * Close one tab of the mounted session. + * Close one tab of the mounted session; permanent defaults and a pane's last tab remain open. * @param tabId - the tab to close. */ close(tabId: TabId): void @@ -286,7 +286,7 @@ export class SidebarRightController implements ISidebarRight { } /** - * Close a tab of one session, for the tab's own action; nothing happens + * Close a tab of one session, preserving permanent defaults and a pane's last tab; nothing happens * for a session whose store was never adopted or whose adoption was released. * Not part of `ISidebarRight`: the Tab domain's path. * @param sessionId - the session the tab is in. @@ -343,7 +343,7 @@ export class SidebarRightController implements ISidebarRight { } /** - * Close one tab of the mounted session. + * Close one tab of the mounted session; permanent defaults and a pane's last tab remain open. * @param tabId - the tab to close. */ close(tabId: TabId): void { diff --git a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx index 008ba4f387..f555e7b9f0 100644 --- a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx +++ b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx @@ -43,6 +43,7 @@ import { dockLabels } from '../labels.ts' import type { SidebarRightOpenTabOptions } from '../service.ts' import type { SidebarRightTabDefinition } from '../tab-registry.ts' import type { createSidebarRightStore, SurfaceState } from '../stores.ts' +import { canCloseTab } from '../stores.ts' import type { TabOccurrence } from '../tab-domain.ts' import type { SidebarRightTabNavigation } from '../contract/slots.ts' import type { TabHookContext } from '../tab-info.ts' @@ -306,6 +307,7 @@ function SidebarPanel(panel: PanelProps & { width: number; panelRef: RefObject guideIn(surface.layout, paneId) === undefined} + canCloseTab={tabId => canCloseTab(surface, tabId)} intents={intentsFor(sessionId, actions, openTab)} labels={dockLabels(t)} renderTab={bodiesFor(panel)} @@ -328,6 +330,7 @@ function Floats(panel: PanelProps): ReactNode {
canCloseTab(surface, tabId)} intents={intentsFor(sessionId, actions, openTab)} labels={dockLabels(t)} renderTab={bodiesFor(panel)} diff --git a/packages/client/ui-sidebar-right/src/client/stores.ts b/packages/client/ui-sidebar-right/src/client/stores.ts index 2301388974..4f3ef580cf 100644 --- a/packages/client/ui-sidebar-right/src/client/stores.ts +++ b/packages/client/ui-sidebar-right/src/client/stores.ts @@ -11,7 +11,7 @@ * and keeps snapshot identity honest. * * The settle step is this product's rule, not the kit's: a docked pane never - * stays empty, and the last pane reseeds the guide tab, so there is always at + * stays empty, and the last pane reseeds the default tab, so there is always at * least one tab to look at. * * A focus that changes nothing — a tab already active in its already-active @@ -26,14 +26,14 @@ */ import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store' import type { - DockMode, DockZone, FloatRect, History, LayoutOp, LayoutState, Mint, PaneId, SplitId, TabId, + DockMode, DockZone, FloatRect, History, LayoutOp, LayoutState, Mint, PaneId, SplitId, TabId, TabRecord, } from '@deepseek-ai/dsh-client-ui-dockkit' import { activeDockPaneId, createInitialState, dockPaneIds, EMPTY_HISTORY, findPaneContentTab, findTabPane, planDropTab, planDuplicateTab, planFloatTab, planOpenContent, planPlaceTab, planResizeSplit, planSetExpanded, planSetMode, planSettle, planSplitPane, planUnfloatPane, record, replay, stepBack, stepForward, } from '@deepseek-ai/dsh-client-ui-dockkit' -import { GUIDE_KIND, makeGuideTab, pageAddress } from './contract/seed.ts' +import { GUIDE_KIND, pageAddress, type SidebarRightSeed } from './contract/seed.ts' /** One session's docking surface: the layout, its sequence, and the id counter. */ export interface SurfaceState { @@ -41,6 +41,8 @@ export interface SurfaceState { readonly history: History /** How many ids this surface has minted; carried so replay stays reproducible. */ readonly minted: number + /** Sole-entry defaults protected from close; retired ids remain protected when undo restores them. */ + readonly permanentTabIds: readonly TabId[] } /** @@ -55,7 +57,25 @@ export interface SidebarRightState { } /** A planner call, as the store needs it: state and a mint in, operations out. */ -type SurfacePlan = (state: LayoutState, mint: Mint) => readonly LayoutOp[] +type SurfacePlan = (state: LayoutState, mint: Mint, makeTab: (id: TabId) => TabRecord) => readonly LayoutOp[] + +/** + * Decide whether an explicit close may remove a tab. + * @param surface - current surface including default-tab protection. + * @param tabId - tab requested for closing. + * @returns false for missing tabs, protected defaults, and a pane's last tab. + */ +export function canCloseTab(surface: SurfaceState, tabId: TabId): boolean { + if (surface.layout.tabs[tabId] === undefined || surface.permanentTabIds.includes(tabId)) return false + return findTabPane(surface.layout, tabId).tabs.length > 1 +} + +/** Build a default tab and retain its close protection outside the docking kit's data. */ +function seedRecord(id: TabId, seed: () => SidebarRightSeed, permanentTabIds: TabId[]): TabRecord { + const initial = seed() + if (initial.permanent) permanentTabIds.push(id) + return { id, kind: initial.kind, title: initial.title, contentId: pageAddress(initial.kind) } +} /** * What the navigation controller asks the store to open, the address already @@ -86,16 +106,18 @@ function counting(from: number): { mint: Mint; used: () => number } { } /** - * The surface a session starts with: collapsed, one pane, one guide tab. - * @param seedTitle - the guide type's display name at mint time. + * The surface a session starts with: collapsed, one pane, one default tab. + * @param seed - the registered default page at mint time. * @returns the initial surface. */ -export function createSurface(seedTitle: () => string): SurfaceState { +export function createSurface(seed: () => SidebarRightSeed): SurfaceState { const counter = counting(0) + const permanentTabIds: TabId[] = [] return { - layout: createInitialState({ next: counter.mint }, id => makeGuideTab(id, seedTitle())), + layout: createInitialState({ next: counter.mint }, id => seedRecord(id, seed, permanentTabIds)), history: EMPTY_HISTORY, minted: counter.used(), + permanentTabIds, } } @@ -141,19 +163,21 @@ function arriving(state: LayoutState, tabId: TabId, toPaneId: PaneId, otherwise: * whole intent as one history entry. * @param surface - the session's current surface. * @param plan - the kit planner to consult. - * @param seedTitle - the guide type's display name, for a reseeded root pane. + * @param seed - the registered default page for an empty docked pane. * @returns the next surface, or the same one when the intent changes nothing. */ -function advance(surface: SurfaceState, plan: SurfacePlan, seedTitle: () => string): SurfaceState { +function advance(surface: SurfaceState, plan: SurfacePlan, seed: () => SidebarRightSeed): SurfaceState { const counter = counting(surface.minted) - const planned = plan(surface.layout, counter.mint) + const permanentTabIds = [...surface.permanentTabIds] + const makeTab = (id: TabId): TabRecord => seedRecord(id, seed, permanentTabIds) + const planned = plan(surface.layout, counter.mint, makeTab) if (planned.length === 0) return surface // The settle planner reads the state the intent produces, so it is applied // to a scratch copy first; the record then applies both parts once. const after = replay(surface.layout, planned) - const settled = planSettle(after, counter.mint, id => makeGuideTab(id, seedTitle())) + const settled = planSettle(after, counter.mint, makeTab) const stepped = record(surface.history, surface.layout, [...planned, ...settled]) - return { layout: stepped.state, history: stepped.history, minted: counter.used() } + return { layout: stepped.state, history: stepped.history, minted: counter.used(), permanentTabIds } } /** @@ -165,11 +189,11 @@ function advance(surface: SurfaceState, plan: SurfacePlan, seedTitle: () => stri function seat( state: SidebarRightState, sessionId: string, - seedTitle: () => string, + seed: () => SidebarRightSeed, next: (surface: SurfaceState) => SurfaceState, ): Record { const existing = state.bySession[sessionId] - const updated = next(existing ?? createSurface(seedTitle)) + const updated = next(existing ?? createSurface(seed)) return updated === existing ? state.bySession : { ...state.bySession, [sessionId]: updated } } @@ -214,39 +238,39 @@ function stepped(surface: SurfaceState, step: HistoryStepper): SurfaceState { /** * Create the Sidebar store handle. * - * The seed title arrives as a thunk rather than a string: a pane is seeded + * The default page arrives as a thunk: a pane is seeded * whenever one is created, which can be long after the store was built and in a * language the user has since changed to. - * @param seedTitle - the guide type's display name, read at each mint. + * @param seed - the registered default page, read at each mint. * @returns the handle (spec, type, identity, and factory in one). */ export function createSidebarRightStore( - seedTitle: () => string, + seed: () => SidebarRightSeed, ): EngineStoreHandle { return defineStore({ init: (): SidebarRightState => ({ bySession: {} }), actions: { // Materialize a session's surface without changing it, so the first read // after a session switch sees the collapsed default rather than nothing. - open: (d, sessionId: string) => { d.bySession = seat(d, sessionId, seedTitle, surface => surface) }, + open: (d, sessionId: string) => { d.bySession = seat(d, sessionId, seed, surface => surface) }, setExpanded: (d, sessionId: string, expanded: boolean) => { - d.bySession = seat(d, sessionId, seedTitle, s => advance(s, state => planSetExpanded(state, expanded), seedTitle)) + d.bySession = seat(d, sessionId, seed, s => advance(s, state => planSetExpanded(state, expanded), seed)) }, toggleExpanded: (d, sessionId: string) => { - d.bySession = seat(d, sessionId, seedTitle, s => advance(s, state => planSetExpanded(state, !state.expanded), seedTitle)) + d.bySession = seat(d, sessionId, seed, s => advance(s, state => planSetExpanded(state, !state.expanded), seed)) }, // Switching presentation is recorded like any other change, so stepping // back through the sequence puts the surface back the way it was drawn. setMode: (d, sessionId: string, mode: DockMode) => { - d.bySession = seat(d, sessionId, seedTitle, s => advance(s, state => planSetMode(state, mode), seedTitle)) + d.bySession = seat(d, sessionId, seed, s => advance(s, state => planSetMode(state, mode), seed)) }, // `settled` reports the pane the split created, synchronously, because // actions return nothing; it is not called when nothing was split. splitPane: (d, sessionId: string, paneId?: PaneId, settled?: (paneId: PaneId) => void) => { - d.bySession = seat(d, sessionId, seedTitle, (s) => { - const next = advance(s, (state, mint) => dockPaneIds(state).length >= 2 + d.bySession = seat(d, sessionId, seed, (s) => { + const next = advance(s, (state, mint, makeTab) => dockPaneIds(state).length >= 2 ? [] - : planSplitPane(state, mint, paneId, id => makeGuideTab(id, seedTitle())), seedTitle) + : planSplitPane(state, mint, paneId, makeTab), seed) if (settled !== undefined && next !== s) { const before = new Set(dockPaneIds(s.layout)) for (const id of dockPaneIds(next.layout)) { @@ -261,7 +285,7 @@ export function createSidebarRightStore( // closing the tab it replaces. `settled` reports the tab the planner // landed on, synchronously, because actions return nothing. openContent: (d, sessionId: string, intent, settled) => { - d.bySession = seat(d, sessionId, seedTitle, s => advance(s, (state, mint) => { + d.bySession = seat(d, sessionId, seed, s => advance(s, (state, mint) => { const { kind, contentId, title, replaceTab: replace } = intent const ops: LayoutOp[] = [...planSetExpanded(state, true)] // A replaced tab lends its pane and slot; one that floats cannot (a @@ -287,66 +311,66 @@ export function createSidebarRightStore( if (replace !== undefined && replace !== planned.tabId) ops.push({ type: 'closeTab', tabId: replace }) settled(planned.tabId) return ops - }, seedTitle)) + }, seed)) }, // The guide is never copied: the copy would sit beside it in the same pane. duplicateTab: (d, sessionId: string, tabId: TabId) => { - d.bySession = seat(d, sessionId, seedTitle, s => - advance(s, (state, mint) => isGuide(state, tabId) ? [] : planDuplicateTab(state, mint, tabId).ops, seedTitle)) + d.bySession = seat(d, sessionId, seed, s => + advance(s, (state, mint) => isGuide(state, tabId) ? [] : planDuplicateTab(state, mint, tabId).ops, seed)) }, // A tab already gone — closed twice by a racing callback and the user — is // left alone rather than handed to the kit, which refuses an unknown tab. closeTab: (d, sessionId: string, tabId: TabId) => { - d.bySession = seat(d, sessionId, seedTitle, s => - advance(s, state => state.tabs[tabId] === undefined ? [] : [{ type: 'closeTab', tabId }], seedTitle)) + d.bySession = seat(d, sessionId, seed, s => + advance(s, () => canCloseTab(s, tabId) ? [{ type: 'closeTab', tabId }] : [], seed)) }, focusTab: (d, sessionId: string, tabId: TabId) => { - d.bySession = seat(d, sessionId, seedTitle, s => advance(s, state => planFocusTab(state, tabId), seedTitle)) + d.bySession = seat(d, sessionId, seed, s => advance(s, state => planFocusTab(state, tabId), seed)) }, focusPane: (d, sessionId: string, paneId: PaneId) => { - d.bySession = seat(d, sessionId, seedTitle, s => advance(s, state => planFocusPane(state, paneId), seedTitle)) + d.bySession = seat(d, sessionId, seed, s => advance(s, state => planFocusPane(state, paneId), seed)) }, placeTab: (d, sessionId: string, tabId: TabId, toPaneId: PaneId, index: number) => { - d.bySession = seat(d, sessionId, seedTitle, s => - advance(s, state => arriving(state, tabId, toPaneId, () => planPlaceTab(state, tabId, toPaneId, index)), seedTitle)) + d.bySession = seat(d, sessionId, seed, s => + advance(s, state => arriving(state, tabId, toPaneId, () => planPlaceTab(state, tabId, toPaneId, index)), seed)) }, // Only a centre release lands in the target pane; an edge release makes a // new pane, where nothing can already be. dropTab: (d, sessionId: string, tabId: TabId, paneId: PaneId, zone: DockZone) => { - d.bySession = seat(d, sessionId, seedTitle, s => advance(s, (state, mint) => { + d.bySession = seat(d, sessionId, seed, s => advance(s, (state, mint) => { if (zone === 'top' || zone === 'bottom') return [] if (zone !== 'center' && dockPaneIds(state).length >= 2) return [] const plan = (): readonly LayoutOp[] => planDropTab(state, mint, tabId, paneId, zone) return zone === 'center' ? arriving(state, tabId, paneId, plan) : plan() - }, seedTitle)) + }, seed)) }, floatTab: (d, sessionId: string, tabId: TabId, rect?: FloatRect) => { - d.bySession = seat(d, sessionId, seedTitle, s => - advance(s, (state, mint) => planFloatTab(state, mint, tabId, rect).ops, seedTitle)) + d.bySession = seat(d, sessionId, seed, s => + advance(s, (state, mint) => planFloatTab(state, mint, tabId, rect).ops, seed)) }, // A floating pane holds one tab; docking it back lands in the active // docked pane, and only a guide is subject to the merge rule there. unfloatPane: (d, sessionId: string, paneId: PaneId) => { - d.bySession = seat(d, sessionId, seedTitle, s => advance(s, (state) => { + d.bySession = seat(d, sessionId, seed, s => advance(s, (state) => { const guide = paneGuide(state, paneId) const plan = (): readonly LayoutOp[] => planUnfloatPane(state, paneId) return guide === undefined ? plan() : arriving(state, guide, activeDockPaneId(state), plan) - }, seedTitle)) + }, seed)) }, moveFloat: (d, sessionId: string, paneId: PaneId, x: number, y: number) => { - d.bySession = seat(d, sessionId, seedTitle, s => advance(s, () => [{ type: 'moveFloat', paneId, x, y }], seedTitle)) + d.bySession = seat(d, sessionId, seed, s => advance(s, () => [{ type: 'moveFloat', paneId, x, y }], seed)) }, resizeFloat: (d, sessionId: string, paneId: PaneId, rect: FloatRect) => { - d.bySession = seat(d, sessionId, seedTitle, s => advance(s, () => [{ type: 'resizeFloat', paneId, rect }], seedTitle)) + d.bySession = seat(d, sessionId, seed, s => advance(s, () => [{ type: 'resizeFloat', paneId, rect }], seed)) }, resizeSplit: (d, sessionId: string, splitId: SplitId, sizes: readonly number[]) => { - d.bySession = seat(d, sessionId, seedTitle, s => advance(s, () => planResizeSplit(splitId, sizes, 0.2), seedTitle)) + d.bySession = seat(d, sessionId, seed, s => advance(s, () => planResizeSplit(splitId, sizes, 0.2), seed)) }, undo: (d, sessionId: string) => { - d.bySession = seat(d, sessionId, seedTitle, s => stepped(s, stepBack)) + d.bySession = seat(d, sessionId, seed, s => stepped(s, stepBack)) }, redo: (d, sessionId: string) => { - d.bySession = seat(d, sessionId, seedTitle, s => stepped(s, stepForward)) + d.bySession = seat(d, sessionId, seed, s => stepped(s, stepForward)) }, }, }) diff --git a/packages/client/ui-sidebar-right/tests/apply.client.spec.ts b/packages/client/ui-sidebar-right/tests/apply.client.spec.ts index 3f9a30d29b..78917534ed 100644 --- a/packages/client/ui-sidebar-right/tests/apply.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/apply.client.spec.ts @@ -157,6 +157,10 @@ describe('ui-sidebar-right apply', () => { const occurrence = ctx.sidebarRight.tabDomain.occurrence(SESSION, guide) expect(resources.pin).toHaveBeenCalledWith('sidebar://guide', occurrence.signal) occurrence.tabActions.close() + expect(instance.getSnapshot().bySession[SESSION]?.layout.tabs[guide.id]).toBe(guide) + expect(occurrence.signal.aborted).toBe(false) + instance.actions.openContent(SESSION, { kind: 'text', contentId: 'file:test', title: 'test' }, () => {}) + occurrence.tabActions.close() expect(instance.getSnapshot().bySession[SESSION]?.layout.tabs[guide.id]).toBeUndefined() expect(occurrence.signal.aborted).toBe(true) }) diff --git a/packages/client/ui-sidebar-right/tests/expand-button.client.spec.tsx b/packages/client/ui-sidebar-right/tests/expand-button.client.spec.tsx index 41a402c8ca..a48a2f2933 100644 --- a/packages/client/ui-sidebar-right/tests/expand-button.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/expand-button.client.spec.tsx @@ -27,7 +27,7 @@ function hookOf(inst: { subscribe: (fn: () => void) => () => void; getSnapsho * documented cast keeps the harness to what is actually exercised. */ function mountButton() { - const instance = createSidebarRightStore(() => 'Start').create() + const instance = createSidebarRightStore(() => ({ kind: 'guide', title: 'Start', permanent: false })).create() const props = { sessionId: SESSION, useStore: hookOf(instance), diff --git a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx index 19d28629e8..df79052a9f 100644 --- a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx @@ -57,7 +57,7 @@ function transition(property = 'transform') { } } -async function mountSeat(viewportWidth = 1440, canShow = true) { +async function mountSeat(viewportWidth = 1440, canShow = true, entryCount = 0) { const runtime = await SlotTestRuntime.create() runtimes.push(runtime) const frame = { openRightbar: vi.fn(), closeRightbar: vi.fn() } @@ -94,6 +94,7 @@ async function mountSeat(viewportWidth = 1440, canShow = true) { runtime.ctx.sidebarRightTabs.register({ id: 'test/text', kind: 'text', priority: 'builtin', patterns: ['dsh-resource://file/**'], title: address => address.slice(address.lastIndexOf('/') + 1), + guide: Array.from({ length: entryCount }, (_, order) => ({ order, title: () => 'Test', description: () => 'Test page' })), }) runtime.slots.register({ name: 'sidebar.right.pane.tab', key: 'test/text' }, Body) runtime.slots.register({ name: 'sidebar.right.pane.tab.title', key: 'test/text' }, Title) @@ -129,6 +130,40 @@ describe('RightbarSeat presentation', () => { expect(h.layout()).toBe(retained) }) + it.each([0, 1, 2])('selects the default from %i guide entries and protects a single tab', async (entryCount) => { + const h = await mountSeat(1440, true, entryCount) + act(() => { h.controller.toggleExpanded() }) + const initial = Object.values(h.layout().tabs)[0]! + expect(initial.kind).toBe(entryCount === 1 ? 'text' : 'guide') + expect(h.view.container.querySelectorAll('[data-dockkit-tab-close]')).toHaveLength(0) + const before = h.layout() + act(() => { h.controller.close(initial.id) }) + expect(h.layout()).toBe(before) + fireEvent.contextMenu(element(h.view.container, '[data-dockkit-tab]')) + expect(document.querySelector('[data-dockkit-tab-menu] [role^="menuitem"]')).toBeNull() + if (entryCount !== 1) { + expect(h.view.container.querySelector('[data-dockkit-add-tab]')).toBeNull() + return + } + fireEvent.click(element(h.view.container, '[data-dockkit-add-tab]')) + const guide = Object.values(h.layout().tabs).find(tab => tab.kind === 'guide')! + expect(h.view.container.querySelector('[data-dockkit-add-tab]')).toBeNull() + expect(h.view.container.querySelector(`[data-dockkit-tab-close="${initial.id}"]`)).toBeNull() + expect(h.view.container.querySelector(`[data-dockkit-tab-close="${guide.id}"]`)).not.toBeNull() + act(() => { h.controller.close(initial.id) }) + expect(h.layout().tabs[initial.id]).toBe(initial) + const preview = h.open('ordinary.txt') + expect(h.view.container.querySelector(`[data-dockkit-tab-close="${preview.id}"]`)).not.toBeNull() + act(() => { h.controller.close(preview.id) }) + expect(h.layout().tabs[preview.id]).toBeUndefined() + fireEvent.click(element(h.view.container, `[data-dockkit-tab-close="${guide.id}"]`)) + expect(Object.keys(h.layout().tabs)).toEqual([initial.id]) + expect(h.view.container.querySelectorAll('[data-dockkit-tab-close]')).toHaveLength(0) + expect(h.view.container.querySelector('[data-dockkit-add-tab]')).not.toBeNull() + act(() => { h.controller.split() }) + expect(Object.values(h.layout().tabs).map(tab => tab.kind)).toEqual(['text', 'text']) + }) + it('keeps the panel mounted while collapsed and releases the frame on unmount', async () => { const h = await mountSeat() const panel = element(h.view.container, '[data-sidebar-right-panel]') @@ -473,6 +508,10 @@ describe('slot-owned useTabInfo', () => { expect(getPane(h.layout(), right).tabs.filter(id => h.layout().tabs[id]?.kind === 'guide')).toHaveLength(1) const closing = [...getPane(h.layout(), right).tabs] act(() => { for (const tabId of closing) h.actions.closeTab(SESSION, tabId) }) + expect(dockPaneIds(h.layout())).toHaveLength(2) + const remaining = getPane(h.layout(), right).tabs[0]! + expect(h.view.container.querySelector(`[data-dockkit-tab-close="${remaining}"]`)).toBeNull() + act(() => { h.actions.placeTab(SESSION, remaining, dockPaneIds(h.layout())[0]!, 0) }) expect(dockPaneIds(h.layout())).toHaveLength(1) expect(splitButtons()).toHaveLength(1) expect(splitButtons()[0]?.disabled).toBe(false) diff --git a/packages/client/ui-sidebar-right/tests/service.client.spec.ts b/packages/client/ui-sidebar-right/tests/service.client.spec.ts index 6e1c4025e7..10d6b96745 100644 --- a/packages/client/ui-sidebar-right/tests/service.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/service.client.spec.ts @@ -48,7 +48,7 @@ function harness() { }) const pin = vi.fn<(address: string, signal: AbortSignal) => void>() const { controller, adopt } = createSidebarRightController(tabs, pin) - const instance = createSidebarRightStore(() => 'seed').create() + const instance = createSidebarRightStore(() => ({ kind: 'guide', title: 'seed', permanent: false })).create() const layout = (): LayoutState => { const surface = instance.getSnapshot().bySession[SESSION] if (surface === undefined) throw new Error('expected a surface') @@ -445,7 +445,7 @@ describe('SidebarRightController — a tab\'s own actions', () => { const guideOccurrence = controller.tabDomain.occurrence(SESSION, guide) // The user switches sessions: the other seat binds with the other session's // own instance, whose store knows nothing of this session. - const other = createSidebarRightStore(() => 'seed').create(OTHER) + const other = createSidebarRightStore(() => ({ kind: 'guide', title: 'seed', permanent: false })).create(OTHER) const releaseOther = adopt(OTHER, other) other.actions.open(OTHER) const otherSurface = other.getSnapshot().bySession[OTHER] @@ -519,7 +519,7 @@ describe('SidebarRightController — a tab\'s own actions', () => { // Adopting another instance for the session ends the earlier adoption's // subscription with its routing: the old store's commits sync nothing, the // new store's commits reconcile the session against its own layout. - const replacement = createSidebarRightStore(() => 'seed').create() + const replacement = createSidebarRightStore(() => ({ kind: 'guide', title: 'seed', permanent: false })).create() const third = adopt(SESSION, replacement) const pins = pin.mock.calls.length instance.actions.openContent(SESSION, { kind: 'text', contentId: 'dsh-resource://file/session/s-test/c.txt', title: 'c' }, () => {}) diff --git a/packages/client/ui-sidebar-right/tests/stores.client.spec.ts b/packages/client/ui-sidebar-right/tests/stores.client.spec.ts index d7a040734c..6c0f767ec0 100644 --- a/packages/client/ui-sidebar-right/tests/stores.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/stores.client.spec.ts @@ -15,7 +15,7 @@ import { createSidebarRightStore } from '../src/client/stores.ts' const SESSION = 's-test' function harness() { - const instance = createSidebarRightStore(() => 'Start').create() + const instance = createSidebarRightStore(() => ({ kind: 'guide', title: 'Start', permanent: false })).create() instance.actions.open(SESSION) const surface = () => { const held = instance.getSnapshot().bySession[SESSION] diff --git a/packages/client/ui-sidebar-right/tests/tab-info.client.spec.tsx b/packages/client/ui-sidebar-right/tests/tab-info.client.spec.tsx index 81c495f20b..8acb213762 100644 --- a/packages/client/ui-sidebar-right/tests/tab-info.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/tab-info.client.spec.tsx @@ -21,7 +21,7 @@ afterEach(() => { }) function harness() { - const instance = createSidebarRightStore(() => 'Start').create() + const instance = createSidebarRightStore(() => ({ kind: 'guide', title: 'Start', permanent: false })).create() const domain = new TabDomain({ openResourceIn: vi.fn(), openTabIn: vi.fn(), closeIn: vi.fn() }, vi.fn()) domains.push(domain) const navigationSources = new Map() diff --git a/packages/client/ui-sidebar-right/tests/tab-registry.client.spec.ts b/packages/client/ui-sidebar-right/tests/tab-registry.client.spec.ts index 2576115f33..905e6a4187 100644 --- a/packages/client/ui-sidebar-right/tests/tab-registry.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/tab-registry.client.spec.ts @@ -11,6 +11,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { SidebarRightTabRegistry } from '../src/client/tab-registry.ts' import type { SidebarRightTabDefinition } from '../src/client/tab-registry.ts' +import { defaultSeed } from '../src/client/contract/seed.ts' /** A type recognizing `patterns`, titled by its kind. */ function typeFor( @@ -27,6 +28,11 @@ function ranked(registry: SidebarRightTabRegistry, address: string): string[] { } describe('SidebarRightTabRegistry — recognition', () => { + it('rejects default-page resolution when the selected kind is not registered', () => { + expect(() => defaultSeed(new SidebarRightTabRegistry(new Context()))) + .toThrow('default tab kind "guide" is not registered') + }) + it('matches a pattern containing ":" against the whole address', () => { const registry = new SidebarRightTabRegistry(new Context()) registry.register(typeFor('guide', ['sidebar://guide'])) diff --git a/packages/client/ui-sidebar-textpreview/README.i18n.yaml b/packages/client/ui-sidebar-textpreview/README.i18n.yaml index 3336e9ce03..6aa17d2f9d 100644 --- a/packages/client/ui-sidebar-textpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-textpreview/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-textpreview/README.md -README.md: aa8364023637598ca84635321376fed07eef90cf -README.zh.md: ab157cb83045f414bf9cc9547c838d5c41cfd286 +README.md: 671367221b4b336bfccb1052054b210f5a42c5ad +README.zh.md: 990b4175332ba1c72f98f4060c315ec8912cec8b diff --git a/packages/client/ui-sidebar-textpreview/README.md b/packages/client/ui-sidebar-textpreview/README.md index aa83640236..671367221b 100644 --- a/packages/client/ui-sidebar-textpreview/README.md +++ b/packages/client/ui-sidebar-textpreview/README.md @@ -1,5 +1,5 @@ --- -description: "The right Sidebar's plain-text viewer tab type for the dsh web client: paged reads of one workspace file, line navigation, wrap, reload, and the fallback claim on every file resource address." +description: "Document previews in the right Sidebar: shared file loading and controls, selectable Markdown, code, PDF and HTML renderers, and plain-text fallback." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The right Sidebar's plain-text viewer: one workspace text file, read one page of lines at a time, with line navigation, wrap, and reload. It is the fallback type for every `file` resource address, and the template for a tab type shipped from outside `ui-sidebar-right`: every import from the Sidebar is a type, the file's metadata comes from the shared `file` resource, the text is the type's own business, and the type's controls live in its own body. +Preview workspace documents in the right Sidebar and choose among registered renderers without opening another tab. Markdown and code receive accumulated text pages; PDF and HTML receive complete bytes; unknown file extensions use plain text. The tab owns loading, file status, renderer selection, wrap, and reload, while document bodies register through the same metadata registry and child slot. The Sidebar tab kind is `text`. ## Table of Contents @@ -26,30 +26,47 @@ The right Sidebar's plain-text viewer: one workspace text file, read one page of ## What it registers -- **The type** — `ctx.sidebarRightTabs.register(...)` with id `@deepseek-ai/dsh-client-ui-sidebar-textpreview` (this implementation's identity in the tab system, and the key its body registers under), kind `text`, pattern `dsh-resource://file/**`, band `fallback`. A type registered at the `extension` or `builtin` band for a narrower pattern (say `*.png`) takes those addresses; everything else lands here. The whole address is the content identity, so two files with one name in different directories, or one path under two sessions, are two tabs; the decoded basename is the tab title. -- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its header row shows the Host's absolute path and exposes its full value in a tooltip, with the type's two controls at its end: a wrap toggle (on by default; long lines wrap until the reader turns it off, per tab) and a reload button. The Sidebar's tab strip carries no controls of this type. The body takes the pane body's full height: the header row stays put and the file body below is the one scroller, so a short file leaves no unstyled space and a long file scrolls under a fixed path. Until metadata provides an absolute path, the header uses the requested path. -- **One store and one face**, session-scoped and bucketed by tab id. The store holds the pages read so far (keyed by the 1-based line each starts at, with the file version they belong to), the end-of-file flag, the read in flight or its failure, and the view: scroll offset, wrap (initially on), and the navigation revision the body last answered. The face (`loadPage`, `reloadPages`) performs the reads and writes through the store's actions. The bucket is forgotten when the owner's `signal` aborts, which is when the tab record is gone. +- **The type** — `ctx.sidebarRightTabs.register(...)` with id `@deepseek-ai/dsh-client-ui-sidebar-textpreview` (this implementation's identity in the tab system, and the key its body registers under), kind `text`, pattern `dsh-resource://file/**`, band `fallback`. `canOpen` accepts only Session addresses, whose paths may be relative or absolute; bare `absolute` addresses are not claimed. A type registered at the `extension` or `builtin` band for a narrower pattern (say `*.png`) takes those addresses; other supported files land here. The whole address is the content identity, so two files with one name in different directories, or one path under two sessions, are two tabs; the decoded basename is the tab title. +- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path, and a dropdown for matching renderers plus plain text. A wrap toggle appears only when the selected renderer declares `wrap: true`; the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. The shared body below owns document scrolling. +- **Shared loading and view state**, session-scoped and bucketed by tab id. The store holds accumulated pages or complete bytes, read and observed versions, loading/failure state, renderer choice, scroll offset, wrap, and the answered navigation revision. The ordinary inject face calls Remote readers and writes through declared store actions. Reloads and loading-mode changes retire older requests; the tab's abort signal forgets its state. + +Document implementations register metadata with `ctx.documentPreviews.register({ id, extensions, priority, title, loading, wrap? })` and a body under the same `id` in the keyed, Session-scoped `sidebar.right.tab.document` child slot. Own both registrations with effects and wait for the child slot through `ctx.slots.inject`. Bodies receive `resourceAddress`, prepared `content`, `wrap`, and the standard `useTabInfo`/`useResource` hooks; they do not receive a custom resource loader. Metadata declares `loading: 'text-pages'` or `'bytes-complete'`. The registry retains all matching alternatives: `extension` (the default) ranks above `builtin`, then longer suffixes rank first, then registration order. The dropdown preserves a selected implementation while it remains available; removing it selects the next candidate. Builtin bodies use these same registrations. + +### Configuration + +HTML limits are fields on this package's root Client `Config`, configurable on its existing plugin row: + +| Field | Default | Meaning | +|---|---|---| +| `htmlMaxAssetBytes` | `4194304` (4 MiB) | Maximum decoded bytes in one directly referenced local script or stylesheet | +| `htmlMaxTotalBytes` | `33554432` (32 MiB) | Maximum decoded bytes in the root HTML and its unique packed assets together | +| `htmlMaxAssets` | `64` | Maximum distinct local script and stylesheet references packed for one HTML document | ## Addresses -A tab's address is `dsh-resource://file/session//` or `dsh-resource://file/absolute/` (a URI whose authority is the resource protocol, `file`, and whose path opens with the scope), built by `fileAddressFor` in `@deepseek-ai/dsh-util-workspace-path` and read back by `parseFileAddress`; every segment is component-encoded, and this package never splits the string itself. `hostFileOf` in `rpc.ts` turns the address into the session and path the endpoint takes: a `session` address reads under the session it names, with the relative path the Host resolves against that session's workspace root; an `absolute` address reads under the session the slot was mounted for, with the absolute path, and the Host's workspace confinement still applies. A malformed address throws, because the registry routes every `file` address to this type and a caller building one is expected to use the helper. +A tab uses the Session address built by `fileAddressFor`, carrying a relative or absolute path. `hostFileOf(address)` takes the Session only from that address, with no external Session argument; neither current nor Tab Session is borrowed. The Host resolves file and related paths and enforces confinement. Metadata for the same complete address is shared by every UI, including Global components. The [Workspace Files README](../../api/workspace-files/README.md) owns these rules; renderer selection does not change the navigation address. ## How it reads -The body reads its record, navigation and lifetime through `useTabInfo().tab`. Metadata and content come from different places: +The body reads its record, navigation and lifetime through `useTabInfo().tab`. `useResource<'file'>(tab.contentId)` supplies metadata; ordinary inject callbacks supply content reads: -- `useResource<'file'>(tab.contentId)`, the standard hook from `@deepseek-ai/dsh-client-resources`, yields `{ absolutePath, version, bytes, changed }` from the `file` provider in `@deepseek-ai/dsh-api-workspace-files`. The body reads `changed` and the failed state: when the agent wrote the file after the last `stat`, a bar announces it with a reload button, and when the resource is `failed` — the file gone, or the Host refusing it — a failure bar takes that place with the failure's line and the same reload button, ahead of any pending `changed`. Either way the pages already read stay on screen: the text is never replaced under the reader. -- Pages come from `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`, bound in `rpc.ts` and called by the face with the session and path the address names. The first mount reads the first page; a **Load more** button at the end of the loaded text reads the next until `eof`. Each page carries its line count (`lines`), which is how one empty line and a page past the end read differently. A first page from a newer file version replaces the pages of the older one; a later page from a newer version is not adopted — the walk restarts from the first page, so the body never mixes two versions. A failed page shows one sentence per `workspace-file/*` code (`not-found`, `outside-workspace`, `too-large` for a page over the byte cap, `not-text`, `not-regular-file`) or the transport's own message, with a retry for the same page. -- **Reload** — the change bar's button and the header's reload control both call the resource's `reload()` (a fresh `stat`, which clears `changed`) and the face's `reloadPages` (drop the pages, read the first one again). A reload retires the reads still in flight — the face keeps a request generation per tab, and a page settling from an older generation writes nothing. The scroll offset is kept, so the reader stays where they were. +- The resource snapshot contains only `status`, `value`, and `failure`; `value` is `WorkspaceFileStat` metadata. Content reads do not wait for the first metadata frame once the provider is available. Observation failures take precedence over Preview's change notice; neither automatically replaces loaded content. +- **Text pages** — plain text, Markdown, and code read through an inject callback to `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`. The first mount reads page one; scrolling to the body end or **Load more** requests the next page until `eof`. The owner delivers the accumulated prefix as `{ kind: 'text', text, pages, eof }`, including source offsets and line counts. Markdown and code render that prefix incrementally; they do not render each page as a separate document. A newer-version page past page one restarts from the beginning rather than mixing versions. Failures retain loaded content and offer a retry. +- **Complete bytes** — PDF and HTML use an inject callback to `remote.workspaceFiles.readAll(sessionId, path, signal)`. `rpc.ts` decodes the wire base64 into `data: Uint8Array` for `{ kind: 'bytes', data }`. The Host's `maxFileBytes` cap rejects oversized files rather than truncating them. PDF copies retained bytes before worker transfer, keeping the Preview buffer usable. Bytes stay in transient view state, never persisted layouts or Session JSONL. Loading-mode changes retire previous results. +- **Reload** — only the current Preview tab rereads through its Remote callbacks, preserving its scroll preference and retiring older requests. Its change notice compares the read version and the observation captured at read start with later `resource.value.version`; an already observed version does not become a new change after refresh. Reads neither refresh shared metadata nor clear another tab's notice. -Copy comes from the `sidebarTextpreview` locale namespace. +HTML runs in a Blob iframe with exactly `sandbox="allow-scripts"`, without `allow-same-origin`; scripts cannot access the parent application's origin or file reader. The renderer loads directly declared relative `.js` classic scripts and `.css` stylesheets through its ordinary inject callback to `remote.workspaceFiles.readRelated`, under the configured limits. Host code resolves the related path; `rpc.ts` decodes the returned bytes. Inside the renderer, base64 is used only to embed the iframe bootstrap payload in script text. A `` leaves dependency resolution to the browser, as do HTTPS resources. Local module imports, CSS `url()`/`@import`, and dynamic `fetch` do not use Host file access. Read failures, invalid UTF-8, or exceeded limits fail the preview rather than publishing a partial asset package. Replacing or unmounting the document releases its Blob URL. + +Shared copy comes from `sidebarTextpreview`; each builtin renderer owns its localized labels. + +Initial reads, additional pages, and HTML/PDF preparation share a loading indicator that respects reduced-motion preferences. Loaded pages stay visible while another page loads. Code previews show source line numbers by default without including them in copied text; plain text uses the same font size and line height as code. ## Navigation -`ctx.sidebarRight.openResource(address, { params: { line } })` — the `read` tool row passes its `offset` this way — arrives as `navigation.params`, which the body narrows to the `file` resource type's declared parameters (`SidebarRightResourceParamsMap['file']`, `{ line?: number }`, 1-based) without runtime validation: `params` is a typed same-process value. If the loaded pages do not reach that line, the body reads the next page, again, until they do or the file ends; then it scrolls the line to the top and marks it, once per `navigation.revision`. A body remounting for the same revision restores the reader's scroll offset instead. Opening the same file again without `revealIfOpened: false` focuses the existing tab and delivers the new parameters as a new revision. +`ctx.sidebarRight.openResource(address, { params: { line } })` carries a 1-based source line through the `file` parameters. In `text-pages` mode, the owner loads sequential pages until that line or EOF, then locates a source-line anchor if the renderer supplies one; plain text supplies these anchors. Byte-mode renderers do not consume source-line navigation. Each navigation revision is answered once; remounting restores the shared scroll offset. Opening the same file without `revealIfOpened: false` focuses its existing tab and delivers a new revision. ## Model Experience @@ -63,8 +80,9 @@ No direct effect; what the user reads here never enters a model request. ## Known Limitations and Deferred Work -- **Plain text only.** No syntax highlighting, images, rendered markdown, or search; a directory address fails with `not-regular-file`. -- **Sequential pages.** A line far into a large file loads every page before it; there is no seek to an arbitrary offset. +- **Preview, not editing.** The viewers provide no file editing or shared search interface; a directory address fails with `not-regular-file`. Unknown extensions use the plain-text reader and remain subject to its UTF-8/NUL checks. +- **Sequential text and bounded complete files.** Deep source lines require the preceding pages; PDF and HTML require a complete result within the Host's `maxFileBytes` cap. +- **Finite local HTML dependencies.** Only direct classic `.js` and stylesheet `.css` references are packed. Browser-resolved resources retain browser origin and network restrictions; no runtime file-read bridge is exposed to the iframe. - **Package-local wrap glyph.** `IconWrapOutline16` lives in `src/client/icons.tsx` until the shared icon set carries one; the props contract already matches. - **Scroll writes are unthrottled.** Every scroll event records its offset in the store; the line blocks are memoized so the resulting re-render hands React the same elements back. @@ -78,4 +96,4 @@ None. -**Runtime invariant:** No companion is published. The type's only runtime state is one Slot store per tab, written by the body that owns it and forgotten on the tab's abort signal; there is no second observation of it to compare against. +**Runtime invariant:** No companion is published. Renderer metadata, document loading, and view state belong to the local registry and declared Slot stores, with no independent runtime source to compare against; registration disposal and tab lifetimes are covered by behavior tests. diff --git a/packages/client/ui-sidebar-textpreview/README.zh.md b/packages/client/ui-sidebar-textpreview/README.zh.md index ab157cb830..990b417533 100644 --- a/packages/client/ui-sidebar-textpreview/README.zh.md +++ b/packages/client/ui-sidebar-textpreview/README.zh.md @@ -1,5 +1,5 @@ --- -description: "dsh Web 客户端右侧 Sidebar 的纯文本查看器 tab 类型:对一个工作区文件分页读取,带行导航、换行、重新读取,并兜底认领每个 file 资源地址。" +description: "右侧 Sidebar 的文档预览:共享文件加载与控件,可选 Markdown、代码、PDF 和 HTML 渲染器,并以纯文本兜底。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -右侧 Sidebar 的纯文本查看器:一个工作区文本文件,一次读一页行,带行号导航、换行与重新读取。它是每个 `file` 资源地址的兜底类型,也是 `ui-sidebar-right` 之外交付的 tab 类型的样板:来自 Sidebar 的每个 import 都是类型,文件的元数据来自共享的 `file` 资源,正文是类型自己的事,类型的控件住在自己的体里。 +在右侧 Sidebar 预览工作区文档,无需另开 tab 即可切换已注册的渲染器。Markdown 和代码接收累计文本页;PDF 和 HTML 接收完整字节;未知文件扩展名使用纯文本。tab 负责加载、文件状态、渲染器选择、换行和重新载入,文档正文通过同一元数据注册表与子 slot 注册。Sidebar tab 的 kind 为 `text`。 ## 目录 @@ -26,30 +26,47 @@ kind: "package-reference" ## 注册了什么 -- **类型** —— `ctx.sidebarRightTabs.register(...)`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-textpreview`(这个实现在 tab 系统里的唯一键,也是其体注册所用的 key),kind `text`,pattern `dsh-resource://file/**`,档位 `fallback`。在 `extension` 或 `builtin` 档以更窄 pattern(比如 `*.png`)注册的类型接走那些地址;其余一切落到这里。整个地址就是内容身份,所以不同目录下同名的两个文件、或同一路径在两个会话之下,是两个 tab;解码后的 basename 是 tab 标题。 -- **体** —— keyed 坑位 `sidebar.right.pane.tab`,键为类型的 id。它的头部行显示 Host 的绝对路径,并在提示中保留完整值,末端是类型的两个控件:换行开关(默认开;长行折行直到读者关掉它,按 tab 记)与重新读取按钮。Sidebar 的 tab 条不承载这个类型的任何控件。体占满 pane 体的全部高度:头部行不动,其下的文件体是唯一的滚动者,于是短文件不留没有样式的空白,长文件在固定的路径下滚动。 元数据尚未提供绝对路径时,头部使用请求路径。 -- **一个 store 与一个 face**,会话作用域、按 tab id 分桶。store 持有已读的页(以每页起始的 1 起行号为键,连同它们所属的文件版本)、文件末尾标志、进行中的读取或其失败,以及视图:滚动位置、换行(初始为开)、体最近答过的导航 revision。face(`loadPage`、`reloadPages`)执行读取并经 store 的 action 写入。owner 的 `signal` abort 时——即 tab 记录消失时——桶被忘掉。 +- **类型** —— `ctx.sidebarRightTabs.register(...)`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-textpreview`(这个实现在 tab 系统里的唯一键,也是其体注册所用的 key),kind `text`,pattern `dsh-resource://file/**`,档位 `fallback`。`canOpen` 只接受 Session 地址,其中路径可为相对或绝对路径;不认领裸 `absolute` 地址。在 `extension` 或 `builtin` 档以更窄 pattern(比如 `*.png`)注册的类型接走那些地址;其他受支持文件落到这里。整个地址就是内容身份,所以不同目录下同名的两个文件、或同一路径在两个会话之下,是两个 tab;解码后的 basename 是 tab 标题。 +- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径,并提供匹配渲染器及纯文本的下拉选择。仅当所选渲染器声明 `wrap: true` 时显示换行开关;该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。下方的共享正文区域负责文档滚动。 +- **共享加载与视图状态**,会话作用域、按 tab id 分桶。store 持有累计页或完整字节、读取与观察版本、加载/失败状态、渲染器选择、滚动位置、换行和已响应的导航 revision。普通 inject face 调用 Remote 读取,并经声明的 store action 写入。重新载入和加载模式变化会淘汰旧请求;tab 的中止信号清理其状态。 + +文档实现在 `ctx.documentPreviews.register({ id, extensions, priority, title, loading, wrap? })` 注册元数据,并以相同 `id` 向 keyed、Session 作用域的子 slot `sidebar.right.tab.document` 注册正文。两处注册都由 effect 持有,通过 `ctx.slots.inject` 等待子 slot。正文接收 `resourceAddress`、准备好的 `content`、`wrap` 和标准 `useTabInfo`/`useResource` 钩子,不接收自定义资源加载器。元数据声明 `loading: 'text-pages'` 或 `'bytes-complete'`。注册表保留所有匹配备选:`extension`(默认)优先于 `builtin`,随后按更长的后缀、再按注册顺序排列。所选实现仍可用时,下拉选择保持不变;移除后选择下一个候选。内置正文也使用相同注册方式。 + +### 配置 + +HTML 上限是本包根 Client `Config` 的字段,可在现有插件配置项中设置: + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `htmlMaxAssetBytes` | `4194304`(4 MiB) | 单个直接引用的本地脚本或样式表的最大解码字节数 | +| `htmlMaxTotalBytes` | `33554432`(32 MiB) | 根 HTML 与去重后打包资源合计的最大解码字节数 | +| `htmlMaxAssets` | `64` | 一份 HTML 文档最多打包的不同本地脚本与样式表引用数 | ## 地址 -tab 的地址是 `dsh-resource://file/session//<相对该会话工作区根的路径>` 或 `dsh-resource://file/absolute/<去掉前导 / 的绝对路径>`(一个 URI,authority 是资源协议 `file`,路径以作用域开头),由 `@deepseek-ai/dsh-util-workspace-path` 的 `fileAddressFor` 构造、`parseFileAddress` 读回;每段都做 component 编码,本包从不自己拆这个串。`rpc.ts` 里的 `hostFileOf` 把地址变成端点所需的会话与路径:`session` 地址在它命名的会话下读取,相对路径由 Host 对该会话的工作区根解析;`absolute` 地址在坑位被挂载的会话下以绝对路径读取,Host 的工作区限制照样适用。畸形地址直接抛错,因为注册表把每个 `file` 地址都路由给这个类型,造地址的调用方本应使用助手。 +tab 使用 `fileAddressFor` 构造的 Session 地址,携带相对或绝对路径。`hostFileOf(address)` 仅从地址取得 Session,不接收外部 Session 参数,也不借用当前或 Tab Session。Host 解析文件及关联路径,并执行包含检查。任何 UI(包括 Global 组件)都共享同一完整地址的元数据。[Workspace Files README](../../api/workspace-files/README.zh.md)定义这些规则;渲染器选择不改变导航地址。 ## 怎么读 -正文通过 `useTabInfo().tab` 读取记录、导航和生命周期。元数据与内容来自不同的地方: +正文通过 `useTabInfo().tab` 读取记录、导航和生命周期。`useResource<'file'>(tab.contentId)` 提供元数据,普通 inject 回调提供内容读取: -- `useResource<'file'>(tab.contentId)`——来自 `@deepseek-ai/dsh-client-resources` 的标准 hook——从 `@deepseek-ai/dsh-api-workspace-files` 的 `file` 提供方得到 `{ absolutePath, version, bytes, changed }`。体读 `changed` 与失败态:agent 在上次 `stat` 之后写了文件时,一条提示带着重新载入按钮出现;资源为 `failed` 时——文件没了,或 Host 拒绝——一条失败条占据同一位置,显示失败句与同一个重新载入按钮,并优先于尚未处理的 `changed`。两种情况下已读的页都留在屏幕上:正文绝不在读者眼前被替换。 -- 页来自 `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`,在 `rpc.ts` 绑定、由 face 以地址所命名的会话与路径调用。首次挂载读第一页;已加载文本末尾的 **加载更多** 按钮读下一页直到 `eof`。每页带着自己的行数(`lines`),单个空行与越过文件末尾的页由此区分。来自更新文件版本的第一页替换旧版本的页;更新版本的后续页不被采用——从第一页重新走一遍,于是体永不混合两个版本。失败的页按 `workspace-file/*` 错误码各显示一句(`not-found`、`outside-workspace`、超过字节上限的页 `too-large`、`not-text`、`not-regular-file`)或传输层自己的消息,并带一个重读同一页的重试。 -- **重新载入** —— 变更提示条的按钮与头部的重新读取控件都调用资源的 `reload()`(重新 `stat`,清掉 `changed`)与 face 的 `reloadPages`(丢掉所有页,重读第一页)。重载淘汰仍在飞的读取——face 按 tab 记请求代次,旧代次结算的页什么也不写。滚动位置保留,读者停在原处。 +- 资源快照仅包含 `status`、`value` 和 `failure`;`value` 是 `WorkspaceFileStat` 元数据。提供方可用后,内容读取无需等待首个元数据帧。观察失败优先于 Preview 的变更提示显示;两者都不会自动替换已加载内容。 +- **文本页** —— 纯文本、Markdown 和代码通过 inject 回调调用 `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`。首次挂载读取第一页;滚动到正文末尾或点击 **加载更多** 会读取下一页,直到 `eof`。owner 以 `{ kind: 'text', text, pages, eof }` 提供累计前缀,包含源码行偏移和行数。Markdown 和代码增量渲染此前缀,不把每页当成独立文档。第一页之后到达的更新版本页会使读取从头开始,避免混合版本。失败时保留已加载内容并提供重试。 +- **完整字节** —— PDF 和 HTML 通过 inject 回调调用 `remote.workspaceFiles.readAll(sessionId, path, signal)`。`rpc.ts` 将线路上的 base64 解码为 `data: Uint8Array`,供 `{ kind: 'bytes', data }` 使用。Host 的 `maxFileBytes` 上限拒绝超大文件,不截断。PDF 在传给 worker 前复制保留的字节,使 Preview 缓冲区仍可使用。字节仅保存在临时视图状态中,绝不进入持久布局或 Session JSONL。加载模式变化会淘汰先前结果。 +- **重新载入** —— 仅当前 Preview tab 通过自己的 Remote 回调重读,保留滚动偏好并淘汰旧请求。变更提示将读取版本及起读时的观察版本与后续 `resource.value.version` 比较;刷新前已观察到的版本不会被当成新变化。读取既不刷新共享元数据,也不清除其它 tab 的提示。 -文案来自 `sidebarTextpreview` locale 命名空间。 +HTML 在 Blob iframe 中运行,沙箱属性严格为 `sandbox="allow-scripts"`,不含 `allow-same-origin`;脚本无法访问父应用的源或文件读取接口。渲染器在配置上限内,通过普通 inject 回调调用 `remote.workspaceFiles.readRelated`,加载直接声明的相对 `.js` 经典脚本和 `.css` 样式表。Host 代码解析关联路径,`rpc.ts` 解码返回的字节。在渲染器内部,base64 仅用于把 iframe 引导载荷嵌入脚本文本。`` 将依赖解析交给浏览器,HTTPS 资源也由浏览器处理。本地模块 import、CSS `url()`/`@import` 和动态 `fetch` 不使用 Host 文件访问。读取失败、无效 UTF-8 或超出上限都使预览失败,不发布部分资源包。替换或卸载文档会释放其 Blob URL。 + +共享文案来自 `sidebarTextpreview`;各内置渲染器拥有自己的本地化标签。 + +首次读取、追加页及 HTML/PDF 准备共用加载指示器,并遵循减少动态效果偏好。下一页加载期间保留已显示的内容。代码预览默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。 ## 导航 -`ctx.sidebarRight.openResource(address, { params: { line } })`——`read` 工具行以此传它的 `offset`——以 `navigation.params` 到达,体把它收窄为 `file` 资源类型声明的参数(`SidebarRightResourceParamsMap['file']`,`{ line?: number }`,1 起),不做运行时校验:`params` 是同进程的类型化值。已加载的页够不到该行时,体读下一页,再读,直到覆盖它或文件结束;然后把该行滚到顶部并标记,每个 `navigation.revision` 一次。同一 revision 下重新挂载的体恢复读者的滚动位置而不再跳。不带 `revealIfOpened: false` 再次打开同一文件时聚焦已有 tab,并把新参数作为新 revision 送达。 +`ctx.sidebarRight.openResource(address, { params: { line } })` 通过 `file` 参数携带 1 起算的源码行号。在 `text-pages` 模式下,owner 顺序加载到该行或 EOF;渲染器提供源码行锚点时,再定位到对应锚点,纯文本提供这些锚点。字节模式渲染器不消费源码行导航。每个导航 revision 只响应一次;重新挂载恢复共享滚动位置。不带 `revealIfOpened: false` 打开同一文件时聚焦已有 tab,并送达新 revision。 ## 模型体验 @@ -63,8 +80,9 @@ tab 的地址是 `dsh-resource://file/session//<相对该会话工作 ## 已知限制与延期工作 -- **只有纯文本。** 没有语法高亮、图片、Markdown 渲染或搜索;目录地址以 `not-regular-file` 失败。 -- **页按顺序加载。** 大文件深处的一行要先加载它之前的每一页;没有到任意偏移的 seek。 +- **预览而非编辑。** 查看器不提供文件编辑或共享搜索接口;目录地址以 `not-regular-file` 失败。未知扩展名使用纯文本读取,仍受其 UTF-8/NUL 检查限制。 +- **文本顺序分页,完整文件受限。** 定位深处源码行需要先加载此前各页;PDF 和 HTML 必须取得 Host `maxFileBytes` 上限内的完整结果。 +- **本地 HTML 依赖集合有限。** 只打包直接引用的经典 `.js` 脚本和 `.css` 样式表。浏览器解析的资源仍受浏览器源与网络规则限制;iframe 不获得运行时文件读取桥接。 - **换行图标为包内自绘。** `IconWrapOutline16` 住在 `src/client/icons.tsx`,直到共享图标集提供为止;props 契约已经一致。 - **滚动写入未节流。** 每次滚动事件都把偏移记进 store;行块已 memo 化,于是由此引发的重渲染交还给 React 的是同一批元素。 @@ -78,4 +96,4 @@ tab 的地址是 `dsh-resource://file/session//<相对该会话工作 -**运行时不变量:** 不发布 companion。该类型唯一的运行时状态是每 tab 一份的 Slot store,由持有它的正文写入、随 tab 的中止信号忘掉;没有第二个观测源可与之比对。 +**运行时不变式:** 不发布伴生入口。渲染器元数据、文档加载和视图状态归本地注册表与声明的 Slot store 所有,没有可比对的独立运行时来源;注册释放和 tab 生命周期由行为测试覆盖。 diff --git a/packages/client/ui-sidebar-textpreview/package.json b/packages/client/ui-sidebar-textpreview/package.json index 7801a14e1d..945f411fcf 100644 --- a/packages/client/ui-sidebar-textpreview/package.json +++ b/packages/client/ui-sidebar-textpreview/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-sidebar-textpreview", - "description": "Text preview tab type for the right Sidebar: the fallback viewer for file: addresses, drawn from the file resource with line navigation, wrap, and reload", + "description": "Extensible document previews for Sidebar files: Markdown, highlighted code, PDF, HTML, and plain text", "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" @@ -28,6 +28,7 @@ "dsh": { "client": { "inject": [ + "@deepseek-ai/dsh-api-gateway", "@deepseek-ai/dsh-api-workspace-files", "@deepseek-ai/dsh-client-ui-sidebar-right", "@deepseek-ai/dsh-client-ui-session", @@ -41,11 +42,19 @@ "watch": "tsdown --watch" }, "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "pdfjs-dist": "6.3.289", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-workspace-files": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", @@ -70,6 +79,7 @@ "files": [ "lib/index.js", "lib/client.js", + "lib/pdfjs-NOTICES.txt", "lib/types/**/*.d.ts" ] } diff --git a/packages/client/ui-sidebar-textpreview/src/client/LoadingIndicator.module.css b/packages/client/ui-sidebar-textpreview/src/client/LoadingIndicator.module.css new file mode 100644 index 0000000000..017ad8a7af --- /dev/null +++ b/packages/client/ui-sidebar-textpreview/src/client/LoadingIndicator.module.css @@ -0,0 +1,25 @@ +.loading { + display: inline-flex; + gap: 8px; + align-items: center; + vertical-align: middle; + white-space: normal; +} + +.icon { + display: flex; + flex: none; + animation: turn 0.8s linear infinite; +} + +@keyframes turn { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .icon { + animation: none; + } +} diff --git a/packages/client/ui-sidebar-textpreview/src/client/LoadingIndicator.tsx b/packages/client/ui-sidebar-textpreview/src/client/LoadingIndicator.tsx new file mode 100644 index 0000000000..fff0ba2e3a --- /dev/null +++ b/packages/client/ui-sidebar-textpreview/src/client/LoadingIndicator.tsx @@ -0,0 +1,13 @@ +/** Shared indeterminate loading feedback for document reads and rendering. */ +import type { ReactNode } from 'react' +import clsx from 'clsx' +import { IconLoadingOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import css from './LoadingIndicator.module.css' + +/** @param props - localized status label and optional placement style. @returns an animated, accessible loading status. */ +export function LoadingIndicator({ label, className }: { label: string; className?: string | undefined }): ReactNode { + return + + {label} + +} diff --git a/packages/client/ui-sidebar-textpreview/src/client/TextPreview.module.css b/packages/client/ui-sidebar-textpreview/src/client/TextPreview.module.css index 1bd16a6a23..49397c41be 100644 --- a/packages/client/ui-sidebar-textpreview/src/client/TextPreview.module.css +++ b/packages/client/ui-sidebar-textpreview/src/client/TextPreview.module.css @@ -66,10 +66,15 @@ word-break: break-word; } +/* Sticky code banners meet the scrollport edge; prose owns its initial spacing. */ +.body:has([data-code-preview], [data-document-markdown]) { + padding: 0; +} + /* One page of lines; pages abut so the file reads as one. */ .page { margin: 0; - font: inherit; + font: var(--dsw-font-markdown-code-block); white-space: inherit; } @@ -167,3 +172,13 @@ color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); } + +.viewerTool { + width: auto; + max-width: 160px; + padding: 0 6px; + overflow: hidden; + font-size: 12px; + white-space: nowrap; + text-overflow: ellipsis; +} diff --git a/packages/client/ui-sidebar-textpreview/src/client/TextPreview.tsx b/packages/client/ui-sidebar-textpreview/src/client/TextPreview.tsx index 83ce3b5459..64c62a32d1 100644 --- a/packages/client/ui-sidebar-textpreview/src/client/TextPreview.tsx +++ b/packages/client/ui-sidebar-textpreview/src/client/TextPreview.tsx @@ -1,119 +1,100 @@ /** - * The text preview's body: a file's pages, or the reason the next one is not showing. + * The text preview's body: a file's content, or the reason it is not showing. * * Two sources meet here. The standard `useResource` hook gives the file's - * metadata — its version and whether the agent wrote it since — and this type's - * own store holds the pages it read through its face. A Host-reported change is + * metadata — its version — and this type's + * own store holds the content it read through its face. A Host-reported change is * announced, not applied: reloading under a reader would lose their place, so * the bar waits for a click. A failed metadata frame — the file gone, its * workspace unknown — takes the same bar's place over the pages already loaded, - * with the same reload. The type's controls, wrap and reload, sit at the end of + * with the same reload. The type's controls, viewer choice, wrap and reload, sit at the end of * the path row; the Sidebar's strip carries none of them. */ -import { useEffect, useMemo, useRef } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import type { ReactNode } from 'react' import clsx from 'clsx' -import type { InjectFace, PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' -import { IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +import { IconRefreshOutline16, Menu } from '@deepseek-ai/dsh-client-ui-primitives' import type { TextInjected } from './face.ts' import { failureLine } from './failure-line.ts' import { IconWrapOutline16 } from './icons.tsx' +import { LoadingIndicator } from './LoadingIndicator.tsx' import { hostFileOf } from './rpc.ts' -import type { TextPage, TextStore } from './store.ts' +import type { TextStore } from './store.ts' +import type { DocumentContent } from './document/contract.ts' +import { matchingDocumentPreviews } from './document/registry.ts' +import type { DocumentPreviewDefinition } from './document/registry.ts' +import { PLAIN_BODY_ID } from './text/index.ts' +import { loadedPages, lastLineLoaded, scrollToLine } from './text/lines.ts' import css from './TextPreview.module.css' +export { linesOf, loadedPages, lastLineLoaded, scrollToLine } from './text/lines.ts' +export type { LoadedPage } from './text/lines.ts' + +/** Private registration inputs; the framework binds the registry source to useDocumentPreviews. */ +export interface TextPreviewInjected extends TextInjected { + readonly hooks: { readonly documentPreviews: ObservableSnapshot } +} + /** The body's composed props: the tab, its navigation, the shared store and face, and copy. */ export type TextPreviewProps = & PropsRuntime<'sidebar.right.pane.tab'> + & PropsRenderSlots<'sidebar.right.tab.document'> & PropsStore - & InjectFace + & InjectFace & PropsLocale<'sidebarTextpreview'> -/** - * A page's lines. The Host joins a page's lines with `\n` without a terminator - * and counts them, so a page past the file's last line (`lines: 0`) has none - * and a page holding one empty line (`lines: 1`, `text: ''`) has one; a - * trailing `\n` ends an empty last line. - * @param page - the page's text and line count. - * @returns the lines in order. - */ -export function linesOf(page: TextPage): string[] { - return page.lines === 0 ? [] : page.text.split('\n') -} - -/** One loaded page: the 1-based line it starts at, its text, and its line count. */ -export interface LoadedPage extends TextPage { - readonly offset: number -} - -/** - * The loaded pages in file order. - * @param pages - the store's page table. - * @returns the pages, ascending by offset. - */ -export function loadedPages(pages: Record): LoadedPage[] { - return Object.entries(pages) - .map(([offset, page]) => ({ offset: Number(offset), ...page })) - .sort((left, right) => left.offset - right.offset) -} - -/** - * The last line the loaded pages reach, by the Host's line counts; 0 before the first page. - * @param pages - the loaded pages, ascending. - * @returns the 1-based last loaded line. - */ -export function lastLineLoaded(pages: readonly LoadedPage[]): number { - const last = pages.at(-1) - return last === undefined ? 0 : last.offset + last.lines - 1 -} - -/** - * Scroll the body so one line sits at its top. A line the pages do not hold - * leaves the body where it is. - * @param body - the scrolling container; lines are positioned against it. - * @param line - 1-based line. - */ -export function scrollToLine(body: HTMLElement, line: number): void { - const row = body.querySelector(`[data-textpreview-line="${line}"]`) - if (row instanceof HTMLElement) body.scrollTop = row.offsetTop -} - /** * The text type's body, registered under `sidebar.right.pane.tab` as `text`. * @param props - composed slot props. - * @returns the pages read so far with their controls, or a progress line. + * @returns the content read so far with its controls, or a progress line. */ export function TextPreview({ - useTabInfo, sessionId, useResource, useStore, actions, loadPage, reloadPages, t, + useTabInfo, useResource, useStore, actions, loadPage, reloadPages, + loadAll, reloadAll, useDocumentPreviews, renderSlot, t, }: TextPreviewProps): ReactNode { const { tab } = useTabInfo() const { navigation, signal } = tab const meta = useResource<'file'>(tab.contentId) - const file = useMemo(() => hostFileOf(tab.contentId, sessionId), [tab.contentId, sessionId]) + const canRead = meta.status !== 'none' + const file = useMemo(() => hostFileOf(tab.contentId), [tab.contentId]) const state = useStore(s => s.byTab[tab.id]) + const definitions = useDocumentPreviews(value => value) + const candidates = useMemo(() => { + const matched = matchingDocumentPreviews(definitions, file.path) + const fallback = definitions.find(definition => definition.id === PLAIN_BODY_ID) + return fallback === undefined ? matched : [...matched, fallback] + }, [definitions, file.path]) + const selected = candidates.find(candidate => candidate.id === state?.rendererId) ?? candidates[0] + const mode = selected?.loading + const current = (state?.mode ?? 'text-pages') === mode ? state : undefined const bodyRef = useRef(null) + const [menuOpen, setMenuOpen] = useState(false) // Every tab of this type is a `file` resource address, so its params are the // `file` type's; the union is narrowed on the one field read, not validated. const line = navigation.params !== undefined && 'line' in navigation.params ? navigation.params.line : undefined - const pages = state?.pages + const pages = current?.pages const loaded = useMemo(() => loadedPages(pages ?? {}), [pages]) const loadedThrough = lastLineLoaded(loaded) - const hasPages = loaded.length > 0 + const hasContent = loaded.length > 0 || current?.complete !== undefined - // First mount reads the first page; a body coming back to a tab with pages + // First mount reads the first page; a body coming back to a tab with content // reads nothing, because the store outlives the body. - const started = state !== undefined + const started = current !== undefined useEffect(() => { - if (!started) loadPage(tab.id, file, 1, signal) - }, [started, tab.id, file, signal, loadPage]) + if (started || !canRead || mode === undefined) return + if (mode === 'text-pages') loadPage(tab.id, file, 1, signal, meta.value?.version) + else loadAll(tab.id, file, signal, meta.value?.version) + }, [started, tab.id, file, signal, loadPage, loadAll, canRead, mode, meta.value?.version]) - // Come back where the reader was once there are pages to scroll: on a remount, - // and after a reload rebuilt the pages. Keyed on page presence only, so a + // Come back where the reader was once there is content to scroll: on a remount, + // and after a reload rebuilt the content. Keyed on content presence only, so a // scroll write never re-lands. useEffect(() => { const body = bodyRef.current - if (hasPages && body !== null && state !== undefined) body.scrollTop = state.scrollTop - }, [hasPages]) + if (hasContent && body !== null && state !== undefined) body.scrollTop = state.scrollTop + }, [hasContent]) // Answer a navigation once: a line the pages do not reach yet loads the next // page (again, until the pages cover it or the file ends); a line they hold @@ -121,13 +102,15 @@ export function TextPreview({ // restores the reader's place instead. useEffect(() => { const body = bodyRef.current - if (state === undefined || body === null || state.revision === navigation.revision) return - if (line === undefined) { + if (current === undefined || body === null || current.revision === navigation.revision) return + if (line === undefined || mode !== 'text-pages') { actions.navigated(tab.id, navigation.revision) return } - if (line > loadedThrough && !state.eof) { - if (!state.loading && state.failure === undefined) loadPage(tab.id, file, loadedThrough + 1, signal) + if (line > loadedThrough && !current.eof) { + if (!current.loading && current.failure === undefined && canRead) { + loadPage(tab.id, file, loadedThrough + 1, signal, meta.value?.version) + } return } scrollToLine(body, line) @@ -135,45 +118,44 @@ export function TextPreview({ // Recorded here as well as by the scroll event, so the store holds the // landing before any later navigation reads it. actions.scrolled(tab.id, body.scrollTop) - }, [navigation.revision, line, loadedThrough, state?.eof, state?.loading, state?.failure, started]) + }, [ + navigation.revision, line, loadedThrough, current?.eof, current?.loading, current?.failure, started, + mode, file, canRead, meta.value?.version, + ]) - // One block per line inside one block per page, so a line has an offset to - // scroll to and a target can be marked. The trailing newline keeps an empty - // line one line tall. Memoized so a scroll write's re-render hands React the - // same elements back. - const rows = useMemo(() => loaded.map(page => ( -
-      {linesOf(page).map((content, index) => {
-        const number = page.offset + index
-        const target = number === line
-        return (
-          
- {content}{'\n'} -
- ) - })} -
- )), [loaded, line]) + const content = useMemo((): DocumentContent | undefined => { + if (mode === 'bytes-complete') { + return current?.complete === undefined ? undefined : { kind: 'bytes', data: current.complete.data } + } + return { kind: 'text', pages: loaded, text: loaded.filter(page => page.lines > 0).map(page => page.text).join('\n'), eof: current?.eof ?? false } + }, [mode, loaded, current?.complete, current?.eof]) - if (state === undefined) { + if (state === undefined || selected === undefined) { return (
-

{t('loading')}

+ {meta.status === 'none' + ?

{t('resourceUnavailable')}

+ : }
) } const next = loadedThrough + 1 - const displayPath = meta.value?.absolutePath ?? file.path - // Reload does two things at once: stat again through the resource (which - // clears `changed`, or a failed frame) and read the pages again through the face. - const reload = (): void => { meta.reload(); reloadPages(tab.id, file, signal) } + const displayPath = meta.value?.absolutePath ?? current?.complete?.absolutePath ?? file.path + const observedVersion = meta.value?.version + const changed = current?.version !== undefined && observedVersion !== undefined + && observedVersion !== current.version && observedVersion !== current.observedVersion + const loadNext = (): void => { + if (!canRead || current?.loading || current?.eof) return + if (mode === 'text-pages') loadPage(tab.id, file, next, signal, meta.value?.version) + else loadAll(tab.id, file, signal, meta.value?.version) + } + const reload = (): void => { + if (!canRead) return + if (mode === 'text-pages') reloadPages(tab.id, file, signal, meta.value?.version) + else reloadAll(tab.id, file, signal, meta.value?.version) + } return ( -
+
{meta.failure !== undefined ? ( // The file's metadata failed — gone, or its workspace unknown — which @@ -190,7 +172,7 @@ export function TextPreview({

) - : meta.value?.changed === true && ( + : changed && (

{t('changed')} +

{ setMenuOpen(value => !value) }}> + {selected.title()} + + )} + items={candidates.map(candidate => ({ id: candidate.id, label: candidate.title() }))} + selectedId={selected.id} + onSelect={(id) => { actions.selected(tab.id, id); setMenuOpen(false) }} + onClose={() => { setMenuOpen(false) }} + align="end" + portal + dense + /> + {selected.wrap === true && ( + + )}

)} - {!state.eof && state.failure === undefined && ( + {mode === 'text-pages' && !current?.eof && current?.failure === undefined && ( )} + {mode === 'bytes-complete' && current?.complete === undefined && current?.failure === undefined && ( + + )}
) diff --git a/packages/client/ui-sidebar-textpreview/src/client/code/CodeBody.module.css b/packages/client/ui-sidebar-textpreview/src/client/code/CodeBody.module.css new file mode 100644 index 0000000000..27e1fa813b --- /dev/null +++ b/packages/client/ui-sidebar-textpreview/src/client/code/CodeBody.module.css @@ -0,0 +1,34 @@ +.renderer { + min-height: 100%; + min-width: 0; + white-space: normal; + background: var(--dsw-alias-markdown-code-block); +} + +.renderer .code { + --dsl-code-block-border-radius: 0px; + --dsl-code-block-line-white-space: pre; + /* Source lines resolve their offset against the document owner's scroller. */ + position: static; + margin: 0; + min-width: 0; +} + +/* Both axes scroll in the document owner, not in an inner code window. */ +.renderer .code pre { + box-sizing: border-box; + min-width: 100%; + overflow: visible; + white-space: pre; + word-break: normal; + overflow-wrap: normal; +} + +.renderer[data-wrap='true'] .code { + --dsl-code-block-line-white-space: pre-wrap; +} + +.renderer[data-wrap='true'] .code pre { + white-space: pre-wrap; + overflow-wrap: anywhere; +} diff --git a/packages/client/ui-sidebar-textpreview/src/client/code/CodeBody.tsx b/packages/client/ui-sidebar-textpreview/src/client/code/CodeBody.tsx new file mode 100644 index 0000000000..a665c9fb50 --- /dev/null +++ b/packages/client/ui-sidebar-textpreview/src/client/code/CodeBody.tsx @@ -0,0 +1,33 @@ +/** Incrementally highlighted source; the document owner supplies the accumulated text and wrap preference. */ +import type { ReactNode } from 'react' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import { CodeBlock } from '@deepseek-ai/dsh-client-ui-primitives' +import { parseFileAddress } from '@deepseek-ai/dsh-util-workspace-path' +import type { DocumentPreviewProps } from '../document/contract.ts' +import { languageForPath } from './languages.ts' +import type {} from './locales.ts' +import css from './CodeBody.module.css' + +/** Document owner props and this renderer's localized controls. */ +export type CodeBodyProps = DocumentPreviewProps & PropsLocale<'sidebarCodePreview'> + +/** @param props - accumulated document contents and framework props. @returns one stable CodeBlock, or no body for byte contents. */ +export function CodeBody({ resourceAddress, content, wrap, t }: CodeBodyProps): ReactNode { + if (content.kind !== 'text') return null + const file = parseFileAddress(resourceAddress) + if (file === undefined) throw new Error(`ui-sidebar-textpreview: not a file address "${resourceAddress}"`) + const language = languageForPath(file.path) + return ( +
+ +
+ ) +} diff --git a/packages/client/ui-sidebar-textpreview/src/client/code/index.ts b/packages/client/ui-sidebar-textpreview/src/client/code/index.ts new file mode 100644 index 0000000000..a996ee5722 --- /dev/null +++ b/packages/client/ui-sidebar-textpreview/src/client/code/index.ts @@ -0,0 +1,26 @@ +/** Code preview metadata and body registered through the public document extension points. */ +import type { Context } from '@deepseek-ai/cordis' +import type {} from '../index.ts' +import { CodeBody } from './CodeBody.tsx' +import { CODE_EXTENSIONS } from './languages.ts' +import { en, zh } from './locales.ts' + +const ID = '@deepseek-ai/dsh-client-ui-sidebar-textpreview/code' +const NS = 'sidebarCodePreview' + +/** @param ctx - owning plugin context. Register localized metadata and the matching keyed document body. */ +export function apply(ctx: Context): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en })) + const t = ctx.locale.bind(NS) + ctx.effect(() => ctx.documentPreviews.register({ + id: ID, + extensions: CODE_EXTENSIONS, + priority: 'builtin', + title: () => t('title'), + loading: 'text-pages', + wrap: true, + })) + ctx.effect(() => ctx.slots.inject('sidebar.right.tab.document', () => ctx.slots.register( + { name: 'sidebar.right.tab.document', key: ID, locale: NS }, CodeBody, + ))) +} diff --git a/packages/client/ui-sidebar-textpreview/src/client/code/languages.ts b/packages/client/ui-sidebar-textpreview/src/client/code/languages.ts new file mode 100644 index 0000000000..3423df465b --- /dev/null +++ b/packages/client/ui-sidebar-textpreview/src/client/code/languages.ts @@ -0,0 +1,46 @@ +/** File suffixes mapped to grammars already supported by the shared CodeBlock. */ +const languageExtensions: Readonly> = { + typescript: ['ts', 'tsx', 'mts', 'cts'], + javascript: ['js', 'jsx', 'mjs', 'cjs'], + shellscript: ['sh', 'bash', 'zsh'], + json: ['json', 'jsonc', 'jsonl', 'ndjson'], + python: ['py', 'pyw', 'pyi'], + ruby: ['rb', 'rake', 'gemspec'], + go: ['go'], + rust: ['rs'], + java: ['java'], + c: ['c', 'h'], + cpp: ['cc', 'cpp', 'cxx', 'hh', 'hpp', 'hxx'], + csharp: ['cs'], + kotlin: ['kt', 'kts'], + swift: ['swift'], + php: ['php'], + yaml: ['yaml', 'yml'], + toml: ['toml'], + ini: ['ini'], + markdown: ['md', 'markdown'], + mdx: ['mdx'], + html: ['html', 'htm', 'xhtml'], + css: ['css'], + scss: ['scss'], + less: ['less'], + sql: ['sql'], + xml: ['xml', 'xsd', 'xsl', 'xslt'], + lua: ['lua'], +} + +const languages = new Map(Object.entries(languageExtensions) + .flatMap(([language, extensions]) => extensions.map(extension => [extension, language] as const))) + +/** Recognized suffixes shared by renderer selection and language hints. */ +export const CODE_EXTENSIONS: readonly string[] = [...languages.keys()] + +/** + * Select the shared highlighter's grammar for a filename. + * @param path - decoded source filename or path. + * @returns a supported grammar hint, or undefined for other suffixes. + */ +export function languageForPath(path: string): string | undefined { + const extension = /\.([^./]+)$/u.exec(path.replaceAll('\\', '/'))?.[1]?.toLowerCase() + return extension === undefined ? undefined : languages.get(extension) +} diff --git a/packages/client/ui-sidebar-textpreview/src/client/code/locales.ts b/packages/client/ui-sidebar-textpreview/src/client/code/locales.ts new file mode 100644 index 0000000000..e251d7f97c --- /dev/null +++ b/packages/client/ui-sidebar-textpreview/src/client/code/locales.ts @@ -0,0 +1,23 @@ +/** Locale-owned code renderer name and CodeBlock controls. */ +import type {} from '@deepseek-ai/dsh-client-ui-slots' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Code document implementation name and copy controls. */ + sidebarCodePreview: keyof typeof zh + } +} + +/** Simplified Chinese dictionary and key source. */ +export const zh = { + title: '代码', + copy: '复制', + copied: '已复制', +} + +/** English dictionary with the same keys. */ +export const en = { + title: 'Code', + copy: 'Copy', + copied: 'Copied', +} satisfies Record diff --git a/packages/client/ui-sidebar-textpreview/src/client/definition.ts b/packages/client/ui-sidebar-textpreview/src/client/definition.ts index 9a6cf946b8..a86072ddf1 100644 --- a/packages/client/ui-sidebar-textpreview/src/client/definition.ts +++ b/packages/client/ui-sidebar-textpreview/src/client/definition.ts @@ -1,11 +1,11 @@ /** * Stage one of this package's registration: what the `text` tab type IS. * - * The type claims every `dsh-resource://file/` address in either scope — - * `session//` or `absolute/` — at the `fallback` band: it + * The type claims every `dsh-resource://file/session//` + * address at the `fallback` band: it * is the plain viewer that any more specific type for the same address should * beat, the position VS Code's text editor holds among its editors. `canOpen` - * refuses an address `parseFileAddress` rejects at claim time, where an + * refuses an address `parseFileAddress` rejects or that has no Session at claim time, where an * unclaimed address is the documented wiring error. */ import type { SidebarRightTabDefinition } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' @@ -48,7 +48,7 @@ export function textDefinition(): SidebarRightTabDefinition { kind: TEXTPREVIEW_KIND, patterns: ['dsh-resource://file/**'], priority: 'fallback', - canOpen: address => parseFileAddress(address) !== undefined, + canOpen: address => parseFileAddress(address)?.scope === 'session', title: basenameOf, } } diff --git a/packages/client/ui-sidebar-textpreview/src/client/document/contract.ts b/packages/client/ui-sidebar-textpreview/src/client/document/contract.ts new file mode 100644 index 0000000000..0ee7fae35d --- /dev/null +++ b/packages/client/ui-sidebar-textpreview/src/client/document/contract.ts @@ -0,0 +1,54 @@ +/** Document renderer slot: the owner supplies shared file state, renderers own their presentation. */ +import type { PropsRuntime, SlotHookFactory } from '@deepseek-ai/dsh-client-ui-slots' +import type { UseSidebarRightTabInfo } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' + +/** One loaded text window, retaining source line positions. */ +export interface DocumentTextPage { + readonly offset: number + readonly text: string + readonly lines: number +} + +/** + * Contents prepared by the preview owner using ordinary file reads. + * Byte arrays are transient UI input, never persisted layout or Session data. + */ +export type DocumentContent = + | { readonly kind: 'text'; readonly text: string; readonly pages: readonly DocumentTextPage[]; readonly eof: boolean } + | { readonly kind: 'bytes'; readonly data: Uint8Array } + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotMap { + /** Document body selected by a registered implementation id. */ + 'sidebar.right.tab.document': { + kind: 'keyed' + scope: 'session' + owner: { + /** Original file address, also readable through the standard useResource hook. */ + readonly resourceAddress: string + /** Loaded content; text is an accumulated prefix until eof. */ + readonly content: DocumentContent + /** The document toolbar's current wrapping preference. */ + readonly wrap: boolean + } + hookContext: UseSidebarRightTabInfo + inject: { + hooks: { + tabInfo: SlotHookFactory<'sidebar.right.tab.document', UseSidebarRightTabInfo> + } + } + } + } +} + +/** Standard input for every document body; entry-local stores and locale props can be intersected with it. */ +export type DocumentPreviewProps = PropsRuntime<'sidebar.right.tab.document'> + +/** + * Forward the framework's tab reader to the selected document body. + * @param _standard - framework standard props. + * @param useTabInfo - enclosing tab's bound reader. + * @returns the same reader, without another subscription adapter. + */ +export const documentTabInfoFactory: SlotHookFactory<'sidebar.right.tab.document', UseSidebarRightTabInfo> = + (_standard, useTabInfo) => useTabInfo diff --git a/packages/client/ui-sidebar-textpreview/src/client/document/registry.ts b/packages/client/ui-sidebar-textpreview/src/client/document/registry.ts new file mode 100644 index 0000000000..29c583d17e --- /dev/null +++ b/packages/client/ui-sidebar-textpreview/src/client/document/registry.ts @@ -0,0 +1,103 @@ +/** File-extension preview registrations; component dispatch belongs to the keyed document slot. */ +import { notifySubscribers } from '@deepseek-ai/dsh-client-store' + +/** How the document owner delivers file contents to a renderer. */ +export type DocumentLoadMode = 'text-pages' | 'bytes-complete' + +/** One renderer implementation, independent of its component registration. */ +export interface DocumentPreviewDefinition { + /** Unique implementation name, also used as the document slot key. */ + readonly id: string + /** File suffixes without a leading dot; compound suffixes such as tar.gz are accepted. */ + readonly extensions: readonly string[] + /** External implementations win over product implementations; defaults to extension. */ + readonly priority?: 'builtin' | 'extension' + /** Localized implementation label, evaluated when the toolbar renders. @returns the visible name. */ + readonly title: () => string + /** Content delivery mode supplied by the document owner. */ + readonly loading: DocumentLoadMode + /** Whether the implementation consumes the document's wrap preference. */ + readonly wrap?: boolean +} + +/** + * Rank an observed definition snapshot without consulting mutable service state. + * @param definitions - registered implementations in registration order. + * @param path - decoded filename or file path. + * @returns matching implementations, external band first, then longest suffix. + */ +export function matchingDocumentPreviews( + definitions: readonly DocumentPreviewDefinition[], + path: string, +): readonly DocumentPreviewDefinition[] { + const normalized = path.replaceAll('\\', '/').toLowerCase() + const name = normalized.slice(normalized.lastIndexOf('/') + 1) + return definitions.map((definition, order) => ({ + definition, order, + rank: definition.priority === 'builtin' ? 0 : 1, + length: Math.max(0, ...definition.extensions + .map(extension => extension.toLowerCase().replace(/^\./u, '')) + .filter(extension => name.endsWith(`.${extension}`)) + .map(extension => extension.length)), + })) + .filter(candidate => candidate.length > 0) + .sort((left, right) => right.rank - left.rank || right.length - left.length || left.order - right.order) + .map(candidate => candidate.definition) +} + +/** Observable registry of all live implementations, including lower-priority alternatives. */ +export class DocumentPreviewRegistry { + private readonly registered = new Map() + private readonly listeners = new Set<() => void>() + private snapshot: readonly DocumentPreviewDefinition[] = [] + + /** + * Read the current registrations. + * @returns the same snapshot until a registration changes. + */ + readonly getSnapshot = (): readonly DocumentPreviewDefinition[] => this.snapshot + + /** + * Observe registration changes. + * @param listener - registration-change observer. + * @returns its disposer. + */ + readonly subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + + /** + * Register metadata separately from the matching keyed slot component. + * @param definition - unique implementation and recognized suffixes. + * @returns an idempotent disposer; duplicate live implementation names throw. + */ + register(definition: DocumentPreviewDefinition): () => void { + if (this.registered.has(definition.id)) { + throw new Error(`documentPreviews: duplicate implementation "${definition.id}"`) + } + this.registered.set(definition.id, definition) + this.publish() + let active = true + return () => { + if (!active) return + active = false + this.registered.delete(definition.id) + this.publish() + } + } + + /** + * List every matching implementation in automatic-selection order. + * @param path - decoded file path; matching never resolves filesystem access. + * @returns extension band first, then longest suffix, then registration order. + */ + candidates(path: string): readonly DocumentPreviewDefinition[] { + return matchingDocumentPreviews(this.snapshot, path) + } + + private publish(): void { + this.snapshot = [...this.registered.values()] + notifySubscribers(this.listeners, '[document-previews] registry') + } +} diff --git a/packages/client/ui-sidebar-textpreview/src/client/face.ts b/packages/client/ui-sidebar-textpreview/src/client/face.ts index fdf5f1d282..cf761ee9c7 100644 --- a/packages/client/ui-sidebar-textpreview/src/client/face.ts +++ b/packages/client/ui-sidebar-textpreview/src/client/face.ts @@ -19,8 +19,10 @@ import type { BoundActions } from '@deepseek-ai/dsh-client-store' import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { ReadWorkspaceFilePage, SessionFile } from './rpc.ts' +import type { ReadDocumentBytes, ReadWorkspaceFilePage, SessionFile } from './rpc.ts' +import { documentFileBytes } from './rpc.ts' import type { TextStore } from './store.ts' +import type { DocumentLoadMode } from './document/registry.ts' /** The preview's injected business face, as the body receives it. */ export interface TextInjected { @@ -33,8 +35,9 @@ export interface TextInjected { * @param file - the session and workspace path the tab's address names. * @param offset - 1-based line the page starts at. * @param signal - the tab record's lifetime. + * @param observedVersion - metadata version observed at read start. */ - readonly loadPage: (tabId: TabId, file: SessionFile, offset: number, signal: AbortSignal) => void + readonly loadPage: (tabId: TabId, file: SessionFile, offset: number, signal: AbortSignal, observedVersion?: string) => void /** * Drop every page and read the first one again, for a file the Host reports * changed. The view is kept, so the reader stays where they were; a page read @@ -42,8 +45,25 @@ export interface TextInjected { * @param tabId - the tab being drawn. * @param file - the session and workspace path the tab's address names. * @param signal - the tab record's lifetime. + * @param observedVersion - metadata version observed at read start. */ - readonly reloadPages: (tabId: TabId, file: SessionFile, signal: AbortSignal) => void + readonly reloadPages: (tabId: TabId, file: SessionFile, signal: AbortSignal, observedVersion?: string) => void + /** + * Read the complete file for a whole-file renderer. + * @param tabId - owning tab. + * @param file - the session and workspace path the tab's address names. + * @param signal - tab lifetime. + * @param observedVersion - metadata version observed at read start. + */ + readonly loadAll: (tabId: TabId, file: SessionFile, signal: AbortSignal, observedVersion?: string) => void + /** + * Discard the old complete result and read again. + * @param tabId - owning tab. + * @param file - the session and workspace path the tab's address names. + * @param signal - tab lifetime. + * @param observedVersion - metadata version observed at read start. + */ + readonly reloadAll: (tabId: TabId, file: SessionFile, signal: AbortSignal, observedVersion?: string) => void } /** @@ -54,14 +74,19 @@ export interface TextInjected { interface TabReads { generation: number version: string | undefined + mode: DocumentLoadMode } /** - * Bind the preview's face to one paged read. + * Bind the preview's face to one paged read and one complete-byte read. * @param read - the bound `workspaceFiles.read` call. + * @param readAll - ordinary complete-byte Remote read. * @returns the Slot `inject` factory: bound actions in, face out. The slot's session id is unused because the address carries its own. */ -export function textFace(read: ReadWorkspaceFilePage): (sessionId: SessionId, actions: BoundActions) => TextInjected { +export function textFace( + read: ReadWorkspaceFilePage, + readAll: ReadDocumentBytes, +): (sessionId: SessionId, actions: BoundActions) => TextInjected { return (_sessionId: SessionId, actions: BoundActions): TextInjected => { const tabs = new Map() // Reached with a live signal only: the record's end forgets the tab's @@ -69,7 +94,7 @@ export function textFace(read: ReadWorkspaceFilePage): (sessionId: SessionId, ac const readsOf = (tabId: TabId, signal: AbortSignal): TabReads => { const held = tabs.get(tabId) if (held !== undefined) return held - const created: TabReads = { generation: 0, version: undefined } + const created: TabReads = { generation: 0, version: undefined, mode: 'text-pages' } tabs.set(tabId, created) signal.addEventListener('abort', () => { tabs.delete(tabId) @@ -77,11 +102,21 @@ export function textFace(read: ReadWorkspaceFilePage): (sessionId: SessionId, ac }, { once: true }) return created } - const loadPage = (tabId: TabId, file: SessionFile, offset: number, signal: AbortSignal): void => { - if (signal.aborted) return + const modeOf = (tabId: TabId, signal: AbortSignal, mode: DocumentLoadMode): TabReads => { const reads = readsOf(tabId, signal) + if (reads.mode !== mode) { + reads.mode = mode + reads.generation++ + reads.version = undefined + actions.reset(tabId) + } + return reads + } + const loadPage = (tabId: TabId, file: SessionFile, offset: number, signal: AbortSignal, observedVersion?: string): void => { + if (signal.aborted) return + const reads = modeOf(tabId, signal, 'text-pages') const { generation } = reads - actions.loading(tabId) + actions.loading(tabId, 'text-pages', observedVersion) void read(file.sessionId, file.path, offset, signal).then((result) => { if (signal.aborted || reads.generation !== generation) return if (!result.ok) { @@ -91,21 +126,42 @@ export function textFace(read: ReadWorkspaceFilePage): (sessionId: SessionId, ac // Pages of two versions never meet: a newer file past the first line // restarts the walk from line 1, where the store adopts the new version. if (offset !== 1 && reads.version !== undefined && result.value.version !== reads.version) { - restart(tabId, file, signal) + restart(tabId, file, signal, observedVersion) return } reads.version = result.value.version actions.page(tabId, result.value) }) } - const restart = (tabId: TabId, file: SessionFile, signal: AbortSignal): void => { + const loadAll = (tabId: TabId, file: SessionFile, signal: AbortSignal, observedVersion?: string): void => { + if (signal.aborted) return + const reads = modeOf(tabId, signal, 'bytes-complete') + const { generation } = reads + actions.loading(tabId, 'bytes-complete', observedVersion) + void readAll(file, signal).then((result) => { + if (signal.aborted || reads.generation !== generation) return + if (!result.ok) { + actions.failed(tabId, result.error) + return + } + reads.version = result.value.version + actions.complete(tabId, documentFileBytes(result.value)) + }) + } + const restart = ( + tabId: TabId, file: SessionFile, signal: AbortSignal, observedVersion?: string, mode: DocumentLoadMode = 'text-pages', + ): void => { if (signal.aborted) return const reads = readsOf(tabId, signal) reads.generation += 1 reads.version = undefined actions.reset(tabId) - loadPage(tabId, file, 1, signal) + if (mode === 'text-pages') loadPage(tabId, file, 1, signal, observedVersion) + else loadAll(tabId, file, signal, observedVersion) + } + return { + loadPage, reloadPages: restart, loadAll, + reloadAll: (tabId, file, signal, observedVersion) => { restart(tabId, file, signal, observedVersion, 'bytes-complete') }, } - return { loadPage, reloadPages: restart } } } diff --git a/packages/client/ui-sidebar-textpreview/src/client/html/HtmlBody.module.css b/packages/client/ui-sidebar-textpreview/src/client/html/HtmlBody.module.css new file mode 100644 index 0000000000..02f352bb66 --- /dev/null +++ b/packages/client/ui-sidebar-textpreview/src/client/html/HtmlBody.module.css @@ -0,0 +1,15 @@ +.frame { + display: block; + width: 100%; + height: 100%; + min-height: 240px; + border: none; + background: var(--dsw-alias-bg-base); +} + +.status { + margin: 0; + padding: 10px; + color: var(--dsw-alias-label-secondary); + white-space: normal; +} diff --git a/packages/client/ui-sidebar-textpreview/src/client/html/HtmlBody.tsx b/packages/client/ui-sidebar-textpreview/src/client/html/HtmlBody.tsx new file mode 100644 index 0000000000..f16def571c --- /dev/null +++ b/packages/client/ui-sidebar-textpreview/src/client/html/HtmlBody.tsx @@ -0,0 +1,76 @@ +/** Complete HTML rendered in a script-enabled opaque iframe, without parent application access. */ +import { useEffect, useMemo, useState } from 'react' +import type { ReactNode } from 'react' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { DocumentPreviewProps } from '../document/contract.ts' +import { LoadingIndicator } from '../LoadingIndicator.tsx' +import { createHtmlDocument } from './bootstrap.ts' +import { packHtml } from './pack.ts' +import type { HtmlPackLimits, ReadHtmlRelative } from './pack.ts' +import { createReadHtmlRelative } from './read-relative.ts' +import type { ReadHtmlRelated } from './read-relative.ts' +import type {} from './locales.ts' +import css from './HtmlBody.module.css' + +/** Standard document inputs plus this renderer's dictionary. */ +export type HtmlBodyProps = DocumentPreviewProps & PropsLocale<'documentHtml'> & { + /** Static dependencies are packed only when the owner supplies explicit limits. */ + readonly limits: HtmlPackLimits | undefined + /** Ordinary Remote callback bound by this renderer's Slot inject. */ + readonly readRelated: ReadHtmlRelated +} + +type FrameInput = { + readonly data: Uint8Array + readonly readRelative: ReadHtmlRelative + readonly limits: HtmlPackLimits | undefined +} + +type FrameState = FrameInput & { readonly url: string | undefined } + +/** One mounted file owns its root Blob; replacing content also replaces the browsing context. */ +function HtmlFrame({ data, readRelative, limits, t }: FrameInput & { t: HtmlBodyProps['t'] }): ReactNode { + const [frame, setFrame] = useState() + useEffect(() => { + const controller = new AbortController() + let url: string | undefined + void (async () => { + try { + const bundle = limits !== undefined + ? await packHtml(data, readRelative, limits, controller.signal) + : { data, assets: [] } + controller.signal.throwIfAborted() + const html = createHtmlDocument(bundle) + url = URL.createObjectURL(new Blob([html], { type: 'text/html' })) + setFrame({ data, readRelative, limits, url }) + } catch { + if (!controller.signal.aborted) setFrame({ data, readRelative, limits, url: undefined }) + } + })() + return () => { + controller.abort() + if (url !== undefined) URL.revokeObjectURL(url) + } + }, [data, readRelative, limits]) + + if (frame?.data !== data || frame.readRelative !== readRelative || frame.limits !== limits) { + return + } + if (frame.url === undefined) return

{t('failed')}

+ return