Quellcode durchsuchen

fix(web-sidebar): resolve review feedback on document preview polish

- files tree records its scroll offset in a ref and commits it once on
  unmount, only while the owner's signal is live, so a scroll neither
  re-renders the tree nor writes to a forgotten bucket
- LoadingIndicator drops iconOnly and always renders icon-only
- registry and the unviewable list share one suffix-matching module, and
  register() rejects binaryExtensions entries outside extensions
- drop the ambiguous key suffix from the unviewable office group
- extract the text preview's header path block shared by the unsupported
  and content branches; clsx for static class joins; CSS trailing newline
- sync the preview-operations and polish Agent Notes with shipped facts
Yif vor 1 Woche
Ursprung
Commit
f51addaa0d
23 geänderte Dateien mit 154 neuen und 84 gelöschten Zeilen
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml
  2. 3 3
      .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md
  3. 3 3
      .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md
  4. 2 2
      .agents/notes/implemented/feature/2026-09-11-sidebar-document-preview-polish.i18n.yaml
  5. 5 5
      .agents/notes/implemented/feature/2026-09-11-sidebar-document-preview-polish.md
  6. 5 5
      .agents/notes/implemented/feature/2026-09-11-sidebar-document-preview-polish.zh.md
  7. 4 6
      packages/client/ui-sidebar-documentpreview/src/client/LoadingIndicator.tsx
  8. 25 18
      packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx
  9. 16 12
      packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts
  10. 36 0
      packages/client/ui-sidebar-documentpreview/src/client/document/suffix.ts
  11. 5 5
      packages/client/ui-sidebar-documentpreview/src/client/document/unviewable.ts
  12. 2 1
      packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.tsx
  13. 2 2
      packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.tsx
  14. 1 1
      packages/client/ui-sidebar-documentpreview/src/client/pdf/PdfBody.module.css
  15. 2 1
      packages/client/ui-sidebar-documentpreview/src/client/pdf/PdfBody.tsx
  16. 9 0
      packages/client/ui-sidebar-documentpreview/tests/document-registry.client.spec.ts
  17. 1 1
      packages/client/ui-sidebar-documentpreview/tests/document-unviewable.client.spec.ts
  18. 2 9
      packages/client/ui-sidebar-documentpreview/tests/loading-indicator.client.spec.tsx
  19. 2 2
      packages/client/ui-sidebar-files/README.i18n.yaml
  20. 1 1
      packages/client/ui-sidebar-files/README.md
  21. 1 1
      packages/client/ui-sidebar-files/README.zh.md
  22. 12 2
      packages/client/ui-sidebar-files/src/client/FilesBody.tsx
  23. 13 2
      packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.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-08-document-preview-operations.md
-2026-09-08-document-preview-operations.md: 3703933273e743c8df32bf0352fc276fe21dcb93
-2026-09-08-document-preview-operations.zh.md: b4896e95959d0f276ee69dfeaee9528319981714
+2026-09-08-document-preview-operations.md: 43cc8d935763512a53379466bb796b5cac469793
+2026-09-08-document-preview-operations.zh.md: 44eccba894b3748a1d8640561a9eb760de481919

+ 3 - 3
.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md

