فهرست منبع

fix(client): narrow document preview contracts

imccyu 1 هفته پیش
والد
کامیت
93b30b9765
27فایلهای تغییر یافته به همراه224 افزوده شده و 267 حذف شده
  1. 2 2
      .agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml
  2. 4 2
      .agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md
  3. 4 2
      .agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md
  4. 2 2
      packages/client/ui-sidebar-documentpreview/README.i18n.yaml
  5. 3 12
      packages/client/ui-sidebar-documentpreview/README.md
  6. 3 12
      packages/client/ui-sidebar-documentpreview/README.zh.md
  7. 0 1
      packages/client/ui-sidebar-documentpreview/package.json
  8. 3 26
      packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx
  9. 0 2
      packages/client/ui-sidebar-documentpreview/src/client/document/layout.ts
  10. 10 15
      packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.tsx
  11. 2 8
      packages/client/ui-sidebar-documentpreview/src/client/html/index.ts
  12. 7 13
      packages/client/ui-sidebar-documentpreview/src/client/html/pack.ts
  13. 2 21
      packages/client/ui-sidebar-documentpreview/src/client/index.ts
  14. 3 12
      packages/client/ui-sidebar-documentpreview/src/client/pdf/PdfBody.tsx
  15. 1 3
      packages/client/ui-sidebar-documentpreview/src/client/pdf/document.ts
  16. 4 1
      packages/client/ui-sidebar-documentpreview/src/client/text/lines.ts
  17. 3 14
      packages/client/ui-sidebar-documentpreview/tests/html-apply.client.spec.ts
  18. 11 12
      packages/client/ui-sidebar-documentpreview/tests/html-body.client.spec.tsx
  19. 30 21
      packages/client/ui-sidebar-documentpreview/tests/html-pack.client.spec.ts
  20. 2 7
      packages/client/ui-sidebar-documentpreview/tests/pdf-body.client.spec.tsx
  21. 7 5
      packages/client/ui-sidebar-documentpreview/tests/pdf-document.client.spec.ts
  22. 65 0
      packages/client/ui-sidebar-documentpreview/tests/pdf-license-bundle.client.spec.ts
  23. 1 1
      packages/client/ui-sidebar-documentpreview/tests/pdf-smoke.client.spec.ts
  24. 37 54
      packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx
  25. 0 3
      packages/client/ui-sidebar-documentpreview/tsconfig.json
  26. 18 13
      packages/client/ui-sidebar-documentpreview/tsdown.config.ts
  27. 0 3
      pnpm-lock.yaml

+ 2 - 2
.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: e8565f0e516b03b267a87f47763532928941ba21
-2026-09-05-sidebar-text-preview-and-file-tree.zh.md: 93396b1618223c95899969f63a93c33b8977e0b5
+2026-09-05-sidebar-text-preview-and-file-tree.md: 173c9fe2530c5e075821a6981a22f48991121c02
+2026-09-05-sidebar-text-preview-and-file-tree.zh.md: f6fdb9ba000bebe360f7370200688f1ca008f8b3

+ 4 - 2
.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md

@@ -28,6 +28,8 @@ A pane holds at most one guide, and the docking layer enforces it as product beh
 
 ### The text preview
 
+The [Document Preview decision](../architecture/2026-09-08-document-preview-operations.md) supersedes this section's renderer, loading, and resource-observation details. The fallback tab registration, paged source navigation, and body-owned controls remain in force.
+
 `text` is the fallback viewer for Session-scoped files. Its registration is `{ id: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview', kind: 'text', patterns: ['dsh-resource://file/**'], priority: 'fallback', canOpen, title: basenameOf }`. `canOpen` accepts only addresses whose parsed scope is `session`. The pattern contains `:` and so matches the whole address; `fallback` is the lowest band, so a type at `extension` or `builtin` with a narrower pattern (`*.png`, say) takes those addresses and everything else lands here, while the text type stays in the candidate list for any file. The `id` is the package name and doubles as the `key` of the body seat, so an extension that takes the `text` kind over cannot make the seat pick up this body by mistake. The title is the address's decoded last segment: the whole address stays the content identity — two files with one name in different directories, or one path under two sessions, are two tabs — and only the chip text is shortened.
 
 A tab's address is `dsh-resource://file/session/<sessionId>/<path relative to that session's workspace root>` or `dsh-resource://file/absolute/<absolute path>` ([Workspace Files](../architecture/2026-09-05-workspace-files-service.md) owns the grammar and the `fileAddressFor` / `parseFileAddress` helpers in `dsh-util-workspace-path`). The preview never splits the string itself: `hostFileOf` in `rpc.ts` calls `parseFileAddress` and yields the `{ sessionId, path }` the endpoint takes — a `session` address reads under the session it names with the relative path the Host resolves, an `absolute` address reads under the session the slot was mounted for with the absolute path — and a malformed address throws, a programming error, because the registry routes every `file` address to this type and a caller building one is expected to use the helper.
