Merge pull request #4044 from deepseek-harness/fix/sidebar-document-preview-polish

Sidebar refine: 侧边栏 PDF/图片预览/格式空态/loading 等整体优化
This commit is contained in:
Yifffan authored and GitHub committed 2026-09-14 18:37:30 +08:00
commit 7c4067c24e
50 files changed
+668 -148

No files matched your search

@@ -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
@@ -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.
@@ -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 渲染。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-11-sidebar-document-preview-polish.md
2026-09-11-sidebar-document-preview-polish.md: b78141881513167a08050afb287eb4d1bcb43995
2026-09-11-sidebar-document-preview-polish.zh.md: 43016af0dff72ed09a19271535912ba9abd65d9c
@@ -0,0 +1,35 @@
# Agent Note: Sidebar document preview polish
Status: implemented
English | [中文](2026-09-11-sidebar-document-preview-polish.zh.md)
## Problem
The Sidebar document preview accumulated several experience defects (issue #3974). Images rendered at their intrinsic CSS-pixel size, so a wide image overflowed the pane and forced horizontal scrolling. The viewer dropdown always appended the plain-text fallback, so bitmap and PDF files offered a "Plain text" choice whose result is unreadable bytes, and files with one real renderer still showed a control with nothing meaningful to switch to. Binary containers with no renderer at all (video, archives, office documents) fell into the plain-text reader and surfaced a read error instead of a designed empty state. Each renderer carried its own loading copy and position, so opening a file flashed through several differently worded, differently placed indicators. PDF pages sat inside a double inset that shrank every page below the pane's width. Switching sidebar tabs remounted the file tree at scroll top, losing the reader's place.
## Decision
**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; `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` 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 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
**A boolean `binary` flag per definition.** The image renderer covers both bitmaps and SVG under one registration, so binariness is a property of the suffix, not the renderer.
**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.** 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 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.
@@ -0,0 +1,35 @@
# Agent Note:侧边栏文档预览打磨
Status: implemented
[English](2026-09-11-sidebar-document-preview-polish.md) | 中文
## 问题
侧边栏文档预览积累了多处体验缺陷(issue #3974)。图片按固有 CSS 像素尺寸渲染,宽图撑出面板、只能横向滚动。查看器下拉总是追加纯文本兜底,位图和 PDF 文件因此提供一个结果是不可读字节的「纯文本」选项,而只有一个真实渲染器的文件仍显示一个没有可切换项的控件。完全没有渲染器的二进制容器(视频、压缩包、office 文档)落入纯文本读取器,呈现的是读取报错而非设计过的空态。各渲染器自带不同的 loading 文案和位置,打开文件时会闪过多个措辞、位置都不同的指示器。PDF 页面被双层内边距包裹,每一页都窄于面板宽度。切换侧栏 tab 会让文件树重挂载回到顶部,丢掉读者原来的位置。
## 决定
**图片宽度适配。** 图片外框跟随滚动容器的宽度并带 12px 内边距;图片本身使用 `max-width: 100%` 和 8px 圆角,宽图按纵横比缩小到面板宽度,小图保持固有尺寸并由 auto margin 居中,超高图在共享正文中纵向滚动。高度适配曾被考虑后放弃:它需要外框定高,且混合纵向场景会在没有用户诉求的情况下产生意外布局。
**二进制查看器选项。** `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` 只渲染图标,标签作为 `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,正文在本地记录自己的滚动偏移、只在卸载时且 owner 的 signal 仍存活的情况下提交一次,重挂载时在 layout effect 中恢复。已加载的层本就在存储中比正文活得久,因此重挂载的树在偏移落回之前已按完整高度布局。
## 曾考虑的替代方案
**按定义级布尔 `binary` 标志。** 图片渲染器在一个注册中同时覆盖位图和 SVG,二进制与否是后缀的属性,不是渲染器的属性。
**在预览 owner 中按硬编码后缀列表过滤已注册渲染器。** owner 会重复各渲染器已持有的知识,外部渲染器也无法扩展该集合;owner 侧列表只服务于没有渲染器认领的后缀。
**单项静态查看器标签。** 把唯一候选渲染为静态名称,会在头部放一个不提供任何操作的控件;它只是噪音,因此头部什么都不渲染。
## 后果
图片不再产生横向滚动;面板宽度是唯一布局输入,因此未增加缩放控件。位图和 PDF tab 不显示查看器控件;SVG 保留含纯文本选项的菜单;未被认领的二进制容器显示空态且不读取。从打开到首个内容,每种预览都只显示一个居中的纯图标 spinner,PDF 页面以安静的占位块出现。任何 tab 切换之后,文件树都回到读者离开的位置。注册表单元测试固定 `binaryDocumentPath` 的匹配行为与 `register` 对 `extensions` 之外二进制后缀的拒绝,工具栏测试固定兜底/菜单/空态分支,files-body 测试固定卸载时的滚动捕获与跨重挂载的恢复,keyless Web document-preview 场景按面板测量适配宽度后的 SVG 并按后缀断言查看器控件的有无。
+45 -20
View File
@@ -80,7 +80,7 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
}
})
it('opens text, isolated HTML, intrinsic images, and rendered PDF from the Session workspace', async () => {
it('opens text, isolated HTML, width-fitted images, and rendered PDF from the Session workspace', async () => {
onTestFailed(async () => {
await mkdir(SHOT_DIR, { recursive: true })
await saveFailureShot(page, `screenshots/0908-document-preview/smoke-${process.pid}`)
@@ -130,6 +130,7 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
'</svg>',
].join('')),
writeFile(join(cwd, 'smoke.pdf'), pdfFixture()),
writeFile(join(cwd, 'clip.mp4'), Buffer.from([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70])),
])
const column = page.locator('[data-rightbar-col]')
@@ -175,6 +176,7 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
await column.locator('[data-files-entry="file"]').getByRole('button', { name, exact: true }).click()
await expect.poll(async () => (await preview.getAttribute('data-textpreview-url'))?.endsWith(`/${name}`)).toBe(true)
}
// Binary suffixes (bitmaps, PDF) drop the plain-text fallback; a single remaining viewer renders no control.
const viewer = preview.locator('[data-document-viewer-menu]')
const body = preview.locator('[data-textpreview-body]')
const sections = ['# Document preview']
@@ -276,9 +278,9 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
].join('\n'))
await openFile('smoke.pdf')
await expect.poll(() => viewer.innerText()).toBe('PDF')
const canvas = preview.getByRole('img', { name: 'PDF page 1', exact: true })
await canvas.waitFor({ state: 'visible', timeout: 30_000 })
expect(await viewer.count()).toBe(0)
expect(await preview.locator('[role="toolbar"]').count()).toBe(0)
expect(await preview.locator('[data-pdf-page]').count()).toBe(2)
await expect.poll(() => canvasColor(canvas), { timeout: 30_000 }).toBe('red')
@@ -308,7 +310,7 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
await successShot(page, 'pdf')
sections.push([
'## PDF', '',
`- Viewer: ${await viewer.innerText()}`,
`- Viewer menu hidden: ${String(await viewer.count() === 0)}`,
`- Worker: ${workerNames.find(name => name === 'dsh-pdf')}`,
`- Continuous pages: ${await preview.locator('[data-pdf-page]').count()}`,
`- Horizontal overflow: ${String(await body.evaluate(node => node.scrollWidth > node.clientWidth))}`,
@@ -317,9 +319,9 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
].join('\n'))
await openFile('tiny.png')
await expect.poll(() => viewer.innerText()).toBe('Image')
const tinyImage = preview.getByRole('img', { name: 'Image preview: tiny.png', exact: true })
await tinyImage.waitFor({ state: 'visible', timeout: 15_000 })
expect(await viewer.count()).toBe(0)
expect(await tinyImage.evaluate(node => ({
width: (node as HTMLImageElement).naturalWidth,
height: (node as HTMLImageElement).naturalHeight,
@@ -339,25 +341,35 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
await openFile('large.svg')
await expect.poll(() => viewer.innerText()).toBe('Image')
await viewer.click()
await page.getByRole('menuitem', { name: 'Plain text', exact: true }).waitFor({ timeout: 15_000 })
await page.keyboard.press('Escape')
const largeImage = preview.getByRole('img', { name: 'Image preview: large.svg', exact: true })
await largeImage.waitFor({ state: 'visible', timeout: 15_000 })
expect(await largeImage.evaluate(node => ({
naturalWidth: (node as HTMLImageElement).naturalWidth,
naturalHeight: (node as HTMLImageElement).naturalHeight,
width: getComputedStyle(node).width,
height: getComputedStyle(node).height,
}))).toEqual({ naturalWidth: 1200, naturalHeight: 1600, width: '1200px', height: '1600px' })
expect(await body.evaluate(node => ({
horizontal: node.scrollWidth > node.clientWidth,
vertical: node.scrollHeight > node.clientHeight,
}))).toEqual({ horizontal: true, vertical: true })
const fitted = await largeImage.evaluate((node) => {
const image = node as HTMLImageElement
const scroller = image.closest('[data-textpreview-body]')
if (scroller === null) throw new Error('image document scroller is unavailable')
const rect = image.getBoundingClientRect()
return {
naturalWidth: image.naturalWidth,
naturalHeight: image.naturalHeight,
width: rect.width,
height: rect.height,
paneWidth: scroller.clientWidth,
paneHeight: scroller.clientHeight,
}
})
expect(fitted).toMatchObject({ naturalWidth: 1200, naturalHeight: 1600 })
expect(fitted.width).toBeLessThan(1200)
// Width fit: the image fills the frame's 12px-inset box while the aspect ratio holds.
expect(Math.abs((fitted.paneWidth - 24) - fitted.width)).toBeLessThanOrEqual(1)
expect(fitted.height / fitted.width).toBeCloseTo(1600 / 1200, 2)
const scrolled = await body.evaluate((node) => {
node.scrollLeft = node.scrollWidth
node.scrollTop = node.scrollHeight
return { left: node.scrollLeft, top: node.scrollTop }
return { left: node.scrollLeft, horizontalOverflow: node.scrollWidth > node.clientWidth }
})
expect(scrolled.left).toBeGreaterThan(0)
expect(scrolled.top).toBeGreaterThan(0)
expect(scrolled).toEqual({ left: 0, horizontalOverflow: false })
expect(await page.locator('html').getAttribute('data-image-preview-escape')).toBeNull()
const releaseRead = Promise.withResolvers<undefined>()
@@ -450,12 +462,25 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
].join('\n'))
await openFile('notes.unknown')
await expect.poll(() => viewer.innerText()).toBe('Plain text')
const plainLines = preview.locator('[data-textpreview-line]')
await expect.poll(() => plainLines.count()).toBe(2)
// Plain text is the only candidate, so no viewer menu renders.
expect(await viewer.count()).toBe(0)
const fallback = (await plainLines.allTextContents()).map(line => line.trim())
expect(fallback).toEqual(['UNKNOWN_SUFFIX', 'Plain fallback.'])
sections.push(['## Unknown suffix', '', `- Viewer: ${await viewer.innerText()}`, `- Text: ${fallback.join(' | ')}`].join('\n'))
sections.push(['## Unknown suffix', '', `- Viewer menu hidden: ${String(await viewer.count() === 0)}`, `- Text: ${fallback.join(' | ')}`].join('\n'))
await filesTab.click()
await column.locator('[data-files-entry="file"]').getByRole('button', { name: 'clip.mp4', exact: true }).click()
const unsupported = column.locator('[data-textpreview-state="unsupported"]')
await unsupported.waitFor({ timeout: 15_000 })
const unsupportedLine = await unsupported.locator('[data-textpreview-unsupported]').innerText()
expect(unsupportedLine).toContain('Preview is not available for this file type yet.')
expect(await unsupported.locator('[data-textpreview-path]').innerText()).toContain('clip.mp4')
expect(await unsupported.locator('[data-document-viewer-menu]').count()).toBe(0)
expect(await unsupported.locator('[data-textpreview-tool="reload"]').count()).toBe(0)
await successShot(page, 'unsupported')
sections.push(['## Unviewable binary', '', '- State: unsupported', `- Line: ${unsupportedLine.trim()}`].join('\n'))
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await compareOrRefreshGolden(EXPECTED, sections.join('\n\n'), MODE)
@@ -2,7 +2,7 @@
import type { ComponentType } from 'react'
import type { InputTriggerCandidate } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
import {
IconCompactOutline16, IconDownloadOutline16, IconGoalOutline16, IconPlanOutline14, IconSendOutline16,
IconCompactOutline16, IconDownloadOutline16, IconGoalOutline16, IconPaperPlaneOutline14, IconPlanOutline14,
IconShieldOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -41,7 +41,7 @@ function hostFace(name: BuiltinCommandName, icon: ComponentType<IconProps>): rea
const HOST_FACES: ReadonlyMap<BuiltinCommandName, HostFace> = new Map([
hostFace('goal', IconGoalOutline16),
hostFace('plan', IconPlanOutline14),
hostFace('feedback', IconSendOutline16),
hostFace('feedback', IconPaperPlaneOutline14),
hostFace('compact', IconCompactOutline16),
hostFace('permission', IconShieldOutline16),
hostFace('export', IconDownloadOutline16),
@@ -518,11 +518,11 @@ export const IconUserOutline16 = ({ size = 16, className }: IconProps) => (
</svg>
)
/** ic_ds_send_outline_16 */
export const IconSendOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
/** ic_ds_paper_plane_outline_14 */
export const IconPaperPlaneOutline14 = ({ size = 14, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M8.3125 0.981587C8.66767 1.0545 8.97902 1.20558 9.2627 1.43374C9.48724 1.61438 9.73029 1.85933 9.97949 2.10854L14.707 6.83608L13.293 8.25014L9 3.95717V15.0431H7V3.95717L2.70703 8.25014L1.29297 6.83608L6.02051 2.10854C6.26971 1.85933 6.51277 1.61438 6.7373 1.43374C6.97662 1.24126 7.28445 1.04542 7.6875 0.981587C7.8973 0.94841 8.1031 0.956564 8.3125 0.981587Z"
d="M11.8249 1.11733C12.4401 0.929305 13.0795 1.42149 13.0238 2.08968L12.2321 11.5935C12.1751 12.2735 11.4312 12.6646 10.8386 12.3263L7.99539 10.7011L6.0967 12.7005L6.07413 12.7248L6.04808 12.7465C5.55601 13.1565 4.80867 12.8069 4.80833 12.1665V8.69211C4.80843 8.44469 4.90686 8.20727 5.08181 8.0323L9.79684 3.31641L2.46775 6.6988L4.10251 7.35253L3.67364 8.42559L1.57874 7.5878V7.5852C0.807611 7.2988 0.757207 6.21577 1.5145 5.86622L11.7025 1.16421L11.8249 1.11733ZM5.96474 11.1603L6.9614 10.1107L5.96474 9.54118V11.1603ZM6.32937 8.41864L11.1086 11.149L11.791 2.95698L6.32937 8.41864Z"
fill="currentColor"
/>
</svg>
@@ -4,7 +4,7 @@ import { afterEach, describe, expect, it } from 'vitest'
import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
import {
IconAlarmClockOutline16, IconApiOutline14, IconArchiveOutline20, IconFolderClose16,
IconGoalOutline16, IconSendOutline16,
IconGoalOutline16, IconSendOutline14,
} from '@deepseek-ai/dsh-client-ui-primitives'
afterEach(cleanup)
@@ -40,7 +40,7 @@ describe('ic_ds_ icon set', () => {
})
it('size and className props land on the root svg', () => {
const { container } = render(<IconSendOutline16 size={20} className="x" />)
const { container } = render(<IconSendOutline14 size={20} className="x" />)
const svg = container.querySelector('svg')!
expect(svg.getAttribute('width')).toBe('20')
expect(svg.getAttribute('height')).toBe('20')
@@ -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: e3659ed08a7c82f7c5ef314806ea07262f421caa
README.zh.md: 29393ed6b8694ac8fd8f68f398c12e633e237351
README.md: 3da629129f36d18a20eef74be8f96a1455d532f3
README.zh.md: 6f1eb7eef1f690ebdfdf38cfdd1f62b1314d7d66
@@ -27,10 +27,10 @@ Preview readable files in the right Sidebar and choose among registered renderer
## What it registers
- **The type** — `ctx.sidebarRightTabs.register(...)` with id `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` (this implementation's identity in the tab system, and the key its body registers under), kind `text`, pattern `dsh-resource://file/**`, band `fallback`. `canOpen` accepts only Session addresses, whose paths may be relative or absolute; bare `absolute` addresses are not claimed. A type registered at the `extension` or `builtin` band for a narrower pattern (say `*.png`) takes those addresses; other supported files land here. The whole address is the content identity, so two files with one name in different directories, or one path under two sessions, are two tabs; the decoded basename is the tab title, and the keyed `sidebar.right.pane.tab.title` seat places its extension-specific `FileTypeIcon` before that title.
- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path; directories use tertiary label colour, the name uses primary label colour, and a clipped path retains and fades toward its final segment while its tooltip exposes the full value. A dropdown selects among matching renderers and plain text. A wrap toggle appears only when the selected renderer declares `wrap: true`; its glyph describes the mode the click selects, and the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. The body reaches every pane edge; each renderer owns its content inset and may own an inner scrollport. This intentionally differs from the Files tab's 2px right-side scrollbar offset: previews keep the full pane width so edge-to-edge HTML and code scrollports end at the pane edge.
- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path; directories use tertiary label colour, the name uses primary label colour, and a clipped path retains and fades toward its final segment while its tooltip exposes the full value. A dropdown selects among matching renderers and plain text; a suffix declared binary (bitmap images, PDF) drops the plain-text fallback, and a single remaining candidate renders no viewer control at all. A known binary container suffix with no registered renderer (audio, video, archives, office documents) shows an unsupported empty state under the path header — the file-type icon and one explanatory line — and never issues a read. A wrap toggle appears only when the selected renderer declares `wrap: true`; its glyph describes the mode the click selects, and the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. The body reaches every pane edge; each renderer owns its content inset and may own an inner scrollport. This intentionally differs from the Files tab's 2px right-side scrollbar offset: previews keep the full pane width so edge-to-edge HTML and code scrollports end at the pane edge.
- **Shared loading and view state**, session-scoped and bucketed by tab id. The store holds accumulated pages or complete bytes, read and observed versions, loading/failure state, renderer choice, scroll offset, wrap, and the answered navigation revision. The ordinary inject face calls Remote readers and writes through declared store actions. Reloads and loading-mode changes retire older requests; the tab's abort signal forgets its state.
Document implementations register metadata with `ctx.documentPreviews.register({ id, extensions, priority, title, loading, wrap? })` and a body under the same `id` in the keyed, Session-scoped `sidebar.right.tab.document` child slot. Own both registrations with effects and wait for the child slot through `ctx.slots.inject`. Bodies receive `resourceAddress`, prepared `content`, `wrap`, `scrollportRef`, and the standard `useTabInfo`/`useResource` hooks; they do not receive a custom resource loader. A renderer that owns an inner scrolling element attaches `scrollportRef` to it, and the owner returns to the shared body when that element unmounts. 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.
Document implementations register metadata with `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap? })` and a body under the same `id` in the keyed, Session-scoped `sidebar.right.tab.document` child slot. `binaryExtensions` names the suffixes among `extensions` whose bytes are not readable text; a file matching one loses the plain-text fallback among its viewer choices. Own both registrations with effects and wait for the child slot through `ctx.slots.inject`. Bodies receive `resourceAddress`, prepared `content`, `wrap`, `scrollportRef`, and the standard `useTabInfo`/`useResource` hooks; they do not receive a custom resource loader. A renderer that owns an inner scrolling element attaches `scrollportRef` to it, and the owner returns to the shared body when that element unmounts. 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.
<a id="addresses"></a>
## Addresses
@@ -49,11 +49,11 @@ The body reads its record, navigation and lifetime through `useTabInfo().tab`. `
HTML fills the body edge to edge 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.
PNG, JPEG, GIF, WebP, BMP, ICO, and SVG render through Blob URLs in an `<img>` static-image context. The image keeps its intrinsic CSS-pixel dimensions; a smaller image centres in the shared scroller, and larger dimensions scroll on either axis. The renderer provides neither zoom nor drag-to-pan. SVG markup never enters the application DOM or an iframe, so its scripts cannot execute or reach the parent page. Replacing or unmounting the image revokes its Blob URL.
PNG, JPEG, GIF, WebP, BMP, ICO, and SVG render through Blob URLs in an `<img>` static-image context, rounded inside a 12px inset. A wider image scales down to the pane's width at its intrinsic aspect ratio, a smaller image centres at its intrinsic CSS-pixel dimensions, and a taller image scrolls vertically. The renderer provides neither zoom nor drag-to-pan. SVG markup never enters the application DOM or an iframe, so its scripts cannot execute or reach the parent page. Replacing or unmounting the image revokes its Blob URL.
Shared copy comes from `sidebarDocumentPreview`; each builtin renderer owns its localized labels.
Initial reads, additional pages, and HTML/PDF/image preparation share a loading indicator that respects reduced-motion preferences. Loaded pages stay visible while another page loads. PDF pages form one vertical, width-fitted sequence and render lazily near the viewport. Code previews show source line numbers by default without including them in copied text; plain text uses the same font size and line height as code. Code sits on the pane's own background rather than the chat card's fill; its banner is adjacent to a full-height inner scrollport, so both scrollbars begin below the copy control.
Initial reads, additional pages, and HTML/PDF/image preparation share an icon-only loading spinner that exposes its label to assistive technology and respects reduced-motion preferences; every wait before content exists centres the spinner in the pane, so opening a file shows one spinner in one position until the body appears. Loaded pages stay visible while another page loads. PDF pages fill the pane's width edge to edge as one vertical sequence and render lazily near the viewport; an unrendered page holds its place as a quiet 3:4 placeholder block. Code previews show source line numbers by default without including them in copied text; plain text uses the same font size and line height as code. Code sits on the pane's own background rather than the chat card's fill; its banner is adjacent to a full-height inner scrollport, so both scrollbars begin below the copy control.
<a id="navigation"></a>
## Navigation
@@ -74,7 +74,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, HTML, and images require a complete result within the Host's `maxFileBytes` cap.
- **Byte-view scroll state is not restored.** PDF, HTML, and images can return to the top when their renderer remounts or reloads; image horizontal position is never restored, and HTML iframe scrolling belongs to its opaque browsing context.
- **Byte-view scroll state is not restored.** PDF, HTML, and images can return to the top when their renderer remounts or reloads; images fit the pane's width and never scroll horizontally, and 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 glyphs.** `IconWrapFill16` and `IconNowrapFill16` live in `src/client/icons.tsx` until the shared icon set carries them; their props already match the shared icon contract.
- **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.
@@ -27,10 +27,10 @@ kind: "package-reference"
## 注册了什么
- **类型** —— `ctx.sidebarRightTabs.register(...)`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`(这个实现在 tab 系统中的身份,也是其正文注册所用的键),kind `text`,pattern `dsh-resource://file/**`,档位 `fallback`。`canOpen` 只接受 Session 地址,其中路径可为相对或绝对路径;不认领裸 `absolute` 地址。在 `extension` 或 `builtin` 档以更窄 pattern(比如 `*.png`)注册的类型接走那些地址;其他受支持文件落到这里。整个地址就是内容身份,所以不同目录下同名的两个文件、或同一路径在两个会话之下,是两个 tab;解码后的 basename 是 tab 标题,keyed slot `sidebar.right.pane.tab.title` 会在标题前放置按扩展名选择的 `FileTypeIcon`。
- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径;目录使用三级标签色,文件名使用一级标签色,路径过长时保留末段并向开头淡出,提示中仍提供完整值。下拉菜单可在匹配的渲染器与纯文本间切换。仅当所选渲染器声明 `wrap: true` 时显示换行开关;图标表示点击后切换到的模式,该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。正文贴合格的每条边,各渲染器自行提供内容留白,并可拥有内部滚动区。这与 Files tab 右侧预留 2px 滚动条间距的布局有意不同:Preview 使用格的完整宽度,使贴边 HTML 与代码滚动区终止于格的边缘。
- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径;目录使用三级标签色,文件名使用一级标签色,路径过长时保留末段并向开头淡出,提示中仍提供完整值。下拉菜单可在匹配的渲染器与纯文本间切换;声明为二进制的后缀(位图图片、PDF)不提供纯文本兜底,只剩一个候选时完全不渲染查看器控件。已知的二进制容器后缀(音频、视频、压缩包、office 文档)没有注册渲染器时,在路径头部下方显示不支持预览的空态——文件类型图标加一行说明——并且不会发起读取。仅当所选渲染器声明 `wrap: true` 时显示换行开关;图标表示点击后切换到的模式,该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。正文贴合格的每条边,各渲染器自行提供内容留白,并可拥有内部滚动区。这与 Files tab 右侧预留 2px 滚动条间距的布局有意不同:Preview 使用格的完整宽度,使贴边 HTML 与代码滚动区终止于格的边缘。
- **共享加载与视图状态**,会话作用域、按 tab id 分桶。store 持有累计页或完整字节、读取与观察版本、加载/失败状态、渲染器选择、滚动位置、换行和已响应的导航 revision。普通 inject face 调用 Remote 读取,并经声明的 store action 写入。重新载入和加载模式变化会淘汰旧请求;tab 的中止信号清理其状态。
文档实现在 `ctx.documentPreviews.register({ id, extensions, priority, title, loading, wrap? })` 注册元数据,并以相同 `id` 向 keyed、Session 作用域的子 slot `sidebar.right.tab.document` 注册正文。两处注册都由 effect 持有,通过 `ctx.slots.inject` 等待子 slot。正文接收 `resourceAddress`、准备好的 `content`、`wrap`、`scrollportRef` 和标准 `useTabInfo`/`useResource` 钩子,不接收自定义资源加载器。拥有内部滚动元素的渲染器把 `scrollportRef` 挂到该元素上;该元素卸载后,owner 恢复使用共享正文。元数据声明 `loading: 'text-pages'` 或 `'bytes-complete'`。注册表保留所有匹配备选:`extension`(默认)优先于 `builtin`,随后按更长的后缀、再按注册顺序排列。所选实现仍可用时,下拉选择保持不变;移除后选择下一个候选。内置正文也使用相同注册方式。
文档实现在 `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap? })` 注册元数据,并以相同 `id` 向 keyed、Session 作用域的子 slot `sidebar.right.tab.document` 注册正文。`binaryExtensions` 列出 `extensions` 中字节不可按文本阅读的后缀;匹配这类后缀的文件在其查看器选项中不再提供纯文本兜底。两处注册都由 effect 持有,通过 `ctx.slots.inject` 等待子 slot。正文接收 `resourceAddress`、准备好的 `content`、`wrap`、`scrollportRef` 和标准 `useTabInfo`/`useResource` 钩子,不接收自定义资源加载器。拥有内部滚动元素的渲染器把 `scrollportRef` 挂到该元素上;该元素卸载后,owner 恢复使用共享正文。元数据声明 `loading: 'text-pages'` 或 `'bytes-complete'`。注册表保留所有匹配备选:`extension`(默认)优先于 `builtin`,随后按更长的后缀、再按注册顺序排列。所选实现仍可用时,下拉选择保持不变;移除后选择下一个候选。内置正文也使用相同注册方式。
<a id="addresses"></a>
## 地址
@@ -49,11 +49,11 @@ tab 使用 `fileAddressFor` 构造的 Session 地址,携带相对或绝对路
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。
PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `<img>` 静态图片上下文中渲染。图片保持固有 CSS 像素尺寸;小图在共享滚动区内居中,大图可沿任一轴滚动。渲染器既不提供缩放,也不提供拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此其中的脚本无法执行,也无法访问父页面。替换或卸载图片会撤销其 Blob URL。
PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `<img>` 静态图片上下文中渲染,带 12px 内边距和圆角。宽图按固有纵横比缩小到面板宽度,小图按固有 CSS 像素尺寸居中,超高图纵向滚动。渲染器既不提供缩放,也不提供拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此其中的脚本无法执行,也无法访问父页面。替换或卸载图片会撤销其 Blob URL。
共享文案来自 `sidebarDocumentPreview`;各内置渲染器拥有自己的本地化标签。
首次读取、追加页及 HTML/PDF/图片准备共用加载指示器,并遵循减少动态效果偏好。下一页加载期间保留已显示的内容。PDF 页面组成一个纵向、适配宽度的连续序列,并在接近视口时惰性渲染。代码预览默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;复制条与占满剩余高度的内部滚动区相邻,因此横纵滚动条都从复制控件下方开始。
首次读取、追加页及 HTML/PDF/图片准备共用仅图标的加载 spinner,其标签暴露给辅助技术,并遵循减少动态效果偏好;内容出现前的每个等待都把 spinner 居中在面板中,打开文件到正文出现始终是同一位置的一个 spinner。下一页加载期间保留已显示的内容。PDF 页面贴边占满面板宽度,组成一个纵向连续序列并在接近视口时惰性渲染;未渲染的页以安静的 3:4 占位块保持位置。代码预览默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;复制条与占满剩余高度的内部滚动区相邻,因此横纵滚动条都从复制控件下方开始。
<a id="navigation"></a>
## 导航
@@ -74,7 +74,7 @@ PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `<img>` 静态
<a id="known-limitations-and-deferred-work"></a>
- **预览而非编辑。** 查看器不提供文件编辑或共享搜索接口;目录地址以 `not-regular-file` 失败。未知扩展名使用纯文本读取,仍受其 UTF-8/NUL 检查限制。
- **文本顺序分页,完整文件受限。** 定位到较深处的源码行需要先加载此前各页;PDF、HTML 和图片必须取得 Host `maxFileBytes` 上限内的完整结果。
- **字节视图不恢复滚动位置。** PDF、HTML 与图片的渲染器重新挂载或重新载入时可能回到顶部;图片的横向位置始终不恢复,HTML iframe 的滚动属于其不透明浏览上下文。
- **字节视图不恢复滚动位置。** PDF、HTML 与图片的渲染器重新挂载或重新载入时可能回到顶部;图片适配面板宽度、不产生横向滚动,HTML iframe 的滚动属于其不透明浏览上下文。
- **本地 HTML 依赖集合有限。** 只打包直接引用的经典 `.js` 脚本和 `.css` 样式表。浏览器解析的资源仍受浏览器源与网络规则限制;iframe 不获得运行时文件读取桥接。
- **换行图标为包内自绘。** `IconWrapFill16` 与 `IconNowrapFill16` 住在 `src/client/icons.tsx`,直到共享图标集提供为止;它们的 props 已与共享图标契约一致。
- **滚动写入未节流。** 每次滚动事件都把偏移记进 store;行块已 memo 化,于是由此引发的重渲染交还给 React 的是同一批元素。
@@ -4,10 +4,16 @@ import clsx from 'clsx'
import { IconLoadingOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './LoadingIndicator.module.css'
/** @param props - localized status label and optional placement style. @returns an animated, accessible loading status. */
export function LoadingIndicator({ label, className }: { label: string; className?: string | undefined }): ReactNode {
return <span className={clsx(css.loading, className)} role="status" data-document-loading>
/**
* @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 }: {
label: string
className?: string | undefined
}): ReactNode {
return <span className={clsx(css.loading, className)} role="status" aria-label={label} data-document-loading>
<span className={css.icon} aria-hidden="true"><IconLoadingOutline16 /></span>
<span>{label}</span>
</span>
}
@@ -217,14 +217,27 @@
white-space: normal;
}
/* The whole pane before any content exists, so the line centres like the
empty states below instead of sitting in the corner. */
.status {
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-start;
align-items: center;
justify-content: center;
box-sizing: border-box;
height: 100%;
padding: 12px 10px;
}
/* The first read, centred in the still-empty body; loaded content replaces it. */
.bodyLoading {
justify-content: center;
box-sizing: border-box;
width: 100%;
height: 100%;
}
.action {
padding: 4px 10px;
color: var(--dsw-alias-label-primary);
@@ -24,8 +24,9 @@ import { LoadingIndicator } from './LoadingIndicator.tsx'
import { hostFileOf } from './rpc.ts'
import type { TextStore } from './store.ts'
import type { DocumentContent } from './document/contract.ts'
import { matchingDocumentPreviews } from './document/registry.ts'
import { binaryDocumentPath, matchingDocumentPreviews } from './document/registry.ts'
import type { DocumentPreviewDefinition } from './document/registry.ts'
import { unviewableBinaryPath } from './document/unviewable.ts'
import { PLAIN_BODY_ID } from './text/index.ts'
import { loadedPages, lastLineLoaded, scrollToLine } from './text/lines.ts'
import css from './TextPreview.module.css'
@@ -56,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[]> }
@@ -85,11 +103,14 @@ export function TextPreview({
const file = useMemo(() => hostFileOf(tab.contentId), [tab.contentId])
const state = useStore(s => s.byTab[tab.id])
const definitions = useDocumentPreviews(value => value)
const unviewable = useMemo(() => unviewableBinaryPath(file.path), [file.path])
const candidates = useMemo(() => {
const matched = matchingDocumentPreviews(definitions, file.path)
if (matched.length > 0 && binaryDocumentPath(definitions, file.path)) return matched
if (matched.length === 0 && unviewable) return matched
const fallback = definitions.find(definition => definition.id === PLAIN_BODY_ID)
return fallback === undefined ? matched : [...matched, fallback]
}, [definitions, file.path])
}, [definitions, file.path, unviewable])
const selected = candidates.find(candidate => candidate.id === state?.rendererId) ?? candidates[0]
const mode = selected?.loading
const current = (state?.mode ?? 'text-pages') === mode ? state : undefined
@@ -173,6 +194,24 @@ export function TextPreview({
return { kind: 'text', pages: loaded, text: loaded.filter(page => page.lines > 0).map(page => page.text).join('\n'), eof: current.eof }
}, [mode, loaded, current?.complete, current?.eof])
// 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 { name: unsupportedName } = pathPartsOf(displayPath)
return (
<div className={css.preview} data-textpreview-state="unsupported" data-textpreview-url={tab.contentId}>
<div className={css.header}>
<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(unsupportedName)} size={36} className={css.emptyIcon} />
<p className={css.emptyLine}>{t('unsupportedFile')}</p>
</div>
</div>
</div>
)
}
if (state === undefined || selected === undefined) {
return (
<div className={css.status} data-textpreview-state="loading">
@@ -183,7 +222,7 @@ export function TextPreview({
)
}
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
@@ -230,27 +269,25 @@ 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>
<Menu
open={menuOpen}
anchor={(
<button type="button" className={clsx(css.tool, css.viewerTool)} aria-label={t('openWith')} title={selected.title()} data-document-viewer-menu onClick={() => { setMenuOpen(value => !value) }}>
{selected.title()}
</button>
<HeaderPath pathRef={pathRef} pathTextRef={pathTextRef} path={displayPath} />
{candidates.length > 1
&& (
<Menu
open={menuOpen}
anchor={(
<button type="button" className={clsx(css.tool, css.viewerTool)} aria-label={t('openWith')} title={selected.title()} data-document-viewer-menu onClick={() => { setMenuOpen(value => !value) }}>
{selected.title()}
</button>
)}
items={candidates.map(candidate => ({ id: candidate.id, label: candidate.title() }))}
selectedId={selected.id}
onSelect={(id) => { actions.selected(tab.id, id); setMenuOpen(false) }}
onClose={() => { setMenuOpen(false) }}
align="end"
portal
dense
/>
)}
items={candidates.map(candidate => ({ id: candidate.id, label: candidate.title() }))}
selectedId={selected.id}
onSelect={(id) => { actions.selected(tab.id, id); setMenuOpen(false) }}
onClose={() => { setMenuOpen(false) }}
align="end"
portal
dense
/>
{selected.wrap === true && (
// The tooltip names the action while the stable aria name and
// `aria-pressed` expose the control and its current state.
@@ -295,7 +332,7 @@ export function TextPreview({
}}
>
{!hasContent && current?.failure === undefined && (
<LoadingIndicator className={css.statusLine} label={t('loading')} />
<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,
@@ -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'
@@ -10,6 +11,12 @@ export interface DocumentPreviewDefinition {
readonly id: string
/** File suffixes without a leading dot; compound suffixes such as tar.gz are accepted. */
readonly extensions: readonly string[]
/**
* 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. */
readonly priority?: 'builtin' | 'extension'
/** Localized implementation label, evaluated when the toolbar renders. @returns the visible name. */
@@ -30,21 +37,31 @@ 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)
.map(candidate => candidate.definition)
}
/**
* Whether any registered implementation declares the filename's suffix binary.
* @param definitions - registered implementations.
* @param path - decoded filename or file path.
* @returns true when a declared binary suffix matches the filename.
*/
export function binaryDocumentPath(
definitions: readonly DocumentPreviewDefinition[],
path: string,
): boolean {
const name = documentFileName(path)
return definitions.some(definition => matchedSuffixLength(name, definition.binaryExtensions ?? []) > 0)
}
/** Observable registry of all live implementations, including lower-priority alternatives. */
export class DocumentPreviewRegistry {
private readonly registered = new Map<string, DocumentPreviewDefinition>()
@@ -69,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
@@ -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))
}
@@ -0,0 +1,37 @@
/**
* Known binary container suffixes with no registered renderer. The preview
* owner shows these the unsupported empty state instead of the plain-text
* fallback; any renderer registration for a suffix takes precedence because
* the owner consults this list only when no implementation matches. A suffix
* 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',
// audio
'mp3', 'wav', 'flac', 'ogg', 'm4a', 'aac', 'wma', 'opus',
// archives
'zip', 'gz', 'tgz', 'bz2', 'xz', 'zst', '7z', 'rar', 'tar', 'jar',
// 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
'ttf', 'otf', 'woff', 'woff2', 'eot',
// disk images and databases
'dmg', 'iso', 'img', 'sqlite', 'db',
// design documents and image formats without a renderer
'psd', 'ai', 'sketch', 'tiff', 'tif', 'heic', 'heif', 'avif',
]
/**
* Whether a filename's suffix is a known binary container that no renderer claims.
* @param path - decoded filename or file path.
* @returns true when the suffix belongs to the unviewable binary list.
*/
export function unviewableBinaryPath(path: string): boolean {
return matchedSuffixLength(documentFileName(path), UNVIEWABLE_BINARY_EXTENSIONS) > 0
}
@@ -13,3 +13,12 @@
color: var(--dsw-alias-label-secondary);
white-space: normal;
}
/* The packing wait fills the still-empty body and centres, like the image
renderer's status. */
.opening {
justify-content: center;
box-sizing: border-box;
width: 100%;
min-height: 100%;
}
@@ -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} label={t('loading')} />
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,8 +2,8 @@
export const zh = {
title: 'HTML',
frame: 'HTML 文档预览',
loading: '正在准备 HTML 预览…',
failed: '无法预览这份 HTML 文档。',
loading: '正在读取…',
failed: '无法预览这份 HTML 文档',
} satisfies Record<string, string>
/** HTML renderer dictionary keys. */
@@ -13,7 +13,7 @@ export type HtmlPreviewKey = keyof typeof zh
export const en = {
title: 'HTML',
frame: 'HTML document preview',
loading: 'Preparing HTML preview…',
loading: 'Reading…',
failed: 'This HTML document could not be previewed.',
} satisfies Record<HtmlPreviewKey, string>
@@ -1,23 +1,28 @@
/* The frame follows the scroller's width, so the image's percentage maximum
resolves against the pane and never scrolls horizontally; a taller image
grows the frame and scrolls vertically. */
.frame {
display: flex;
box-sizing: border-box;
width: max-content;
min-width: 100%;
width: 100%;
height: max-content;
min-height: 100%;
padding: 8px;
padding: 12px;
font-family: var(--dsw-font, sans-serif);
white-space: normal;
}
/* A wider image shrinks to the pane's width at its aspect ratio; a smaller
one keeps its intrinsic size. The auto margins centre it either way. */
.image {
display: block;
flex: none;
width: auto;
max-width: none;
max-width: 100%;
height: auto;
max-height: none;
margin: auto;
border-radius: 8px;
user-select: none;
}
@@ -1,4 +1,4 @@
/** Complete image bytes rendered at their intrinsic CSS-pixel dimensions. */
/** Complete image bytes rendered rounded within an inset frame, scaled down to the pane's width. */
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import { pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path'
@@ -46,9 +46,11 @@ export function imageMediaType(path: string): ImageMediaType | undefined {
}
/**
* Present complete image bytes without fitting or scaling them to the pane.
* Present complete image bytes fitted to the pane's width.
* @param props - document bytes, resource identity, and locale.
* @returns an intrinsic-size image whose containing document body provides scrolling.
* @returns a rounded image scaled down to the pane's width at its aspect
* ratio — a smaller image centres at its intrinsic size — whose containing
* document body provides vertical scrolling.
*/
export function ImageBody({ content, resourceAddress, t }: ImageBodyProps): ReactNode {
const path = useMemo(() => hostFileOf(resourceAddress).path, [resourceAddress])
@@ -11,6 +11,9 @@ export const IMAGE_BODY_ID = '@deepseek-ai/dsh-client-ui-sidebar-documentpreview
/** File suffixes rendered by the builtin image body. */
export const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico', 'svg'] as const
/** Bitmap suffixes whose bytes are unreadable as text; SVG stays out because its XML source is worth reading. */
export const BINARY_IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico'] as const
/**
* Describe the builtin image renderer independently from its keyed body slot.
* @param title - locale-owned implementation name.
@@ -20,6 +23,7 @@ export function imageBodyDefinition(title: () => string): DocumentPreviewDefinit
return {
id: IMAGE_BODY_ID,
extensions: IMAGE_EXTENSIONS,
binaryExtensions: BINARY_IMAGE_EXTENSIONS,
priority: 'builtin',
title,
loading: 'bytes-complete',
@@ -2,9 +2,9 @@
export const zh = {
title: '图片',
preview: '图片预览:{name}',
loading: '正在打开图片…',
failed: '无法显示这张图片。',
unsupported: '图片预览需要完整文件内容。',
loading: '正在读取…',
failed: '无法显示这张图片',
unsupported: '图片预览需要完整文件内容',
} satisfies Record<string, string>
/** Image renderer dictionary keys. */
@@ -14,7 +14,7 @@ export type ImagePreviewKey = keyof typeof zh
export const en = {
title: 'Image',
preview: 'Image preview: {name}',
loading: 'Opening image…',
loading: 'Reading…',
failed: 'This image could not be displayed.',
unsupported: 'Image preview requires the complete file contents.',
} satisfies Record<ImagePreviewKey, string>
@@ -10,7 +10,7 @@
export const zh = {
loading: '正在读取…',
loadMore: '加载更多',
changed: '文件已更新,当前显示为旧内容。',
changed: '文件已更新,当前显示为旧内容',
reloadNow: '重新载入',
reload: '重新读取文件',
'wrap.enable': '自动换行',
@@ -18,12 +18,13 @@ export const zh = {
'wrap.aria': '自动换行',
openWith: '打开方式',
'viewer.text': '纯文本',
resourceUnavailable: '文件资源服务不可用。',
rendererUnavailable: '预览器 {name} 不可用。',
'error.notFound': '文件不存在,可能已被移动或删除。',
'error.tooLarge': '单页内容超过 {limit} 上限,无法读取。',
'error.notText': '非文本文件,暂时无法预览。',
'error.notRegularFile': '该路径不是普通文件,没有可显示的内容。',
resourceUnavailable: '文件资源服务不可用',
rendererUnavailable: '预览器 {name} 不可用',
unsupportedFile: '该格式文件暂时无法预览',
'error.notFound': '文件不存在,可能已被移动或删除',
'error.tooLarge': '单页内容超过 {limit} 上限,无法读取',
'error.notText': '该格式文件暂时无法预览',
'error.notRegularFile': '该路径不是普通文件,没有可显示的内容',
'error.unavailable': '读取失败:{message}',
retry: '重试',
} satisfies Record<string, string>
@@ -45,9 +46,10 @@ export const en = {
'viewer.text': 'Plain text',
resourceUnavailable: 'The file resource service is unavailable.',
rendererUnavailable: 'The {name} preview is unavailable.',
unsupportedFile: 'Preview is not available for this file type yet.',
'error.notFound': 'File not found. It may have been moved or deleted.',
'error.tooLarge': 'This page exceeds the {limit} limit and cannot be read.',
'error.notText': 'Not a text file, preview is unavailable for now.',
'error.notText': 'Preview is not available for this file type yet.',
'error.notRegularFile': 'Not a regular file, nothing to display.',
'error.unavailable': 'Read failed: {message}',
retry: 'Retry',
@@ -4,7 +4,6 @@
box-sizing: border-box;
width: 100%;
min-height: 0;
padding: 8px;
overflow-x: hidden;
font-family: var(--dsw-font, sans-serif);
white-space: normal;
@@ -14,16 +13,15 @@
box-sizing: border-box;
width: 100%;
min-width: 0;
padding: 12px;
overflow-x: hidden;
}
/* An unrendered page holds its place as a quiet block; the block itself is
the loading feedback, so no spinner competes with the centred open wait. */
.placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
aspect-ratio: 3 / 4;
background: var(--dsw-alias-bg-skeleton);
}
.canvas {
@@ -47,3 +45,12 @@
font-size: 13px;
line-height: 1.5;
}
/* The document-open wait fills the still-empty body and centres, matching the
owner's read spinner position. */
.opening {
justify-content: center;
box-sizing: border-box;
width: 100%;
min-height: 100%;
}
@@ -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'
@@ -64,7 +65,9 @@ export function PdfBody(props: PdfBodyProps): ReactNode {
}
}, [data, tab.signal, attempt])
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')} />
// 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={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>
@@ -126,9 +129,9 @@ function PdfPage({ document, page, requested: initiallyRequested, onVisible, sig
return () => { lifetime.abort() }
}, [document, page, requested, signal, attempt])
return <div ref={host} className={css.page} data-pdf-page={page}>
{failure === undefined && state !== 'ready' && <div className={css.placeholder}>
{requested && <LoadingIndicator className={css.status} label={t('rendering')} />}
</div>}
{failure === undefined && state !== 'ready' && (requested
? <div className={css.placeholder} role="status" aria-label={t('rendering')} />
: <div className={css.placeholder} />)}
{failure !== undefined && <div className={css.status} role="alert">
<span>{failureText(failure.error, t)}</span>
<Button size="sm" onClick={() => { setAttempt(value => value + 1) }}>{t('retry')}</Button>
@@ -15,7 +15,7 @@ export const PDF_BODY_ID = '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/p
* @returns the complete-file PDF registration.
*/
export function pdfBodyDefinition(title: () => string): DocumentPreviewDefinition {
return { id: PDF_BODY_ID, extensions: ['pdf'], priority: 'builtin', title, loading: 'bytes-complete', wrap: false }
return { id: PDF_BODY_ID, extensions: ['pdf'], binaryExtensions: ['pdf'], priority: 'builtin', title, loading: 'bytes-complete', wrap: false }
}
/** @param ctx - context carrying the locale, document registry, and slot registry. */
@@ -2,12 +2,12 @@
export const zh = {
title: 'PDF',
pageImage: 'PDF 第 {page} 页',
loading: '正在打开 PDF…',
loading: '正在读取…',
rendering: '正在绘制页面…',
failed: '无法显示 PDF:{message}',
password: '此 PDF 需要密码,暂不支持预览。',
workerFailed: 'PDF 渲染进程无法继续,请重试。',
unsupported: 'PDF 预览需要完整文件内容。',
password: '此 PDF 需要密码,暂不支持预览',
workerFailed: 'PDF 渲染进程无法继续,请重试',
unsupported: 'PDF 预览需要完整文件内容',
retry: '重试',
} satisfies Record<string, string>
@@ -18,7 +18,7 @@ export type PdfLocaleKey = keyof typeof zh
export const en = {
title: 'PDF',
pageImage: 'PDF page {page}',
loading: 'Opening PDF…',
loading: 'Reading…',
rendering: 'Rendering page…',
failed: 'Cannot display PDF: {message}',
password: 'This PDF requires a password; password-protected previews are not supported.',
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { DocumentPreviewRegistry } from '../src/client/document/registry.ts'
import { binaryDocumentPath, DocumentPreviewRegistry } from '../src/client/document/registry.ts'
import type { DocumentPreviewDefinition } from '../src/client/document/registry.ts'
function definition(id: string, overrides: Partial<DocumentPreviewDefinition> = {}): DocumentPreviewDefinition {
@@ -58,6 +58,25 @@ describe('document preview implementations', () => {
expect(listener).toHaveBeenCalledTimes(2)
})
it('marks a path binary only for declared binary suffixes, case-insensitively', () => {
const image = definition('image', { extensions: ['png', 'svg'], binaryExtensions: ['png'] })
const text = definition('text', { extensions: [] })
expect(binaryDocumentPath([image, text], '/work/photo.PNG')).toBe(true)
expect(binaryDocumentPath([image, text], 'C:\\work\\photo.png')).toBe(true)
expect(binaryDocumentPath([image, text], '/work/diagram.svg')).toBe(false)
expect(binaryDocumentPath([image, text], '/work.png/plain')).toBe(false)
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')
@@ -7,6 +7,7 @@ import { TextPreview } from '../src/client/TextPreview.tsx'
import type { TextPreviewProps } from '../src/client/TextPreview.tsx'
import type { DocumentPreviewDefinition } from '../src/client/document/registry.ts'
import { CodeBody } from '../src/client/code/CodeBody.tsx'
import { PLAIN_BODY_ID } from '../src/client/text/index.ts'
import { ABSOLUTE_PATH, FILE, harness, page, settle, TAB_ID } from './fixtures.client.ts'
afterEach(cleanup)
@@ -44,7 +45,8 @@ describe('document toolbar', () => {
const body = view.container.querySelector('[data-textpreview-body]')
expect(view.container.querySelector('[data-code-preview]')).toBeNull()
expect(body?.firstElementChild).toBe(view.getByRole('status'))
expect(view.getByRole('status').textContent).toBe('loading')
expect(view.getByRole('status').getAttribute('aria-label')).toBe('loading')
expect(view.getByRole('status').textContent).toBe('')
expect(view.container.querySelector('[data-textpreview-more]')).toBeNull()
await act(async () => { first.resolve(page(1, ['const prefix = 1;'], false)); await first.promise })
@@ -83,7 +85,7 @@ describe('document toolbar', () => {
h.read.mockReturnValueOnce(pending.promise)
const view = render(<TextPreview {...codeProps(h)} />)
expect(view.container.querySelector('[data-code-preview]')).toBeNull()
expect(view.getByRole('status').textContent).toBe('loading')
expect(view.getByRole('status').getAttribute('aria-label')).toBe('loading')
await act(async () => { pending.resolve(page(1, [], true)); await pending.promise })
expect(view.container.querySelector('[data-code-preview]')).not.toBeNull()
expect(view.container.querySelector('[data-code-preview] pre')?.textContent).toBe('')
@@ -106,7 +108,7 @@ describe('document toolbar', () => {
const props: TextPreviewProps = { ...h.props(), useDocumentPreviews: selector => selector([binary]), renderSlot }
const view = render(<TextPreview {...props} />)
expect(view.getByRole('status').hasAttribute('data-document-loading')).toBe(true)
expect(view.getByRole('status').textContent).toBe('loading')
expect(view.getByRole('status').getAttribute('aria-label')).toBe('loading')
expect(view.container.querySelector('[data-textpreview-body]')?.firstElementChild).toBe(view.getByRole('status'))
expect(renderSlot).not.toHaveBeenCalled()
await act(async () => {
@@ -156,9 +158,68 @@ describe('document toolbar', () => {
h.controller.abort()
})
it('drops the plain-text fallback for a declared binary suffix and hides the viewer control entirely', async () => {
const h = harness()
h.bytes.mockResolvedValue({ ok: true, value: { absolutePath: '/host/project/work/photo.png', version: 'v1', offset: 0, data: btoa('x'), bytes: 1, eof: true } })
const image: DocumentPreviewDefinition = {
id: 'image', extensions: ['png', 'svg'], binaryExtensions: ['png'], title: () => 'Image', loading: 'bytes-complete',
}
const plain: DocumentPreviewDefinition = {
id: PLAIN_BODY_ID, extensions: [], title: () => 'viewer.text', loading: 'text-pages', wrap: true,
}
const base = h.props()
const info = base.useTabInfo()
const address = 'dsh-resource://file/session/s-1/work/photo.png'
const props: TextPreviewProps = {
...base,
useTabInfo: () => ({ ...info, tab: { ...info.tab, contentId: address, navigation: { ...info.tab.navigation, address } } }),
useDocumentPreviews: selector => selector([image, plain]),
renderSlot: vi.fn(() => null),
} as TextPreviewProps
const view = render(<TextPreview {...props} />)
await settle()
expect(view.container.querySelector('[data-document-viewer-menu]')).toBeNull()
expect(view.container.querySelector('[data-textpreview-tool="reload"]')).not.toBeNull()
h.controller.abort()
})
it('keeps the plain-text fallback in the picker for a non-binary suffix of the same viewer', async () => {
const h = harness()
h.bytes.mockResolvedValue({ ok: true, value: { absolutePath: '/host/project/work/logo.svg', version: 'v1', offset: 0, data: btoa('<svg/>'), bytes: 6, eof: true } })
const image: DocumentPreviewDefinition = {
id: 'image', extensions: ['png', 'svg'], binaryExtensions: ['png'], title: () => 'Image', loading: 'bytes-complete',
}
const plain: DocumentPreviewDefinition = {
id: PLAIN_BODY_ID, extensions: [], title: () => 'viewer.text', loading: 'text-pages', wrap: true,
}
const base = h.props()
const info = base.useTabInfo()
const address = 'dsh-resource://file/session/s-1/work/logo.svg'
const props: TextPreviewProps = {
...base,
useTabInfo: () => ({ ...info, tab: { ...info.tab, contentId: address, navigation: { ...info.tab.navigation, address } } }),
useDocumentPreviews: selector => selector([image, plain]),
renderSlot: vi.fn(() => null),
} as TextPreviewProps
const view = render(<TextPreview {...props} />)
await settle()
fireEvent.click(view.container.querySelector('[data-document-viewer-menu]')!)
expect(screen.getByRole('menuitem', { name: 'Image' })).toBeDefined()
expect(screen.getByRole('menuitem', { name: 'viewer.text' })).toBeDefined()
h.controller.abort()
})
it('dismisses the implementation picker with Escape without changing the selected implementation', async () => {
const h = harness({ 1: page(1, ['held'], true) })
const view = render(<TextPreview {...h.props()} />)
const base = h.props()
const code: DocumentPreviewDefinition = {
id: 'code', extensions: ['md'], title: () => 'Code', loading: 'text-pages', wrap: true,
}
const plain: DocumentPreviewDefinition = {
id: PLAIN_BODY_ID, extensions: [], title: () => 'viewer.text', loading: 'text-pages', wrap: true,
}
const props: TextPreviewProps = { ...base, useDocumentPreviews: selector => selector([code, plain]) }
const view = render(<TextPreview {...props} />)
await settle()
fireEvent.click(view.container.querySelector('[data-document-viewer-menu]')!)
expect(screen.getByRole('menuitem', { name: 'viewer.text' })).toBeDefined()
@@ -167,4 +228,42 @@ describe('document toolbar', () => {
expect(h.instance.getSnapshot().byTab[TAB_ID]?.rendererId).toBeUndefined()
h.controller.abort()
})
it('shows the unsupported empty state for a known binary suffix with no renderer, reading nothing', async () => {
const h = harness()
const base = h.props()
const info = base.useTabInfo()
const address = 'dsh-resource://file/session/s-1/work/clip.mp4'
const props: TextPreviewProps = {
...base,
useTabInfo: () => ({ ...info, tab: { ...info.tab, contentId: address, navigation: { ...info.tab.navigation, address } } }),
}
const view = render(<TextPreview {...props} />)
await settle()
expect(view.container.querySelector('[data-textpreview-state="unsupported"]')).not.toBeNull()
expect(view.container.querySelector('[data-textpreview-unsupported]')?.textContent).toContain('unsupportedFile')
expect(view.container.querySelector('[data-textpreview-path]')).not.toBeNull()
expect(view.container.querySelector('[data-document-viewer-menu]')).toBeNull()
expect(view.container.querySelector('[data-textpreview-tool="reload"]')).toBeNull()
expect(h.read).not.toHaveBeenCalled()
expect(h.bytes).not.toHaveBeenCalled()
h.controller.abort()
})
it('keeps the plain-text fallback for an unlisted unknown suffix', async () => {
const h = harness({ 1: page(1, ['plain line'], true) })
const base = h.props()
const info = base.useTabInfo()
const address = 'dsh-resource://file/session/s-1/work/server.log'
const props: TextPreviewProps = {
...base,
useTabInfo: () => ({ ...info, tab: { ...info.tab, contentId: address, navigation: { ...info.tab.navigation, address } } }),
}
const view = render(<TextPreview {...props} />)
await settle()
expect(view.container.querySelector('[data-textpreview-state="unsupported"]')).toBeNull()
expect(h.read).toHaveBeenCalledTimes(1)
expect(view.container.textContent).toContain('plain line')
h.controller.abort()
})
})
@@ -0,0 +1,25 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
import { unviewableBinaryPath } from '../src/client/document/unviewable.ts'
describe('unviewableBinaryPath', () => {
it('matches one sample from every listed category', () => {
for (const path of [
'clip.mp4', 'song.mp3', 'bundle.zip', 'report.docx', 'tool.exe',
'face.woff2', 'image.dmg', 'design.psd',
]) expect(unviewableBinaryPath(path), path).toBe(true)
})
it('matches regardless of case, directories, and path separators', () => {
expect(unviewableBinaryPath('MOVIE.MP4')).toBe(true)
expect(unviewableBinaryPath('work/deep/archive.tar.gz')).toBe(true)
expect(unviewableBinaryPath('work\\deep\\notes.docx')).toBe(true)
})
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', 'private.key',
]) expect(unviewableBinaryPath(path), path).toBe(false)
})
})
@@ -129,7 +129,7 @@ describe('HtmlBody', () => {
readRelated: bytes,
} as unknown as HtmlBodyProps
render(<HtmlBody {...initial} />)
expect(screen.getByRole('status').textContent).toBe(en.loading)
expect(screen.getByRole('status').getAttribute('aria-label')).toBe(en.loading)
expect(screen.getByRole('status').hasAttribute('data-document-loading')).toBe(true)
expect(create).not.toHaveBeenCalled()
expect(await screen.findByTitle(en.frame)).toBeTruthy()
@@ -1,5 +1,5 @@
// @vitest-environment jsdom
/** Image Blob ownership, media types, intrinsic rendering, and failure states. */
/** Image Blob ownership, media types, pane-contained rendering, and failure states. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
@@ -64,7 +64,7 @@ describe('ImageBody', () => {
expect(revoke).toHaveBeenCalledExactlyOnceWith('blob:https://preview.invalid/1')
})
it('keeps the image unscaled and non-draggable, then shows it after decoding succeeds', async () => {
it('keeps the image non-draggable, then shows it after decoding succeeds', async () => {
render(<ImageBody {...props('photo.svg')} />)
const image = await screen.findByRole('img', { hidden: true })
expect(image.getAttribute('alt')).toBe('Image preview: photo.svg')
@@ -3,7 +3,7 @@ import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { DocumentPreviewRegistry } from '../src/client/document/registry.ts'
import { ImageBody } from '../src/client/image/ImageBody.tsx'
import { apply, IMAGE_BODY_ID, IMAGE_EXTENSIONS, imageBodyDefinition } from '../src/client/image/index.ts'
import { apply, BINARY_IMAGE_EXTENSIONS, IMAGE_BODY_ID, IMAGE_EXTENSIONS, imageBodyDefinition } from '../src/client/image/index.ts'
import { en, zh } from '../src/client/image/locales.ts'
let dispose: (() => Promise<void>) | undefined
@@ -16,6 +16,7 @@ describe('image registration', () => {
expect(definition).toEqual({
id: IMAGE_BODY_ID,
extensions: IMAGE_EXTENSIONS,
binaryExtensions: BINARY_IMAGE_EXTENSIONS,
priority: 'builtin',
title,
loading: 'bytes-complete',
@@ -0,0 +1,16 @@
// @vitest-environment jsdom
/** 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'
afterEach(cleanup)
describe('LoadingIndicator', () => {
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('')
expect(status.getAttribute('aria-label')).toBe('Reading…')
})
})
@@ -99,7 +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 />)
expect(screen.getByRole('status').textContent).toBe('Opening PDF…')
expect(screen.getByRole('status').getAttribute('aria-label')).toBe('Reading…')
expect(screen.getByRole('status').hasAttribute('data-document-loading')).toBe(true)
await act(async () => { loads[0]!.deferred.resolve(documentOf()) })
await act(async () => {})
@@ -245,7 +245,7 @@ describe('TextPreview — pages', () => {
const next = Promise.withResolvers<Awaited<ReturnType<typeof h.read>>>()
h.read.mockReturnValueOnce(next.promise)
click(view.container, '[data-textpreview-more]')
expect(view.getByRole('status').textContent).toBe('loading')
expect(view.getByRole('status').getAttribute('aria-label')).toBe('loading')
expect(lines(view.container)).toEqual(['held\n'])
await act(async () => { next.resolve(page(2, ['tail'], true)); await next.promise })
expect(view.queryByRole('status')).toBeNull()
@@ -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: 28e052b5a6739118c691431aad88964a8f6d19cd
README.zh.md: fe5a10d19b3957f555c08eeaf6f48ceb44daecaf
README.md: efaf0a584e0e8617d0cb97442f9ebb5fa1fe7b59
README.zh.md: a0d0abffcd6e9f4e979d9a6548537745d0a74ba6
+1 -1
View File
@@ -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), and `expanded`. 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
@@ -43,7 +43,7 @@ kind: "package-reference"
被端点条目上限截断的层以一条标记收尾;空层如实说明;失败的层按错误码各显示一行(`workspace-file/not-found`、`outside-workspace`、`not-directory`),其他情况显示传输层自己的消息。重新读取丢弃所有已列出的层并只对展开中的层重新请求;折叠的层在下次打开时重新拉取。没有工作目录的会话只显示一行说明,而不是树。
状态保存在类型自己的存储里,按 tab id 分桶:`root`、`levels`(每个绝对路径的 loading / ready / failed)与 `expanded`。owner 的 `signal` 终结一个桶:中止时忘掉该 tab,其后才结算的列表什么也不写。
状态保存在类型自己的存储里,按 tab id 分桶:`root`、`levels`(每个绝对路径的 loading / ready / failed)、`expanded` 与 `scrollTop`——滚动期间偏移由正文自己记录,卸载时一次性写入。存储比 body 活得久,切到其他侧栏 tab 再切回来时树带着已加载的层重新挂载,滚动位置也随之恢复。owner 的 `signal` 终结一个桶:中止时忘掉该 tab,其后才结算的列表与卸载时的偏移提交都什么也不写。
<a id="model-experience"></a>
## 模型体验
@@ -176,7 +176,26 @@ export function FilesBody({
const state = useStore(store => store.byTab[tab.id])
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
// and the stored offset re-lands exactly. A fresh tree stores 0.
const seeded = state !== undefined
useLayoutEffect(() => {
const body = bodyRef.current
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.
@@ -228,7 +247,12 @@ export function FilesBody({
</button>
</div>
{/* jscpd:ignore-end */}
<div className={css.body}>
<div
ref={bodyRef}
className={css.body}
data-files-body
onScroll={(event) => { scrollTopRef.current = event.currentTarget.scrollTop }}
>
<ul className={css.level}><Level path={state.root} tree={tree} /></ul>
</div>
</div>
@@ -1,6 +1,6 @@
/**
* The file tree's view state: which directories are expanded, and what each
* loaded level contains.
* The file tree's view state: which directories are expanded, what each
* loaded level contains, and where the body is scrolled to.
*
* The tree is not one resource. A directory listing per level, expanded lazily,
* is state the type owns — so it lives in a Slot-standard exclusive store
@@ -47,6 +47,8 @@ export interface FilesTabState {
levels: Record<string, LevelState>
/** Expanded absolute directory paths, root included. */
expanded: string[]
/** The body's scroll offset in px, so a remounted tree comes back where the reader was. */
scrollTop: number
}
/** Every tab's tree, keyed by tab id. */
@@ -74,6 +76,7 @@ type FilesActions = {
loaded: (draft: FilesState, tabId: TabId, path: string, level: DirLevel) => void
failed: (draft: FilesState, tabId: TabId, path: string, failure: RemoteFailure) => void
toggled: (draft: FilesState, tabId: TabId, path: string) => void
scrolled: (draft: FilesState, tabId: TabId, scrollTop: number) => void
reset: (draft: FilesState, tabId: TabId) => void
forget: (draft: FilesState, tabId: TabId) => void
}
@@ -96,7 +99,7 @@ export function createFilesStore(): EngineStoreHandle<FilesState, FilesActions>
* @param root - absolute path of the workspace root.
*/
start: (d, tabId: TabId, root: string) => {
d.byTab[tabId] = { root, levels: {}, expanded: [root] }
d.byTab[tabId] = { root, levels: {}, expanded: [root], scrollTop: 0 }
},
/**
* Mark one directory as being listed.
@@ -141,6 +144,15 @@ export function createFilesStore(): EngineStoreHandle<FilesState, FilesActions>
if (at >= 0) state.expanded.splice(at, 1)
else state.expanded.push(path)
},
/**
* Record where one tab's body is scrolled to.
* @param d - draft state.
* @param tabId - the tab being drawn.
* @param scrollTop - the body's scroll offset, in px.
*/
scrolled: (d, tabId: TabId, scrollTop: number) => {
bucket(d, tabId).scrollTop = scrollTop
},
/**
* Drop every loaded level, keeping what is expanded.
*
@@ -184,6 +184,28 @@ describe('FilesBody', () => {
expect(view.container.querySelector('[data-files-reload]')?.getAttribute('aria-label')).toBe(zh.reload)
})
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 } })
// 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 }))
@@ -53,6 +53,8 @@ export interface Mounted {
readonly face: FilesInjected
readonly controller: AbortController
readonly tabActions: MockedTabActions
/** Render a fresh body over the same store and face, as a tab switch remounts it. */
readonly remount: () => RenderResult
}
/** One store instance, one face, one owner share. */
@@ -96,5 +98,5 @@ function harness(cwd: string | null) {
export function mountBody(cwd: string | null = ROOT): Mounted {
const { shared, ...hands } = harness(cwd)
const view = render(<FilesBody {...shared as unknown as FilesBodyProps} />)
return { ...hands, view }
return { ...hands, view, remount: () => render(<FilesBody {...shared as unknown as FilesBodyProps} />) }
}
@@ -33,7 +33,7 @@ describe('createFilesStore', () => {
const { actions } = store
const getSnapshot = (): ReturnType<typeof store.getSnapshot> => store.getSnapshot()
actions.start(TAB, ROOT)
expect(getSnapshot().byTab[TAB]).toEqual({ root: ROOT, levels: {}, expanded: [ROOT] })
expect(getSnapshot().byTab[TAB]).toEqual({ root: ROOT, levels: {}, expanded: [ROOT], scrollTop: 0 })
})
it('walks one level through loading, ready, and failed', () => {
@@ -75,7 +75,15 @@ describe('createFilesStore', () => {
actions.toggled(TAB, child)
actions.loaded(TAB, child, LEVEL)
actions.reset(TAB)
expect(getSnapshot().byTab[TAB]).toEqual({ root: ROOT, levels: {}, expanded: [ROOT, child] })
expect(getSnapshot().byTab[TAB]).toEqual({ root: ROOT, levels: {}, expanded: [ROOT, child], scrollTop: 0 })
})
it('remembers where the body is scrolled to', () => {
const store = createFilesStore().create()
const { actions } = store
actions.start(TAB, ROOT)
actions.scrolled(TAB, 120)
expect(store.getSnapshot().byTab[TAB]!.scrollTop).toBe(120)
})
it('refuses to write a level for a tab that was never started', () => {
@@ -27,7 +27,7 @@
## PDF
- Viewer: PDF
- Viewer menu hidden: true
- Worker: dsh-pdf
- Continuous pages: 2
- Horizontal overflow: false
@@ -44,5 +44,10 @@
## Unknown suffix
- Viewer: Plain text
- Viewer menu hidden: true
- Text: UNKNOWN_SUFFIX | Plain fallback.
## Unviewable binary
- State: unsupported
- Line: Preview is not available for this file type yet.
@@ -1,5 +1,4 @@
- text: {{cwd}}/a.txt
- button "Open with": Plain text
- button "Line wrap" [pressed]:
- img
- button "Read the file again":