@@ -16,9 +16,9 @@ Document Preview separates resource observation from content reads. The [resourc
 
 Readable files use `dsh-resource://file/session/<sessionId>/<path>`. 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-documentpreview/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.
+[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/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 only when at least two exist and remembers a manual choice per tab; plain text is the fallback except for suffixes a registration declares binary or the owner's unviewable list names ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). 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, PDF, and images read complete `Uint8Array<ArrayBuffer>` 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. PNG, JPEG, GIF, WebP, BMP, ICO, and SVG use image-specific Blob URLs in an `<img>` static-image context. They retain intrinsic CSS-pixel dimensions; auto margins centre images smaller than the shared scroller, while larger dimensions extend its horizontal or vertical scroll range. The renderer provides no zoom or drag-to-pan. SVG markup never enters the application DOM or an iframe, so scripts remain inert and cannot reach the parent page. Replacing HTML or an image revokes its root Blob URL.
+Markdown and code reuse the incremental primitives with cumulative paged text. HTML, PDF, and images read complete `Uint8Array<ArrayBuffer>` 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. PNG, JPEG, GIF, WebP, BMP, ICO, and SVG use image-specific Blob URLs in an `<img>` static-image context. An image wider than the pane scales down to its width at its aspect ratio; a smaller image keeps its intrinsic CSS-pixel dimensions centred by auto margins, and a taller image extends the shared scroller's vertical range ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The renderer provides no zoom or drag-to-pan. SVG markup never enters the application DOM or an iframe, so scripts remain inert and cannot reach the parent page. Replacing HTML or an image revokes its root Blob URL.
 
 ## Alternatives considered
 
@@ -38,4 +38,4 @@ Markdown and code reuse the incremental primitives with cumulative paged text. H
 
 ## 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, intrinsic raster and SVG rendering with two-axis scrolling, inert SVG scripts, and lazy continuous PDF Worker rendering.
+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, width-fitted raster and SVG rendering, inert SVG scripts, and lazy continuous PDF Worker rendering.

+ 3 - 3
.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md

@@ -16,9 +16,9 @@ Document Preview 将资源观察与内容读取分开。[资源模型](2026-09-0
 
 可读取的文件使用 `dsh-resource://file/session/<sessionId>/<path>`。路径可以相对工作区,也可以是绝对路径;编码后的绝对路径保留前导斜杠。`fileAddressFor` 始终生成这种 Session 地址。提供方与 Preview RPC 只从该地址取 Session,不取当前选择、首个持有者或 tab 所属 Session。不带 Session 的 `absolute` URI 无法读取;提供方报告 `workspace-file/unknown-workspace`。Session 授权是文件协议规则,不是额外的 Resource 身份。
 
-[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md) 负责格式选择和加载策略。元数据通过 `ctx.documentPreviews` 注册;组件单独注册到 keyed `sidebar.right.tab.document` Slot。扩展注册优先于内置注册,其次比较后缀长度和注册顺序。工具栏列出匹配候选,按 tab 记住手动选择;纯文本是兜底。子组件收到累积文本或完整原生字节、原始资源地址,以及标准 `useResource` 和 `useTabInfo` 钩子。Preview 经普通注入调用既有 `read`、`readAll` 与 `readRelated`,在自己的 `rpc.ts` 解码字节。刷新仍按 tab 独立进行,不引入资源 reload、共享 `changed` 确认、额外资源包装层或内容 Session。
+[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md) 负责格式选择和加载策略。元数据通过 `ctx.documentPreviews` 注册;组件单独注册到 keyed `sidebar.right.tab.document` Slot。扩展注册优先于内置注册,其次比较后缀长度和注册顺序。工具栏仅在候选不少于两个时列出匹配候选,按 tab 记住手动选择;纯文本是兜底,但注册声明为二进制的后缀和 owner 的 unviewable 清单所列后缀除外([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。子组件收到累积文本或完整原生字节、原始资源地址,以及标准 `useResource` 和 `useTabInfo` 钩子。Preview 经普通注入调用既有 `read`、`readAll` 与 `readRelated`,在自己的 `rpc.ts` 解码字节。刷新仍按 tab 独立进行,不引入资源 reload、共享 `changed` 确认、额外资源包装层或内容 Session。
 
-Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML、PDF 和图片读取完整 `Uint8Array<ArrayBuffer>` 数据;Host 传输保持 base64。发布后的缓冲区只读借用,绝不持久化进布局或 Session JSON。PDF.js 在自有 Worker 中运行,字体和解码数据以相同版本随包发布,转移输入前先复制,以保留 Preview 的缓冲区。HTML 在 Blob iframe 中运行,设置 `sandbox="allow-scripts"`,不授予同源、弹窗、表单、下载或顶层导航权限。浏览器保持正常的外部网络规则。有上限的静态本地 JS/CSS 读取由父页面负责;不透明源 iframe 创建自己的资源 Blob,因为它不能加载父源创建的 Blob。PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 使用图片专用 Blob URL,在 `<img>` 静态图片上下文中渲染。它们保留固有 CSS 像素尺寸;auto margin 让小于共享滚动区的图片居中,较大的尺寸则扩展横向或纵向滚动范围。渲染器不提供缩放或拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此脚本保持不可执行,也无法访问父页面。替换 HTML 或图片时会撤销其根 Blob URL。
+Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML、PDF 和图片读取完整 `Uint8Array<ArrayBuffer>` 数据;Host 传输保持 base64。发布后的缓冲区只读借用,绝不持久化进布局或 Session JSON。PDF.js 在自有 Worker 中运行,字体和解码数据以相同版本随包发布,转移输入前先复制,以保留 Preview 的缓冲区。HTML 在 Blob iframe 中运行,设置 `sandbox="allow-scripts"`,不授予同源、弹窗、表单、下载或顶层导航权限。浏览器保持正常的外部网络规则。有上限的静态本地 JS/CSS 读取由父页面负责;不透明源 iframe 创建自己的资源 Blob,因为它不能加载父源创建的 Blob。PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 使用图片专用 Blob URL,在 `<img>` 静态图片上下文中渲染。比面板宽的图片按纵横比缩小到面板宽度;较小的图片保留固有 CSS 像素尺寸并由 auto margin 居中,较高的图片扩展共享滚动区的纵向范围([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。渲染器不提供缩放或拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此脚本保持不可执行,也无法访问父页面。替换 HTML 或图片时会撤销其根 Blob URL。
 
 ## 考虑过的替代方案
 
@@ -38,4 +38,4 @@ Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML、P
 
 ## 影响
 
-替换渲染器不需要改变 Tab 或文件协议。全文格式承担有上限的整文件内存成本,PDF 增加随包发布的 Worker、字体和解码器字节。格式选择和查看状态仅属于当前页面,不是持久 Session 数据。Preview 独立于元数据观察,拥有 RPC 取消和原生缓冲区。tab 保留读取版本及读取开始时捕获的观察版本;刷新它既不丢弃其他 tab 的内容,也不清除其变更提示。文件读取仍非事务,不透明版本只比较相等性、不排序。[录制的浏览器场景](../../../../apps/web/tests/document-preview.e2e.ts) 覆盖共用工具栏、增量文本、隔离的 HTML 依赖、可双轴滚动的固有尺寸位图与 SVG 渲染、不可执行的 SVG 脚本,以及惰性连续 PDF Worker 渲染。
+替换渲染器不需要改变 Tab 或文件协议。全文格式承担有上限的整文件内存成本,PDF 增加随包发布的 Worker、字体和解码器字节。格式选择和查看状态仅属于当前页面,不是持久 Session 数据。Preview 独立于元数据观察,拥有 RPC 取消和原生缓冲区。tab 保留读取版本及读取开始时捕获的观察版本;刷新它既不丢弃其他 tab 的内容,也不清除其变更提示。文件读取仍非事务,不透明版本只比较相等性、不排序。[录制的浏览器场景](../../../../apps/web/tests/document-preview.e2e.ts) 覆盖共用工具栏、增量文本、隔离的 HTML 依赖、按宽度适配的位图与 SVG 渲染、不可执行的 SVG 脚本,以及惰性连续 PDF Worker 渲染。

+ 2 - 2
.agents/notes/implemented/feature/2026-09-11-sidebar-document-preview-polish.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-11-sidebar-document-preview-polish.md
-2026-09-11-sidebar-document-preview-polish.md: 89aa963660da899a7978b470544bbef5f1ce2d71
-2026-09-11-sidebar-document-preview-polish.zh.md: 8458dd667c88db87c1a8d9c9cc82fcb49637eaef
+2026-09-11-sidebar-document-preview-polish.md: b78141881513167a08050afb287eb4d1bcb43995
+2026-09-11-sidebar-document-preview-polish.zh.md: 43016af0dff72ed09a19271535912ba9abd65d9c

+ 5 - 5
.agents/notes/implemented/feature/2026-09-11-sidebar-document-preview-polish.md

@@ -12,15 +12,15 @@ The Sidebar document preview accumulated several experience defects (issue #3974
 
 **Image width fit.** The image frame follows the scroller's width with a 12px inset; the image itself carries `max-width: 100%` and an 8px corner radius, so a wider image scales down to the pane's width at its aspect ratio, a smaller image keeps its intrinsic size centred by auto margins, and a taller image scrolls vertically in the shared body. Height fitting was considered and dropped: it needs a fixed-height frame, and mixed portrait cases produced surprising layouts for no user request.
 
-**Binary viewer choices.** `DocumentPreviewDefinition` gains optional `binaryExtensions`, the suffixes among a renderer's `extensions` whose bytes are not readable text. The renderer owns this knowledge: the image body declares `png, jpg, jpeg, gif, webp, bmp, ico` and leaves `svg` out because SVG source is readable XML; the PDF body declares `pdf`. `binaryDocumentPath` in the registry module answers whether any registered definition declares a filename's suffix binary, and the preview owner skips the plain-text fallback for such files. The header renders the viewer menu only when at least two candidates exist; a single candidate shows no viewer control at all.
+**Binary viewer choices.** `DocumentPreviewDefinition` gains optional `binaryExtensions`, the suffixes among a renderer's `extensions` whose bytes are not readable text; `register` rejects an entry absent from `extensions`. The renderer owns this knowledge: the image body declares `png, jpg, jpeg, gif, webp, bmp, ico` and leaves `svg` out because SVG source is readable XML; the PDF body declares `pdf`. `binaryDocumentPath` in the registry module answers whether any registered definition declares a filename's suffix binary, and the preview owner skips the plain-text fallback for such files. The header renders the viewer menu only when at least two candidates exist; a single candidate shows no viewer control at all.
 
 **Unsupported empty state.** The owner-side list in `document/unviewable.ts` names binary container suffixes (video, audio, archives, office documents, executables, fonts, disk images, design formats) that no renderer claims. The owner consults it only when no implementation matches, so any renderer registration always wins; a matching file shows the path header, the file-type icon, and one `unsupportedFile` line, and never issues a read. An uncertain suffix stays out of the list and keeps the plain-text fallback; a text-claimed file whose bytes fail the reader's checks reports the same copy through `error.notText`.
 
-**Unified loading.** `LoadingIndicator` gains `iconOnly`, which moves the label to `aria-label` and renders no visible text; every renderer's loading copy is the shared "Reading…". Every wait before content exists — the owner's first read, PDF parsing, HTML packaging, image decoding — centres the spinner in the pane, so opening a file shows one spinner in one position until the body appears. An unrendered PDF page holds its place as a static 3:4 placeholder block on the theme's skeleton token `--dsw-alias-bg-skeleton` with no spinner; specialising the document-open wait into the first page's placeholder was tried and reverted because the owner's centred read spinner precedes it, and the handoff visibly jumped positions. A shimmer animation on placeholders was considered and dropped for per-page animation cost.
+**Unified loading.** `LoadingIndicator` renders icon-only, carrying its label as `aria-label` with no visible text; every renderer's loading copy is the shared "Reading…". Every wait before content exists — the owner's first read, PDF parsing, HTML packaging, image decoding — centres the spinner in the pane, so opening a file shows one spinner in one position until the body appears. An unrendered PDF page holds its place as a static 3:4 placeholder block on the theme's skeleton token `--dsw-alias-bg-skeleton` with no spinner; the document-open wait stays the owner's centred read spinner rather than the first page's placeholder, because the read spinner precedes the placeholder and a handoff between them visibly jumps positions. Placeholders carry no shimmer animation, whose per-page cost outweighs its value.
 
 **PDF full-bleed and copy.** The PDF body and page insets are removed so pages fill the pane's width edge to edge; the image renderer keeps its own 12px inset. Chinese error and status lines across the preview dictionaries drop trailing full stops.
 
-**Files tree scroll restore.** `ui-sidebar-files` follows the document preview's own pattern: the tree store gains `scrollTop` with a `scrolled` action, the body records its scroll offset, and a remount restores it in a layout effect. Loaded levels already outlive the body in the store, so the remounted tree lays out at full height before the offset re-lands.
+**Files tree scroll restore.** `ui-sidebar-files` follows the document preview's own pattern: the tree store gains `scrollTop` with a `scrolled` action, the body tracks its scroll offset locally and commits it once, on unmount and only while the owner's signal is live, and a remount restores it in a layout effect. Loaded levels already outlive the body in the store, so the remounted tree lays out at full height before the offset re-lands.
 
 ## Alternatives considered
 
@@ -28,8 +28,8 @@ The Sidebar document preview accumulated several experience defects (issue #3974
 
 **Filtering in the preview owner by a hardcoded suffix list for registered renderers.** The owner would duplicate knowledge each renderer already holds, and external renderers could not extend the set; the owner-side list exists only for suffixes no renderer claims.
 
-**A one-item static viewer label.** An earlier iteration rendered the single remaining candidate as a static name; a control that offers no action is noise, so the header now renders nothing.
+**A one-item static viewer label.** Rendering the single remaining candidate as a static name puts a control that offers no action in the header; it is noise, so the header renders nothing.
 
 ## Consequences
 
-Images never scroll horizontally; the pane's width is the only layout input, so no zoom control was added. Bitmap and PDF tabs show no viewer control; SVG keeps the menu with the plain-text choice; unclaimed binary containers show the empty state without reading. From open to first content every preview shows one centred icon-only spinner, and PDF pages appear as quiet placeholder blocks. The file tree comes back where the reader left it after any tab switch. Registry unit tests pin `binaryDocumentPath` matching, toolbar tests pin the fallback/menu/empty-state branches, files-body tests pin scroll capture and restore across a remount, and the keyless Web document-preview scenario measures the width-fitted SVG against the pane and asserts the viewer control's absence per suffix.
+Images never scroll horizontally; the pane's width is the only layout input, so no zoom control was added. Bitmap and PDF tabs show no viewer control; SVG keeps the menu with the plain-text choice; unclaimed binary containers show the empty state without reading. From open to first content every preview shows one centred icon-only spinner, and PDF pages appear as quiet placeholder blocks. The file tree comes back where the reader left it after any tab switch. Registry unit tests pin `binaryDocumentPath` matching and `register`'s rejection of a binary suffix outside `extensions`, toolbar tests pin the fallback/menu/empty-state branches, files-body tests pin scroll capture on unmount and restore across a remount, and the keyless Web document-preview scenario measures the width-fitted SVG against the pane and asserts the viewer control's absence per suffix.

+ 5 - 5
.agents/notes/implemented/feature/2026-09-11-sidebar-document-preview-polish.zh.md

@@ -12,15 +12,15 @@ Status: implemented
 
 **图片宽度适配。** 图片外框跟随滚动容器的宽度并带 12px 内边距;图片本身使用 `max-width: 100%` 和 8px 圆角,宽图按纵横比缩小到面板宽度,小图保持固有尺寸并由 auto margin 居中,超高图在共享正文中纵向滚动。高度适配曾被考虑后放弃:它需要外框定高,且混合纵向场景会在没有用户诉求的情况下产生意外布局。
 
-**二进制查看器选项。** `DocumentPreviewDefinition` 新增可选的 `binaryExtensions`,即渲染器 `extensions` 中字节不可按文本阅读的后缀。这份知识由渲染器持有:图片正文声明 `png, jpg, jpeg, gif, webp, bmp, ico`,不含 `svg`,因为 SVG 源码是可读的 XML;PDF 正文声明 `pdf`。注册表模块的 `binaryDocumentPath` 判断是否有已注册定义把文件名后缀声明为二进制,预览 owner 对这类文件跳过纯文本兜底。头部仅在候选不少于两个时渲染查看器菜单;只剩一个候选时完全不渲染查看器控件。
+**二进制查看器选项。** `DocumentPreviewDefinition` 新增可选的 `binaryExtensions`,即渲染器 `extensions` 中字节不可按文本阅读的后缀;`register` 拒绝不在 `extensions` 内的条目。这份知识由渲染器持有:图片正文声明 `png, jpg, jpeg, gif, webp, bmp, ico`,不含 `svg`,因为 SVG 源码是可读的 XML;PDF 正文声明 `pdf`。注册表模块的 `binaryDocumentPath` 判断是否有已注册定义把文件名后缀声明为二进制,预览 owner 对这类文件跳过纯文本兜底。头部仅在候选不少于两个时渲染查看器菜单;只剩一个候选时完全不渲染查看器控件。
 
 **不支持预览的空态。** owner 侧的 `document/unviewable.ts` 列出没有渲染器认领的二进制容器后缀(视频、音频、压缩包、office 文档、可执行文件、字体、磁盘镜像、设计格式)。owner 仅在没有实现匹配时查询该列表,因此任何渲染器注册始终优先;匹配的文件显示路径头部、文件类型图标和一行 `unsupportedFile` 说明,并且不会发起读取。不确定的后缀不进入列表、保留纯文本兜底;被文本认领但字节未通过读取器检查的文件通过 `error.notText` 报告同一句文案。
 
-**统一 loading。** `LoadingIndicator` 新增 `iconOnly`,把标签移入 `aria-label`、不渲染可见文字;所有渲染器的 loading 文案统一为共享的「正在读取…」。内容出现前的每个等待——owner 首次读取、PDF 解析、HTML 打包、图片解码——都把 spinner 居中在面板中,打开文件到正文出现始终是同一位置的一个 spinner。未渲染的 PDF 页以主题骨架色 `--dsw-alias-bg-skeleton` 上的静态 3:4 占位块保持位置、不带 spinner;曾尝试把文档打开等待特化进第一页的占位块,但 owner 的居中读取 spinner 在它之前,交接会明显跳位,因此回退。占位块的扫光动画因逐页动画的性能成本被考虑后放弃。
+**统一 loading。** `LoadingIndicator` 只渲染图标,标签作为 `aria-label` 携带、没有可见文字;所有渲染器的 loading 文案统一为共享的「正在读取…」。内容出现前的每个等待——owner 首次读取、PDF 解析、HTML 打包、图片解码——都把 spinner 居中在面板中,打开文件到正文出现始终是同一位置的一个 spinner。未渲染的 PDF 页以主题骨架色 `--dsw-alias-bg-skeleton` 上的静态 3:4 占位块保持位置、不带 spinner;文档打开的等待保持为 owner 的居中读取 spinner,而不是特化进第一页的占位块,因为读取 spinner 在占位块之前出现,两者交接会明显跳位。占位块不带扫光动画,其逐页动画成本高于价值。
 
 **PDF 铺满与文案。** 移除 PDF 正文和页面的内边距,页面贴边占满面板宽度;图片渲染器保留自己的 12px 内边距。预览各字典的中文报错与状态文案去掉句尾句号。
 
-**文件树滚动恢复。** `ui-sidebar-files` 沿用文档预览自己的模式:树存储新增 `scrollTop` 与 `scrolled` action,正文记录自己的滚动偏移,重挂载时在 layout effect 中恢复。已加载的层本就在存储中比正文活得久,因此重挂载的树在偏移落回之前已按完整高度布局。
+**文件树滚动恢复。** `ui-sidebar-files` 沿用文档预览自己的模式:树存储新增 `scrollTop` 与 `scrolled` action,正文在本地记录自己的滚动偏移、只在卸载时且 owner 的 signal 仍存活的情况下提交一次,重挂载时在 layout effect 中恢复。已加载的层本就在存储中比正文活得久,因此重挂载的树在偏移落回之前已按完整高度布局。
 
 ## 曾考虑的替代方案
 
@@ -28,8 +28,8 @@ Status: implemented
 
 **在预览 owner 中按硬编码后缀列表过滤已注册渲染器。** owner 会重复各渲染器已持有的知识,外部渲染器也无法扩展该集合;owner 侧列表只服务于没有渲染器认领的后缀。
 
-**单项静态查看器标签。** 较早的迭代把唯一候选渲染为静态名称;一个不提供任何操作的控件只是噪音,因此头部现在什么都不渲染。
+**单项静态查看器标签。** 把唯一候选渲染为静态名称,会在头部放一个不提供任何操作的控件;它只是噪音,因此头部什么都不渲染。
 
 ## 后果
 
-图片不再产生横向滚动;面板宽度是唯一布局输入,因此未增加缩放控件。位图和 PDF tab 不显示查看器控件;SVG 保留含纯文本选项的菜单;未被认领的二进制容器显示空态且不读取。从打开到首个内容,每种预览都只显示一个居中的纯图标 spinner,PDF 页面以安静的占位块出现。任何 tab 切换之后,文件树都回到读者离开的位置。注册表单元测试固定 `binaryDocumentPath` 的匹配行为,工具栏测试固定兜底/菜单/空态分支,files-body 测试固定滚动捕获与跨重挂载的恢复,keyless Web document-preview 场景按面板测量适配宽度后的 SVG 并按后缀断言查看器控件的有无。
+图片不再产生横向滚动;面板宽度是唯一布局输入,因此未增加缩放控件。位图和 PDF tab 不显示查看器控件;SVG 保留含纯文本选项的菜单;未被认领的二进制容器显示空态且不读取。从打开到首个内容,每种预览都只显示一个居中的纯图标 spinner,PDF 页面以安静的占位块出现。任何 tab 切换之后,文件树都回到读者离开的位置。注册表单元测试固定 `binaryDocumentPath` 的匹配行为与 `register` 对 `extensions` 之外二进制后缀的拒绝,工具栏测试固定兜底/菜单/空态分支,files-body 测试固定卸载时的滚动捕获与跨重挂载的恢复,keyless Web document-preview 场景按面板测量适配宽度后的 SVG 并按后缀断言查看器控件的有无。

+ 4 - 6
packages/client/ui-sidebar-documentpreview/src/client/LoadingIndicator.tsx

@@ -5,17 +5,15 @@ import { IconLoadingOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
 import css from './LoadingIndicator.module.css'
 
 /**
- * @param props - localized status label, optional placement style, and
- * `iconOnly` to keep the label as the accessible name without visible text.
+ * @param props - localized status label, carried as the accessible name with
+ * no visible text, and optional placement style.
  * @returns an animated, accessible loading status.
  */
-export function LoadingIndicator({ label, className, iconOnly = false }: {
+export function LoadingIndicator({ label, className }: {
   label: string
   className?: string | undefined
-  iconOnly?: boolean
 }): ReactNode {
-  return <span className={clsx(css.loading, className)} role="status" aria-label={iconOnly ? label : undefined} data-document-loading>
+  return <span className={clsx(css.loading, className)} role="status" aria-label={label} data-document-loading>
     <span className={css.icon} aria-hidden="true"><IconLoadingOutline16 /></span>
-    {!iconOnly && <span>{label}</span>}
   </span>
 }

+ 25 - 18
packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx

@@ -57,6 +57,23 @@ function usePathClipped(
   }, [box, text, path, shown])
 }
 
+/** The header's path: directories greyed, the final segment in full ink, faded when clipped. */
+function HeaderPath({ pathRef, pathTextRef, path }: {
+  pathRef: RefObject<HTMLDivElement>
+  pathTextRef: RefObject<HTMLSpanElement>
+  path: string
+}): ReactNode {
+  const { directory, name } = pathPartsOf(path)
+  return (
+    <div ref={pathRef} className={css.path} title={path} data-textpreview-path>
+      <span ref={pathTextRef} className={css.pathText}>
+        {directory !== '' && <span className={css.pathDirectory}>{directory}</span>}
+        <span className={css.pathName}>{name}</span>
+      </span>
+    </div>
+  )
+}
+
 /** Private registration inputs; the framework binds the registry source to useDocumentPreviews. */
 export interface TextPreviewInjected extends TextInjected {
   readonly hooks: { readonly documentPreviews: ObservableSnapshot<readonly DocumentPreviewDefinition[]> }
@@ -180,20 +197,15 @@ export function TextPreview({
   // A known binary suffix with no matching renderer never reads: no plain-text
   // fallback, no viewer control, only the path and the unsupported line.
   if (selected === undefined && unviewable) {
-    const parts = pathPartsOf(displayPath)
+    const { name: unsupportedName } = pathPartsOf(displayPath)
     return (
       <div className={css.preview} data-textpreview-state="unsupported" data-textpreview-url={tab.contentId}>
         <div className={css.header}>
-          <div ref={pathRef} className={css.path} title={displayPath} data-textpreview-path>
-            <span ref={pathTextRef} className={css.pathText}>
-              {parts.directory !== '' && <span className={css.pathDirectory}>{parts.directory}</span>}
-              <span className={css.pathName}>{parts.name}</span>
-            </span>
-          </div>
+          <HeaderPath pathRef={pathRef} pathTextRef={pathTextRef} path={displayPath} />
         </div>
         <div className={css.body} data-textpreview-body>
           <div className={css.empty} data-textpreview-unsupported>
-            <FileTypeIcon kind={classifyFileType(parts.name)} size={36} className={css.emptyIcon} />
+            <FileTypeIcon kind={classifyFileType(unsupportedName)} size={36} className={css.emptyIcon} />
             <p className={css.emptyLine}>{t('unsupportedFile')}</p>
           </div>
         </div>
@@ -205,12 +217,12 @@ export function TextPreview({
       <div className={css.status} data-textpreview-state="loading">
         {meta.status === 'none'
           ? <p className={css.statusLine}>{t('resourceUnavailable')}</p>
-          : <LoadingIndicator className={css.statusLine} label={t('loading')} iconOnly />}
+          : <LoadingIndicator className={css.statusLine} label={t('loading')} />}
       </div>
     )
   }
   const next = loadedThrough + 1
-  const { directory, name } = pathPartsOf(displayPath)
+  const { name } = pathPartsOf(displayPath)
   const observedVersion = meta.value?.version
   const changed = current?.version !== undefined && observedVersion !== undefined
     && observedVersion !== current.version && observedVersion !== current.observedVersion
@@ -257,12 +269,7 @@ export function TextPreview({
           </p>
         )}
       <div className={css.header}>
-        <div ref={pathRef} className={css.path} title={displayPath} data-textpreview-path>
-          <span ref={pathTextRef} className={css.pathText}>
-            {directory !== '' && <span className={css.pathDirectory}>{directory}</span>}
-            <span className={css.pathName}>{name}</span>
-          </span>
-        </div>
+        <HeaderPath pathRef={pathRef} pathTextRef={pathTextRef} path={displayPath} />
         {candidates.length > 1
           && (
             <Menu
@@ -325,7 +332,7 @@ export function TextPreview({
         }}
       >
         {!hasContent && current?.failure === undefined && (
-          <LoadingIndicator className={clsx(css.statusLine, css.bodyLoading)} label={t('loading')} iconOnly />
+          <LoadingIndicator className={clsx(css.statusLine, css.bodyLoading)} label={t('loading')} />
         )}
         {content !== undefined && renderSlot('sidebar.right.tab.document', {
           resourceAddress: tab.contentId, content, wrap: state.wrap, scrollportRef: bindScrollport,
@@ -372,7 +379,7 @@ export function TextPreview({
             data-textpreview-more
             onClick={loadNext}
           >
-            {current.loading ? <LoadingIndicator label={t('loading')} iconOnly /> : t('loadMore')}
+            {current.loading ? <LoadingIndicator label={t('loading')} /> : t('loadMore')}
           </button>
         )}
       </div>

+ 16 - 12
packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts

@@ -1,5 +1,6 @@
 /** File-extension preview registrations; component dispatch belongs to the keyed document slot. */
 import { notifySubscribers } from '@deepseek-ai/dsh-client-store'
+import { documentFileName, matchedSuffixLength, normalizeSuffix } from './suffix.ts'
 
 /** How the document owner delivers file contents to a renderer. */
 export type DocumentLoadMode = 'text-pages' | 'bytes-complete'
@@ -13,6 +14,7 @@ export interface DocumentPreviewDefinition {
   /**
    * Suffixes among `extensions` whose bytes are not readable text; a file
    * matching one loses the plain-text fallback among its viewer choices.
+   * Every entry must appear in `extensions`; `register` rejects strays.
    */
   readonly binaryExtensions?: readonly string[]
   /** External implementations win over product implementations; defaults to extension. */
@@ -35,15 +37,11 @@ export function matchingDocumentPreviews(
   definitions: readonly DocumentPreviewDefinition[],
   path: string,
 ): readonly DocumentPreviewDefinition[] {
-  const normalized = path.replaceAll('\\', '/').toLowerCase()
-  const name = normalized.slice(normalized.lastIndexOf('/') + 1)
+  const name = documentFileName(path)
   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)),
+    length: matchedSuffixLength(name, definition.extensions),
   }))
     .filter(candidate => candidate.length > 0)
     .sort((left, right) => right.rank - left.rank || right.length - left.length || left.order - right.order)
@@ -60,10 +58,8 @@ export function binaryDocumentPath(
   definitions: readonly DocumentPreviewDefinition[],
   path: string,
 ): boolean {
-  const normalized = path.replaceAll('\\', '/').toLowerCase()
-  const name = normalized.slice(normalized.lastIndexOf('/') + 1)
-  return definitions.some(definition => (definition.binaryExtensions ?? [])
-    .some(extension => name.endsWith(`.${extension.toLowerCase().replace(/^\./u, '')}`)))
+  const name = documentFileName(path)
+  return definitions.some(definition => matchedSuffixLength(name, definition.binaryExtensions ?? []) > 0)
 }
 
 /** Observable registry of all live implementations, including lower-priority alternatives. */
@@ -90,13 +86,21 @@ export class DocumentPreviewRegistry {
 
   /**
    * 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.
+   * @param definition - unique implementation and recognized suffixes; every
+   * `binaryExtensions` entry must appear in `extensions`.
+   * @returns an idempotent disposer; duplicate live implementation names and
+   * binary suffixes outside `extensions` throw.
    */
   register(definition: DocumentPreviewDefinition): () => void {
     if (this.registered.has(definition.id)) {
       throw new Error(`documentPreviews: duplicate implementation "${definition.id}"`)
     }
+    const declared = new Set(definition.extensions.map(normalizeSuffix))
+    for (const extension of definition.binaryExtensions ?? []) {
+      if (!declared.has(normalizeSuffix(extension))) {
+        throw new Error(`documentPreviews: "${definition.id}" declares binary suffix "${extension}" outside its extensions`)
+      }
+    }
     this.registered.set(definition.id, definition)
     this.publish()
     let active = true

+ 36 - 0
packages/client/ui-sidebar-documentpreview/src/client/document/suffix.ts

@@ -0,0 +1,36 @@
+/**
+ * Filename suffix matching shared by the preview registry and the owner's
+ * unviewable list, so every consumer normalizes paths and suffixes alike.
+ */
+
+/**
+ * Normalize one declared suffix for comparison.
+ * @param extension - declared file suffix, with or without a leading dot.
+ * @returns the suffix lowercased with any leading dot dropped.
+ */
+export function normalizeSuffix(extension: string): string {
+  return extension.toLowerCase().replace(/^\./u, '')
+}
+
+/**
+ * The filename a path's suffixes are matched against.
+ * @param path - decoded filename or file path; `\` is accepted as a separator.
+ * @returns the lowercased final path segment.
+ */
+export function documentFileName(path: string): string {
+  const normalized = path.replaceAll('\\', '/').toLowerCase()
+  return normalized.slice(normalized.lastIndexOf('/') + 1)
+}
+
+/**
+ * The longest declared suffix ending the filename.
+ * @param name - lowercased filename from {@link documentFileName}.
+ * @param extensions - declared suffixes; compound suffixes such as `tar.gz` are accepted.
+ * @returns the matched suffix's normalized length, or 0 when none matches.
+ */
+export function matchedSuffixLength(name: string, extensions: readonly string[]): number {
+  return Math.max(0, ...extensions
+    .map(normalizeSuffix)
+    .filter(extension => name.endsWith(`.${extension}`))
+    .map(extension => extension.length))
+}

+ 5 - 5
packages/client/ui-sidebar-documentpreview/src/client/document/unviewable.ts

@@ -6,6 +6,7 @@
  * belongs here only when its bytes are never readable text — an uncertain
  * suffix stays out and keeps the plain-text fallback.
  */
+import { documentFileName, matchedSuffixLength } from './suffix.ts'
 const UNVIEWABLE_BINARY_EXTENSIONS: readonly string[] = [
   // video
   'mp4', 'mov', 'avi', 'mkv', 'webm', 'flv', 'wmv', 'm4v',
@@ -13,8 +14,9 @@ const UNVIEWABLE_BINARY_EXTENSIONS: readonly string[] = [
   'mp3', 'wav', 'flac', 'ogg', 'm4a', 'aac', 'wma', 'opus',
   // archives
   'zip', 'gz', 'tgz', 'bz2', 'xz', 'zst', '7z', 'rar', 'tar', 'jar',
-  // office documents
-  'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', 'pages', 'key', 'numbers',
+  // office documents — `key` stays out: it collides with key/credential files
+  // that are readable text, and the uncertain suffix keeps the plain-text fallback
+  'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', 'pages', 'numbers',
   // executables and compiled objects
   'exe', 'dll', 'so', 'dylib', 'bin', 'o', 'class', 'pyc', 'wasm',
   // fonts
@@ -31,7 +33,5 @@ const UNVIEWABLE_BINARY_EXTENSIONS: readonly string[] = [
  * @returns true when the suffix belongs to the unviewable binary list.
  */
 export function unviewableBinaryPath(path: string): boolean {
-  const normalized = path.replaceAll('\\', '/').toLowerCase()
-  const name = normalized.slice(normalized.lastIndexOf('/') + 1)
-  return UNVIEWABLE_BINARY_EXTENSIONS.some(extension => name.endsWith(`.${extension}`))
+  return matchedSuffixLength(documentFileName(path), UNVIEWABLE_BINARY_EXTENSIONS) > 0
 }

+ 2 - 1
packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.tsx

@@ -1,6 +1,7 @@
 /** 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 clsx from 'clsx'
 import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
 import type { DocumentPreviewProps } from '../document/contract.ts'
 import { LoadingIndicator } from '../LoadingIndicator.tsx'
@@ -49,7 +50,7 @@ function HtmlFrame({ data, readRelative, t }: FrameInput & { t: HtmlBodyProps['t
   }, [data, readRelative])
 
   if (frame?.data !== data || frame.readRelative !== readRelative) {
-    return <LoadingIndicator className={`${css.status} ${css.opening}`} label={t('loading')} iconOnly />
+    return <LoadingIndicator className={clsx(css.status, css.opening)} label={t('loading')} />
   }
   if (frame.url === undefined) return <p className={css.status} role="alert">{t('failed')}</p>
   return <iframe key={frame.url} className={css.frame} src={frame.url} sandbox="allow-scripts" title={t('frame')} data-html-preview />

+ 2 - 2
packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.tsx

@@ -76,7 +76,7 @@ export function ImageBody({ content, resourceAddress, t }: ImageBodyProps): Reac
     return <p className={css.status} role="alert">{t('unsupported')}</p>
   }
   if (source?.data !== data || source.mediaType !== mediaType) {
-    return <LoadingIndicator className={css.status} label={t('loading')} iconOnly />
+    return <LoadingIndicator className={css.status} label={t('loading')} />
   }
   if (source.kind === 'failed') return <p className={css.status} role="alert">{t('failed')}</p>
   const { name } = pathPartsOf(path)
@@ -91,7 +91,7 @@ function LoadedImage({ url, name, t }: {
 }): ReactNode {
   const [state, setState] = useState<'loading' | 'ready' | 'failed'>('loading')
   return <div className={css.frame} data-image-preview>
-    {state === 'loading' && <LoadingIndicator className={css.status} label={t('loading')} iconOnly />}
+    {state === 'loading' && <LoadingIndicator className={css.status} label={t('loading')} />}
     {state === 'failed' && <p className={css.status} role="alert">{t('failed')}</p>}
     <img
       className={css.image}

+ 1 - 1
packages/client/ui-sidebar-documentpreview/src/client/pdf/PdfBody.module.css

@@ -53,4 +53,4 @@
   box-sizing: border-box;
   width: 100%;
   min-height: 100%;
-}
+}

+ 2 - 1
packages/client/ui-sidebar-documentpreview/src/client/pdf/PdfBody.tsx

@@ -1,5 +1,6 @@
 /** PDF page presentation; binary content and tab information come from the document owner. */
 import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
+import clsx from 'clsx'
 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'
@@ -66,7 +67,7 @@ export function PdfBody(props: PdfBodyProps): ReactNode {
   if (data === undefined) return <p className={css.status} role="alert">{t('unsupported')}</p>
   // The open wait centres like the owner's read spinner before it, so one
   // spinner position covers everything until the first page block appears.
-  if (load?.data !== data) return <LoadingIndicator className={`${css.status} ${css.opening}`} label={t('loading')} iconOnly />
+  if (load?.data !== data) return <LoadingIndicator className={clsx(css.status, css.opening)} label={t('loading')} />
   if (load.kind === 'failed') {
     return <div className={css.status} role="alert">
       <span>{failureText(load.error, t)}</span>

+ 9 - 0
packages/client/ui-sidebar-documentpreview/tests/document-registry.client.spec.ts

@@ -68,6 +68,15 @@ describe('document preview implementations', () => {
     expect(binaryDocumentPath([text], '/work/photo.png')).toBe(false)
   })
 
+  it('rejects a binary suffix outside the declared extensions without registering', () => {
+    const registry = new DocumentPreviewRegistry()
+    expect(() => registry.register(definition('image', { extensions: ['png', 'svg'], binaryExtensions: ['pdf'] })))
+      .toThrow(/binary suffix "pdf" outside its extensions/u)
+    expect(registry.getSnapshot()).toEqual([])
+    registry.register(definition('image', { extensions: ['PNG', 'svg'], binaryExtensions: ['.png'] }))
+    expect(registry.getSnapshot()).toHaveLength(1)
+  })
+
   it('rejects duplicate ids without replacing the existing registration', () => {
     const registry = new DocumentPreviewRegistry()
     const entry = definition('markdown')

+ 1 - 1
packages/client/ui-sidebar-documentpreview/tests/document-unviewable.client.spec.ts

@@ -19,7 +19,7 @@ describe('unviewableBinaryPath', () => {
   it('leaves renderer-claimed, text, and unknown suffixes to their existing paths', () => {
     for (const path of [
       'main.ts', 'README.md', 'photo.png', 'page.html', 'paper.pdf', 'logo.svg',
-      'server.log', 'config.env', 'notes.unknown', 'Makefile', 'mp4',
+      'server.log', 'config.env', 'notes.unknown', 'Makefile', 'mp4', 'private.key',
     ]) expect(unviewableBinaryPath(path), path).toBe(false)
   })
 })

+ 2 - 9
packages/client/ui-sidebar-documentpreview/tests/loading-indicator.client.spec.tsx

@@ -1,5 +1,5 @@
 // @vitest-environment jsdom
-/** The shared loading status: visible label by default, icon-only on request. */
+/** The shared loading status: an icon-only spinner named by its label. */
 import { afterEach, describe, expect, it } from 'vitest'
 import { cleanup, render } from '@testing-library/react'
 import { LoadingIndicator } from '../src/client/LoadingIndicator.tsx'
@@ -7,16 +7,9 @@ import { LoadingIndicator } from '../src/client/LoadingIndicator.tsx'
 afterEach(cleanup)
 
 describe('LoadingIndicator', () => {
-  it('shows the label beside the spinner by default', () => {
+  it('renders no visible text and carries the label as the accessible name', () => {
     const { getByRole } = render(<LoadingIndicator label="Reading…" />)
     const status = getByRole('status')
-    expect(status.textContent).toBe('Reading…')
-    expect(status.getAttribute('aria-label')).toBeNull()
-  })
-
-  it('moves the label to the accessible name when icon-only', () => {
-    const { getByRole } = render(<LoadingIndicator label="Reading…" iconOnly />)
-    const status = getByRole('status')
     expect(status.textContent).toBe('')
     expect(status.getAttribute('aria-label')).toBe('Reading…')
   })

+ 2 - 2
packages/client/ui-sidebar-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/client/ui-sidebar-files/README.md
-README.md: 24364178c6679d5450bdcb450176431595e44e53
-README.zh.md: a631e5a2df124004ad40c6bf3bf19ed35cc234f4
+README.md: efaf0a584e0e8617d0cb97442f9ebb5fa1fe7b59
+README.zh.md: a0d0abffcd6e9f4e979d9a6548537745d0a74ba6

+ 1 - 1
packages/client/ui-sidebar-files/README.md

@@ -43,7 +43,7 @@ The root is the session's working directory, read from `useSessions().byId[sessi
 
 A level cut by the endpoint's entry cap ends with a marker; an empty level says so; a level that failed shows one line per code — `workspace-file/not-found`, `outside-workspace`, `not-directory` — and the transport's own message otherwise. Reload drops every listed level and asks again for the expanded ones; collapsed levels are fetched again when they next open. A session without a working directory shows a single line instead of a tree.
 
-State lives in the type's own store, bucketed by tab id: `root`, `levels` (loading / ready / failed per absolute path), `expanded`, and `scrollTop`. Because the store outlives the body, switching to another sidebar tab and back remounts the tree with its levels intact and its scroll offset restored. The owner's `signal` ends a bucket: on abort the tab is forgotten and a listing that settles afterwards writes nothing.
+State lives in the type's own store, bucketed by tab id: `root`, `levels` (loading / ready / failed per absolute path), `expanded`, and `scrollTop`, which the body tracks locally while scrolling and commits once when it unmounts. Because the store outlives the body, switching to another sidebar tab and back remounts the tree with its levels intact and its scroll offset restored. The owner's `signal` ends a bucket: on abort the tab is forgotten, and neither a listing that settles afterwards nor the unmount's offset commit writes anything.
 
 <a id="model-experience"></a>
 ## Model Experience

+ 1 - 1
packages/client/ui-sidebar-files/README.zh.md

@@ -43,7 +43,7 @@ kind: "package-reference"
 
 被端点条目上限截断的层以一条标记收尾;空层如实说明;失败的层按错误码各显示一行(`workspace-file/not-found`、`outside-workspace`、`not-directory`),其他情况显示传输层自己的消息。重新读取丢弃所有已列出的层并只对展开中的层重新请求;折叠的层在下次打开时重新拉取。没有工作目录的会话只显示一行说明,而不是树。
 
-状态保存在类型自己的存储里,按 tab id 分桶:`root`、`levels`(每个绝对路径的 loading / ready / failed)、`expanded` 与 `scrollTop`。存储比 body 活得久,切到其他侧栏 tab 再切回来时树带着已加载的层重新挂载,滚动位置也随之恢复。owner 的 `signal` 终结一个桶:中止时忘掉该 tab,其后才结算的列表什么也不写。
+状态保存在类型自己的存储里,按 tab id 分桶:`root`、`levels`(每个绝对路径的 loading / ready / failed)、`expanded` 与 `scrollTop`——滚动期间偏移由正文自己记录,卸载时一次性写入。存储比 body 活得久,切到其他侧栏 tab 再切回来时树带着已加载的层重新挂载,滚动位置也随之恢复。owner 的 `signal` 终结一个桶:中止时忘掉该 tab,其后才结算的列表与卸载时的偏移提交都什么也不写。
 
 <a id="model-experience"></a>
 ## 模型体验

+ 12 - 2
packages/client/ui-sidebar-files/src/client/FilesBody.tsx

@@ -177,6 +177,7 @@ export function FilesBody({
   const pathRef = useRef<HTMLDivElement>(null)
   const pathTextRef = useRef<HTMLSpanElement>(null)
   const bodyRef = useRef<HTMLDivElement>(null)
+  const scrollTopRef = useRef(0)
   usePathClipped(pathRef, pathTextRef, state?.root)
   // Come back where the reader was: loaded levels outlive the body in the
   // store, so a remounted tree lays out at its full height before this runs
@@ -184,8 +185,17 @@ export function FilesBody({
   const seeded = state !== undefined
   useLayoutEffect(() => {
     const body = bodyRef.current
-    if (seeded && body !== null) body.scrollTop = state.scrollTop
+    if (seeded && body !== null) {
+      body.scrollTop = state.scrollTop
+      scrollTopRef.current = body.scrollTop
+    }
   }, [seeded])
+  // Scrolling only moves the ref; the store hears about it once, on unmount,
+  // so a scroll neither re-renders the tree nor writes after the owner's
+  // abort has forgotten the bucket.
+  useEffect(() => () => {
+    if (seeded && !signal.aborted) actions.scrolled(tab.id, scrollTopRef.current)
+  }, [seeded, signal, tab.id, actions])
   useEffect(() => {
     // A bucket gone because the record aborted must not be re-seeded by a
     // component that has not unmounted yet.
@@ -241,7 +251,7 @@ export function FilesBody({
         ref={bodyRef}
         className={css.body}
         data-files-body
-        onScroll={(event) => { actions.scrolled(tab.id, event.currentTarget.scrollTop) }}
+        onScroll={(event) => { scrollTopRef.current = event.currentTarget.scrollTop }}
       >
         <ul className={css.level}><Level path={state.root} tree={tree} /></ul>
       </div>

+ 13 - 2
packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx

@@ -184,17 +184,28 @@ describe('FilesBody', () => {
     expect(view.container.querySelector('[data-files-reload]')?.getAttribute('aria-label')).toBe(zh.reload)
   })
 
-  it('captures the body scroll offset and restores it when a tab switch remounts the tree', async () => {
+  it('captures the body scroll offset on unmount and restores it when a tab switch remounts the tree', async () => {
     const { view, script, instance, remount } = mountBody()
     await act(() => script.settle({ ok: true, value: ROOT_LEVEL }))
     const body = view.container.querySelector('[data-files-body]')!
     fireEvent.scroll(body, { target: { scrollTop: 120 } })
-    expect(instance.getSnapshot().byTab[TAB]!.scrollTop).toBe(120)
+    // Scrolling writes nothing yet: the store hears the offset once, on unmount.
+    expect(instance.getSnapshot().byTab[TAB]!.scrollTop).toBe(0)
     view.unmount()
+    expect(instance.getSnapshot().byTab[TAB]!.scrollTop).toBe(120)
     const back = remount()
     expect(back.container.querySelector('[data-files-body]')!.scrollTop).toBe(120)
   })
 
+  it('a scroll before the owner aborts is not written to a forgotten bucket', async () => {
+    const { view, script, controller, instance } = mountBody()
+    await act(() => script.settle({ ok: true, value: ROOT_LEVEL }))
+    fireEvent.scroll(view.container.querySelector('[data-files-body]')!, { target: { scrollTop: 80 } })
+    act(() => { controller.abort() })
+    view.unmount()
+    expect(instance.getSnapshot().byTab[TAB]).toBeUndefined()
+  })
+
   it('an aborted record is forgotten and not seeded again while the body is still mounted', async () => {
     const { view, script, controller, instance } = mountBody()
     await act(() => script.settle({ ok: true, value: ROOT_LEVEL }))