@@ -40,7 +42,7 @@ Navigation is a `line`. The `read` tool row passes its 1-based `offset` as `open
 
 A changed file is announced, not applied. When the `file` resource reports `changed` — the agent wrote the file through a tool after the last `stat` — a bar above the path row says `文件已被修改,显示的还是旧内容。` / `The file has changed; this is the older text.` with a `重新载入` / `Reload` button. Only the click does two things at once: `meta.reload()` (a fresh `stat`, which clears `changed`) and `reloadPages` (drop every page, read the first one again). The scroll offset is kept, so the reader stays where they were. Nothing else triggers a reload: the tree and the preview do not watch the filesystem, and an external edit is not announced. A resource that turns `failed` — the file deleted, or the Host refusing it — puts a failure bar in the same place, its line from `failure-line.ts` and the same reload button, ahead of any pending `changed`; the pages already read stay beneath it.
 
-The body's header is one row: the file's path as the address names it on the left (12px, tertiary colour, one line, ellipsis when it overflows, full path on hover) and two 24px controls at its right end — a wrap toggle (`自动换行` / `Wrap lines`, pressed state shown, **on by default** per tab: long lines wrap and never scroll horizontally until the reader turns it off, whereupon the file body scrolls horizontally on its own) and a reload button (`重新读取文件` / `Read the file again`) that does exactly what the change bar's button does. Neither control is ever disabled. The preview takes the pane body's full height (`height: 100%` against the pane body, which is a block scroller of definite height) so a short file leaves no separately styled space below it, and the file body — monospace, 13px, line height 1.6, 10px vertical padding — is the only scroller: the header and the change bar stay put while a long file scrolls under them.
+The body's header is one row: the full file path on the left and the matching-renderer menu, conditional wrap toggle, and reload button on the right. The [Document Preview README](../../../../packages/client/ui-sidebar-documentpreview/README.md) owns the current controls and renderer behavior. The preview takes the pane body's full height, and its document body is the scroller beneath the fixed header and change bar.
 
 A failed page keeps the pages already shown and adds one sentence at the end of the loaded text, in terms of the file rather than the transport, with a `重试` / `Retry` button that reads the same page again: `workspace-file/not-found` `这个文件不在了。可能已被移动或删除。` / `That file is gone. It may have been moved or deleted.`; `workspace-file/outside-workspace` `这个文件在工作区之外,侧栏不会读取它。` / `That file is outside the workspace, so the sidebar will not read it.`; `workspace-file/too-large` `这一页太大,侧栏不读取超过 {limit} 的页。` / `That page is too large; the sidebar does not read pages above {limit}.` with the byte cap rendered as `2 MB`; `workspace-file/not-text` `这不是文本文件,没法在这里查看。` / `That is not a text file, so it cannot be shown here.`; `workspace-file/not-regular-file` `这不是一个普通文件,没有可显示的文本。` / `That is not a regular file, so it has no text to show.`; any other failure, carrier or unclassified, `读取失败:{message}` / `Read failed: {message}` with the failure's own message. The mapping lives in `failure-line.ts`, apart from the component so it is testable on its own; a code the reader does not name falls to the generic line carrying the carrier's message. A directory or a binary file therefore shows one failure line and nothing else; an empty file shows the header and an empty body with no marker.
 
@@ -111,7 +113,7 @@ The text preview's `tests/` cover the registry claim and yielding (through the r
 ## Deferred
 
 - Virtualized or seekable page loading (pages load in order), a reload that restores the loaded range, throttled scroll persistence, and a wrap icon in `ui-primitives`.
-- Line numbers, syntax highlighting, rendered Markdown, images, and search in the text preview; a total line count or end-of-file marker.
+- Images, search, a total line count, and an end-of-file marker.
 - Search, an artifact filter, drag-and-drop, rename, a context menu, current-file highlight, filesystem watching, and browsing above the workspace root in the file tree.
 - Product review of the guide's copy, and the guide's behaviour when a type contributes several entries.
 

+ 4 - 2
.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md

@@ -28,6 +28,8 @@ Sidebar 随包交付三个 tab 类型:**引导页**(`ui-sidebar-right`)、
 
 ### 文本预览
 
+[Document Preview 决议](../architecture/2026-09-08-document-preview-operations.zh.md)取代本节的渲染器、加载和资源观察细节。兜底 tab 注册、分页源码导航与正文自有控件仍然有效。
+
 `text` 是 Session 作用域文件的兜底查看器。它的注册定义是 `{ id: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview', kind: 'text', patterns: ['dsh-resource://file/**'], priority: 'fallback', canOpen, title: basenameOf }`。`canOpen` 只接受解析后 scope 为 `session` 的地址。pattern 含 `:`,因此匹配整个地址;`fallback` 是最低档,所以 `extension` 或 `builtin` 档上一个 pattern 更窄的类型(比如 `*.png`)接走那些地址,其余一切落到这里,而 text 类型对任何文件都留在候选列表中。`id` 是包名,兼作体坑位的 `key`,于是一个接管了 `text` kind 的扩展不可能让坑位误拿到这个体。标题是地址解码后的最后一段:整个地址仍是内容身份——不同目录下同名的两个文件、或同一路径在两个会话之下,是两个 tab——只有 chip 上的文字被缩短。
 
 tab 的地址是 `dsh-resource://file/session/<sessionId>/<相对该会话工作区根的路径>` 或 `dsh-resource://file/absolute/<绝对路径>`([Workspace Files](../architecture/2026-09-05-workspace-files-service.zh.md) 拥有这套语法及 `dsh-util-workspace-path` 里的 `fileAddressFor` / `parseFileAddress` 助手)。预览从不自己拆这个串:`rpc.ts` 里的 `hostFileOf` 调 `parseFileAddress` 得到端点所需的 `{ sessionId, path }`——`session` 地址在它命名的会话下以 Host 解析的相对路径读取,`absolute` 地址在坑位被挂载的会话下以绝对路径读取——畸形地址直接抛错,那是程序错误,因为注册表把每个 `file` 地址都路由给这个类型,而造地址的调用方本应使用助手。
@@ -40,7 +42,7 @@ store 是 Slot 标准件:每会话一个独占实例,按 tab id 分桶,持
 
 文件变了只提示,不应用。当 `file` 资源报告 `changed`——agent 在上次 `stat` 之后经工具写了该文件——路径行上方出现一条提示 `文件已被修改,显示的还是旧内容。` / `The file has changed; this is the older text.`,带一个 `重新载入` / `Reload` 按钮。只有点击才同时做两件事:`meta.reload()`(重新 `stat`,清掉 `changed`)与 `reloadPages`(丢掉所有页,重读第 1 页)。滚动位置保留,读者停在原处。没有别的东西触发重载:树和预览都不监听文件系统,外部编辑不会被提示。资源变为 `failed`——文件被删,或 Host 拒绝——时,同一位置出现一条失败条,句子来自 `failure-line.ts`,带同一个重新载入按钮,并优先于尚未处理的 `changed`;已读的页留在它下方。
 
-体的头部是一行:左边是地址所命名的文件路径(12px、三级色、单行、溢出省略号、悬停显示完整路径),右端是两个 24px 控件——换行开关(`自动换行` / `Wrap lines`,显示按下态,**默认开**、按 tab 记:长行折行、绝不横向滚动,直到读者关掉它,此后文件体自己横向滚动)与一个重新读取按钮(`重新读取文件` / `Read the file again`),做的恰是变更提示条按钮做的事。两个控件都永不禁用。预览占满 pane 体的全部高度(对 pane 体取 `height: 100%`;pane 体是高度确定的块级滚动容器),于是短文件下方不留另一块样式不同的空白,而文件体——等宽、13px、行高 1.6、上下 10px 内边距——是唯一的滚动者:长文件在头部与变更提示条之下滚动,二者不动
+正文头部为一行:左侧显示完整文件路径,右侧放匹配渲染器菜单、按条件出现的换行开关和重新载入按钮。[Document Preview README](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md)负责当前控件与渲染器行为。预览占满 pane 正文的全部高度,其文档正文是固定头部与变更提示条下方的滚动区域
 
 某页失败时,已显示的页保留,并在已加载文本末尾加一句以文件而非传输为主语的说明,带一个重读同一页的 `重试` / `Retry` 按钮:`workspace-file/not-found` `这个文件不在了。可能已被移动或删除。` / `That file is gone. It may have been moved or deleted.`;`workspace-file/outside-workspace` `这个文件在工作区之外,侧栏不会读取它。` / `That file is outside the workspace, so the sidebar will not read it.`;`workspace-file/too-large` `这一页太大,侧栏不读取超过 {limit} 的页。` / `That page is too large; the sidebar does not read pages above {limit}.`,字节上限渲染为 `2 MB` 这样的形式;`workspace-file/not-text` `这不是文本文件,没法在这里查看。` / `That is not a text file, so it cannot be shown here.`;`workspace-file/not-regular-file` `这不是一个普通文件,没有可显示的文本。` / `That is not a regular file, so it has no text to show.`;其余任何失败,无论载体层还是未分类,`读取失败:{message}` / `Read failed: {message}` 并带上失败自身的消息。映射住在 `failure-line.ts` 里,与组件分开以便单独测试;读者未命名的错误码落到带传输层消息的通用句。目录或二进制文件因此只显示一行失败说明;空文件显示头部与一个空的体,没有任何标记。
 
@@ -111,7 +113,7 @@ face 是树唯一的异步半边。`start(tabId, root, signal)` 以根展开态
 ## Deferred
 
 - 虚拟化或可 seek 的分页加载(页按顺序加载)、恢复已加载范围的重新载入、节流的滚动位置持久化,以及 `ui-primitives` 里的换行图标。
-- 文本预览的行号、语法高亮、Markdown 渲染、图片与搜索;总行数或文件末尾标记。
+- 图片、搜索、总行数与文件末尾标记。
 - 文件树的搜索、产物过滤、拖拽、重命名、右键菜单、高亮当前文件、文件系统监听,以及浏览到工作区根之上。
 - 引导页文案的产品评审,以及一个类型贡献多个入口时引导页的行为。
 

+ 2 - 2
packages/client/ui-sidebar-documentpreview/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-documentpreview/README.md
-README.md: b8098117e28501c64ceb9085e9e5b139c6387e8e
-README.zh.md: 68ee0c08a590ba7d96b4d6bbaa063867d67d6538
+README.md: b4bf0cadce772f3ae596f8e62ab98e33db82cb46
+README.zh.md: f4eea40230ed9d3ed7669b9980491dfe0afab79e

+ 3 - 12
packages/client/ui-sidebar-documentpreview/README.md

@@ -32,16 +32,6 @@ Preview readable files in the right Sidebar and choose among registered renderer
 
 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 |
-
 <a id="addresses"></a>
 ## Addresses
 
@@ -57,7 +47,7 @@ The body reads its record, navigation and lifetime through `useTabInfo().tab`. `
 - **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<ArrayBuffer>` 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.
 
-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 `<base href>` 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.
+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`, with fixed safety limits of 4 MiB per asset, 32 MiB total, and 64 distinct assets. 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 `<base href>` 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 `sidebarDocumentPreview`; each builtin renderer owns its localized labels.
 
@@ -66,7 +56,7 @@ Initial reads, additional pages, and HTML/PDF preparation share a loading indica
 <a id="navigation"></a>
 ## Navigation
 
-`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.
+`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. Plain-text and code renderers expose source-line anchors; Markdown does not. A navigation remains pending while its selected renderer has no anchor and runs if the user switches to plain text or code. Code navigation places the target below its sticky toolbar. Byte-mode renderers do not consume source-line navigation. Each completed navigation revision is answered once. Opening the same file without `revealIfOpened: false` focuses its existing tab and delivers a new revision.
 
 <a id="model-experience"></a>
 ## Model Experience
@@ -82,6 +72,7 @@ No direct effect; what the user reads here never enters a model request.
 <a id="known-limitations-and-deferred-work"></a>
 - **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.
+- **Byte-view scroll state is not restored.** PDF and HTML can return to the top when their renderer remounts or reloads; HTML iframe scrolling belongs to its opaque browsing context.
 - **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.

+ 3 - 12
packages/client/ui-sidebar-documentpreview/README.zh.md

@@ -32,16 +32,6 @@ kind: "package-reference"
 
 文档实现在 `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 文档最多打包的不同本地脚本与样式表引用数 |
-
 <a id="addresses"></a>
 ## 地址
 
@@ -57,7 +47,7 @@ tab 使用 `fileAddressFor` 构造的 Session 地址,携带相对或绝对路
 - **完整字节** —— PDF 和 HTML 通过 inject 回调调用 `remote.workspaceFiles.readAll(sessionId, path, signal)`。`rpc.ts` 将线路上的 base64 解码为 `data: Uint8Array<ArrayBuffer>`,供 `{ kind: 'bytes', data }` 使用。Host 的 `maxFileBytes` 上限拒绝超大文件,不截断。PDF 在传给 worker 前复制保留的字节,使 Preview 缓冲区仍可使用。字节仅保存在临时视图状态中,绝不进入持久布局或 Session JSONL。加载模式变化会淘汰先前结果。
 - **重新载入** —— 仅当前 Preview tab 通过自己的 Remote 回调重读,保留滚动偏好并淘汰旧请求。变更提示将读取版本及起读时的观察版本与后续 `resource.value.version` 比较;刷新前已观察到的版本不会被当成新变化。读取既不刷新共享元数据,也不清除其它 tab 的提示。
 
-HTML 在 Blob iframe 中运行,沙箱属性严格为 `sandbox="allow-scripts"`,不含 `allow-same-origin`;脚本无法访问父应用的源或文件读取接口。渲染器在配置上限内,通过普通 inject 回调调用 `remote.workspaceFiles.readRelated`,加载直接声明的相对 `.js` 经典脚本和 `.css` 样式表。Host 代码解析关联路径,`rpc.ts` 解码返回的字节。在渲染器内部,base64 仅用于把 iframe 引导载荷嵌入脚本文本。`<base href>` 将依赖解析交给浏览器,HTTPS 资源也由浏览器处理。本地模块 import、CSS `url()`/`@import` 和动态 `fetch` 不使用 Host 文件访问。读取失败、无效 UTF-8 或超出上限都使预览失败,不发布部分资源包。替换或卸载文档会释放其 Blob URL。
+HTML 在 Blob iframe 中运行,沙箱属性严格为 `sandbox="allow-scripts"`,不含 `allow-same-origin`;脚本无法访问父应用的源或文件读取接口。渲染器通过普通 inject 回调调用 `remote.workspaceFiles.readRelated`,加载直接声明的相对 `.js` 经典脚本和 `.css` 样式表;固定安全上限为单个资源 4 MiB、总计 32 MiB、64 个不同资源。Host 代码解析关联路径,`rpc.ts` 解码返回的字节。在渲染器内部,base64 仅用于把 iframe 引导载荷嵌入脚本文本。`<base href>` 将依赖解析交给浏览器,HTTPS 资源也由浏览器处理。本地模块 import、CSS `url()`/`@import` 和动态 `fetch` 不使用 Host 文件访问。读取失败、无效 UTF-8 或超出上限都使预览失败,不发布部分资源包。替换或卸载文档会释放其 Blob URL。
 
 共享文案来自 `sidebarDocumentPreview`;各内置渲染器拥有自己的本地化标签。
 
@@ -66,7 +56,7 @@ HTML 在 Blob iframe 中运行,沙箱属性严格为 `sandbox="allow-scripts"`
 <a id="navigation"></a>
 ## 导航
 
-`ctx.sidebarRight.openResource(address, { params: { line } })` 通过 `file` 参数携带 1 起算的源码行号。在 `text-pages` 模式下,owner 顺序加载到该行或 EOF;渲染器提供源码行锚点时,再定位到对应锚点,纯文本提供这些锚点。字节模式渲染器不消费源码行导航。每个导航 revision 只响应一次;重新挂载恢复共享滚动位置。不带 `revealIfOpened: false` 打开同一文件时聚焦已有 tab,并送达新 revision。
+`ctx.sidebarRight.openResource(address, { params: { line } })` 通过 `file` 参数携带 1 起算的源码行号。在 `text-pages` 模式下,owner 顺序加载到该行或 EOF。纯文本与代码渲染器提供源码行锚点;Markdown 不提供。所选渲染器没有锚点时,导航保持待处理;用户切换到纯文本或代码后执行。代码导航会把目标放在置顶工具栏下方。字节模式渲染器不消费源码行导航。每个完成的导航 revision 只响应一次。不带 `revealIfOpened: false` 打开同一文件时聚焦已有 tab,并送达新 revision。
 
 <a id="model-experience"></a>
 ## 模型体验
@@ -82,6 +72,7 @@ HTML 在 Blob iframe 中运行,沙箱属性严格为 `sandbox="allow-scripts"`
 <a id="known-limitations-and-deferred-work"></a>
 - **预览而非编辑。** 查看器不提供文件编辑或共享搜索接口;目录地址以 `not-regular-file` 失败。未知扩展名使用纯文本读取,仍受其 UTF-8/NUL 检查限制。
 - **文本顺序分页,完整文件受限。** 定位深处源码行需要先加载此前各页;PDF 和 HTML 必须取得 Host `maxFileBytes` 上限内的完整结果。
+- **字节视图不恢复滚动位置。** PDF 与 HTML 的渲染器重新挂载或重新载入时可能回到顶部;HTML iframe 的滚动属于其不透明浏览上下文。
 - **本地 HTML 依赖集合有限。** 只打包直接引用的经典 `.js` 脚本和 `.css` 样式表。浏览器解析的资源仍受浏览器源与网络规则限制;iframe 不获得运行时文件读取桥接。
 - **换行图标为包内自绘。** `IconWrapOutline16` 住在 `src/client/icons.tsx`,直到共享图标集提供为止;props 契约已经一致。
 - **滚动写入未节流。** 每次滚动事件都把偏移记进 store;行块已 memo 化,于是由此引发的重渲染交还给 React 的是同一批元素。

+ 0 - 1
packages/client/ui-sidebar-documentpreview/package.json

@@ -47,7 +47,6 @@
   },
   "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:^",

+ 3 - 26
packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx

@@ -10,7 +10,7 @@
  * 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { useEffect, useMemo, useRef, useState } from 'react'
 import type { ReactNode } from 'react'
 import clsx from 'clsx'
 import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
@@ -25,7 +25,6 @@ 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 { DOCUMENT_LAYOUT_READY_EVENT } from './document/layout.ts'
 import { PLAIN_BODY_ID } from './text/index.ts'
 import { loadedPages, lastLineLoaded, scrollToLine } from './text/lines.ts'
 import css from './TextPreview.module.css'
@@ -71,28 +70,6 @@ export function TextPreview({
   const mode = selected?.loading
   const current = (state?.mode ?? 'text-pages') === mode ? state : undefined
   const bodyRef = useRef<HTMLDivElement | null>(null)
-  const layoutRestore = useRef<{ content: unknown; scrollTop: number; pending: boolean }>({
-    content: undefined, scrollTop: 0, pending: false,
-  })
-  if (layoutRestore.current.content !== current?.complete) {
-    layoutRestore.current = {
-      content: current?.complete,
-      scrollTop: state?.scrollTop ?? 0,
-      pending: current?.complete !== undefined,
-    }
-  }
-  const restoreAfterDocumentLayout = useCallback((event: Event) => {
-    const restore = layoutRestore.current
-    if (!restore.pending) return
-    const body = event.currentTarget as HTMLDivElement
-    body.scrollTop = restore.scrollTop
-    restore.pending = false
-  }, [])
-  const setBody = useCallback((body: HTMLDivElement | null) => {
-    bodyRef.current?.removeEventListener(DOCUMENT_LAYOUT_READY_EVENT, restoreAfterDocumentLayout)
-    bodyRef.current = body
-    body?.addEventListener(DOCUMENT_LAYOUT_READY_EVENT, restoreAfterDocumentLayout)
-  }, [restoreAfterDocumentLayout])
   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.
@@ -144,7 +121,7 @@ export function TextPreview({
     actions.scrolled(tab.id, body.scrollTop)
   }, [
     navigation.revision, line, loadedThrough, current?.eof, current?.loading, current?.failure, started,
-    mode, file, canRead, meta.value?.version,
+    selected?.id, mode, file, canRead, meta.value?.version,
   ])
 
   const content = useMemo((): DocumentContent | undefined => {
@@ -252,7 +229,7 @@ export function TextPreview({
         </button>
       </div>
       <div
-        ref={setBody}
+        ref={bodyRef}
         className={clsx(css.body, state.wrap && css.wrap)}
         data-textpreview-body
         data-textpreview-wrap={state.wrap ? '' : undefined}

+ 0 - 2
packages/client/ui-sidebar-documentpreview/src/client/document/layout.ts

@@ -1,2 +0,0 @@
-/** Event emitted when an asynchronous document body has committed its scrollable layout. */
-export const DOCUMENT_LAYOUT_READY_EVENT = 'dsh-document-layout-ready'

+ 10 - 15
packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.tsx

@@ -6,7 +6,7 @@ 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 type { ReadHtmlRelative } from './pack.ts'
 import { createReadHtmlRelative } from './read-relative.ts'
 import type { ReadHtmlRelated } from './read-relative.ts'
 import type {} from './locales.ts'
@@ -14,8 +14,6 @@ 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
 }
@@ -23,37 +21,34 @@ export type HtmlBodyProps = DocumentPreviewProps & PropsLocale<'documentHtml'> &
 type FrameInput = {
   readonly data: Uint8Array<ArrayBuffer>
   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 {
+function HtmlFrame({ data, readRelative, t }: FrameInput & { t: HtmlBodyProps['t'] }): ReactNode {
   const [frame, setFrame] = useState<FrameState>()
   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: [] }
+        const bundle = await packHtml(data, readRelative, controller.signal)
         controller.signal.throwIfAborted()
         const html = createHtmlDocument(bundle)
         url = URL.createObjectURL(new Blob([html], { type: 'text/html' }))
-        setFrame({ data, readRelative, limits, url })
+        setFrame({ data, readRelative, url })
       } catch {
-        if (!controller.signal.aborted) setFrame({ data, readRelative, limits, url: undefined })
+        if (!controller.signal.aborted) setFrame({ data, readRelative, url: undefined })
       }
     })()
     return () => {
       controller.abort()
       if (url !== undefined) URL.revokeObjectURL(url)
     }
-  }, [data, readRelative, limits])
+  }, [data, readRelative])
 
-  if (frame?.data !== data || frame.readRelative !== readRelative || frame.limits !== limits) {
+  if (frame?.data !== data || frame.readRelative !== readRelative) {
     return <LoadingIndicator className={css.status} label={t('loading')} />
   }
   if (frame.url === undefined) return <p className={css.status} role="alert">{t('failed')}</p>
@@ -62,15 +57,15 @@ function HtmlFrame({ data, readRelative, limits, t }: FrameInput & { t: HtmlBody
 
 /**
  * Render complete HTML with the standard file and tab hooks.
- * @param props - document bytes, hooks, dependency limits and locale.
+ * @param props - document bytes, hooks, related-file reader and locale.
  * @returns an isolated HTML document, or nothing for text delivery.
  */
-export function HtmlBody({ content, resourceAddress, readRelated, useTabInfo, limits, t }: HtmlBodyProps): ReactNode {
+export function HtmlBody({ content, resourceAddress, readRelated, useTabInfo, t }: HtmlBodyProps): ReactNode {
   const { tab } = useTabInfo()
   const readRelative = useMemo(
     () => createReadHtmlRelative(readRelated, resourceAddress, tab.signal),
     [readRelated, resourceAddress, tab.signal],
   )
   if (content.kind !== 'bytes') return null
-  return <HtmlFrame key={resourceAddress} data={content.data} readRelative={readRelative} limits={limits} t={t} />
+  return <HtmlFrame key={resourceAddress} data={content.data} readRelative={readRelative} t={t} />
 }

+ 2 - 8
packages/client/ui-sidebar-documentpreview/src/client/html/index.ts

@@ -6,7 +6,6 @@ import { hostFileOf } from '../rpc.ts'
 import { HtmlBody } from './HtmlBody.tsx'
 import type { HtmlBodyProps } from './HtmlBody.tsx'
 import { en, zh } from './locales.ts'
-import type { HtmlPackLimits } from './pack.ts'
 
 /** HTML implementation identity, shared by metadata and the keyed slot. */
 export const HTML_BODY_ID = '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/html'
@@ -23,20 +22,15 @@ export function htmlBodyDefinition(title: () => string): DocumentPreviewDefiniti
 /**
  * Register the HTML dictionary, metadata and body with reversible effects.
  * @param ctx - owning plugin context.
- * @param limits - explicit static-dependency limits; omitted keeps HTML self-contained or HTTPS-backed.
  */
-export function apply(ctx: Context, limits?: HtmlPackLimits): void {
-  for (const [name, value] of Object.entries(limits ?? {})) {
-    if (!Number.isSafeInteger(value) || value < 1) throw new Error(`HTML ${name} must be a positive safe integer`)
-  }
+export function apply(ctx: Context): void {
   const t = ctx.locale.bind('documentHtml')
   ctx.effect(() => ctx.locale.register('documentHtml', { zh, en }))
   ctx.effect(() => ctx.documentPreviews.register(htmlBodyDefinition(() => t('title'))))
   ctx.effect(() => ctx.slots.inject('sidebar.right.tab.document', () => ctx.slots.register(
     {
       name: 'sidebar.right.tab.document', key: HTML_BODY_ID, locale: 'documentHtml',
-      inject: (): Pick<HtmlBodyProps, 'limits' | 'readRelated'> => ({
-        limits,
+      inject: (): Pick<HtmlBodyProps, 'readRelated'> => ({
         readRelated: (address, relativePath, signal) => {
           const file = hostFileOf(address)
           return ctx.remote.workspaceFiles.readRelated(file.sessionId, file.path, relativePath, signal)

+ 7 - 13
packages/client/ui-sidebar-documentpreview/src/client/html/pack.ts

@@ -11,13 +11,9 @@ import { decodeText } from './bytes.ts'
  */
 export type ReadHtmlRelative = (reference: string, signal: AbortSignal) => Promise<DocumentFileBytes>
 
-/** Explicit limits supplied by the preview's owning configuration. */
-export interface HtmlPackLimits {
-  readonly maxAssetBytes: number
-  /** Decoded root HTML plus all unique decoded assets. */
-  readonly maxTotalBytes: number
-  readonly maxAssets: number
-}
+const MAX_ASSET_BYTES = 4 * 1024 * 1024
+const MAX_TOTAL_BYTES = 32 * 1024 * 1024
+const MAX_ASSETS = 64
 
 /** Whether this reference can be read relative to the original document, never the parent application URL. */
 function relative(reference: string): boolean {
@@ -30,19 +26,17 @@ function relative(reference: string): boolean {
  * links are packed; local CSS url/import, modules and dynamically constructed URLs are unsupported.
  * @param data - complete UTF-8 HTML bytes.
  * @param readRelative - original-document-scoped read, never exposed to the iframe.
- * @param limits - maximum decoded bytes and unique dependency count.
  * @param signal - stops reads and prevents publication after cancellation.
  * @returns complete HTML and its finite static asset set; decoding, limits and read failures reject.
  */
 export async function packHtml(
   data: Uint8Array<ArrayBuffer>,
   readRelative: ReadHtmlRelative,
-  limits: HtmlPackLimits,
   signal: AbortSignal,
 ): Promise<HtmlBundle> {
   signal.throwIfAborted()
   let total = data.byteLength
-  if (total > limits.maxTotalBytes) throw new Error('HTML package exceeds its total byte limit')
+  if (total > MAX_TOTAL_BYTES) throw new Error('HTML package exceeds its total byte limit')
   const template = document.createElement('template')
   template.innerHTML = decodeText(data)
   const assets: HtmlAsset[] = []
@@ -62,14 +56,14 @@ export async function packHtml(
     const kind = script ? 'script' : 'stylesheet'
     const key = `${kind}:${reference}`
     if (seen.has(key)) continue
-    if (assets.length >= limits.maxAssets) throw new Error('HTML package exceeds its asset count limit')
+    if (assets.length >= MAX_ASSETS) throw new Error('HTML package exceeds its asset count limit')
     signal.throwIfAborted()
     const asset = await readRelative(reference, signal)
     signal.throwIfAborted()
     const size = asset.data.byteLength
-    if (size > limits.maxAssetBytes) throw new Error('HTML asset exceeds its byte limit')
+    if (size > MAX_ASSET_BYTES) throw new Error('HTML asset exceeds its byte limit')
     total += size
-    if (total > limits.maxTotalBytes) throw new Error('HTML package exceeds its total byte limit')
+    if (total > MAX_TOTAL_BYTES) throw new Error('HTML package exceeds its total byte limit')
     decodeText(asset.data)
     assets.push({ kind, reference, data: asset.data })
     seen.add(key)

+ 2 - 21
packages/client/ui-sidebar-documentpreview/src/client/index.ts

@@ -11,7 +11,6 @@
  */
 import type { Context as ClientContext } from '@deepseek-ai/cordis'
 import type {} from '@deepseek-ai/dsh-api-remotes/client'
-import z from '@deepseek-ai/schemastery'
 import type {} from '@deepseek-ai/dsh-client-locale/client'
 import type {} from '@deepseek-ai/dsh-client-resources/client'
 import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
@@ -76,29 +75,11 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
  */
 export const inject = ['slots', 'locale', 'sidebarRightTabs', 'remote', 'remote.workspaceFiles']
 
-/** Bounds on HTML's statically referenced local resources. */
-export interface Config {
-  /** Maximum decoded bytes per referenced resource. */
-  htmlMaxAssetBytes: number
-  /** Maximum decoded bytes across the HTML and referenced resources. */
-  htmlMaxTotalBytes: number
-  /** Maximum distinct referenced scripts and stylesheets. */
-  htmlMaxAssets: number
-}
-
-/** Validated limits for static HTML dependency loading. */
-export const Config: z<Partial<Config>, Config> = z.object({
-  htmlMaxAssetBytes: z.natural().min(1).default(4 * 1024 * 1024),
-  htmlMaxTotalBytes: z.natural().min(1).default(32 * 1024 * 1024),
-  htmlMaxAssets: z.natural().min(1).default(64),
-})
-
 /**
  * Client plugin body: register the type, its dictionaries, and its body.
  * @param ctx - client root context carrying the registry, the slots, copy, and the Remote face.
- * @param config - validated local-resource limits.
  */
-export function apply(ctx: ClientContext, config: Config = Config({})): void {
+export function apply(ctx: ClientContext): void {
   const previews = new DocumentPreviewRegistry()
   const disposePreviews = ctx.reflect.provide('documentPreviews', previews)
   ctx.effect(() => disposePreviews)
@@ -123,7 +104,7 @@ export function apply(ctx: ClientContext, config: Config = Config({})): void {
   )), 'ui-sidebar-documentpreview: text body')
   registerText(ctx)
   registerMarkdown(ctx)
-  registerHtml(ctx, { maxAssetBytes: config.htmlMaxAssetBytes, maxTotalBytes: config.htmlMaxTotalBytes, maxAssets: config.htmlMaxAssets })
+  registerHtml(ctx)
   registerPdf(ctx)
   registerCode(ctx)
 }

+ 3 - 12
packages/client/ui-sidebar-documentpreview/src/client/pdf/PdfBody.tsx

@@ -1,10 +1,9 @@
 /** PDF page presentation; binary content and tab information come from the document owner. */
-import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
+import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
 import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { PropsLocale, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
 import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
 import type { DocumentPreviewProps } from '../document/contract.ts'
-import { DOCUMENT_LAYOUT_READY_EVENT } from '../document/layout.ts'
 import { LoadingIndicator } from '../LoadingIndicator.tsx'
 import { DEFAULT_PDF_VIEW, type PdfStore } from './store.ts'
 import { renderPdfPage, type PdfDocument } from './document.ts'
@@ -39,7 +38,6 @@ export function PdfBody(props: PdfBodyProps): ReactNode {
   const { tab } = props.useTabInfo()
   const view = props.useStore(state => state.byTab[tab.id] ?? DEFAULT_PDF_VIEW)
   const data = props.content.kind === 'bytes' ? props.content.data : undefined
-  const body = useRef<HTMLElement>(null)
   const [load, setLoad] = useState<LoadState>()
   const [attempt, setAttempt] = useState(0)
   const { retainTab, actions, t } = props
@@ -65,13 +63,6 @@ export function PdfBody(props: PdfBodyProps): ReactNode {
       void session.dispose()
     }
   }, [data, tab.signal, attempt])
-  useLayoutEffect(() => {
-    if (load?.kind === 'loaded' && load.data === data) {
-      // The loaded branch renders this section in the same commit as the layout effect.
-      (body.current as HTMLElement).dispatchEvent(new Event(DOCUMENT_LAYOUT_READY_EVENT, { bubbles: true }))
-    }
-  }, [data, load])
-
   if (data === undefined) return <p className={css.status} role="alert">{t('unsupported')}</p>
   if (load?.data !== data) return <LoadingIndicator className={css.status} label={t('loading')} />
   if (load.kind === 'failed') {
@@ -80,7 +71,7 @@ export function PdfBody(props: PdfBodyProps): ReactNode {
       <Button size="sm" onClick={() => { setAttempt(value => value + 1) }}>{t('retry')}</Button>
     </div>
   }
-  return <section ref={body} className={css.body} data-pdf-preview>
+  return <section className={css.body} data-pdf-preview>
     {Array.from({ length: load.document.numPages }, (_, index) => (
       <PdfPage key={index} document={load.document} page={index + 1}
         requested={index === 0 || view.page === index + 1} onVisible={pageVisible} signal={tab.signal} t={t} />
@@ -128,7 +119,7 @@ function PdfPage({ document, page, requested: initiallyRequested, onVisible, sig
     const renderSignal = AbortSignal.any([lifetime.signal, signal])
     setState('loading')
     setFailure(undefined)
-    void renderPdfPage(document, page, 1, node, renderSignal, window.devicePixelRatio).then(
+    void renderPdfPage(document, page, node, renderSignal, window.devicePixelRatio).then(
       () => { if (!renderSignal.aborted) setState('ready') },
       (error: unknown) => { if (!renderSignal.aborted) setFailure({ error }) },
     )

+ 1 - 3
packages/client/ui-sidebar-documentpreview/src/client/pdf/document.ts

@@ -27,7 +27,6 @@ export interface PdfPageSize {
  * awaited before the page is cleaned up.
  * @param document - loaded pdfjs document.
  * @param pageNumber - 1-based selected page.
- * @param zoom - scale relative to 96-DPI display size.
  * @param canvas - canvas owned by this render only.
  * @param signal - render lifetime.
  * @param pixelRatio - display pixel ratio.
@@ -36,7 +35,6 @@ export interface PdfPageSize {
 export async function renderPdfPage(
   document: PdfDocument,
   pageNumber: number,
-  zoom: number,
   canvas: HTMLCanvasElement,
   signal: AbortSignal,
   pixelRatio: number,
@@ -45,7 +43,7 @@ export async function renderPdfPage(
   const page: PDFPageProxy = await document.getPage(pageNumber)
   try {
     signal.throwIfAborted()
-    const viewport = page.getViewport({ scale: zoom * 96 / 72 })
+    const viewport = page.getViewport({ scale: 96 / 72 })
     // Limit raster allocation without changing the document's display dimensions.
     const ratio = Math.min(pixelRatio, Math.sqrt(16_777_216 / (viewport.width * viewport.height)))
     canvas.width = Math.max(1, Math.floor(viewport.width * ratio))

+ 4 - 1
packages/client/ui-sidebar-documentpreview/src/client/text/lines.ts

@@ -46,6 +46,9 @@ export function scrollToLine(body: HTMLElement, line: number): boolean {
   const code = body.querySelectorAll('[data-code-preview] pre .line').item(line - 1)
   const row = plain ?? code
   if (!(row instanceof HTMLElement)) return false
-  body.scrollTop = row.offsetTop
+  const codeBlock = code === row ? row.closest<HTMLElement>('.md-code-block') : null
+  const toolbar = codeBlock?.firstElementChild
+  const toolbarHeight = toolbar instanceof HTMLElement ? toolbar.offsetHeight : 0
+  body.scrollTop = Math.max(0, row.offsetTop - toolbarHeight)
   return true
 }

+ 3 - 14
packages/client/ui-sidebar-documentpreview/tests/html-apply.client.spec.ts

@@ -6,14 +6,13 @@ import { DocumentPreviewRegistry } from '../src/client/document/registry.ts'
 import { apply, HTML_BODY_ID, htmlBodyDefinition } from '../src/client/html/index.ts'
 import { HtmlBody } from '../src/client/html/HtmlBody.tsx'
 import { en, zh } from '../src/client/html/locales.ts'
-import type { HtmlPackLimits } from '../src/client/html/pack.ts'
 import type { HtmlBodyProps } from '../src/client/html/HtmlBody.tsx'
 
 type Registration = {
   name: string
   key: string
   locale: string
-  inject: () => Pick<HtmlBodyProps, 'limits' | 'readRelated'>
+  inject: () => Pick<HtmlBodyProps, 'readRelated'>
 }
 
 let dispose: (() => Promise<void>) | undefined
@@ -29,10 +28,7 @@ describe('HTML registration', () => {
     expect(htmlBodyDefinition(title).title()).toBe('localized HTML')
   })
 
-  it.each<HtmlPackLimits | undefined>([
-    undefined,
-    { maxAssetBytes: 1024, maxTotalBytes: 4096, maxAssets: 4 },
-  ])('registers its dictionary and matching keyed body, and removes all contributions on disposal (limits: %j)', async (limits) => {
+  it('registers its dictionary and matching keyed body, and removes all contributions on disposal', async () => {
     const ctx = new Context()
     // No Session or Tab services are mounted; the global callback must use its file address.
     const registry = new DocumentPreviewRegistry()
@@ -50,7 +46,7 @@ describe('HTML registration', () => {
       bind: () => (key: keyof typeof en) => en[key],
       register: (name: string, value: unknown) => { dictionaries.set(name, value); return () => { dictionaries.delete(name) } },
     } as never)
-    const fiber = ctx.plugin({ apply: (owner) => { apply(owner, limits) } })
+    const fiber = ctx.plugin({ apply })
     dispose = async () => { await fiber.dispose() }
     await fiber.await()
     expect(registry.candidates('INDEX.HTM').map(entry => entry.id)).toEqual([HTML_BODY_ID])
@@ -61,7 +57,6 @@ describe('HTML registration', () => {
     expect(registration).toMatchObject({ name: 'sidebar.right.tab.document', key: HTML_BODY_ID, locale: 'documentHtml' })
     expect(register.mock.calls[0]?.[1]).toBe(HtmlBody)
     const injected = registration?.inject()
-    expect(injected?.limits).toEqual(limits)
     expect(typeof injected?.readRelated).toBe('function')
     const signal = new AbortController().signal
     await injected?.readRelated('dsh-resource://file/session/explicit-session/sub/index.html', '../app.js', signal)
@@ -76,10 +71,4 @@ describe('HTML registration', () => {
     expect(bodies.size).toBe(0)
     expect(dictionaries.size).toBe(0)
   })
-
-  it('refuses invalid limits before registering anything', () => {
-    expect(() => {
-      apply(new Context(), { maxAssetBytes: 0, maxTotalBytes: 4096, maxAssets: 4 })
-    }).toThrow('maxAssetBytes must be a positive safe integer')
-  })
 })

+ 11 - 12
packages/client/ui-sidebar-documentpreview/tests/html-body.client.spec.tsx

@@ -37,7 +37,6 @@ function props(text = '<p>hello</p>'): HtmlBodyProps {
     resourceAddress: 'dsh-resource://file/session/html/index.html',
     content: { kind: 'bytes', data: utf8(text) },
     wrap: false,
-    limits: undefined,
     sessionId: 'html' as SessionId,
     useTabInfo: () => ({ tab: { signal } }),
     readRelated: vi.fn(),
@@ -49,10 +48,10 @@ function props(text = '<p>hello</p>'): HtmlBodyProps {
 const utf8 = (text: string): Uint8Array<ArrayBuffer> => new TextEncoder().encode(text)
 
 describe('HtmlBody', () => {
-  it('renders a Blob iframe with only scripts allowed, keeping it mounted for unrelated props', () => {
+  it('renders a Blob iframe with only scripts allowed, keeping it mounted for unrelated props', async () => {
     const initial = props()
     const view = render(<HtmlBody {...initial} />)
-    const iframe = screen.getByTitle(en.frame)
+    const iframe = await screen.findByTitle(en.frame)
     expect(iframe.getAttribute('sandbox')).toBe('allow-scripts')
     expect(iframe.getAttribute('src')).toBe('blob:https://preview.invalid/1')
     expect(create.mock.calls[0]?.[0].type).toBe('text/html')
@@ -64,29 +63,31 @@ describe('HtmlBody', () => {
     expect(revoke).toHaveBeenCalledExactlyOnceWith('blob:https://preview.invalid/1')
   })
 
-  it('destroys the old frame and revokes its Blob when bytes or source file change', () => {
+  it('destroys the old frame and revokes its Blob when bytes or source file change', async () => {
     const view = render(<HtmlBody {...props()} />)
-    const first = screen.getByTitle(en.frame)
+    const first = await screen.findByTitle(en.frame)
     const changed = props('<p>changed</p>')
     view.rerender(<HtmlBody {...changed} />)
-    expect(screen.getByTitle(en.frame)).not.toBe(first)
+    expect(await screen.findByTitle(en.frame)).not.toBe(first)
     expect(first.isConnected).toBe(false)
     expect(revoke).toHaveBeenCalledWith('blob:https://preview.invalid/1')
     view.rerender(<HtmlBody {...changed} resourceAddress="dsh-resource://file/session/html/other.html" />)
+    await screen.findByTitle(en.frame)
     expect(revoke).toHaveBeenCalledWith('blob:https://preview.invalid/2')
     view.unmount()
     expect(revoke).toHaveBeenCalledWith('blob:https://preview.invalid/3')
   })
 
-  it('reports invalid bytes and Blob creation failures without leaving a previous frame running', () => {
+  it('reports invalid bytes and Blob creation failures without leaving a previous frame running', async () => {
     const view = render(<HtmlBody {...props()} />)
+    await screen.findByTitle(en.frame)
     view.rerender(<HtmlBody {...props()} content={{ kind: 'bytes', data: new Uint8Array([255]) }} />)
-    expect(screen.getByRole('alert').textContent).toBe(en.failed)
+    expect((await screen.findByRole('alert')).textContent).toBe(en.failed)
     expect(screen.queryByTitle(en.frame)).toBeNull()
     expect(revoke).toHaveBeenCalledWith('blob:https://preview.invalid/1')
     create.mockImplementationOnce(() => { throw new Error('Blob unavailable') })
     view.rerender(<HtmlBody {...props('different')} />)
-    expect(screen.getByRole('alert').textContent).toBe(en.failed)
+    expect((await screen.findByRole('alert')).textContent).toBe(en.failed)
   })
 
   it('does not create a document for a text-pages delivery', () => {
@@ -103,7 +104,6 @@ describe('HtmlBody', () => {
       ...props('<script src="./app.js"></script>'),
       useTabInfo: () => ({ tab: { signal } }),
       readRelated: bytes,
-      limits: { maxAssetBytes: 100, maxTotalBytes: 1000, maxAssets: 1 },
     } as unknown as HtmlBodyProps
     const view = render(<HtmlBody {...initial} />)
     expect(bytes).toHaveBeenCalledOnce()
@@ -127,7 +127,6 @@ describe('HtmlBody', () => {
       useTabInfo: () => ({ tab: { signal } }),
       useResource,
       readRelated: bytes,
-      limits: { maxAssetBytes: 100, maxTotalBytes: 1000, maxAssets: 1 },
     } as unknown as HtmlBodyProps
     render(<HtmlBody {...initial} />)
     expect(screen.getByRole('status').textContent).toBe(en.loading)
@@ -148,7 +147,7 @@ describe('HtmlBody', () => {
     const useResource = vi.fn(() => ({ value: { version: 'v1' } }))
     const initial = {
       ...props('<script src="./app.js"></script>'), useTabInfo: () => ({ tab: { signal } }),
-      useResource, readRelated: bytes, limits: { maxAssetBytes: 100, maxTotalBytes: 1000, maxAssets: 1 },
+      useResource, readRelated: bytes,
     } as unknown as HtmlBodyProps
     const view = render(<HtmlBody {...initial} />)
     const iframe = await screen.findByTitle(en.frame)

+ 30 - 21
packages/client/ui-sidebar-documentpreview/tests/html-pack.client.spec.ts

@@ -2,10 +2,9 @@
 /** Static dependency discovery has an injected file reader and never exposes it to the iframe. */
 import { describe, expect, it, vi } from 'vitest'
 import { packHtml } from '../src/client/html/pack.ts'
-import type { HtmlPackLimits, ReadHtmlRelative } from '../src/client/html/pack.ts'
+import type { ReadHtmlRelative } from '../src/client/html/pack.ts'
 import type { DocumentFileBytes } from '../src/client/rpc.ts'
 
-const limits: HtmlPackLimits = { maxAssetBytes: 1024, maxTotalBytes: 16 * 1024, maxAssets: 8 }
 const source = '<link rel="stylesheet" href="./main.css"><script src="./main.js"></script>'
 
 const utf8 = (text: string): Uint8Array<ArrayBuffer> => new TextEncoder().encode(text)
@@ -17,7 +16,7 @@ describe('packHtml', () => {
   it('collects direct classic JS and CSS in document order and deduplicates repeated references', async () => {
     const read = vi.fn<ReadHtmlRelative>().mockResolvedValue(file(utf8('/* 你好 */')))
     const signal = new AbortController().signal
-    const bundle = await packHtml(utf8(source + '<script defer src="./main.js"></script>'), read, limits, signal)
+    const bundle = await packHtml(utf8(source + '<script defer src="./main.js"></script>'), read, signal)
     expect(read.mock.calls).toEqual([['./main.css', signal], ['./main.js', signal]])
     expect(bundle.assets.map(asset => [asset.kind, asset.reference])).toEqual([['stylesheet', './main.css'], ['script', './main.js']])
     expect(document.querySelector('script,link')).toBeNull()
@@ -26,14 +25,14 @@ describe('packHtml', () => {
   it('leaves HTTPS, module, file, root-relative, data and runtime dependencies to browser rules', async () => {
     const read = vi.fn<ReadHtmlRelative>()
     const html = '<script src="https://example.invalid/a.js"></script><script src="//example.invalid/a.js"></script><script type="module" src="./module.js"></script><script type="application/ld+json" src="./data.js"></script><script src="file:///a.js"></script><script src="/a.js"></script><script src="data:text/javascript,1"></script><script>fetch("./data.json")</script><link rel="icon" href="./icon.css"><!-- <script src="./comment.js"></script> -->'
-    expect((await packHtml(utf8(html), read, limits, new AbortController().signal)).assets).toEqual([])
+    expect((await packHtml(utf8(html), read, new AbortController().signal)).assets).toEqual([])
     expect(read).not.toHaveBeenCalled()
   })
 
   it('does not turn base-relative browser resources into local file reads', async () => {
     const read = vi.fn<ReadHtmlRelative>()
     for (const base of ['https://example.invalid/assets/', './assets/', 'file:///assets/']) {
-      const bundle = await packHtml(utf8(`<base href="${base}">${source}`), read, limits, new AbortController().signal)
+      const bundle = await packHtml(utf8(`<base href="${base}">${source}`), read, new AbortController().signal)
       expect(bundle.assets).toEqual([])
     }
     expect(read).not.toHaveBeenCalled()
@@ -41,50 +40,60 @@ describe('packHtml', () => {
 
   it('does not read a link without a stylesheet relationship', async () => {
     const read = vi.fn<ReadHtmlRelative>()
-    const bundle = await packHtml(utf8('<link href="./main.css">'), read, limits, new AbortController().signal)
+    const bundle = await packHtml(utf8('<link href="./main.css">'), read, new AbortController().signal)
     expect(bundle.assets).toEqual([])
     expect(read).not.toHaveBeenCalled()
   })
 
   it('passes decoded HTML attributes to the scoped reader without recursing into CSS imports', async () => {
     const read = vi.fn<ReadHtmlRelative>().mockResolvedValue(file(utf8('@import "./child.css";a{background:url(./image.png)}')))
-    const bundle = await packHtml(utf8('<link rel="STYLESHEET" href="main.css?v=1&amp;x=2">'), read, limits, new AbortController().signal)
+    const bundle = await packHtml(utf8('<link rel="STYLESHEET" href="main.css?v=1&amp;x=2">'), read, new AbortController().signal)
     expect(read.mock.calls[0]?.[0]).toBe('main.css?v=1&x=2')
     expect(read).toHaveBeenCalledOnce()
     expect(bundle.assets).toHaveLength(1)
   })
 
-  it('accepts exact decoded limits and rejects oversized root, asset, aggregate and count', async () => {
-    const html = '<script src="a.js"></script>'
-    const data = utf8(html)
-    const size = new TextEncoder().encode(html).length
-    const read = vi.fn<ReadHtmlRelative>().mockResolvedValue(file(utf8('雪')))
+  it('accepts the fixed per-asset limit and rejects oversized roots and assets', async () => {
+    const mebibyte = 1024 * 1024
+    const html = utf8('<script src="a.js"></script>')
+    const read = vi.fn<ReadHtmlRelative>().mockResolvedValue(file(new Uint8Array(4 * mebibyte)))
     const signal = new AbortController().signal
-    await expect(packHtml(data, read, { maxAssetBytes: 3, maxTotalBytes: size + 3, maxAssets: 1 }, signal)).resolves.toMatchObject({ data })
-    await expect(packHtml(data, read, { ...limits, maxTotalBytes: size - 1 }, signal)).rejects.toThrow('total byte limit')
-    await expect(packHtml(data, read, { ...limits, maxAssetBytes: 2 }, signal)).rejects.toThrow('asset exceeds')
-    await expect(packHtml(data, read, { ...limits, maxTotalBytes: size + 2 }, signal)).rejects.toThrow('total byte limit')
-    await expect(packHtml(data, read, { ...limits, maxAssets: 0 }, signal)).rejects.toThrow('asset count limit')
+    await expect(packHtml(html, read, signal)).resolves.toMatchObject({ data: html })
+    await expect(packHtml(new Uint8Array(32 * mebibyte + 1), read, signal)).rejects.toThrow('total byte limit')
+    read.mockResolvedValue(file(new Uint8Array(4 * mebibyte + 1)))
+    await expect(packHtml(html, read, signal)).rejects.toThrow('asset exceeds')
+  })
+
+  it('rejects fixed aggregate and asset-count limits', async () => {
+    const mebibyte = 1024 * 1024
+    const aggregate = Array.from({ length: 8 }, (_, index) => `<script src="${index}.js"></script>`).join('')
+    const read = vi.fn<ReadHtmlRelative>().mockResolvedValue(file(new Uint8Array(4 * mebibyte)))
+    await expect(packHtml(utf8(aggregate), read, new AbortController().signal)).rejects.toThrow('total byte limit')
+
+    const count = Array.from({ length: 65 }, (_, index) => `<script src="${index}.js"></script>`).join('')
+    read.mockResolvedValue(file(new Uint8Array()))
+    await expect(packHtml(utf8(count), read, new AbortController().signal)).rejects.toThrow('asset count limit')
+    expect(read).toHaveBeenCalledTimes(8 + 64)
   })
 
   it('propagates read errors and rejects malformed resource text instead of returning a partial package', async () => {
     const read = vi.fn<ReadHtmlRelative>().mockRejectedValue(new Error('outside workspace'))
-    await expect(packHtml(utf8(source), read, limits, new AbortController().signal)).rejects.toThrow('outside workspace')
+    await expect(packHtml(utf8(source), read, new AbortController().signal)).rejects.toThrow('outside workspace')
     read.mockResolvedValue(file(new Uint8Array([255])))
-    await expect(packHtml(utf8(source), read, limits, new AbortController().signal)).rejects.toThrow()
+    await expect(packHtml(utf8(source), read, new AbortController().signal)).rejects.toThrow()
   })
 
   it('does not read after abort and discards a read that settles after cancellation', async () => {
     const pending = Promise.withResolvers<DocumentFileBytes>()
     const read = vi.fn<ReadHtmlRelative>().mockReturnValue(pending.promise)
     const controller = new AbortController()
-    const packing = packHtml(utf8(source), read, limits, controller.signal)
+    const packing = packHtml(utf8(source), read, controller.signal)
     expect(read).toHaveBeenCalledOnce()
     const rejected = expect(packing).rejects.toMatchObject({ name: 'AbortError' })
     controller.abort()
     pending.resolve(file(utf8('body{}')))
     await rejected
-    await expect(packHtml(utf8(source), read, limits, controller.signal)).rejects.toMatchObject({ name: 'AbortError' })
+    await expect(packHtml(utf8(source), read, controller.signal)).rejects.toMatchObject({ name: 'AbortError' })
     expect(read).toHaveBeenCalledOnce()
   })
 })

+ 2 - 7
packages/client/ui-sidebar-documentpreview/tests/pdf-body.client.spec.tsx

@@ -12,7 +12,6 @@ import type { openPdf } from '../src/client/pdf/runtime.ts'
 const engine = vi.hoisted(() => ({ open: vi.fn<typeof openPdf>(), render: vi.fn<typeof renderPdfPage>() }))
 vi.mock('../src/client/pdf/runtime.ts', () => ({ openPdf: engine.open }))
 vi.mock('../src/client/pdf/document.ts', () => ({ renderPdfPage: engine.render }))
-import { DOCUMENT_LAYOUT_READY_EVENT } from '../src/client/document/layout.ts'
 import { PdfBody, type PdfBodyProps } from '../src/client/pdf/PdfBody.tsx'
 import { createPdfStore, type PdfState } from '../src/client/pdf/store.ts'
 import { en } from '../src/client/pdf/locales.ts'
@@ -100,10 +99,7 @@ describe('PDF body', () => {
   it('shows loading, omits the paging toolbar, and renders a continuous page sequence', async () => {
     const h = harness()
     const view = render(<h.View />)
-    const layoutReady = vi.fn()
-    view.container.addEventListener(DOCUMENT_LAYOUT_READY_EVENT, layoutReady)
     expect(screen.getByRole('status').textContent).toBe('Opening PDF…')
-    expect(layoutReady).not.toHaveBeenCalled()
     expect(screen.getByRole('status').hasAttribute('data-document-loading')).toBe(true)
     await act(async () => { loads[0]!.deferred.resolve(documentOf()) })
     await act(async () => {})
@@ -112,8 +108,7 @@ describe('PDF body', () => {
       .toEqual(['1', '2', '3'])
     expect(screen.getAllByRole('img').map(image => image.getAttribute('aria-label')))
       .toEqual(['PDF page 1', 'PDF page 2', 'PDF page 3'])
-    expect(engine.render.mock.calls.map(([, page, zoom]) => [page, zoom])).toEqual([[1, 1], [2, 1], [3, 1]])
-    expect(layoutReady).toHaveBeenCalledOnce()
+    expect(engine.render.mock.calls.map(([, page]) => page)).toEqual([1, 2, 3])
   })
 
   it('keeps the replacement document when the previous load settles late', async () => {
@@ -175,7 +170,7 @@ describe('PDF body', () => {
     const h = harness()
     const view = render(<h.View />)
     await act(async () => { loads[0]!.deferred.resolve(documentOf(2)) })
-    const signals = engine.render.mock.calls.map(([, , , , signal]) => signal)
+    const signals = engine.render.mock.calls.map(([, , , signal]) => signal)
     expect(signals).toHaveLength(2)
     view.unmount()
     expect(signals.every(signal => signal.aborted)).toBe(true)

+ 7 - 5
packages/client/ui-sidebar-documentpreview/tests/pdf-document.client.spec.ts

@@ -17,7 +17,7 @@ describe('PDF canvas rendering', () => {
       },
       cleanup,
     } as unknown as PDFPageProxy
-    await expect(renderPdfPage({ numPages: 1, getPage: async () => page }, 1, 1, document.createElement('canvas'), controller.signal, 1))
+    await expect(renderPdfPage({ numPages: 1, getPage: async () => page }, 1, document.createElement('canvas'), controller.signal, 1))
       .rejects.toMatchObject({ name: 'AbortError' })
     expect(cancel).toHaveBeenCalledOnce()
     expect(cleanup).toHaveBeenCalledOnce()
@@ -29,7 +29,7 @@ describe('PDF canvas rendering', () => {
     const canvas = document.createElement('canvas')
     const cleanup = vi.fn()
     const render = vi.fn()
-    const pending = renderPdfPage({ numPages: 1, getPage: () => lookup.promise }, 1, 1, canvas, controller.signal, 1)
+    const pending = renderPdfPage({ numPages: 1, getPage: () => lookup.promise }, 1, canvas, controller.signal, 1)
     const rejected = expect(pending).rejects.toMatchObject({ name: 'AbortError' })
     controller.abort()
     lookup.resolve({ cleanup, render } as unknown as PDFPageProxy)
@@ -51,7 +51,7 @@ describe('PDF canvas rendering', () => {
       render: vi.fn(() => { entered.resolve(undefined); return { promise: running.promise, cancel } }),
       cleanup,
     } as unknown as PDFPageProxy
-    const pending = renderPdfPage({ numPages: 1, getPage: async () => page }, 1, 1.5, document.createElement('canvas'), controller.signal, 2)
+    const pending = renderPdfPage({ numPages: 1, getPage: async () => page }, 1, document.createElement('canvas'), controller.signal, 2)
     const rejected = expect(pending).rejects.toThrow('cancelled')
     await entered.promise
     controller.abort()
@@ -64,14 +64,16 @@ describe('PDF canvas rendering', () => {
 
   it('bounds raster allocation while retaining the selected display dimensions', async () => {
     const cleanup = vi.fn()
+    const getViewport = vi.fn(() => ({ width: 8192, height: 8192 }))
     const page = {
-      getViewport: vi.fn(() => ({ width: 8192, height: 8192 })),
+      getViewport,
       render: vi.fn(() => ({ promise: Promise.resolve(), cancel: vi.fn() })),
       cleanup,
     } as unknown as PDFPageProxy
     const canvas = document.createElement('canvas')
-    await expect(renderPdfPage({ numPages: 1, getPage: async () => page }, 1, 4, canvas, new AbortController().signal, 2))
+    await expect(renderPdfPage({ numPages: 1, getPage: async () => page }, 1, canvas, new AbortController().signal, 2))
       .resolves.toEqual({ width: 8192, height: 8192 })
+    expect(getViewport).toHaveBeenCalledWith({ scale: 96 / 72 })
     expect(canvas.width * canvas.height).toBe(16_777_216)
     expect(canvas.style.getPropertyValue('--pdf-page-width')).toBe('8192px')
     expect(cleanup).toHaveBeenCalledOnce()

+ 65 - 0
packages/client/ui-sidebar-documentpreview/tests/pdf-license-bundle.client.spec.ts

@@ -0,0 +1,65 @@
+import { spawnSync } from 'node:child_process'
+import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { dirname, join, resolve } from 'node:path'
+import { createRequire } from 'node:module'
+import { describe, expect, it } from 'vitest'
+
+const packageRoot = resolve(import.meta.dirname, '..')
+const bundlePath = join(packageRoot, 'lib/client.js')
+const require = createRequire(import.meta.url)
+const licenseNames = [
+  'LICENSE',
+  'cmaps/LICENSE',
+  'standard_fonts/LICENSE_FOXIT',
+  'standard_fonts/LICENSE_LIBERATION',
+  'wasm/LICENSE_JBIG2',
+  'wasm/LICENSE_OPENJPEG',
+  'wasm/LICENSE_PDFJS_JBIG2',
+  'wasm/LICENSE_PDFJS_OPENJPEG',
+  'wasm/LICENSE_PDFJS_QCMS',
+  'wasm/LICENSE_QCMS',
+] as const
+
+function run(command: string, args: string[], cwd: string, timeout: number): string {
+  const result = spawnSync(command, args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, timeout })
+  expect(result.error).toBeUndefined()
+  expect(result.signal, result.stderr).toBeNull()
+  expect(result.status, result.stderr).toBe(0)
+  return result.stdout
+}
+
+function runPnpm(args: string[], cwd: string, timeout: number): string {
+  const entrypoint = process.env.npm_execpath
+  if (entrypoint === undefined || entrypoint === '') {
+    if (process.platform === 'win32') throw new Error('npm_execpath is required to run pnpm on Windows')
+    return run('pnpm', args, cwd, timeout)
+  }
+  return /\.[cm]?js$/iu.test(entrypoint)
+    ? run(process.execPath, [entrypoint, ...args], cwd, timeout)
+    : run(entrypoint, args, cwd, timeout)
+}
+
+describe('published PDF.js licenses', () => {
+  it.skipIf(!existsSync(bundlePath))('keeps every bundled license in the packed client artifact', ({ task }) => {
+    const output = mkdtempSync(join(tmpdir(), 'dsh-document-preview-pack-'))
+    try {
+      const packed = JSON.parse(runPnpm([
+        'pack', '--json', '--pack-destination', output,
+      ], packageRoot, task.timeout)) as { filename: string; files: { path: string }[] }
+      expect(packed.files.map(file => file.path)).toContain('lib/client.js')
+      expect(packed.files.some(file => file.path.endsWith('pdfjs-NOTICES.txt'))).toBe(false)
+
+      const client = run('tar', ['-xOf', resolve(packageRoot, packed.filename), 'package/lib/client.js'], packageRoot, task.timeout)
+      expect(client).toContain('//! Bundled PDF.js license notices')
+      const pdfRoot = dirname(require.resolve('pdfjs-dist/package.json'))
+      for (const name of licenseNames) {
+        const source = readFileSync(join(pdfRoot, name), 'utf8').trimEnd()
+        const commented = [`// ${name}`, '// ', ...source.split('\n').map(line => `// ${line}`)].join('\n')
+        expect(client, `${name} must be visible in package/lib/client.js`).toContain(commented)
+      }
+    } finally {
+      rmSync(output, { recursive: true, force: true })
+    }
+  })
+})

+ 1 - 1
packages/client/ui-sidebar-documentpreview/tests/pdf-smoke.client.spec.ts

@@ -45,7 +45,7 @@ describe('PDF.js real-library smoke', () => {
         const target = factory.create(1, 1)
         Object.defineProperty(target.canvas, 'style', { value: { setProperty: () => {} } })
         try {
-          await renderPdfPage(pdf, page, 1, target.canvas, new AbortController().signal, 1)
+          await renderPdfPage(pdf, page, target.canvas, new AbortController().signal, 1)
           const pixel = target.context.getImageData(Math.floor(target.canvas.width / 2), Math.floor(target.canvas.height / 2), 1, 1).data
           expect(pixel[3]).toBe(255)
           if (page === 1) expect(pixel[0]! - pixel[2]!).toBeGreaterThan(150)

+ 37 - 54
packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx

@@ -9,7 +9,6 @@
  * set to. Both are the browser's job; the specs assert the body's arithmetic
  * over them.
  */
-import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'
 import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
 import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
 import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
@@ -18,14 +17,17 @@ import type { OwnerOf } from '@deepseek-ai/dsh-client-ui-slots'
 import { TextPreview } from '../src/client/TextPreview.tsx'
 import type { TextPreviewProps } from '../src/client/TextPreview.tsx'
 import { CodeBody } from '../src/client/code/CodeBody.tsx'
-import { DOCUMENT_LAYOUT_READY_EVENT } from '../src/client/document/layout.ts'
 import type { DocumentPreviewDefinition } from '../src/client/document/registry.ts'
+import { TextBody } from '../src/client/text/TextBody.tsx'
+import { PLAIN_BODY_ID } from '../src/client/text/index.ts'
 import { ABSOLUTE_PATH, ADDRESS, PATH, SESSION, TAB_ID, failure, harness, page, settle } from './fixtures.client.ts'
 
 const LINE_HEIGHT = 20
+const CODE_TOOLBAR_HEIGHT = 36
 
 const originals = {
   offsetTop: Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetTop'),
+  offsetHeight: Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight'),
   scrollTop: Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollTop'),
 }
 
@@ -43,8 +45,13 @@ beforeAll(() => {
   Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
     configurable: true,
     get(this: HTMLElement & { __scrollTop?: number }) { return this.__scrollTop ?? 0 },
-    set(this: HTMLElement & { __scrollTop?: number }, value: number) {
-      this.__scrollTop = this.querySelector('[data-test-pdf-loading]') === null ? value : 0
+    set(this: HTMLElement & { __scrollTop?: number }, value: number) { this.__scrollTop = value },
+  })
+  Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
+    configurable: true,
+    get(this: HTMLElement) {
+      return this.parentElement?.classList.contains('md-code-block') === true
+        && this.parentElement.firstElementChild === this ? CODE_TOOLBAR_HEIGHT : 0
     },
   })
 })
@@ -94,18 +101,6 @@ function codeProps(h: ReturnType<typeof harness>, navigation: { params?: unknown
   }
 }
 
-let finishPdfLayout: (() => void) | undefined
-
-function AsyncPdfLayout(): ReactNode {
-  const [ready, setReady] = useState(false)
-  const section = useRef<HTMLElement>(null)
-  finishPdfLayout = () => { setReady(true) }
-  useLayoutEffect(() => {
-    if (ready) section.current?.dispatchEvent(new Event(DOCUMENT_LAYOUT_READY_EVENT, { bubbles: true }))
-  }, [ready])
-  return ready ? <section ref={section} data-test-pdf-ready /> : <span data-test-pdf-loading />
-}
-
 function body(container: HTMLElement): HTMLElement {
   const element = container.querySelector<HTMLElement>('[data-textpreview-body]')
   if (element === null) throw new Error('expected the file body')
@@ -345,48 +340,36 @@ describe('TextPreview — the file\'s metadata', () => {
 })
 
 describe('TextPreview — navigation and view', () => {
-  it('waits to answer a loaded-line navigation until the renderer exposes the line', async () => {
-    const h = harness({ 1: page(1, ['a', 'b'], true) })
-    const definition: DocumentPreviewDefinition = {
-      id: 'no-lines', extensions: ['md'], title: () => 'No lines', loading: 'text-pages', wrap: true,
-    }
+  it.each([
+    ['Code', 'code', 2 * LINE_HEIGHT - CODE_TOOLBAR_HEIGHT],
+    ['Plain text', PLAIN_BODY_ID, 2 * LINE_HEIGHT],
+  ])('retries a Markdown line navigation after switching to %s', async (_name, rendererId, expectedScrollTop) => {
+    PendingIntersectionObserver.instances = []
+    vi.stubGlobal('IntersectionObserver', PendingIntersectionObserver)
+    const h = harness({ 1: page(1, ['a', 'b', 'c'], true) })
+    const definitions: DocumentPreviewDefinition[] = [
+      { id: 'markdown', extensions: ['md'], title: () => 'Markdown', loading: 'text-pages', wrap: false },
+      { id: 'code', extensions: ['md'], title: () => 'Code', loading: 'text-pages', wrap: true },
+      { id: PLAIN_BODY_ID, extensions: [], title: () => 'Plain text', loading: 'text-pages', wrap: true },
+    ]
+    const base = h.props({ params: { line: 3 }, revision: 1 })
     const props: TextPreviewProps = {
-      ...h.props({ params: { line: 2 }, revision: 1 }),
-      useDocumentPreviews: selector => selector([definition]),
-      renderSlot: () => <div data-test-no-lines />,
+      ...base,
+      useDocumentPreviews: selector => selector(definitions),
+      renderSlot: (_key, owner, opts) => {
+        const documentOwner = owner as unknown as OwnerOf<'sidebar.right.tab.document'>
+        if (opts.entryKey === 'code') return <CodeBody {...base} {...documentOwner} t={key => key} />
+        if (opts.entryKey === PLAIN_BODY_ID) return <TextBody {...base} {...documentOwner} />
+        return <div data-test-no-lines />
+      },
     }
     const view = render(<TextPreview {...props} />)
     await settle()
     expect(body(view.container).scrollTop).toBe(0)
     expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBeUndefined()
-  })
-
-  it('restores complete-document scrolling after its asynchronous layout is ready', () => {
-    const h = harness()
-    h.instance.actions.loading(TAB_ID, 'bytes-complete', 'v1')
-    h.instance.actions.complete(TAB_ID, {
-      absolutePath: ABSOLUTE_PATH, version: 'v1', offset: 0,
-      data: new Uint8Array([1]), bytes: 1, eof: true,
-    })
-    h.instance.actions.scrolled(TAB_ID, 320)
-    const definition: DocumentPreviewDefinition = {
-      id: 'pdf', extensions: ['md'], title: () => 'PDF', loading: 'bytes-complete', wrap: false,
-    }
-    const props: TextPreviewProps = {
-      ...h.props(),
-      useDocumentPreviews: selector => selector([definition]),
-      renderSlot: () => <AsyncPdfLayout />,
-    }
-    const view = render(<TextPreview {...props} />)
-    const scrollBody = body(view.container)
-    expect(scrollBody.scrollTop).toBe(0)
-    act(() => { finishPdfLayout?.() })
-    expect(scrollBody.scrollTop).toBe(320)
-
-    fireEvent.scroll(scrollBody, { target: { scrollTop: 480 } })
-    view.container.querySelector('[data-test-pdf-ready]')
-      ?.dispatchEvent(new Event(DOCUMENT_LAYOUT_READY_EVENT, { bubbles: true }))
-    expect(scrollBody.scrollTop).toBe(480)
+    act(() => { h.instance.actions.selected(TAB_ID, rendererId) })
+    await waitFor(() => { expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBe(1) })
+    expect(body(view.container).scrollTop).toBe(expectedScrollTop)
   })
 
   it('lands on code lines before and after syntax highlighting is ready', async () => {
@@ -397,14 +380,14 @@ describe('TextPreview — navigation and view', () => {
     await settle()
     expect(view.container.querySelector('[data-code-preview] pre.shiki')).toBeNull()
     expect(view.container.querySelectorAll('[data-code-preview] pre .line')).toHaveLength(3)
-    expect(body(view.container).scrollTop).toBe(LINE_HEIGHT)
+    expect(body(view.container).scrollTop).toBe(0)
     expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBe(1)
 
     const block = view.container.querySelector('[data-code-preview] .md-code-block')!
     act(() => { PendingIntersectionObserver.instances[0]!.intersect(block) })
     await waitFor(() => { expect(view.container.querySelector('[data-code-preview] pre.shiki')).not.toBeNull() })
     view.rerender(<TextPreview {...codeProps(h, { params: { line: 3 }, revision: 2 })} />)
-    expect(body(view.container).scrollTop).toBe(2 * LINE_HEIGHT)
+    expect(body(view.container).scrollTop).toBe(2 * LINE_HEIGHT - CODE_TOOLBAR_HEIGHT)
     expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBe(2)
   })
 

+ 0 - 3
packages/client/ui-sidebar-documentpreview/tsconfig.json

@@ -14,9 +14,6 @@
     {
       "path": "../../../vendor/cordis"
     },
-    {
-      "path": "../../../vendor/schemastery"
-    },
     {
       "path": "../../api/remotes/tsconfig.client.json"
     },

+ 18 - 13
packages/client/ui-sidebar-documentpreview/tsdown.config.ts

@@ -9,6 +9,23 @@ const require = createRequire(import.meta.url)
 const workerSpecifier = 'pdfjs-dist/build/pdf.worker.min.mjs?raw'
 const workerModule = '\0dsh-pdf-worker.mjs'
 
+/** License files for PDF.js and the data embedded beside its runtime. */
+function pdfLicenseFiles(root: string): string[] {
+  return ['LICENSE', ...['cmaps', 'standard_fonts', 'wasm'].flatMap(directory =>
+    readdirSync(join(root, directory)).filter(name => name.startsWith('LICENSE')).sort()
+      .map(name => `${directory}/${name}`),
+  )]
+}
+
+/** Keep every bundled PDF.js license visible in the published client artifact. */
+function pdfLicenseBanner(): string {
+  const root = dirname(require.resolve('pdfjs-dist/package.json'))
+  const notice = pdfLicenseFiles(root).map(name =>
+    `${name}\n\n${readFileSync(join(root, name), 'utf8').trimEnd()}`,
+  ).join('\n\n')
+  return ['//! Bundled PDF.js license notices', ...notice.split('\n').map(line => `// ${line}`)].join('\n')
+}
+
 /** Keep font mappings and image decoders in the same artifact as their PDF.js runtime. */
 function pdfAssets(): string {
   const root = dirname(require.resolve('pdfjs-dist/package.json'))
@@ -32,24 +49,12 @@ const pdfWorker: NonNullable<UserConfig['plugins']> = [{
     this.addWatchFile(path)
     return `export default ${JSON.stringify(readFileSync(path, 'utf8'))};`
   },
-  generateBundle() {
-    const root = dirname(require.resolve('pdfjs-dist/package.json'))
-    const licenses = ['LICENSE', ...['cmaps', 'standard_fonts', 'wasm'].flatMap(directory =>
-      readdirSync(join(root, directory)).filter(name => name.startsWith('LICENSE')).sort()
-        .map(name => `${directory}/${name}`),
-    )]
-    const source = licenses.map((name) => {
-      const path = join(root, name)
-      this.addWatchFile(path)
-      return `${name}\n\n${readFileSync(path, 'utf8')}`
-    }).join('\n\n')
-    this.emitFile({ type: 'asset', fileName: 'pdfjs-NOTICES.txt', source })
-  },
 }]
 
 export default (options: Parameters<typeof bundle>[0]): UserConfig[] => bundle(options).map(config =>
   config.name?.endsWith('/client') === true ? {
     ...config,
+    banner: pdfLicenseBanner(),
     plugins: [config.plugins, pdfWorker],
     define: { ...config.define, __DSH_PDFJS_ASSETS__: pdfAssets() },
   } : config,

+ 0 - 3
pnpm-lock.yaml

@@ -3801,9 +3801,6 @@ importers:
       '@deepseek-ai/dsh-util-workspace-path':
         specifier: workspace:^
         version: link:../../util/workspace-path
-      '@deepseek-ai/schemastery':
-        specifier: link:../../../vendor/schemastery
-        version: link:../../../vendor/schemastery
       '@testing-library/react':
         specifier: ^16.1.0
         version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)