Merge pull request #59 from omdsh-dev/feat/plugin-api-v012
feat: 设置页「添加插件」推荐目录 + 复制安装命令;移除内置 Office 预览
This commit is contained in:
@@ -347,7 +347,7 @@ interface FileViewerProps {
|
||||
|---|---|---|---|
|
||||
| `none` | 不需要字节 | (无) | 自渲染(如纯 UI) |
|
||||
| `fsRead` | `/sidebar/api` 的 `fs.read` | `content`, `truncated` | 文本类(CSV/JSON/XML) |
|
||||
| `mediaUrl` | `/sidebar/file` 媒体路由 URL | `mediaUrl` | 图片/PDF/Office(viewer 自己 fetch 字节) |
|
||||
| `mediaUrl` | `/sidebar/file` 媒体路由 URL | `mediaUrl` | 图片/PDF(viewer 自己 fetch 字节) |
|
||||
| `custom` | viewer 的 `load()` 函数 | `customData` | 自定义协议(如远程拉取) |
|
||||
| `binary-download` | 不预览,显示下载按钮 | (无) | 无客户端渲染器的二进制格式 |
|
||||
|
||||
@@ -362,7 +362,7 @@ interface FileViewerProps {
|
||||
|
||||
> **head 字节从哪来**:第一次匹配(纯扩展名)没有 head。`fsRead` 策略读取后若文件为二进制,host 的 `fs.read` 响应会带 `head` 字段(base64,前 4KB),编辑器会用它对 `detect` viewer **重匹配一次**——所以 detect 型 viewer 的实际触发场景是"扩展名匹配落空/二进制文件"。文本文件的 detect 嗅探不在内置流程内(用 `exts` 或 `custom` 策略替代)。
|
||||
|
||||
> **内置 viewer**(不可重复注册,全部 9 个):image(0) / pdf(0) / docx(0) / xlsx(0) / pptx(0) / markdown(0, fsRead) / html(0, fsRead, 沙箱 iframe 预览) / code(-100, catch-all, fsRead) / binary-download(-50, exts doc/xls/ppt + NUL detect)。
|
||||
> **内置 viewer**(不可重复注册,全部 6 个):image(0) / pdf(0) / markdown(0, fsRead) / html(0, fsRead, 沙箱 iframe 预览) / code(-100, catch-all, fsRead) / binary-download(-50, exts doc/xls/ppt + NUL detect)。Office 三件套预览(.docx/.xlsx/.pptx)**不再内置**——已迁至推荐插件(设置页「添加插件」→ 文件预览弹窗里的 Office 预览插件),该插件以相同 id 经 `ctx.betterSidebar.registerFileViewer` 注册。
|
||||
> code 是兜底 viewer:任何其他 viewer 未认领的文件都会落到 code(CodeMirror 文本编辑);二进制文件经 head 重匹配被 binary-download 的 NUL detect 认领(下载按钮)。外部 viewer 注册同扩展名 + 更高 priority 即可覆盖。
|
||||
|
||||
### 4.5 注册示例
|
||||
@@ -517,7 +517,7 @@ interface OpenTabSeed {
|
||||
| **portal 限制** | 整面板 slot 由 ui-layout 独占,外部 tab 只能进入 better-sidebar 的 portal 内部,无法全屏替换 |
|
||||
| **id 冲突** | `registerTab` / `registerFileViewer` 对重复 id 抛错;建议用包前缀(`my-plugin:xxx`) |
|
||||
| **i18n 跟随** | 侧边栏界面文案跟随 DSH 的 `ctx.locale`(`@deepseek-ai/dsh-client-locale`):词典注册在 `betterSidebar` 命名空间,语言偏好(Host-backed `locale.preference`)与浏览器语言不一致时以 DSH 为准并实时切换;locale 服务缺失时回退浏览器语言。插件自身的 `t()`(`src/client/locales.ts`)由 `apply()` 挂接服务;消费插件**不要**依赖此内部函数——标题等字段传字符串或 `() => string` 即可(i18n 友好)。⚠️ 渲染 DSH 的 `MarkdownText` 时必须传 `codeLabels={{ copyLabel: t('copy'), copiedLabel: t('copied') }}`——该组件 cordis-free,漏传则代码块复制按钮回退硬编码中文 |
|
||||
| **懒加载 chunk** | 内置重依赖(Univer/xterm/CodeMirror/pptx/docx)在独立 bundle(`lib/client-<name>.js`)中,经 `/sidebar/bundle` 路由按需下发;每个脚本把 factory 赋到插件自有全局注册表 `globalThis.__dshChunks__[<name>]`,由 `src/client/chunk-loader.ts` 用自定义 require(externals 经 `__DSH_MODULES__` seed 分支解析)物化——**不经过** `__ModuleLoader__` 注册;**核心 bundle 禁止静态 import `src/client/chunks/*`**(会把库拖回启动路径);对消费插件透明——懒加载只作用于内置 descriptor,`component` 契约(`(props) => ReactNode` 纯渲染函数)不变 |
|
||||
| **懒加载 chunk** | 内置重依赖(xterm/CodeMirror)在独立 bundle(`lib/client-<name>.js`)中,经 `/sidebar/bundle` 路由按需下发;每个脚本把 factory 赋到插件自有全局注册表 `globalThis.__dshChunks__[<name>]`,由 `src/client/chunk-loader.ts` 用自定义 require(externals 经 `__DSH_MODULES__` seed 分支解析)物化——**不经过** `__ModuleLoader__` 注册;**核心 bundle 禁止静态 import `src/client/chunks/*`**(会把库拖回启动路径);对消费插件透明——懒加载只作用于内置 descriptor,`component` 契约(`(props) => ReactNode` 纯渲染函数)不变 |
|
||||
|
||||
---
|
||||
|
||||
@@ -601,11 +601,12 @@ function parseCsv(text: string): string[][] { /* ... */ }
|
||||
|
||||
better-sidebar 自己的内置 tab 和 viewer 就是参考实现("吃狗粮"):
|
||||
|
||||
- **`src/client/builtins/`**:7 个内置 tab(explorer/git/subagent/terminal/browser/editor/diff)+ 9 个内置 viewer(image/pdf/docx/xlsx/pptx/markdown/html/code/binary-download)的注册代码(tabs.tsx / viewers.tsx / index.ts)
|
||||
- **`src/client/builtins/`**:7 个内置 tab(explorer/git/subagent/terminal/browser/editor/diff)+ 6 个内置 viewer(image/pdf/markdown/html/code/binary-download)的注册代码(tabs.tsx / viewers.tsx / index.ts;Office 预览已迁至推荐插件,见 plugins-viewers.ts)
|
||||
- **`src/client/service.ts`**:`BetterSidebarService` 接口 + `createBetterSidebarService` 工厂实现
|
||||
- **`src/client/SideCardSection.tsx`**:声明式设置页(注册表驱动清单 + `settings.toggles` 嵌套设置行:switch/text/number + 持久化)
|
||||
- **`tests/service.spec.ts`**:注册表生命周期 / 匹配算法 / dedupe / createTab / 启用态 gating 测试
|
||||
- **`tests/builtins.spec.ts`**:内置注册清单断言(7 tab + 9 viewer + 声明式元数据)
|
||||
- **`docs/plans/2026-08-11-service-registry-design.md`** / **`docs/plans/2026-08-11-declarative-sidebar-settings-design.md`**:设计文档(含实施偏差记录)
|
||||
- **`tests/builtins.spec.ts`**:内置注册清单断言(7 tab + 6 viewer + 声明式元数据)
|
||||
- **`src/client/plugins-tabs.ts`** / **`src/client/plugins-viewers.ts`**:推荐插件目录(名字/url/简介/安装脚本,分别对应 Tab 注册与文件预览注册),在设置页两个「添加插件」弹窗展示(共享类型在 `plugins-shared.ts`);插件作者可按扩展点加一条数据(弹窗内「跳转」直达仓库、「复制」把安装命令写入剪贴板,粘贴到 DSH 所在环境的终端执行)——数据完整性由 `tests/plugin-list.spec.ts` 守护
|
||||
- **`docs/plans/2026-08-11-service-registry-design.md`** / **`docs/plans/2026-08-11-declarative-sidebar-settings-design.md`** / **`docs/plans/2026-08-14-add-plugins-modal-design.md`**:设计文档(含实施偏差记录)
|
||||
|
||||
调试时直接读这些文件即可看到所有 API 的真实用法。
|
||||
|
||||
@@ -26,7 +26,7 @@ https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86
|
||||
|
||||
- **🗂️ 资源管理器**:懒加载目录树(根 = 会话 cwd),点击打开、`@文件` 引用、右键复制路径
|
||||
- **📝 编辑与预览**:CodeMirror 6 编辑(Ctrl/Cmd+S 原子保存,切 Tab 不丢草稿);图片 / Markdown / HTML / PDF / Word / Excel / PPT 内联预览(HTML 走沙箱 iframe)
|
||||
- **⚡ 客户端懒加载**:启动只拉 ~325KB 核心,Office / 终端 / 编辑器等重依赖用到才按需拉取(详见 `docs/plans/2026-08-12-lazy-chunks-design.md`)
|
||||
- **⚡ 客户端懒加载**:启动只拉 ~325KB 核心,终端 / 编辑器等重依赖用到才按需拉取(详见 `docs/plans/2026-08-12-lazy-chunks-design.md`)
|
||||
- **🌐 浏览器**:多开内嵌网页 tab,后退/前进/刷新;内容运行在沙箱 iframe(无法访问界面数据与本地文件,拒绝本机地址),可临时解锁(红色警示);被站点拒绝嵌入时显示原因;外链默认在侧边栏打开
|
||||
- **💻 终端**:xterm.js + node-pty 真实 shell、断线重连回放;可选为模型注入 `terminal_*` 工具;支持自定义字体(字体族 + 9–32px 字号,实时生效)
|
||||
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存/提交/还原等
|
||||
@@ -37,6 +37,7 @@ https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86
|
||||
- **🔁 会话隔离**:布局 / Tab / 面板状态按会话持久化,陈旧状态自动净化;「产出文件」在侧边栏打开
|
||||
- **⚙️ 声明式设置**:设置页「侧边卡片」按注册表渲染开关网格,逐项独立开/关;二级设置(自动展开、终端工具、沙箱等)经齿轮弹窗编辑
|
||||
- **🔌 服务化(基座)**:暴露 `ctx.betterSidebar`,其他插件可注册 tab 与文件预览器(内置 7 tab + 9 viewer 同走一服务);v0.12.0 起支持角标/生命周期回调/状态订阅/定向打开/插件自有设置(见 [AGENTS.md](./AGENTS.md) 与 [外部插件接入指南](./docs/external-plugin-guide.md))
|
||||
- **➕ 添加插件**:设置页「侧边卡片」两个网格(侧边栏内容 / 文件预览)末尾的虚线卡片分别打开 Tab / 预览插件弹窗:声明扩展点、「在 GitHub 上浏览更多插件」按钮、推荐插件目录(「跳转」直达仓库、「复制」安装命令到剪贴板);弹窗不打开终端、无失败路径,不阻塞启动与侧边栏
|
||||
- **🌏 多语言**:界面文案跟随 DSH 语言(zh/en)实时切换,无需刷新
|
||||
|
||||
## 🚀 安装
|
||||
@@ -220,6 +221,15 @@ v0.12.0 起补齐基座能力:类型导出完整(消费者可直接命名 `S
|
||||
- **[`AGENTS.md`](./AGENTS.md)**——仓库内维护的接入文档(全字段、匹配算法、HMR 陷阱、声明式设置、版本探测);
|
||||
- **[`docs/external-plugin-guide.md`](./docs/external-plugin-guide.md)**——面向外部插件开发者的接入指南(含完整最小示例)。
|
||||
|
||||
### ➕ 添加插件(推荐插件目录)
|
||||
|
||||
设置页「侧边卡片」→「侧边栏内容」网格末尾的**虚线卡片**打开「添加 Tab 插件」弹窗、「文件预览」网格末尾的**虚线卡片**打开「添加预览插件」弹窗:各自声明对应扩展点可由插件扩展(`ctx.betterSidebar` 服务)、提供「**在 GitHub 上浏览更多插件**」按钮(新标签页打开 [GitHub topic `dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar))、并展示对应 kind 的推荐插件目录(名字 / 仓库链接 / 简介 / 安装脚本)。每个条目两个按钮:
|
||||
|
||||
- **跳转**:新标签页直达插件仓库;
|
||||
- **复制**:把安装命令(`cd ~/.dsh && dsh plugin --profile web add <包名>`)写入剪贴板,按钮闪现「已复制」——粘贴到 DSH 所在环境的终端执行即可。弹窗保持打开、不打开终端、无失败路径。
|
||||
|
||||
**收录新插件**:按扩展点把一条 `PluginEntry`(`id` = npm 包名、`name`、`url`、`description`(i18n 友好,可在 `src/client/locales.ts` 加 `pluginXxxDesc` 键)、`install` = 完整安装命令)追加到 [`src/client/plugins-tabs.ts`](./src/client/plugins-tabs.ts)(Tab 注册)或 [`src/client/plugins-viewers.ts`](./src/client/plugins-viewers.ts)(文件预览注册),并把仓库打上 `dsh-better-sidebar` topic;数据完整性由 `tests/plugin-list.spec.ts` 守护。
|
||||
|
||||
## 🛠️ 开发与构建
|
||||
|
||||
```sh
|
||||
@@ -242,7 +252,7 @@ pnpm watch # tsdown --watch
|
||||
|
||||
- Git 无 push/pull/fetch;无文件 watcher(手动刷新);工具行内文件打开按钮不可拦截
|
||||
- 终端 Tab 拖到另一分栏会重挂载(shell 重开)
|
||||
- `.xlsx` 预览不保留单元格样式(SheetJS 社区版限制);Office/PPTX 预览内联进 client bundle(约 23MB),首次加载较慢
|
||||
- Office 三件套预览(.docx/.xlsx/.pptx)已移至「推荐插件」(Office 预览插件,见设置页「添加插件」弹窗);未安装时此类文件走代码/下载查看兜底
|
||||
- 浏览器沙箱无登录态/第三方 Cookie 受限,部分站点登录需走弹窗;被 `X-Frame-Options`/`frame-ancestors` 拒绝嵌入的站点(如 arxiv.org)显示原因面板(含「在浏览器中打开」);iframe 内部跳转不进后退栈
|
||||
- HTML 预览渲染的是已保存文件(不反映未保存草稿)
|
||||
- 移动端(<768px)无底部面板:进入窄屏时其标签页一次性并入右侧栏(迁移后回桌面仍保留在右侧栏),桌面端的底部面板只在宽视口下可用;移动端底部首展自动开终端不触发
|
||||
|
||||
+12
-2
@@ -27,7 +27,7 @@ https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86
|
||||
|
||||
- **🗂️ File Explorer**: lazy-loading directory tree (root = session cwd), click to open, `@file` reference, right-click to copy path
|
||||
- **📝 Edit & Preview**: CodeMirror 6 editing (Ctrl/Cmd+S atomic save, drafts survive tab switches); inline preview for images / Markdown / HTML / PDF / Word / Excel / PPT (HTML in a sandboxed iframe)
|
||||
- **⚡ Client-side Lazy Loading**: only ~325KB core at startup; heavy deps (Office / terminal / editor) load on demand (see `docs/plans/2026-08-12-lazy-chunks-design.md`)
|
||||
- **⚡ Client-side Lazy Loading**: only ~325KB core at startup; heavy deps (terminal / editor) load on demand (see `docs/plans/2026-08-12-lazy-chunks-design.md`)
|
||||
- **🌐 Browser**: multiple embedded web tabs, back/forward/refresh; pages run in a sandboxed iframe (no access to UI data or local files, rejects local addresses), temporarily unlockable (red warning); sites refusing embedding show a reason panel; external links open in the sidebar by default
|
||||
- **💻 Terminal**: xterm.js + node-pty real shell, reconnect with transcript replay; optionally injects `terminal_*` tools for the model; custom font (family + 9–32px size, applied live)
|
||||
- **🌿 Git Panel**: real diff + VSCode-style diff tabs, history, right-click to stage/commit/revert etc.
|
||||
@@ -38,6 +38,7 @@ https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86
|
||||
- **🔁 Session Isolation**: layout / tabs / panel states persisted per session, stale state auto-purged; "produced files" open in the sidebar
|
||||
- **⚙️ Declarative Settings**: the "Side Cards" settings section renders a registry-driven toggle grid, each item independently switchable; secondary settings (auto-expand, terminal tools, sandbox, etc.) edited in a native dialog via the gear button
|
||||
- **🔌 Service API**: exposes `ctx.betterSidebar` — other plugins can register tabs and file viewers (the 7 built-in tabs + 9 viewers share the same service, see [AGENTS.md](./AGENTS.md) and the [external plugin guide](./docs/external-plugin-guide.md))
|
||||
- **➕ Add Plugins**: dashed cards at the end of both settings grids ("Sidebar content" / "File viewers") open tab / previewer plugin modals: the extension point declaration, a "Browse more plugins on GitHub" BUTTON, and the recommended plugin catalog ("Open" jumps to the repo, "Copy" puts the install command on the clipboard); no terminal is opened, nothing can fail — never blocking startup or the sidebar
|
||||
- **🌏 i18n**: UI text follows DSH's language setting (zh/en) with live switching, no refresh needed
|
||||
|
||||
## 🚀 Installation
|
||||
@@ -221,6 +222,15 @@ Full integration docs:
|
||||
- **[`AGENTS.md`](./AGENTS.md)** — the in-repo integration doc (full fields, matching algorithm, HMR pitfalls, declarative settings, version detection);
|
||||
- **[`docs/external-plugin-guide.md`](./docs/external-plugin-guide.md)** — the external-plugin guide (with a complete minimal example).
|
||||
|
||||
### ➕ Add Plugins (recommended plugin catalog)
|
||||
|
||||
The dashed card at the end of the "Sidebar content" grid in the "Side Cards" settings section opens the **Add tab plugins** modal; the one at the end of the "File viewers" grid opens the **Add preview plugins** modal. Each declares that its extension point is open to plugins (via the `ctx.betterSidebar` service), offers a "**Browse more plugins on GitHub**" button (opens the [GitHub topic `dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar) in a new tab), and lists the recommended catalog of its kind (name / repo URL / description / install script). Each entry has two buttons:
|
||||
|
||||
- **Open**: jumps to the plugin repo in a new browser tab;
|
||||
- **Copy**: writes the install command (`cd ~/.dsh && dsh plugin --profile web add <package>`) to the clipboard with a transient "Copied" feedback — paste and run it in a terminal where your DSH profile lives. The modal stays open; nothing is opened, nothing can fail.
|
||||
|
||||
**Curating a new plugin**: append a `PluginEntry` to [`src/client/plugins-tabs.ts`](./src/client/plugins-tabs.ts) (tab registrations) or [`src/client/plugins-viewers.ts`](./src/client/plugins-viewers.ts) (file-previewer registrations) — `id` = npm package name, `name`, `url`, `description` (i18n-friendly; add a `pluginXxxDesc` key in `src/client/locales.ts` if needed), `install` = the full install command — and tag your repo with the `dsh-better-sidebar` topic; data integrity is guarded by `tests/plugin-list.spec.ts`.
|
||||
|
||||
## 🛠️ Development & Build
|
||||
|
||||
```sh
|
||||
@@ -243,7 +253,7 @@ pnpm watch # tsdown --watch
|
||||
|
||||
- Git has no push/pull/fetch; no file watcher (manual refresh); tool inline file-open buttons cannot be intercepted
|
||||
- Dragging a terminal tab to another pane remounts it (shell restarts)
|
||||
- `.xlsx` preview does not preserve cell styles (SheetJS community-edition limitation); Office/PPTX preview is inlined into the client bundle (~23MB), slower on first load
|
||||
- Office-suite preview (.docx/.xlsx/.pptx) moved to the recommended office plugin (see the "Add plugins" modals in settings); without it these files fall through to the code/download fallbacks
|
||||
- Browser sandbox has no login state / third-party cookies are restricted; some sites need popup login; sites that refuse embedding via `X-Frame-Options`/`frame-ancestors` (e.g. arxiv.org) show a reason panel (with "Open in browser"); in-iframe navigation does not enter the back stack
|
||||
- HTML preview renders the saved file (not unsaved drafts)
|
||||
- No bottom panel on mobile (<768px): on narrow screens its tabs merge into the right sidebar once (after migrating back to desktop they stay in the right sidebar); the desktop bottom panel is only available on wide viewports; auto-open terminal on first bottom-panel expand does not trigger on mobile
|
||||
|
||||
@@ -14,7 +14,7 @@ better-sidebar 从 v0.4.0 起把自己改造成一个**注册表服务**:
|
||||
- **新页面(tab)**:注册一种新的侧边栏 tab 类型,出现在侧边栏 `+` 菜单里,用户点击后在自己的分栏里打开你的 React 页面;
|
||||
- **文件预览器(file viewer)**:注册一种文件类型预览器,让用户在侧边栏打开文件时走你的渲染组件(覆盖或补充内置的 image/pdf/code 等)。
|
||||
|
||||
内置的 7 个 tab(explorer / git / subagent / terminal / browser / editor / diff)和 9 个 viewer(image / pdf / docx / xlsx / pptx / markdown / html / code / binary-download)**自己也是通过同一套 API 注册的**(吃自己的狗粮),所以外部插件的能力与内置功能完全对等。
|
||||
内置的 7 个 tab(explorer / git / subagent / terminal / browser / editor / diff)和 6 个 viewer(image / pdf / markdown / html / code / binary-download)**自己也是通过同一套 API 注册的**(吃自己的狗粮),所以外部插件的能力与内置功能完全对等。
|
||||
|
||||
关键机制一句话:better-sidebar 的 client half 在 `apply()` 开头执行 `ctx.provide('betterSidebar', service)`(`src/client/index.tsx`),消费插件在 `inject` 里声明 `'betterSidebar'`,Cordis 保证服务就绪后才激活你的插件,然后你调用 `ctx.betterSidebar.registerTab(...)` / `registerFileViewer(...)` 完成注册,返回的 disposer 由 Cordis fiber 在卸载(HMR / 禁用)时自动调用。
|
||||
|
||||
@@ -390,7 +390,7 @@ interface FileViewerProps {
|
||||
|---|---|---|---|
|
||||
| `none` | 不需要字节 | (无) | 自渲染(如纯 UI) |
|
||||
| `fsRead` | `/sidebar/api` 的 `fs.read` | `content`, `truncated` | 文本类(CSV/JSON/XML) |
|
||||
| `mediaUrl` | `/sidebar/file` 媒体路由 URL | `mediaUrl` | 图片/PDF/Office(viewer 自己 fetch 字节) |
|
||||
| `mediaUrl` | `/sidebar/file` 媒体路由 URL | `mediaUrl` | 图片/PDF(viewer 自己 fetch 字节) |
|
||||
| `custom` | viewer 的 `load()` 函数 | `customData` | 自定义协议(如远程拉取) |
|
||||
| `binary-download` | 不预览,显示下载按钮 | (无) | 无客户端渲染器的二进制格式 |
|
||||
|
||||
@@ -405,7 +405,7 @@ interface FileViewerProps {
|
||||
|
||||
> **head 字节从哪来**:第一次匹配(纯扩展名)没有 head。`fsRead` 策略读取后若文件为二进制,host 的 `fs.read` 响应会带 `head` 字段(base64,前 4KB,`src/index.ts` 的 `READ_HEAD_LIMIT`),编辑器会用它对 `detect` viewer **重匹配一次**——所以 detect 型 viewer 的实际触发场景是"扩展名匹配落空/二进制文件"。文本文件的 detect 嗅探不在内置流程内(用 `exts` 或 `custom` 策略替代)。
|
||||
|
||||
> **内置 viewer**(不可重复注册,全部 9 个):image(0) / pdf(0) / docx(0) / xlsx(0) / pptx(0) / markdown(0, fsRead) / html(0, fsRead, 沙箱 iframe 预览) / code(-100, catch-all, fsRead) / binary-download(-50, exts doc/xls/ppt + NUL detect)。
|
||||
> **内置 viewer**(不可重复注册,全部 6 个):image(0) / pdf(0) / markdown(0, fsRead) / html(0, fsRead, 沙箱 iframe 预览) / code(-100, catch-all, fsRead) / binary-download(-50, exts doc/xls/ppt + NUL detect)。Office 三件套预览(.docx/.xlsx/.pptx)**不再内置**——已迁至推荐插件(设置页「添加插件」→ 文件预览弹窗里的 Office 预览插件)。
|
||||
> code 是兜底 viewer:任何其他 viewer 未认领的文件都会落到 code(CodeMirror 文本编辑);二进制文件经 head 重匹配被 binary-download 的 NUL detect 认领(下载按钮)。外部 viewer 注册同扩展名 + 更高 priority 即可覆盖。
|
||||
|
||||
### 5.5 注册示例
|
||||
@@ -761,7 +761,7 @@ function parseCsv(text: string): string[][] { /* ... */ }
|
||||
|
||||
better-sidebar 的内置 tab 和 viewer 就是参考实现("吃狗粮"):
|
||||
|
||||
- **`src/client/builtins/`**:7 个内置 tab(tabs.tsx)+ 9 个内置 viewer(viewers.tsx)的注册代码 + 聚合与 disposer 生命周期(index.ts)
|
||||
- **`src/client/builtins/`**:7 个内置 tab(tabs.tsx)+ 6 个内置 viewer(viewers.tsx)的注册代码 + 聚合与 disposer 生命周期(index.ts)
|
||||
- **`src/client/service.ts`**:`BetterSidebarService` 接口 + `createBetterSidebarService` 工厂实现(含匹配算法、dedupe、createTab、启用态 gating)
|
||||
- **`src/client/Sidebar.tsx`**:`TabContent` 分发(查 `getTab` → 调 descriptor.component;未注册 → `<OrphanedTab/>`)、`+` 菜单构建(order 排序 + available disabled + 禁用过滤)
|
||||
- **`src/client/SideCardSection.tsx`**:声明式设置页(注册表驱动清单 + `settings.toggles` 嵌套开关 + 开关持久化)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# 「添加插件」弹窗 + 推荐插件列表 + 复制安装命令 设计
|
||||
|
||||
**日期**:2026-08-14
|
||||
**状态**:已批准,已实施
|
||||
**作者**:opencode + 用户
|
||||
**当前版本**:v0.12.0(分支 `feat/plugin-api-v012`,已合入 main 后 rebase)
|
||||
**目标版本**:v0.12.0(不 bump)
|
||||
|
||||
## 1. 目标
|
||||
|
||||
1. 为「侧边栏内容(Tab)」与「文件预览(viewer)」两类扩展点分别增加**添加入口**:DSH 设置页「Side card」分区的两个响应式网格(侧边栏内容 / 文件预览)末尾各新增一个与其他卡片同尺寸的**虚线边框**添加卡片(「添加 Tab 插件」/「添加预览插件」)。
|
||||
2. 点击后弹出对应 kind 的原生 Modal:声明该类扩展点可由插件扩展(`ctx.betterSidebar` 服务),提供「在 GitHub 上浏览更多插件」**按钮**(打开 `https://github.com/topics/dsh-better-sidebar`,新标签页),并展示仓库**推荐**的同 kind 插件列表(名字 / url / 简介 / 安装脚本)。
|
||||
3. 每个条目两个动作:**「跳转」按钮**(`window.open` 新标签页直达插件仓库,绕过链接接管)与**「复制」按钮**——点击只把安装脚本写入剪贴板(`writeClipboard`,按钮闪现「已复制」反馈),用户自行粘贴到 DSH 所在环境的终端执行。
|
||||
4. **不关闭任何窗口、不打开终端、无失败路径**:复制是纯 client 动作,不依赖会话/终端/配额,弹窗保持打开可连续复制多个插件。
|
||||
5. 推荐列表为仓库静态数据、**分为两个文件**(`plugins-tabs.ts` Tab 注册目录 / `plugins-viewers.ts` 文件预览注册目录),初始在 viewer 目录收录唯一真实三方插件 `@huanlin/dsh-plugin-better-sidebar-plugin-office`(简介不注版本前提);后续插件只加一条数据。
|
||||
6. 内置 Office 预览(docx/xlsx/pptx)**从本体移除**——已迁至上述推荐插件(见偏差记录),`docx/xlsx/pptx` 文件在未装插件时落 code/下载兜底。
|
||||
|
||||
## 2. 非目标(Out of Scope)
|
||||
|
||||
- **不打开终端**:不做终端预填安装流(曾实现的 `OpenTabSeed.expand`、`TerminalSeedMeta` 预填/提示条、service meta 透传等已全部删除回滚)。
|
||||
- 不加 + 菜单入口、不加编辑器空态入口(入口只在设置页)。
|
||||
- 不做插件在线拉取 / 动态发现(列表为仓库推荐静态数据)。
|
||||
- 不改 host 半的服务契约(`service.ts` 相对 v0.12.0 无 API 变化;host 半仅在 office 移除时缩减了 chunk 白名单)。
|
||||
|
||||
## 3. 实施要点
|
||||
|
||||
### 3.1 数据层:两个目录文件 + 共享词汇
|
||||
|
||||
- `src/client/plugins-shared.ts`:`PluginEntry { id, name, url, description, install }` + `PLUGIN_TOPIC_URL`。
|
||||
- `src/client/plugins-tabs.ts`:`builtinTabPlugins`(当前为空,空态展示)。
|
||||
- `src/client/plugins-viewers.ts`:`builtinViewerPlugins`(种子:office 插件,install = `cd ~/.dsh && dsh plugin --profile web add @huanlin/dsh-plugin-better-sidebar-plugin-office`)。
|
||||
|
||||
### 3.2 UI 层:`src/client/add-plugin-modal.tsx` + `SideCardSection`
|
||||
|
||||
- 两个网格末尾各一个虚线卡片(复用 `.card` 配方 + `IconPlusOutline16`),`addPluginsOpen: PluginKind | null` 状态;Modal 仅在打开时条件挂载(Modal 原语无条件跑 hooks,SSR 规则同 settingsFor)。
|
||||
- `PluginListBody`(提取组件,直接可测):topic 按钮(`window.open`)+「推荐插件」分组(`groupHeading` + count)+ 条目(名字链接 / 简介 / mono 安装命令 / 跳转按钮 / 复制按钮)+ 空态。
|
||||
- **复制**:`void writeClipboard(entry.install)` + `copiedId` 状态闪现 `t('copied')`(1.5s 后还原)。不关闭、不打开、不抛错。
|
||||
|
||||
### 3.3 i18n
|
||||
|
||||
新增键:`addPluginsTabCard/TabCardDesc/ViewerCard/ViewerCardDesc/TabDesc/ViewerDesc`、`addPluginsBrowseMore`、`addPluginsRecommended`(推荐插件)、`addPluginsEmpty`、`openPlugin`(跳转)、`copyInstall`(复制安装命令,按钮 aria-label);复用 `copy`/`copied`/`close`/`settingsDone`。已删除废弃键:`install`、`installNotice`、`installError*`、office 相关键(`viewerDocx/Xlsx/Pptx`、`officeTooLarge/Corrupt/Encrypted/LoadFailed`、`previousSlide/nextSlide/zoom/zoomHint`)。
|
||||
|
||||
### 3.4 内置 Office 预览移除(与推荐插件衔接)
|
||||
|
||||
删除 3 个 viewer 描述符 + 10 个源文件(docx-view/xlsx-view/PptxView/office-view/office-shared/office-types/xlsx-to-univer + chunks/docx|xlsx|pptx)+ 3 个图标 + office CSS + office locale 键;chunk 白名单 5→2(terminal/editor,同步 `bundle-route.ts` / `chunk-loader.ts` / `tsdown.config.ts` / `scripts/package-registry.mjs`);`tests/builtins.spec.ts` 断言 6 个 viewer 且不含 office id。`.docx`(zip、无 NUL)未装插件时落 code 兜底。
|
||||
|
||||
## 4. 边界情况与失败模式
|
||||
|
||||
- **复制失败**:`writeClipboard` 尽力而为(navigator.clipboard → execCommand 兜底),失败静默;按钮反馈照常闪现。无会话/无终端/配额等一切前置条件均不涉及——复制与它们无关。
|
||||
- **空目录**:Tab 目录当前为空,弹窗渲染空态 + topic 按钮。
|
||||
- **版本号**:v0.12.0(`package.json` / `dsh.plugin.json` / `service.ts` 的 `SIDEBAR_SERVICE_VERSION` 三处一致,manifest-consistency 测试守护)。
|
||||
|
||||
## 5. 实施偏差记录
|
||||
|
||||
- **评审修改(第一批)**:入口由 + 菜单/编辑器空态改为**仅设置页虚线卡片**;安装命令并入「安装按钮」单一交互。
|
||||
- **评审修改(第二批)**:① topic 链接改为**按钮**(`window.open` 新标签页);② 添加按钮拆为**两个**(Tab 注册 / 文件预览注册),列表数据拆为**两个文件**;③ 安装错误只内联显示在设置内(预检 + try/catch);④ 终端提示条用品牌高亮色。
|
||||
- **评审修改(第三批)**:内置 Office 预览整体移除(迁至推荐插件);「内置插件」全部改名**「推荐插件」**。
|
||||
- **评审修改(第四批,最终形态)**:设置窗口关闭问题无法根治(openTab 的 store 涟漪会复活弹窗),且用户实测弹窗关闭不可靠——**放弃终端安装流**:安装按钮改为**只复制安装脚本**(`writeClipboard` + 「已复制」反馈),弹窗保持打开;随之删除整个终端预填流:`terminal-meta.ts`、`TerminalView` 的 meta/预填/提示条、`tabs.tsx` 的 meta 透传与 `uiTerminalCount` 导出、`service.ts` 的 `OpenTabSeed.expand` 与 createTab meta 透传(全部回滚到 v0.12.0 原始 API 面)、`.terminalNotice` CSS、相关测试与文档;`install`/`installNotice`/`installError*` 文案键删除,新增 `copyInstall`。
|
||||
- 测试中 `AddPluginModal` 不能 renderToString(React 18 server renderer 拒绝 portal),改为 createRoot + act 挂载后断言 `document.body`(portal 落点)。
|
||||
|
||||
## 6. 验收清单
|
||||
|
||||
- [x] 设置页两个网格末尾各出现虚线边框添加卡片,尺寸与既有卡片一致
|
||||
- [x] 两个弹窗(kind 区分):声明文案 + GitHub topic **按钮** + 对应推荐插件条目(名字/url/简介/安装命令/跳转按钮/复制按钮)
|
||||
- [x] 点「复制」:安装脚本写入剪贴板,按钮闪现「已复制」,弹窗不关闭、不打开终端、无失败路径
|
||||
- [x] 点「跳转」:`window.open` 新标签页直达插件仓库
|
||||
- [x] 内置 Office 预览已移除;chunk 白名单 2 个;无「内置插件」残留文案
|
||||
- [x] 版本号 v0.12.0 三处一致;`pnpm typecheck` / `pnpm test` / `pnpm build` 全绿
|
||||
- [x] README/README_EN/AGENTS.md 更新到位
|
||||
@@ -58,9 +58,6 @@
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/client-registry.js",
|
||||
"lib/client-docx.js",
|
||||
"lib/client-xlsx.js",
|
||||
"lib/client-pptx.js",
|
||||
"lib/client-terminal.js",
|
||||
"lib/client-editor.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
@@ -110,7 +107,6 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@aiden0z/pptx-renderer": "^1.2.4",
|
||||
"@codemirror/commands": "^6.10.4",
|
||||
"@codemirror/lang-cpp": "^6.0.3",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
@@ -132,16 +128,12 @@
|
||||
"@codemirror/state": "^6.7.1",
|
||||
"@codemirror/view": "^6.43.8",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@univerjs/preset-sheets-core": "^0.25.1",
|
||||
"@univerjs/presets": "^0.25.1",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"clsx": "^2.1.1",
|
||||
"docx-preview": "^0.4.0",
|
||||
"node-pty": "^1.1.0",
|
||||
"rxjs": "^7.8.2",
|
||||
"schemastery": "^3.18.0",
|
||||
"ws": "^8.18.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"xterm": "^5.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Generated
+2
-3402
File diff suppressed because it is too large
Load Diff
@@ -31,12 +31,6 @@ const files = [
|
||||
'lib/client-registry.js.map',
|
||||
// Lazy chunks: the client fetches these from the host's /sidebar/bundle
|
||||
// route on first use (see src/client/chunk-loader.ts / src/bundle-route.ts).
|
||||
'lib/client-docx.js',
|
||||
'lib/client-docx.js.map',
|
||||
'lib/client-xlsx.js',
|
||||
'lib/client-xlsx.js.map',
|
||||
'lib/client-pptx.js',
|
||||
'lib/client-pptx.js.map',
|
||||
'lib/client-terminal.js',
|
||||
'lib/client-terminal.js.map',
|
||||
'lib/client-editor.js',
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import type { Context, SidebarHttpRequest, SidebarHttpResponse } from './context-types.ts'
|
||||
|
||||
/** The chunk names the client may request (mirror of src/client/chunk-loader.ts). */
|
||||
export const CHUNK_NAMES = ['docx', 'xlsx', 'pptx', 'terminal', 'editor'] as const
|
||||
export const CHUNK_NAMES = ['terminal', 'editor'] as const
|
||||
export type ChunkName = (typeof CHUNK_NAMES)[number]
|
||||
|
||||
/** Directory of this host-half module (lib/ — the chunk scripts live next to it). */
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
/** High-fidelity browser-native PPTX preview with slide navigation. */
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { downloadUrl, mediaUrl, type SessionScope } from './api.ts'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
type PptxViewerInstance = import('@aiden0z/pptx-renderer').PptxViewer
|
||||
|
||||
type LoadState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'ready'; count: number }
|
||||
| { status: 'error'; message: string }
|
||||
|
||||
export function PptxView(props: { scope: SessionScope; path: string; title: string }) {
|
||||
const { scope, path, title } = props
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const viewerRef = useRef<PptxViewerInstance | null>(null)
|
||||
const [load, setLoad] = useState<LoadState>({ status: 'loading' })
|
||||
const [slide, setSlide] = useState(0)
|
||||
const [navigating, setNavigating] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
const host = hostRef.current
|
||||
if (host === null) return
|
||||
setLoad({ status: 'loading' })
|
||||
setSlide(0)
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch(mediaUrl(scope, path), { signal: controller.signal })
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
const bytes = await response.arrayBuffer()
|
||||
if (controller.signal.aborted) return
|
||||
const { PptxViewer, RECOMMENDED_ZIP_LIMITS } = await import('@aiden0z/pptx-renderer')
|
||||
if (controller.signal.aborted) return
|
||||
const viewer = await PptxViewer.open(bytes, host, {
|
||||
renderMode: 'slide',
|
||||
fitMode: 'contain',
|
||||
lazyMedia: true,
|
||||
lazySlides: true,
|
||||
pdfjs: false,
|
||||
signal: controller.signal,
|
||||
zipLimits: RECOMMENDED_ZIP_LIMITS,
|
||||
onSlideChange: (index) => {
|
||||
if (!controller.signal.aborted) setSlide(index)
|
||||
},
|
||||
})
|
||||
if (controller.signal.aborted) {
|
||||
viewer.destroy()
|
||||
return
|
||||
}
|
||||
viewerRef.current = viewer
|
||||
setSlide(viewer.currentSlideIndex)
|
||||
setLoad({ status: 'ready', count: viewer.slideCount })
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return
|
||||
try {
|
||||
viewerRef.current?.destroy()
|
||||
} catch {
|
||||
// Partially initialized viewer — ignore disposal errors.
|
||||
}
|
||||
viewerRef.current = null
|
||||
host.innerHTML = ''
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
controller.abort()
|
||||
try {
|
||||
viewerRef.current?.destroy()
|
||||
} catch {
|
||||
// Already destroyed.
|
||||
}
|
||||
viewerRef.current = null
|
||||
host.innerHTML = ''
|
||||
}
|
||||
}, [scope.sessionId, scope.cwd, path])
|
||||
|
||||
const navigate = (target: number): void => {
|
||||
const viewer = viewerRef.current
|
||||
if (viewer === null || load.status !== 'ready' || navigating) return
|
||||
const next = Math.max(0, Math.min(load.count - 1, target))
|
||||
if (next === slide) return
|
||||
setNavigating(true)
|
||||
void viewer.goToSlide(next, { behavior: 'auto' }).then(() => {
|
||||
setSlide(viewer.currentSlideIndex)
|
||||
}).catch((error: unknown) => {
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
}).finally(() => {
|
||||
setNavigating(false)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.editorPptx} aria-label={title}>
|
||||
<div className={css.editorPptxToolbar}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.editorPptxButton}
|
||||
disabled={load.status !== 'ready' || slide <= 0 || navigating}
|
||||
onClick={() => { navigate(slide - 1) }}
|
||||
>
|
||||
{t('previousSlide')}
|
||||
</button>
|
||||
<span className={css.editorPptxPosition}>
|
||||
{load.status === 'ready' ? `${slide + 1} / ${load.count}` : '– / –'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.editorPptxButton}
|
||||
disabled={load.status !== 'ready' || slide >= load.count - 1 || navigating}
|
||||
onClick={() => { navigate(slide + 1) }}
|
||||
>
|
||||
{t('nextSlide')}
|
||||
</button>
|
||||
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
|
||||
{t('downloadToView')}
|
||||
</a>
|
||||
</div>
|
||||
<div className={css.editorPptxStage}>
|
||||
<div className={css.editorPptxHost} ref={hostRef} />
|
||||
{load.status !== 'ready' && (
|
||||
<div className={css.editorOfficeOverlay}>
|
||||
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
||||
{load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -198,6 +198,38 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* The "add plugin" card: same grid cell / chrome as the inventory cards
|
||||
(it shares .card), but a DASHED border — the repo's first dashed surface;
|
||||
the hover accent follows the existing drag-drop dashed outlines
|
||||
(sidebar.module.css .dropCenter / .tabBarDrop). */
|
||||
.addCard {
|
||||
align-items: flex-start;
|
||||
padding: 12px;
|
||||
border-style: dashed;
|
||||
border-color: var(--dsw-alias-border-l2);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.addCard:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
border-color: var(--dsw-alias-interactive-bg-hover-accent);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.addCard:hover .cardTitle {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.addCard:hover .cardIconChip {
|
||||
border-color: color-mix(in srgb, var(--dsw-alias-button-primary-fill) 35%, transparent);
|
||||
color: var(--dsw-alias-button-primary-fill);
|
||||
}
|
||||
|
||||
.addCard:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-border-l4);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.cardOn .cardDesc {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
@@ -412,6 +444,189 @@
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* ── "Add plugin" modal ──────────────────────────────────────────────── */
|
||||
|
||||
/* A wide dialog like the settings popup (doubled class keeps the width
|
||||
rule above the primitive's .dialog regardless of stylesheet order). */
|
||||
.pluginModal.pluginModal {
|
||||
width: min(560px, 100%);
|
||||
}
|
||||
|
||||
.pluginList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* The GitHub topic entry: a full-width secondary BUTTON (not a link — it
|
||||
opens a real new browser tab via window.open, bypassing the sidebar
|
||||
link takeover). */
|
||||
.pluginTopicBtn {
|
||||
width: 100%;
|
||||
appearance: none;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 8px;
|
||||
padding: 6px 12px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pluginTopicBtn:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
border-color: var(--dsw-alias-interactive-bg-hover-accent);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.pluginTopicBtn:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-border-l4);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.pluginEmpty {
|
||||
padding: 20px 2px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.pluginEntries {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* One catalog entry: name + install button on the head row, description,
|
||||
then the install command in a mono block (the exact script that gets
|
||||
pre-typed into the terminal — review it here before installing). */
|
||||
.pluginEntry {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
}
|
||||
|
||||
.pluginEntryHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pluginEntryActions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* The per-entry "jump" button: opens the plugin repo in a REAL new browser
|
||||
tab (window.open — a button, so the sidebar link takeover cannot reroute
|
||||
it). Secondary style, quieter than the Install button. */
|
||||
.pluginJumpBtn {
|
||||
flex: none;
|
||||
appearance: none;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 8px;
|
||||
padding: 3px 12px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.pluginJumpBtn:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
border-color: var(--dsw-alias-interactive-bg-hover-accent);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.pluginJumpBtn:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-border-l4);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* The plugin name: a BUTTON (not an anchor — an anchor would be caught by
|
||||
the sidebar link takeover at document capture, ignoring target=_blank)
|
||||
styled like a link; opens the repo in a real new browser tab. */
|
||||
.pluginName {
|
||||
appearance: none;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pluginName:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--dsw-alias-button-primary-fill);
|
||||
}
|
||||
|
||||
.pluginDesc {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.pluginInstall {
|
||||
display: block;
|
||||
padding: 6px 10px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The per-entry copy button: copies the install script to the
|
||||
clipboard (the settings primary-button recipe, smaller than .done). */
|
||||
.pluginCopyBtn {
|
||||
flex: none;
|
||||
appearance: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 3px 12px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
cursor: pointer;
|
||||
background: var(--dsw-alias-label-primary);
|
||||
color: var(--dsw-alias-bg-layer-3);
|
||||
}
|
||||
|
||||
.pluginCopyBtn:hover {
|
||||
background: var(--dsw-alias-button-primary-hover);
|
||||
}
|
||||
|
||||
.pluginCopyBtn:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-brand-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.card,
|
||||
.switchTrack,
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
IconCheckOutline16,
|
||||
IconPlusOutline16,
|
||||
IconSettingsOutline16,
|
||||
Input,
|
||||
Modal,
|
||||
@@ -57,6 +58,7 @@ import {
|
||||
} from '../prefs-shared.ts'
|
||||
import { api } from './api.ts'
|
||||
import { parsePrefs } from './prefs.ts'
|
||||
import { AddPluginModal, type PluginKind } from './add-plugin-modal.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import type { SidebarStore } from './state.ts'
|
||||
import type {
|
||||
@@ -378,6 +380,10 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// Which feature's secondary settings popup is open (null = closed).
|
||||
const [settingsFor, setSettingsFor] = useState<TabDescriptor | FileViewerDescriptor | null>(null)
|
||||
// Whether the "add plugin" modal (a dashed card at the end of the
|
||||
// 侧边栏内容 / 文件预览 grids) is open, and for which extension point
|
||||
// (null = closed).
|
||||
const [addPluginsOpen, setAddPluginsOpen] = useState<PluginKind | null>(null)
|
||||
// The LATEST optimistic prefs, kept in sync with the state. Nested-map
|
||||
// merges (tabsEnabled / viewersEnabled / pluginSettings) MUST build from
|
||||
// this ref, not from the render-time `prefs`: two same-tick writes (e.g.
|
||||
@@ -681,6 +687,22 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
{/* The "add tab plugin" entry: same card size as the inventory,
|
||||
but a dashed border — it opens the TAB-registration plugin
|
||||
modal instead of toggling a feature. */}
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.card, css.addCard)}
|
||||
onClick={() => { setAddPluginsOpen('tab') }}
|
||||
>
|
||||
<span className={css.cardTop}>
|
||||
<span className={css.cardIconChip}>
|
||||
<IconPlusOutline16 size={16} />
|
||||
</span>
|
||||
<span className={css.cardTitle}>{t('addPluginsTabCard')}</span>
|
||||
</span>
|
||||
<span className={css.cardDesc}>{t('addPluginsTabCardDesc')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -705,6 +727,21 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
{/* The "add preview plugin" entry: dashed card opening the
|
||||
FILE-PREVIEWER registration modal. */}
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.card, css.addCard)}
|
||||
onClick={() => { setAddPluginsOpen('viewer') }}
|
||||
>
|
||||
<span className={css.cardTop}>
|
||||
<span className={css.cardIconChip}>
|
||||
<IconPlusOutline16 size={16} />
|
||||
</span>
|
||||
<span className={css.cardTitle}>{t('addPluginsViewerCard')}</span>
|
||||
</span>
|
||||
<span className={css.cardDesc}>{t('addPluginsViewerCardDesc')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -746,6 +783,21 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* The "add plugin" modal (opened by the dashed cards above): declares
|
||||
the extension point of the clicked kind, opens the GitHub topic,
|
||||
and lists the matching recommended plugin catalog with per-entry
|
||||
install buttons (the install flow opens a ~/.dsh terminal with
|
||||
the command pre-typed; failures render inline here, in settings
|
||||
only). Mounted only while open (Modal runs hooks unconditionally
|
||||
— same SSR rule as the settings popup above). */}
|
||||
{addPluginsOpen !== null && (
|
||||
<AddPluginModal
|
||||
service={service}
|
||||
onClose={() => { setAddPluginsOpen(null) }}
|
||||
kind={addPluginsOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error !== null && (
|
||||
<div className={css.error} role="alert">
|
||||
{error}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* The "add plugin" modals (Side card settings → the dashed cards at the
|
||||
* end of the 侧边栏内容 / 文件预览 grids): declare that the sidebar's
|
||||
* extension points — tab pages and file previewers — are open to plugins
|
||||
* (registered through `ctx.betterSidebar`), point at the GitHub topic page
|
||||
* for discovery, and show the repo's recommended plugin catalog of the
|
||||
* matching kind (name / url / description / install script).
|
||||
*
|
||||
* Per entry there are two actions:
|
||||
* - 「跳转」opens the plugin's repo in a REAL new browser tab (window.open
|
||||
* — a button, so the sidebar link takeover cannot reroute it);
|
||||
* - 「安装」only COPIES the install script to the clipboard (writeClipboard)
|
||||
* with a transient "已复制" feedback on the button — the user pastes and
|
||||
* runs it wherever they manage their DSH profile. No terminal is opened,
|
||||
* nothing is closed, nothing can fail outward.
|
||||
*
|
||||
* The body is extracted as {@link PluginListBody} so tests render it
|
||||
* directly — the Modal primitive runs hooks unconditionally, so an open
|
||||
* Modal must never be renderToString'd (same rule as the settingsFor popup
|
||||
* in SideCardSection); the modal itself mounts only while open.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { Modal, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { BetterSidebarService } from './service.ts'
|
||||
import { PLUGIN_TOPIC_URL, type PluginEntry } from './plugins-shared.ts'
|
||||
import { builtinTabPlugins } from './plugins-tabs.ts'
|
||||
import { builtinViewerPlugins } from './plugins-viewers.ts'
|
||||
import { t } from './locales.ts'
|
||||
import css from './SideCardSection.module.css'
|
||||
|
||||
/** Which extension point the modal is adding a plugin for. */
|
||||
export type PluginKind = 'tab' | 'viewer'
|
||||
|
||||
/** The catalog of one kind (kept in two repo files: plugins-tabs.ts /
|
||||
* plugins-viewers.ts). */
|
||||
function catalogOf(kind: PluginKind): readonly PluginEntry[] {
|
||||
return kind === 'tab' ? builtinTabPlugins : builtinViewerPlugins
|
||||
}
|
||||
|
||||
/** How long the "已复制" feedback stays on the copy button. */
|
||||
const COPIED_FEEDBACK_MS = 1500
|
||||
|
||||
/** The modal body: the GitHub topic button + the recommended plugin list
|
||||
* with per-entry jump/copy buttons (extracted for direct testing). */
|
||||
export function PluginListBody(props: { service: BetterSidebarService; kind: PluginKind }) {
|
||||
const { service, kind } = props
|
||||
// Which entry's copy button currently shows the "已复制" feedback.
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null)
|
||||
|
||||
/** Copy the entry's install script to the clipboard and flash the button's
|
||||
* "已复制" label for a moment. The feedback ONLY appears after a
|
||||
* successful write — when the clipboard is unavailable or denied
|
||||
* (writeClipboard resolves false) nothing is shown, so the user is never
|
||||
* told to paste a command that was not placed on the clipboard. Never
|
||||
* closes anything, never throws outward. */
|
||||
const copy = async (entry: PluginEntry): Promise<void> => {
|
||||
const written = await writeClipboard(entry.install)
|
||||
if (!written) return
|
||||
setCopiedId(entry.id)
|
||||
window.setTimeout(() => {
|
||||
setCopiedId(current => (current === entry.id ? null : current))
|
||||
}, COPIED_FEEDBACK_MS)
|
||||
}
|
||||
|
||||
/** Open the plugin's repo in a REAL new browser tab (window.open — a
|
||||
* button, so the sidebar link takeover cannot reroute it). */
|
||||
const jump = (entry: PluginEntry): void => {
|
||||
window.open(entry.url, '_blank', 'noopener')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.pluginList}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.pluginTopicBtn}
|
||||
onClick={() => { window.open(PLUGIN_TOPIC_URL, '_blank', 'noopener') }}
|
||||
>
|
||||
{t('addPluginsBrowseMore')}
|
||||
</button>
|
||||
<div className={css.groupHeading}>
|
||||
<span>{t('addPluginsRecommended')}</span>
|
||||
<span className={css.count}>{catalogOf(kind).length}</span>
|
||||
</div>
|
||||
{catalogOf(kind).length === 0 ? (
|
||||
<div className={css.pluginEmpty}>{t('addPluginsEmpty')}</div>
|
||||
) : (
|
||||
<div className={css.pluginEntries}>
|
||||
{catalogOf(kind).map(entry => (
|
||||
<div key={entry.id} className={css.pluginEntry}>
|
||||
<div className={css.pluginEntryHead}>
|
||||
{/* The name is a BUTTON on the same window.open path as the
|
||||
jump button: as an anchor it would be caught by the
|
||||
document-capture link takeover (which ignores
|
||||
target=_blank) and land in the sidebar browser. */}
|
||||
<button
|
||||
type="button"
|
||||
className={css.pluginName}
|
||||
aria-label={`${t('openPlugin')}: ${entry.name}`}
|
||||
onClick={() => { jump(entry) }}
|
||||
>
|
||||
{entry.name}
|
||||
</button>
|
||||
<span className={css.pluginEntryActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.pluginJumpBtn}
|
||||
aria-label={`${t('openPlugin')}: ${entry.name}`}
|
||||
onClick={() => { jump(entry) }}
|
||||
>
|
||||
{t('openPlugin')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.pluginCopyBtn}
|
||||
aria-label={`${t('copyInstall')}: ${entry.name}`}
|
||||
onClick={() => { copy(entry) }}
|
||||
>
|
||||
{copiedId === entry.id ? t('copied') : t('copy')}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div className={css.pluginDesc}>
|
||||
{typeof entry.description === 'function' ? entry.description() : entry.description}
|
||||
</div>
|
||||
<code className={css.pluginInstall}>{entry.install}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The modal itself (mounted only while open — see the module comment). */
|
||||
export function AddPluginModal(props: { service: BetterSidebarService; onClose: () => void; kind: PluginKind }) {
|
||||
const { service, onClose, kind } = props
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
title={kind === 'tab' ? t('addPluginsTabCard') : t('addPluginsViewerCard')}
|
||||
description={kind === 'tab' ? t('addPluginsTabDesc') : t('addPluginsViewerDesc')}
|
||||
closeLabel={t('close')}
|
||||
className={css.pluginModal}
|
||||
footer={(
|
||||
<button type="button" className={css.done} onClick={onClose}>
|
||||
{t('settingsDone')}
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<PluginListBody service={service} kind={kind} />
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,22 @@
|
||||
/**
|
||||
* The 9 built-in file viewer descriptors: every preview surface is a
|
||||
* registered viewer (image / pdf / docx / xlsx / pptx / markdown / html /
|
||||
* code / binary-download), exactly like external plugins register theirs.
|
||||
* The `binary-download` viewer sniffs NUL bytes via `detect` for unknown
|
||||
* binaries and serves doc/xls/ppt by extension; `code` is the catch-all
|
||||
* (`exts: []`, lowest priority) that claims any file no other viewer did.
|
||||
* The 6 built-in file viewer descriptors: every preview surface is a
|
||||
* registered viewer (image / pdf / markdown / html / code /
|
||||
* binary-download), exactly like external plugins register theirs. Office
|
||||
* previews (.docx / .xlsx / .pptx) are NOT built in anymore — they moved to
|
||||
* the recommended office plugin (see plugins-viewers.ts), which registers
|
||||
* the same ids through this service.
|
||||
*
|
||||
* The heavy viewers (docx/xlsx/pptx and the CodeMirror-backed
|
||||
* markdown/html/code) render through {@link lazyChunkComponent} wrappers —
|
||||
* their libraries are fetched only when such a file is first opened (see
|
||||
* chunk-loader.ts). The descriptor metadata (id/exts/priority/detect) is
|
||||
* identical either way, so matching semantics and external-plugin
|
||||
* overrides are unaffected; the `component` wrapper keeps the descriptor
|
||||
* contract `(props) => ReactNode`.
|
||||
* The `binary-download` viewer sniffs NUL bytes via `detect` for unknown
|
||||
* binaries and serves legacy doc/xls/ppt by extension; `code` is the
|
||||
* catch-all (`exts: []`, lowest priority) that claims any file no other
|
||||
* viewer did.
|
||||
*
|
||||
* The heavy viewers (the CodeMirror-backed markdown/html/code) render
|
||||
* through {@link lazyChunkComponent} wrappers — their libraries are fetched
|
||||
* only when such a file is first opened (see chunk-loader.ts). The
|
||||
* descriptor metadata (id/exts/priority/detect) is identical either way,
|
||||
* so matching semantics and external-plugin overrides are unaffected; the
|
||||
* `component` wrapper keeps the descriptor contract `(props) => ReactNode`.
|
||||
*
|
||||
* Every viewer carries the declarative settings-surface fields — `title`
|
||||
* and `icon` — so the Side card settings page can render the enable/disable
|
||||
@@ -26,9 +30,6 @@ import {
|
||||
IconImageOutline16,
|
||||
IconMarkdownOutline16,
|
||||
IconPdfOutline16,
|
||||
IconDocxOutline16,
|
||||
IconXlsxOutline16,
|
||||
IconPptxOutline16,
|
||||
IconHtmlOutline16,
|
||||
} from '../icons.tsx'
|
||||
import type { ComponentType } from 'react'
|
||||
@@ -37,17 +38,14 @@ import { t } from '../locales.ts'
|
||||
import css from '../sidebar.module.css'
|
||||
|
||||
/**
|
||||
* Lazy wrappers over the chunk-resident viewer components. The `pick`
|
||||
* functions are module-level (stable identity — the wrapper effect depends
|
||||
* on them); the cast bridges the chunk exports record to the descriptor
|
||||
* prop shape (the views read only their own subset of FileViewerProps).
|
||||
* Lazy wrapper over the chunk-resident viewer component. The `pick`
|
||||
* function is module-level (stable identity — the wrapper effect depends
|
||||
* on it); the cast bridges the chunk exports record to the descriptor prop
|
||||
* shape (the view reads only its own subset of FileViewerProps).
|
||||
*/
|
||||
const LazyDocx = lazyChunkComponent<FileViewerProps>('docx', (mod) => mod.DocxView as ComponentType<FileViewerProps> | undefined)
|
||||
const LazyXlsx = lazyChunkComponent<FileViewerProps>('xlsx', (mod) => mod.XlsxView as ComponentType<FileViewerProps> | undefined)
|
||||
const LazyPptx = lazyChunkComponent<FileViewerProps>('pptx', (mod) => mod.PptxView as ComponentType<FileViewerProps> | undefined)
|
||||
const LazyTextEditor = lazyChunkComponent<FileViewerProps>('editor', (mod) => mod.TextEditor as ComponentType<FileViewerProps> | undefined)
|
||||
|
||||
/** The 9 built-in file viewer descriptors. */
|
||||
/** The 6 built-in file viewer descriptors. */
|
||||
export function builtinViewers(): readonly FileViewerDescriptor[] {
|
||||
return [
|
||||
{
|
||||
@@ -72,30 +70,6 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
|
||||
<PdfView scope={scope} path={path} title={title} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'docx',
|
||||
title: () => t('viewerDocx'),
|
||||
icon: (size: number) => <IconDocxOutline16 size={size} />,
|
||||
exts: ['docx'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: (props) => <LazyDocx {...props} />,
|
||||
},
|
||||
{
|
||||
id: 'xlsx',
|
||||
title: () => t('viewerXlsx'),
|
||||
icon: (size: number) => <IconXlsxOutline16 size={size} />,
|
||||
exts: ['xlsx'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: (props) => <LazyXlsx {...props} />,
|
||||
},
|
||||
{
|
||||
id: 'pptx',
|
||||
title: () => t('viewerPptx'),
|
||||
icon: (size: number) => <IconPptxOutline16 size={size} />,
|
||||
exts: ['pptx'],
|
||||
fetchStrategy: 'mediaUrl',
|
||||
component: (props) => <LazyPptx {...props} />,
|
||||
},
|
||||
{
|
||||
id: 'markdown',
|
||||
title: () => t('viewerMarkdown'),
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* Lazy chunk loader for the client bundle. The heavy preview/terminal
|
||||
* libraries (Univer, docx-preview, pptx-renderer, CodeMirror, xterm — the
|
||||
* office/terminal/editor stacks, tens of MB) live in separate build-time
|
||||
* bundles (`lib/client-<name>.js`) fetched only on first use of the feature
|
||||
* that needs them, so startup downloads/parses only the ~1MB core bundle.
|
||||
* libraries (CodeMirror, xterm — the editor/terminal stacks, several MB)
|
||||
* live in separate build-time bundles (`lib/client-<name>.js`) fetched only
|
||||
* on first use of the feature that needs them, so startup downloads/parses
|
||||
* only the ~1MB core bundle. (The office stack — Univer / docx-preview /
|
||||
* pptx-renderer — is no longer bundled here: Office previews moved to the
|
||||
* recommended office plugin, see plugins-viewers.ts.)
|
||||
*
|
||||
* How a chunk script works (see tsdown.config.ts chunkBundle):
|
||||
*
|
||||
@@ -42,7 +44,7 @@
|
||||
* open. Chunk-only source edits still need a manual page refresh (the HMR
|
||||
* poll watches only client.js).
|
||||
*/
|
||||
export type ChunkName = 'docx' | 'xlsx' | 'pptx' | 'terminal' | 'editor'
|
||||
export type ChunkName = 'terminal' | 'editor'
|
||||
|
||||
/** The module exports a chunk factory provides (namespace-ish record). */
|
||||
export type ChunkExports = Record<string, unknown>
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
/**
|
||||
* Lazy chunk entry: the .docx preview (docx-preview + jszip). Built as
|
||||
* `lib/client-docx.js` and registered under `dsh-better-sidebar/docx` —
|
||||
* fetched only when a .docx is first opened (see chunk-loader.ts and
|
||||
* docs/plans/2026-08-12-lazy-chunks-design.md). Never import this module
|
||||
* from the core bundle: it pulls the office library into the startup path.
|
||||
*/
|
||||
export { DocxView } from '../docx-view.tsx'
|
||||
@@ -1,8 +0,0 @@
|
||||
/**
|
||||
* Lazy chunk entry: the .pptx preview (pptx-renderer). Built as
|
||||
* `lib/client-pptx.js` and registered under `dsh-better-sidebar/pptx` —
|
||||
* fetched only when a .pptx is first opened (see chunk-loader.ts and
|
||||
* docs/plans/2026-08-12-lazy-chunks-design.md). Never import this module
|
||||
* from the core bundle: it pulls the renderer into the startup path.
|
||||
*/
|
||||
export { PptxView } from '../PptxView.tsx'
|
||||
@@ -1,9 +0,0 @@
|
||||
/**
|
||||
* Lazy chunk entry: the .xlsx preview (Univer family + SheetJS, several MB).
|
||||
* Built as `lib/client-xlsx.js` and registered under
|
||||
* `dsh-better-sidebar/xlsx` — fetched only when a .xlsx is first opened
|
||||
* (see chunk-loader.ts and docs/plans/2026-08-12-lazy-chunks-design.md).
|
||||
* Never import this module from the core bundle: it pulls Univer into the
|
||||
* startup path.
|
||||
*/
|
||||
export { XlsxView } from '../xlsx-view.tsx'
|
||||
@@ -1,113 +0,0 @@
|
||||
/**
|
||||
* The .docx preview component: renders via docx-preview (preserved
|
||||
* styles/images/tables). Lives in its own module (split out of the former
|
||||
* office-view.tsx) so it builds as a standalone lazy chunk
|
||||
* (`lib/client-docx.js`) — the library is loaded only when a .docx is first
|
||||
* opened, and never pulls in the Univer/xlsx stack.
|
||||
*
|
||||
* Errors degrade to the shared download-button affordance, so a corrupted /
|
||||
* encrypted / oversized file always leaves the user with a way to get it.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { mediaUrl } from './api.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { BinaryFallback, type LoadState, type OfficeViewProps } from './office-shared.tsx'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/**
|
||||
* Render a .docx file via docx-preview. The library renders into a container
|
||||
* div (no canvas); images and styles are inlined. Unmounting clears the
|
||||
* container's innerHTML — docx-preview has no dispose API, but tearing down
|
||||
* the DOM is enough.
|
||||
*/
|
||||
export function DocxView(props: OfficeViewProps): JSX.Element {
|
||||
const { scope, path, title } = props
|
||||
const viewportRef = useRef<HTMLDivElement>(null)
|
||||
const wrapRef = useRef<HTMLDivElement>(null)
|
||||
const [load, setLoad] = useState<LoadState>({ status: 'loading' })
|
||||
const [zoom, setZoom] = useState(100)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const container = viewportRef.current
|
||||
const wrap = wrapRef.current
|
||||
if (container === null || wrap === null) return
|
||||
setZoom(100)
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch(mediaUrl(scope, path))
|
||||
if (cancelled) return
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
const buf = await response.arrayBuffer()
|
||||
if (cancelled) return
|
||||
// docx-preview ships its own CSS through the className option; the
|
||||
// wrapper div scopes its render output.
|
||||
const { renderAsync } = await import('docx-preview')
|
||||
await renderAsync(buf, wrap, undefined, {
|
||||
className: 'docx',
|
||||
inWrapper: true,
|
||||
ignoreWidth: false,
|
||||
ignoreHeight: false,
|
||||
breakPages: true,
|
||||
experimental: false,
|
||||
})
|
||||
if (!cancelled) setLoad({ status: 'ready' })
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
// Tear down the rendered DOM so a reopen starts clean.
|
||||
if (wrap !== null) wrap.innerHTML = ''
|
||||
}
|
||||
}, [scope.sessionId, scope.cwd, path])
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = viewportRef.current
|
||||
if (viewport === null) return
|
||||
const onWheel = (event: WheelEvent): void => {
|
||||
if (!event.altKey) return
|
||||
event.preventDefault()
|
||||
const delta = event.deltaY < 0 ? 10 : -10
|
||||
setZoom(current => Math.max(50, Math.min(200, current + delta)))
|
||||
}
|
||||
viewport.addEventListener('wheel', onWheel, { passive: false })
|
||||
return () => { viewport.removeEventListener('wheel', onWheel) }
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={css.editorDocx}>
|
||||
<div className={css.editorDocxViewport} ref={viewportRef}>
|
||||
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
||||
{load.status === 'error' && <BinaryFallback scope={scope} path={path} message={load.message} />}
|
||||
{load.status !== 'error' && (
|
||||
<div
|
||||
className={css.editorDocxWrap}
|
||||
ref={wrapRef}
|
||||
aria-label={title}
|
||||
style={{ zoom: zoom / 100 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={css.editorDocxZoom}>
|
||||
<span className={css.editorDocxZoomHint}>{t('zoomHint')}</span>
|
||||
<input
|
||||
className={css.editorDocxZoomRange}
|
||||
type="range"
|
||||
min={50}
|
||||
max={200}
|
||||
step={10}
|
||||
value={zoom}
|
||||
aria-label={t('zoom')}
|
||||
onChange={(event) => { setZoom(Number(event.currentTarget.value)) }}
|
||||
/>
|
||||
<span className={css.editorDocxZoomValue}>{zoom}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -83,33 +83,6 @@ export const IconPdfOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Word viewer glyph: a document frame with a "W". */
|
||||
export const IconDocxOutline16 = ({ 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">
|
||||
<path d="M3.5 1.5h6.5L13.5 5v9.5h-10z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
<path d="M9.5 1.5V5h4" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
<path d="M6.2 13.4 7.4 10l1.2 3.4M7.4 10.6l-.35-1.1c-.2-.62.2-1.25.85-1.25h.2c.65 0 1.05.63.85 1.25l-.35 1.1" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M8.75 10.6 9.2 9.4" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Excel viewer glyph: a spreadsheet grid. */
|
||||
export const IconXlsxOutline16 = ({ 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">
|
||||
<rect x="1.5" y="2" width="13" height="12" rx="2" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M1.5 6h13M1.5 9.5h13M6 6v8M10.5 6v8" stroke="currentColor" strokeWidth="1.25" />
|
||||
<path d="m3.8 13.2 2-3M5.8 13.2l-2-3" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** PowerPoint viewer glyph: a chart with rising bars. */
|
||||
export const IconPptxOutline16 = ({ 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">
|
||||
<rect x="1.5" y="2.5" width="13" height="11" rx="2" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M4 12.5v-3M7 12.5V7M10 12.5V4.5M13 12.5v-1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Markdown viewer glyph: the classic "M with a down arrow" badge. */
|
||||
export const IconMarkdownOutline16 = ({ 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">
|
||||
|
||||
@@ -176,11 +176,12 @@ export function apply(ctx: Context): void {
|
||||
|
||||
// The IME guard: composition keys (candidate arrows, confirm, cancel)
|
||||
// belong to the input method, never to page JS. Inlined third-party UI
|
||||
// (Univer's office controls) has shipped unguarded keydown handlers that
|
||||
// hijack ArrowUp/ArrowDown and break Chinese input (#562 regression); the
|
||||
// document-capture guard neutralizes the whole class before React or any
|
||||
// native listener sees the event. Registered as early as possible so no
|
||||
// other capture-phase listener can win the ordering race.
|
||||
// (formerly Univer's office controls, #562 regression) has shipped
|
||||
// unguarded keydown handlers that hijack ArrowUp/ArrowDown and break
|
||||
// Chinese input; the document-capture guard neutralizes the whole class
|
||||
// before React or any native listener sees the event. Registered as
|
||||
// early as possible so no other capture-phase listener can win the
|
||||
// ordering race.
|
||||
ctx.effect(
|
||||
() => {
|
||||
try {
|
||||
|
||||
+24
-22
@@ -120,14 +120,6 @@ export const zh = {
|
||||
settingsConflict: '设置已被其他窗口修改,请重试',
|
||||
binaryNoPreview: '此文件类型不支持预览',
|
||||
downloadToView: '下载查看',
|
||||
officeTooLarge: '文件过大,无法预览',
|
||||
officeCorrupt: '文件损坏或格式无效',
|
||||
officeEncrypted: '不支持加密文件',
|
||||
officeLoadFailed: 'Office 预览组件加载失败',
|
||||
previousSlide: '上一页',
|
||||
nextSlide: '下一页',
|
||||
zoom: '缩放',
|
||||
zoomHint: 'Alt + 滚轮',
|
||||
settingsSubagentTitle: '检测到子代理时自动展开任务管理页',
|
||||
settingsSubagentDesc: '当前会话产生新的子代理时,自动展开侧边栏并打开任务管理页;关闭后需手动打开',
|
||||
settingsJobsTitle: '有新后台任务时自动展开后台任务页',
|
||||
@@ -149,9 +141,6 @@ export const zh = {
|
||||
settingsViewerCatchAll: '兜底:任意文件',
|
||||
viewerImage: '图片',
|
||||
viewerPdf: 'PDF',
|
||||
viewerDocx: 'Word 文档',
|
||||
viewerXlsx: 'Excel 表格',
|
||||
viewerPptx: 'PPT 演示',
|
||||
viewerMarkdown: 'Markdown',
|
||||
viewerCode: '代码',
|
||||
viewerBinary: '二进制下载',
|
||||
@@ -217,6 +206,18 @@ export const zh = {
|
||||
jobKill: '终止',
|
||||
jobKillConfirm: '再次点击确认终止',
|
||||
jobKillError: '终止失败',
|
||||
addPluginsTabCard: '添加 Tab 插件',
|
||||
addPluginsTabCardDesc: '注册新的侧边栏页面',
|
||||
addPluginsViewerCard: '添加预览插件',
|
||||
addPluginsViewerCardDesc: '注册新的文件类型预览',
|
||||
addPluginsTabDesc: '侧边栏页面(Tab)可以由插件扩展。插件通过 ctx.betterSidebar 服务注册;点击「安装」复制安装命令,粘贴到 DSH 所在环境的终端执行。',
|
||||
addPluginsViewerDesc: '文件预览器可以由插件扩展。插件通过 ctx.betterSidebar 服务注册;点击「安装」复制安装命令,粘贴到 DSH 所在环境的终端执行。',
|
||||
addPluginsBrowseMore: '在 GitHub 上浏览更多插件(topic: dsh-better-sidebar)',
|
||||
addPluginsRecommended: '推荐插件',
|
||||
addPluginsEmpty: '暂未收录插件,欢迎在 GitHub topic 下发布你的插件',
|
||||
openPlugin: '跳转',
|
||||
copyInstall: '复制安装命令',
|
||||
pluginOfficeDesc: '为 better-sidebar 编辑器提供 Office 三件套预览(.docx / .xlsx / .pptx),把重型 Office 渲染库拆出主包、按需安装',
|
||||
}
|
||||
|
||||
/** The en dictionary (key-set-equal to zh, enforced by the type annotation). */
|
||||
@@ -330,14 +331,6 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
settingsConflict: 'The setting changed in another window — please retry',
|
||||
binaryNoPreview: 'This file type cannot be previewed',
|
||||
downloadToView: 'Download to view',
|
||||
officeTooLarge: 'File too large to preview',
|
||||
officeCorrupt: 'File is corrupt or in an invalid format',
|
||||
officeEncrypted: 'Encrypted files are not supported',
|
||||
officeLoadFailed: 'Office preview component failed to load',
|
||||
previousSlide: 'Previous',
|
||||
nextSlide: 'Next',
|
||||
zoom: 'Zoom',
|
||||
zoomHint: 'Alt + wheel',
|
||||
settingsSubagentTitle: 'Auto-open the Tasks page when a subagent appears',
|
||||
settingsSubagentDesc: 'Expand the side card and open the Tasks page when the current conversation spawns a new subagent; turn off to open it manually',
|
||||
settingsJobsTitle: 'Auto-open the Jobs page on a new background job',
|
||||
@@ -359,9 +352,6 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
settingsViewerCatchAll: 'Catch-all: any file',
|
||||
viewerImage: 'Image',
|
||||
viewerPdf: 'PDF',
|
||||
viewerDocx: 'Word',
|
||||
viewerXlsx: 'Excel',
|
||||
viewerPptx: 'PowerPoint',
|
||||
viewerMarkdown: 'Markdown',
|
||||
viewerCode: 'Code',
|
||||
viewerBinary: 'Binary download',
|
||||
@@ -427,6 +417,18 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
jobKill: 'Kill',
|
||||
jobKillConfirm: 'Click again to confirm kill',
|
||||
jobKillError: 'Kill failed',
|
||||
addPluginsTabCard: 'Add tab plugins',
|
||||
addPluginsTabCardDesc: 'Register a new sidebar page',
|
||||
addPluginsViewerCard: 'Add preview plugins',
|
||||
addPluginsViewerCardDesc: 'Register a file-type preview',
|
||||
addPluginsTabDesc: 'Sidebar pages (tabs) can be extended by plugins. Plugins register through the ctx.betterSidebar service; clicking Install copies the install command — paste it into a terminal where your DSH profile lives and run it.',
|
||||
addPluginsViewerDesc: 'File previewers can be extended by plugins. Plugins register through the ctx.betterSidebar service; clicking Install copies the install command — paste it into a terminal where your DSH profile lives and run it.',
|
||||
addPluginsBrowseMore: 'Browse more plugins on GitHub (topic: dsh-better-sidebar)',
|
||||
addPluginsRecommended: 'Recommended plugins',
|
||||
addPluginsEmpty: 'No plugins curated yet — publish yours under the GitHub topic',
|
||||
openPlugin: 'Open',
|
||||
copyInstall: 'Copy install command',
|
||||
pluginOfficeDesc: 'Office-suite preview (.docx / .xlsx / .pptx) for the better-sidebar editor, keeping the heavy Office render libraries out of the core bundle',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* Shared pieces of the Office preview views (docx / xlsx): the load-state
|
||||
* union, the shared props, and the download fallback. The two views live in
|
||||
* their own modules (docx-view.tsx / xlsx-view.tsx) so the bundler can emit
|
||||
* them as separate lazy chunks — docx-preview and the Univer family (several
|
||||
* MB) must never share a script (see docs/plans/2026-08-12-lazy-chunks-design.md).
|
||||
*/
|
||||
import { downloadUrl, type SessionScope } from './api.ts'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** Loading / ready / error state shared by both views. */
|
||||
export type LoadState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'ready' }
|
||||
| { status: 'error'; message: string }
|
||||
|
||||
/** Shared props. */
|
||||
export interface OfficeViewProps {
|
||||
scope: SessionScope
|
||||
path: string
|
||||
title: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared error / fallback affordance: the failure reason plus a download
|
||||
* link, so the user always has a path to the file. Used by both DocxView and
|
||||
* XlsxView, and matches the binary placeholder download link.
|
||||
*/
|
||||
export function BinaryFallback(props: { scope: SessionScope; path: string; message: string }): JSX.Element {
|
||||
const { scope, path, message } = props
|
||||
return (
|
||||
<div className={css.editorBinary}>
|
||||
<span className={css.editorBinaryNotice}>{message}</span>
|
||||
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
|
||||
{t('downloadToView')}
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* Office file type dispatch — pure functions mapping file extensions to
|
||||
* preview kinds. Kept separate from the editor host so the mapping is
|
||||
* unit-testable without mounting React or pulling in docx-preview / Univer.
|
||||
*
|
||||
* The host side never inspects Office extensions: the binary NUL probe in
|
||||
* {@link ../index.ts} (readText) tags every Office file as binary, and the
|
||||
* /sidebar/file media route serves raw bytes for any extension. This module
|
||||
* is the client's sole source of truth for "is this previewable here".
|
||||
*/
|
||||
|
||||
/** Extensions rendered with docx-preview (preserved styles/images/tables). */
|
||||
export const OFFICE_DOCX_EXT = '.docx' as const
|
||||
|
||||
/** Extensions rendered with Univer (sheets: data + formulas + formatting). */
|
||||
export const OFFICE_XLSX_EXT = '.xlsx' as const
|
||||
|
||||
/** Extensions rendered with the browser-native PPTX viewer. */
|
||||
export const OFFICE_PPTX_EXT = '.pptx' as const
|
||||
|
||||
/** Extensions that get a download-only affordance (no client-side renderer). */
|
||||
export const OFFICE_DOWNLOAD_ONLY_EXT = ['.doc', '.xls', '.ppt'] as readonly string[]
|
||||
|
||||
/**
|
||||
* The preview strategy for one path's extension.
|
||||
* - `'docx'` — render via docx-preview.
|
||||
* - `'xlsx'` — render via Univer.
|
||||
* - `'pptx'` — render via pptx-renderer.
|
||||
* - `'download-only'` — show the binary download button (no preview renderer
|
||||
* covers legacy OLE .doc/.xls/.ppt on the client).
|
||||
* - `null` — not an Office file; the caller's existing image / md / text / binary
|
||||
* pipeline handles it.
|
||||
*/
|
||||
export type OfficeKind = 'docx' | 'xlsx' | 'pptx' | 'download-only' | null
|
||||
|
||||
/**
|
||||
* Map a lowercased extension (with leading dot) to its preview kind.
|
||||
*
|
||||
* @param ext - the path extension (with leading dot) (`.docx`,
|
||||
* `.xlsx`, `.pptx`, `.doc`, `.xls`, `.ppt`, …); `''` when the path has none.
|
||||
*/
|
||||
export function officeKindForExt(ext: string): OfficeKind {
|
||||
if (ext === OFFICE_DOCX_EXT) return 'docx'
|
||||
if (ext === OFFICE_XLSX_EXT) return 'xlsx'
|
||||
if (ext === OFFICE_PPTX_EXT) return 'pptx'
|
||||
if (OFFICE_DOWNLOAD_ONLY_EXT.includes(ext)) return 'download-only'
|
||||
return null
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* Compat re-export shim for the historical `src/client/office-view.tsx`
|
||||
* path. The office preview views were split into docx-view.tsx /
|
||||
* xlsx-view.tsx (+ office-shared.tsx) so the bundler can emit each library
|
||||
* as its own lazy chunk (see docs/plans/2026-08-12-lazy-chunks-design.md).
|
||||
* This module keeps deep imports of this path resolvable through the
|
||||
* `./src/*` exports subpath — it deliberately re-exports BOTH views, so it
|
||||
* must never be imported from the core bundle or a chunk entry (it would
|
||||
* drag both libraries into that bundle).
|
||||
*/
|
||||
export { DocxView } from './docx-view.tsx'
|
||||
export { XlsxView } from './xlsx-view.tsx'
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Shared vocabulary of the recommended plugin catalogs: the entry shape and
|
||||
* the GitHub topic URL. The two catalogs live in sibling modules —
|
||||
* `plugins-tabs.ts` (tab registrations) and `plugins-viewers.ts` (file
|
||||
* previewer registrations) — and are shown in the two "add plugin" modals
|
||||
* (Side card settings → the dashed cards at the end of the 侧边栏内容 /
|
||||
* 文件预览 grids).
|
||||
*/
|
||||
|
||||
/** The GitHub topic page listing every repo tagged `dsh-better-sidebar`. */
|
||||
export const PLUGIN_TOPIC_URL = 'https://github.com/topics/dsh-better-sidebar'
|
||||
|
||||
/** One curated plugin entry (name / url / description / install script). */
|
||||
export interface PluginEntry {
|
||||
/** Unique id (the npm package name). */
|
||||
id: string
|
||||
/** Short display name. */
|
||||
name: string
|
||||
/** GitHub repository URL. */
|
||||
url: string
|
||||
/** One-line description (i18n friendly: string or () => string). */
|
||||
description: string | (() => string)
|
||||
/** The full shell command pre-filled into the install terminal (not
|
||||
* executed until the user presses Enter). */
|
||||
install: string
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* The built-in catalog of TAB-registration plugins (sidebar pages),
|
||||
* shown in the "add tab plugin" modal (Side card settings → 侧边栏内容 grid
|
||||
* → the dashed card). Adding an entry: append one object here (unique
|
||||
* `id` = npm package name, `url` = GitHub repo, `description` =
|
||||
* i18n-friendly, `install` = the full shell command pre-filled into the
|
||||
* install terminal — it starts with `cd ~/.dsh` so the install runs with
|
||||
* the DSH home as the working directory). Data integrity is guarded by
|
||||
* `tests/plugin-list.spec.ts`.
|
||||
*/
|
||||
import type { PluginEntry } from './plugins-shared.ts'
|
||||
|
||||
/** Tab-registration plugins (empty until a real one exists — the modal
|
||||
* renders the empty state and points at the GitHub topic). */
|
||||
export const builtinTabPlugins: readonly PluginEntry[] = []
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* The built-in catalog of FILE-PREVIEWER plugins (file-type previewers),
|
||||
* shown in the "add preview plugin" modal (Side card settings → 文件预览
|
||||
* grid → the dashed card). Adding an entry: append one object here (unique
|
||||
* `id` = npm package name, `url` = GitHub repo, `description` =
|
||||
* i18n-friendly, `install` = the full shell command pre-filled into the
|
||||
* install terminal — it starts with `cd ~/.dsh` so the install runs with
|
||||
* the DSH home as the working directory). Data integrity is guarded by
|
||||
* `tests/plugin-list.spec.ts`.
|
||||
*/
|
||||
import { t } from './locales.ts'
|
||||
import type { PluginEntry } from './plugins-shared.ts'
|
||||
|
||||
/** File-previewer plugins (alphabetical order). */
|
||||
export const builtinViewerPlugins: readonly PluginEntry[] = [
|
||||
{
|
||||
id: '@huanlin/dsh-plugin-better-sidebar-plugin-office',
|
||||
name: 'Office 预览插件',
|
||||
url: 'https://github.com/HuanLinOTO/dsh-plugin-better-sidebar-plugin-office',
|
||||
description: () => t('pluginOfficeDesc'),
|
||||
install: 'cd ~/.dsh && dsh plugin --profile web add @huanlin/dsh-plugin-better-sidebar-plugin-office',
|
||||
},
|
||||
]
|
||||
@@ -772,7 +772,7 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Binary / download-only files (pptx, OLE .doc/.xls/.ppt, unknown binaries):
|
||||
/* Binary / download-only files (legacy OLE .doc/.xls/.ppt, unknown binaries):
|
||||
a centered notice plus a download link — the user always gets the file. */
|
||||
.editorBinary {
|
||||
flex: 1;
|
||||
@@ -1108,62 +1108,6 @@
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Word preview: the document scrolls independently while the zoom control
|
||||
stays pinned to the bottom edge. */
|
||||
.editorDocx {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
}
|
||||
|
||||
.editorDocxViewport {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
}
|
||||
|
||||
/* docx-preview renders into a wrapper div with .docx className; the host
|
||||
above scrolls while the wrapper holds the page flow. */
|
||||
.editorDocxWrap {
|
||||
flex: none;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.editorDocxZoom {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 34px;
|
||||
padding: 4px 10px;
|
||||
border-top: 1px solid var(--dsw-alias-border-l1);
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
}
|
||||
|
||||
.editorDocxZoomHint,
|
||||
.editorDocxZoomValue {
|
||||
flex: none;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.editorDocxZoomValue {
|
||||
width: 36px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.editorDocxZoomRange {
|
||||
flex: 1;
|
||||
min-width: 72px;
|
||||
accent-color: var(--dsw-alias-brand-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.editorPdf {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -1222,94 +1166,6 @@
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
.editorXlsx {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
}
|
||||
|
||||
.editorUniverHost {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.editorOfficeOverlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
}
|
||||
|
||||
.editorPptx {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
}
|
||||
|
||||
.editorPptxToolbar {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l1);
|
||||
}
|
||||
|
||||
.editorPptxToolbar .editorDownloadLink {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.editorPptxButton {
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 6px;
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: var(--dsw-font-xxs-strong-12);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.editorPptxButton:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.editorPptxButton:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.editorPptxPosition {
|
||||
min-width: 64px;
|
||||
text-align: center;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.editorPptxStage {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editorPptxHost {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* ── Terminal ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.terminalWrap {
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
/**
|
||||
* Convert a SheetJS (xlsx) workbook into Univer's {@link IWorkbookData} so
|
||||
* {@link XlsxView} can render it through the Univer sheets preset.
|
||||
*
|
||||
* v1 scope (matches docs/plans/2026-08-10-office-preview-design.md §3.6):
|
||||
* - Sheet names + order
|
||||
* - Cell values, typed (string / number / boolean / error → force-string)
|
||||
* - Cell formulas (text form; Univer's formula engine computes them)
|
||||
* - Merged cells (!merges → mergeData)
|
||||
* - Column widths (!cols → columnData) and row heights (!rows → rowData)
|
||||
*
|
||||
* Out of scope for v1 (SheetJS community edition does not parse them and the
|
||||
* Pro edition is commercial): cell styles (font/fill/border/alignment),
|
||||
* conditional formatting, charts. Users who need style fidelity get the
|
||||
* download button on the binary placeholder.
|
||||
*
|
||||
* The function is pure (no DOM, no Univer imports) so it is unit-testable
|
||||
* without jsdom or canvas.
|
||||
*/
|
||||
import type * as XLSX from 'xlsx'
|
||||
// Types come from @univerjs/presets (which re-exports @univerjs/core's type
|
||||
// surface). The enum values BooleanNumber / CellValueType are imported as
|
||||
// runtime values from the same module — the bundler inlines them, and the
|
||||
// test environment has @univerjs/presets available as a dependency.
|
||||
import { BooleanNumber, CellValueType, type LocaleType, type ICellData, type IColumnData, type IRowData, type IWorkbookData, type IWorksheetData, type IObjectArrayPrimitiveType, type IObjectMatrixPrimitiveType, type IRange } from '@univerjs/presets'
|
||||
|
||||
/** SheetJS workbook (the slice of the module we use). */
|
||||
type XLSXWorkbook = XLSX.WorkBook
|
||||
|
||||
/**
|
||||
* Build a Univer workbook snapshot from a SheetJS workbook.
|
||||
*
|
||||
* @param wb - the parsed workbook from `XLSX.read(buf, { type: 'array' })`
|
||||
* @param appVersion - the Univer package version, written into `appVersion`
|
||||
* (Univer requires the field but does not gate on it).
|
||||
* @param locale - the locale Univer should render in.
|
||||
*/
|
||||
export function xlsxWorkbookToUniver(
|
||||
wb: XLSXWorkbook,
|
||||
appVersion: string,
|
||||
locale: LocaleType,
|
||||
): IWorkbookData {
|
||||
const sheetOrder: string[] = []
|
||||
const sheets: Record<string, Partial<IWorksheetData>> = {}
|
||||
|
||||
wb.SheetNames.forEach((name, index) => {
|
||||
const sheetId = `sheet-${index}`
|
||||
sheetOrder.push(sheetId)
|
||||
sheets[sheetId] = worksheetToUniver(wb.Sheets[name] ?? {}, name, sheetId)
|
||||
})
|
||||
|
||||
// Univer requires at least one sheet; an empty workbook gets a placeholder.
|
||||
if (sheetOrder.length === 0) {
|
||||
const fallbackId = 'sheet-0'
|
||||
sheetOrder.push(fallbackId)
|
||||
sheets[fallbackId] = {
|
||||
id: fallbackId,
|
||||
name: 'Sheet1',
|
||||
rowCount: 20,
|
||||
columnCount: 26,
|
||||
cellData: {},
|
||||
rowData: {},
|
||||
columnData: {},
|
||||
mergeData: [],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'workbook',
|
||||
name: wb.Props?.Title || 'Workbook',
|
||||
appVersion,
|
||||
locale,
|
||||
styles: {},
|
||||
sheetOrder,
|
||||
sheets,
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert one SheetJS worksheet to a Univer IWorksheetData. */
|
||||
function worksheetToUniver(
|
||||
ws: XLSX.WorkSheet,
|
||||
name: string,
|
||||
sheetId: string,
|
||||
): Partial<IWorksheetData> {
|
||||
const cellData: IObjectMatrixPrimitiveType<ICellData> = {}
|
||||
const merges: IRange[] = []
|
||||
|
||||
// Cell values + formulas: iterate every cell address (skip the !-prefixed
|
||||
// special keys SheetJS uses for sheet-level metadata).
|
||||
for (const addr of Object.keys(ws)) {
|
||||
if (addr.startsWith('!')) continue
|
||||
const cell = ws[addr] as XLSX.CellObject | undefined
|
||||
if (cell === undefined) continue
|
||||
const { r, c } = decodeAddr(addr)
|
||||
const converted = convertCell(cell)
|
||||
if (converted === null) continue
|
||||
let row = cellData[r]
|
||||
if (row === undefined) {
|
||||
row = {}
|
||||
cellData[r] = row
|
||||
}
|
||||
row[c] = converted
|
||||
}
|
||||
|
||||
// Merged cells: SheetJS's Range uses { s: { r, c }, e: { r, c } } (0-indexed),
|
||||
// which is exactly Univer's IRange shape — copy verbatim.
|
||||
if (Array.isArray(ws['!merges'])) {
|
||||
for (const m of ws['!merges']!) {
|
||||
if (m === undefined || m === null) continue
|
||||
merges.push({ startRow: m.s.r, endRow: m.e.r, startColumn: m.s.c, endColumn: m.e.c })
|
||||
}
|
||||
}
|
||||
|
||||
const { rowCount, columnCount } = dimensions(ws, cellData)
|
||||
|
||||
return {
|
||||
id: sheetId,
|
||||
name,
|
||||
tabColor: '',
|
||||
hidden: BooleanNumber.FALSE,
|
||||
freeze: { startRow: -1, startColumn: -1, xSplit: 0, ySplit: 0 },
|
||||
rowCount,
|
||||
columnCount,
|
||||
defaultColumnWidth: 96,
|
||||
defaultRowHeight: 22,
|
||||
mergeData: merges,
|
||||
cellData,
|
||||
rowData: convertRows(ws['!rows']),
|
||||
columnData: convertCols(ws['!cols']),
|
||||
rowHeader: { width: 46 },
|
||||
columnHeader: { height: 20 },
|
||||
showGridlines: BooleanNumber.TRUE,
|
||||
rightToLeft: BooleanNumber.FALSE,
|
||||
}
|
||||
}
|
||||
|
||||
/** Map a SheetJS CellObject onto Univer's ICellData (or null for empty). */
|
||||
function convertCell(cell: XLSX.CellObject): ICellData | null {
|
||||
const out: ICellData = {}
|
||||
// Type mapping: b→BOOLEAN, n→NUMBER, s→STRING, d→STRING, e→FORCE_STRING, z→skip
|
||||
switch (cell.t) {
|
||||
case 'b':
|
||||
out.t = CellValueType.BOOLEAN
|
||||
out.v = cell.v === true
|
||||
break
|
||||
case 'n':
|
||||
out.t = CellValueType.NUMBER
|
||||
out.v = typeof cell.v === 'number' ? cell.v : Number(cell.v)
|
||||
break
|
||||
case 'e':
|
||||
// Excel error strings like #REF! — display verbatim, no formula engine.
|
||||
out.t = CellValueType.FORCE_STRING
|
||||
out.v = String(cell.w ?? cell.v ?? '')
|
||||
break
|
||||
case 's':
|
||||
case 'd':
|
||||
case 'z':
|
||||
default:
|
||||
// String / date / stub. SheetJS renders dates as ISO strings in `w`
|
||||
// when cellDates is on; otherwise `v` is the serial number. Use `w`
|
||||
// (formatted text) when present so users see what Excel showed.
|
||||
out.t = CellValueType.STRING
|
||||
out.v = cell.w ?? (cell.v !== undefined ? String(cell.v) : '')
|
||||
break
|
||||
}
|
||||
// Formula: keep as text; Univer's formula engine computes on load.
|
||||
if (typeof cell.f === 'string' && cell.f !== '') {
|
||||
out.f = cell.f
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Convert SheetJS `!cols` (ColInfo[]) to Univer columnData (sparse, by index). */
|
||||
function convertCols(cols: XLSX.ColInfo[] | undefined): IObjectArrayPrimitiveType<Partial<IColumnData>> {
|
||||
if (!Array.isArray(cols)) return {}
|
||||
const out: IObjectArrayPrimitiveType<Partial<IColumnData>> = {}
|
||||
cols.forEach((info, index) => {
|
||||
if (info === undefined || info === null) return
|
||||
const w = info.wpx ?? (info.width !== undefined ? Math.round(info.width * 7) : undefined)
|
||||
out[index] = {
|
||||
...(w !== undefined ? { w } : {}),
|
||||
...(info.hidden === true ? { hd: BooleanNumber.TRUE } : {}),
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
/** Convert SheetJS `!rows` (RowInfo[]) to Univer rowData (sparse, by index). */
|
||||
function convertRows(rows: XLSX.RowInfo[] | undefined): IObjectArrayPrimitiveType<Partial<IRowData>> {
|
||||
if (!Array.isArray(rows)) return {}
|
||||
const out: IObjectArrayPrimitiveType<Partial<IRowData>> = {}
|
||||
rows.forEach((info, index) => {
|
||||
if (info === undefined || info === null) return
|
||||
const h = info.hpx ?? info.hpt
|
||||
out[index] = {
|
||||
...(h !== undefined ? { h: Math.round(h) } : {}),
|
||||
...(info.hidden === true ? { hd: BooleanNumber.TRUE } : {}),
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the worksheet dimensions. SheetJS's `!ref` is authoritative when
|
||||
* present; otherwise fall back to the max row/col we actually wrote, with a
|
||||
* small floor so the canvas has somewhere to paint.
|
||||
*/
|
||||
function dimensions(
|
||||
ws: XLSX.WorkSheet,
|
||||
cellData: IObjectMatrixPrimitiveType<ICellData>,
|
||||
): { rowCount: number; columnCount: number } {
|
||||
const ref = ws['!ref']
|
||||
if (typeof ref === 'string' && ref !== '') {
|
||||
const range = decodeRange(ref)
|
||||
return {
|
||||
rowCount: Math.max(range.endRow + 1, 20),
|
||||
columnCount: Math.max(range.endColumn + 1, 10),
|
||||
}
|
||||
}
|
||||
let maxRow = -1
|
||||
let maxCol = -1
|
||||
for (const r of Object.keys(cellData)) {
|
||||
const rowNum = Number(r)
|
||||
if (rowNum > maxRow) maxRow = rowNum
|
||||
const rowObj = cellData[rowNum]!
|
||||
for (const c of Object.keys(rowObj)) {
|
||||
const colNum = Number(c)
|
||||
if (colNum > maxCol) maxCol = colNum
|
||||
}
|
||||
}
|
||||
return {
|
||||
rowCount: Math.max(maxRow + 1, 20),
|
||||
columnCount: Math.max(maxCol + 1, 10),
|
||||
}
|
||||
}
|
||||
|
||||
/** Decode an A1-style address (e.g. "AB12") to 0-indexed {row, col}. */
|
||||
function decodeAddr(addr: string): { r: number; c: number } {
|
||||
let col = 0
|
||||
let i = 0
|
||||
while (i < addr.length) {
|
||||
const ch = addr.charCodeAt(i)
|
||||
if (ch >= 65 && ch <= 90) {
|
||||
col = col * 26 + (ch - 64)
|
||||
i += 1
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
const row = Number(addr.slice(i)) - 1
|
||||
return { r: row, c: col - 1 }
|
||||
}
|
||||
|
||||
/** Decode an A1-style range (e.g. "A1:AB12") to 0-indexed bounds. */
|
||||
function decodeRange(range: string): { startRow: number; endRow: number; startColumn: number; endColumn: number } {
|
||||
const at = range.indexOf(':')
|
||||
const start = decodeAddr(at === -1 ? range : range.slice(0, at))
|
||||
const end = at === -1 ? start : decodeAddr(range.slice(at + 1))
|
||||
return { startRow: start.r, endRow: end.r, startColumn: start.c, endColumn: end.c }
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
/**
|
||||
* The .xlsx preview component: renders via the Univer sheets preset (data +
|
||||
* formulas + formatting). Lives in its own module (split out of the former
|
||||
* office-view.tsx) so it builds as a standalone lazy chunk
|
||||
* (`lib/client-xlsx.js`) — the Univer family and SheetJS pull in several MB
|
||||
* but are loaded only when a .xlsx is first opened. The docx stack never
|
||||
* shares this script.
|
||||
*
|
||||
* Errors degrade to the shared download-button affordance, so a corrupted /
|
||||
* encrypted / oversized file always leaves the user with a way to get it.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { mediaUrl } from './api.ts'
|
||||
import { isZh, t } from './locales.ts'
|
||||
import { BinaryFallback, type LoadState, type OfficeViewProps } from './office-shared.tsx'
|
||||
import { xlsxWorkbookToUniver } from './xlsx-to-univer.ts'
|
||||
import css from './sidebar.module.css'
|
||||
// Univer's stylesheets ride the same dsh-css-inline pipeline as xterm's CSS
|
||||
// (one <style data-plugin-css> tag, idempotent). Static import so the styles
|
||||
// are present before the first .xlsx opens.
|
||||
import '@univerjs/preset-sheets-core/lib/index.css'
|
||||
|
||||
/**
|
||||
* Render a .xlsx file via Univer. The sheets preset creates a canvas-based
|
||||
* spreadsheet (formula bar, sheet tabs, formula engine) sized to its
|
||||
* container, so the host fills the pane. Unmounting calls `univer.dispose()`
|
||||
* — without it the canvas, workers, and DOM listeners leak (mirrors the
|
||||
* xterm dispose discipline in TerminalView).
|
||||
*/
|
||||
export function XlsxView(props: OfficeViewProps): JSX.Element {
|
||||
const { scope, path, title } = props
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const univerRef = useRef<{ dispose: () => void } | null>(null)
|
||||
const [load, setLoad] = useState<LoadState>({ status: 'loading' })
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const host = hostRef.current
|
||||
if (host === null) return
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch(mediaUrl(scope, path))
|
||||
if (cancelled) return
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
const buf = await response.arrayBuffer()
|
||||
if (cancelled) return
|
||||
|
||||
// Dynamic imports — collapsed into the chunk by codeSplitting:false.
|
||||
// The dynamic form keeps the source readable: each lib is only pulled
|
||||
// in when an .xlsx is actually opened (semantically; the chunk still
|
||||
// contains all of them — the chunk itself only loads on first .xlsx).
|
||||
const XLSX = await import('xlsx')
|
||||
const { createUniver, LocaleType, mergeLocales } = await import('@univerjs/presets')
|
||||
const { UniverSheetsCorePreset } = await import('@univerjs/preset-sheets-core')
|
||||
// Locales follow the DSH i18n system (the active locale); falls
|
||||
// back to the browser language, then en-US.
|
||||
const localePack = await (isZh()
|
||||
? import('@univerjs/preset-sheets-core/locales/zh-CN').then(m => m.default).catch(() => null)
|
||||
: import('@univerjs/preset-sheets-core/locales/en-US').then(m => m.default).catch(() => null))
|
||||
|
||||
const wb = XLSX.read(buf, { type: 'array' })
|
||||
const locale = isZh() ? LocaleType.ZH_CN : LocaleType.EN_US
|
||||
const workbookData = xlsxWorkbookToUniver(wb, '0.25.1', locale)
|
||||
|
||||
if (cancelled) return
|
||||
const { univer, univerAPI } = createUniver({
|
||||
locale,
|
||||
locales: localePack !== null ? { [locale]: mergeLocales(localePack) } : {},
|
||||
presets: [UniverSheetsCorePreset({ container: host })],
|
||||
})
|
||||
univerRef.current = univer
|
||||
univerAPI.createWorkbook(workbookData)
|
||||
if (!cancelled) setLoad({ status: 'ready' })
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
try {
|
||||
univerRef.current?.dispose()
|
||||
} catch {
|
||||
// Partially initialized instance — ignore disposal errors.
|
||||
}
|
||||
univerRef.current = null
|
||||
host.innerHTML = ''
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
// Critical: dispose the Univer instance (canvas + workers + listeners).
|
||||
try {
|
||||
univerRef.current?.dispose()
|
||||
} catch {
|
||||
// Already torn down — ignore.
|
||||
}
|
||||
univerRef.current = null
|
||||
// Clear the host in case dispose left DOM behind.
|
||||
if (host !== null) host.innerHTML = ''
|
||||
}
|
||||
}, [scope.sessionId, scope.cwd, path])
|
||||
|
||||
return (
|
||||
<div className={css.editorXlsx} aria-label={title}>
|
||||
{/* Univer exclusively owns this element's descendants. React only owns
|
||||
the sibling overlay, so Univer cannot remove a React child. */}
|
||||
<div className={css.editorUniverHost} ref={hostRef} />
|
||||
{load.status !== 'ready' && (
|
||||
<div className={css.editorOfficeOverlay}>
|
||||
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
||||
{load.status === 'error' && <BinaryFallback scope={scope} path={path} message={load.message} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* "Add plugin" modal tests: the body component (PluginListBody) renders the
|
||||
* GitHub topic BUTTON + the recommended plugin catalog of the matching kind
|
||||
* with per-entry jump/copy buttons. The copy button COPIES the install
|
||||
* script to the clipboard (writeClipboard) with a transient "Copied"
|
||||
* feedback — nothing is opened, nothing is closed, nothing can fail
|
||||
* outward.
|
||||
*
|
||||
* The body is tested directly — the Modal primitive runs hooks
|
||||
* unconditionally, so an open Modal must never be renderToString'd (same
|
||||
* rule as the settingsFor popup). The click paths are exercised with
|
||||
* createRoot + act().
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { renderToString } from 'react-dom/server'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { PLUGIN_TOPIC_URL, type PluginEntry } from '../src/client/plugins-shared.ts'
|
||||
import { builtinTabPlugins } from '../src/client/plugins-tabs.ts'
|
||||
import { builtinViewerPlugins } from '../src/client/plugins-viewers.ts'
|
||||
import { AddPluginModal, PluginListBody } from '../src/client/add-plugin-modal.tsx'
|
||||
import { createBetterSidebarService } from '../src/client/service.ts'
|
||||
import { createSidebarStore } from '../src/client/state.ts'
|
||||
|
||||
// The act() environment flag (React 18.2 reads it before flushing effects).
|
||||
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
const officeEntry = builtinViewerPlugins[0] as PluginEntry
|
||||
|
||||
describe('PluginListBody (render)', () => {
|
||||
it('viewer kind renders the topic button and the office catalog entry', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
const html = renderToString(createElement(PluginListBody, { service, kind: 'viewer' }))
|
||||
// The topic is a BUTTON (window.open in a new tab), not an anchor.
|
||||
expect(html).toContain('Browse more plugins on GitHub')
|
||||
// The name is a BUTTON on the same window.open path (an anchor would be
|
||||
// caught by the sidebar link takeover) — no href anywhere in the body.
|
||||
expect(html).not.toContain('href=')
|
||||
// The seeded catalog entry: name, description, install script,
|
||||
// a jump button (opens the repo in a NEW browser tab) and a copy button.
|
||||
expect(html).toContain(officeEntry.name)
|
||||
expect(html).toContain(officeEntry.install.replaceAll('&', '&'))
|
||||
expect(html).toContain('Open')
|
||||
expect(html).toContain('Copy')
|
||||
})
|
||||
|
||||
it('tab kind renders its own empty state (no office entry)', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
const html = renderToString(createElement(PluginListBody, { service, kind: 'tab' }))
|
||||
expect(html).toContain('No plugins curated yet')
|
||||
expect(html).not.toContain(officeEntry.name)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AddPluginModal wiring', () => {
|
||||
it('mounts the body through the Modal for each kind (client render)', () => {
|
||||
// The Modal primitive renders a portal into document.body — the server
|
||||
// renderer refuses portals, so mount it client-side (createRoot + act
|
||||
// in jsdom) and assert on the portaled body HTML.
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
for (const kind of ['tab', 'viewer'] as const) {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const root = createRoot(container)
|
||||
act(() => {
|
||||
root.render(createElement(AddPluginModal, { service, onClose: () => {}, kind }))
|
||||
})
|
||||
const html = document.body.innerHTML
|
||||
if (kind === 'viewer') {
|
||||
expect(html).toContain(officeEntry.name)
|
||||
} else {
|
||||
expect(html).toContain('No plugins curated yet')
|
||||
}
|
||||
act(() => { root.unmount() })
|
||||
container.remove()
|
||||
document.body.innerHTML = ''
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/** Mount PluginListBody and click its copy button (the first one). */
|
||||
function mountBody(service: ReturnType<typeof createBetterSidebarService>, kind: 'tab' | 'viewer' = 'viewer'):
|
||||
{ clickCopy: () => void; buttonLabel: () => string | null; unmount: () => void } {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const root = createRoot(container)
|
||||
act(() => {
|
||||
root.render(createElement(PluginListBody, { service, kind }))
|
||||
})
|
||||
return {
|
||||
clickCopy: async () => {
|
||||
const button = container.querySelector('button[aria-label^="Copy install command:"]')!
|
||||
await act(async () => { (button as HTMLButtonElement).click() })
|
||||
},
|
||||
buttonLabel: () => {
|
||||
const button = container.querySelector('button[aria-label^="Copy install command:"]')
|
||||
return button?.textContent ?? null
|
||||
},
|
||||
unmount: () => {
|
||||
act(() => { root.unmount() })
|
||||
container.remove()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('PluginListBody copy click (interactive)', () => {
|
||||
it('clicking Copy writes the install script to the clipboard and flashes "Copied"', async () => {
|
||||
vi.spyOn(primitives, 'writeClipboard').mockResolvedValue(true)
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
|
||||
const body = mountBody(service)
|
||||
expect(body.buttonLabel()).toBe('Copy')
|
||||
await body.clickCopy()
|
||||
|
||||
expect(primitives.writeClipboard).toHaveBeenCalledWith(officeEntry.install)
|
||||
expect(body.buttonLabel()).toBe('Copied')
|
||||
body.unmount()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('copying does NOT close anything and the modal body stays mounted', async () => {
|
||||
vi.spyOn(primitives, 'writeClipboard').mockResolvedValue(true)
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
|
||||
const body = mountBody(service)
|
||||
await body.clickCopy()
|
||||
|
||||
// The entry is still rendered (nothing closed, no navigation, no open).
|
||||
expect(body.buttonLabel()).toBe('Copied')
|
||||
expect(service.getSnapshot().state).toBeUndefined()
|
||||
body.unmount()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('a DENIED clipboard write shows no "Copied" feedback (never claims a copy that did not happen)', async () => {
|
||||
vi.spyOn(primitives, 'writeClipboard').mockResolvedValue(false)
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
|
||||
const body = mountBody(service)
|
||||
await body.clickCopy()
|
||||
|
||||
expect(primitives.writeClipboard).toHaveBeenCalledWith(officeEntry.install)
|
||||
expect(body.buttonLabel()).toBe('Copy')
|
||||
body.unmount()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('both the name button and the jump button open the repo in a NEW browser tab (window.open, not a link)', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
const opened: Array<[string, string, string]> = []
|
||||
const original = window.open
|
||||
window.open = ((url: string, target: string, features: string) => {
|
||||
opened.push([url, target, features])
|
||||
return null
|
||||
}) as typeof window.open
|
||||
try {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const root = createRoot(container)
|
||||
act(() => {
|
||||
root.render(createElement(PluginListBody, { service, kind: 'viewer' }))
|
||||
})
|
||||
const jumpButtons = [...container.querySelectorAll('button[aria-label^="Open:"]')]
|
||||
expect(jumpButtons).toHaveLength(2) // the name + the jump button
|
||||
act(() => { (jumpButtons[0] as HTMLButtonElement).click() })
|
||||
act(() => { (jumpButtons[1] as HTMLButtonElement).click() })
|
||||
expect(opened).toEqual([
|
||||
[officeEntry.url, '_blank', 'noopener'],
|
||||
[officeEntry.url, '_blank', 'noopener'],
|
||||
])
|
||||
act(() => { root.unmount() })
|
||||
container.remove()
|
||||
} finally {
|
||||
window.open = original
|
||||
}
|
||||
})
|
||||
})
|
||||
+15
-12
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* Built-in registration tests: the plugin registers 7 tabs and 9 file
|
||||
* Built-in registration tests: the plugin registers 7 tabs and 6 file
|
||||
* viewers through the same service external plugins use (dogfooding);
|
||||
* the catch-all `code` viewer, the NUL-sniffing `binary-download` viewer,
|
||||
* and the html/browser sandbox settings pin the registry's behavior.
|
||||
* and the html sandbox settings pin the registry's behavior. (Office
|
||||
* previews are NOT built in — they moved to the recommended office plugin,
|
||||
* see src/client/plugins-viewers.ts.)
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
// First import: browser globals before the xterm-carrying builtin graph loads.
|
||||
@@ -97,11 +99,16 @@ describe('built-in tab registrations', () => {
|
||||
})
|
||||
|
||||
describe('built-in file viewer registrations', () => {
|
||||
it('registers the 9 built-in file viewers', () => {
|
||||
it('registers the 6 built-in file viewers (office previews live in the recommended office plugin)', () => {
|
||||
const { service } = setup()
|
||||
expect(service.getFileViewers().map(v => v.id).sort()).toEqual(
|
||||
['binary-download', 'code', 'docx', 'html', 'image', 'markdown', 'pdf', 'pptx', 'xlsx'],
|
||||
['binary-download', 'code', 'html', 'image', 'markdown', 'pdf'],
|
||||
)
|
||||
// Office previews are not built in: docx/xlsx/pptx files fall through to
|
||||
// the download-only binary viewer (or a registered office plugin).
|
||||
expect(service.getFileViewers().map(v => v.id)).not.toContain('docx')
|
||||
expect(service.getFileViewers().map(v => v.id)).not.toContain('xlsx')
|
||||
expect(service.getFileViewers().map(v => v.id)).not.toContain('pptx')
|
||||
})
|
||||
|
||||
it('code is the catch-all at the lowest priority', () => {
|
||||
@@ -138,11 +145,14 @@ describe('built-in file viewer registrations', () => {
|
||||
expect(toggles[1]?.desc).toBeDefined()
|
||||
})
|
||||
|
||||
it('binary-download claims legacy office by extension', () => {
|
||||
it('binary-download claims legacy office by extension (office previews are not built in)', () => {
|
||||
const { service } = setup()
|
||||
expect(service.matchFileViewer('old.doc')?.id).toBe('binary-download')
|
||||
expect(service.matchFileViewer('old.xls')?.id).toBe('binary-download')
|
||||
expect(service.matchFileViewer('old.ppt')?.id).toBe('binary-download')
|
||||
// Modern office files (zip containers, NUL-free) fall through to the
|
||||
// catch-all code viewer without an office plugin registered.
|
||||
expect(service.matchFileViewer('book.docx', new Uint8Array([0x50, 0x4b, 0x03, 0x04]))?.id).toBe('code')
|
||||
})
|
||||
|
||||
it('binary-download NUL detect claims unknown-extension binaries over code', () => {
|
||||
@@ -155,13 +165,6 @@ describe('built-in file viewer registrations', () => {
|
||||
expect(service.matchFileViewer('blob.zzz', new Uint8Array([0x61, 0x62]))?.id).toBe('code')
|
||||
})
|
||||
|
||||
it('office mediaUrl viewers beat the binary sniffers for their own extensions', () => {
|
||||
const { service } = setup()
|
||||
// A .docx is a zip (no NUL in its head): the docx viewer (priority 0)
|
||||
// claims it before binary-download (-50) is consulted.
|
||||
expect(service.matchFileViewer('book.docx', new Uint8Array([0x50, 0x4b, 0x03, 0x04]))?.id).toBe('docx')
|
||||
})
|
||||
|
||||
it('every built-in viewer carries the declarative settings surface (title + icon)', () => {
|
||||
const { service } = setup()
|
||||
for (const viewer of service.getFileViewers()) {
|
||||
|
||||
+13
-11
@@ -42,7 +42,7 @@ function req(method: string, url: string, headers: Record<string, string> = {}):
|
||||
/** One handler instance over a scratch dir with a fake chunk file. */
|
||||
function setup(): { handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>; dir: string; cleanup: () => void } {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'bundle-route-'))
|
||||
writeFileSync(join(dir, 'client-xlsx.js'), 'window.__ModuleLoader__ && 0;')
|
||||
writeFileSync(join(dir, 'client-editor.js'), 'window.__ModuleLoader__ && 0;')
|
||||
const handler = createBundleRouteHandler(() => true, dir)
|
||||
return { handler, dir, cleanup: () => { rmSync(dir, { recursive: true, force: true }) } }
|
||||
}
|
||||
@@ -52,7 +52,7 @@ describe('/sidebar/bundle route', () => {
|
||||
const { handler, cleanup } = setup()
|
||||
try {
|
||||
const res = fakeRes()
|
||||
await handler(req('GET', '/sidebar/bundle/xlsx.js'), res as unknown as ServerResponse)
|
||||
await handler(req('GET', '/sidebar/bundle/editor.js'), res as unknown as ServerResponse)
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers['content-type']).toBe('text/javascript; charset=utf-8')
|
||||
expect(res.headers['cache-control']).toBe('no-cache')
|
||||
@@ -67,10 +67,10 @@ describe('/sidebar/bundle route', () => {
|
||||
const { handler, cleanup } = setup()
|
||||
try {
|
||||
const first = fakeRes()
|
||||
await handler(req('GET', '/sidebar/bundle/xlsx.js'), first as unknown as ServerResponse)
|
||||
await handler(req('GET', '/sidebar/bundle/editor.js'), first as unknown as ServerResponse)
|
||||
const etag = first.headers.etag!
|
||||
const second = fakeRes()
|
||||
await handler(req('GET', '/sidebar/bundle/xlsx.js', { 'if-none-match': etag }), second as unknown as ServerResponse)
|
||||
await handler(req('GET', '/sidebar/bundle/editor.js', { 'if-none-match': etag }), second as unknown as ServerResponse)
|
||||
expect(second.status).toBe(304)
|
||||
expect(second.body).toBe('')
|
||||
expect(second.headers.etag).toBe(etag)
|
||||
@@ -83,10 +83,10 @@ describe('/sidebar/bundle route', () => {
|
||||
const { handler, dir, cleanup } = setup()
|
||||
try {
|
||||
const first = fakeRes()
|
||||
await handler(req('GET', '/sidebar/bundle/xlsx.js'), first as unknown as ServerResponse)
|
||||
writeFileSync(join(dir, 'client-xlsx.js'), 'window.__ModuleLoader__ && 1;')
|
||||
await handler(req('GET', '/sidebar/bundle/editor.js'), first as unknown as ServerResponse)
|
||||
writeFileSync(join(dir, 'client-editor.js'), 'window.__ModuleLoader__ && 1;')
|
||||
const second = fakeRes()
|
||||
await handler(req('GET', '/sidebar/bundle/xlsx.js', { 'if-none-match': first.headers.etag! }), second as unknown as ServerResponse)
|
||||
await handler(req('GET', '/sidebar/bundle/editor.js', { 'if-none-match': first.headers.etag! }), second as unknown as ServerResponse)
|
||||
expect(second.status).toBe(200)
|
||||
expect(second.headers.etag).not.toBe(first.headers.etag)
|
||||
expect(second.body).toContain('&& 1')
|
||||
@@ -112,7 +112,9 @@ describe('/sidebar/bundle route', () => {
|
||||
const { handler, cleanup } = setup()
|
||||
try {
|
||||
const res = fakeRes()
|
||||
await handler(req('GET', '/sidebar/bundle/editor.js'), res as unknown as ServerResponse)
|
||||
// 'terminal' is allowlisted but the scratch fixture only ships
|
||||
// client-editor.js — the built file is missing → 404.
|
||||
await handler(req('GET', '/sidebar/bundle/terminal.js'), res as unknown as ServerResponse)
|
||||
expect(res.status).toBe(404)
|
||||
} finally {
|
||||
cleanup()
|
||||
@@ -123,7 +125,7 @@ describe('/sidebar/bundle route', () => {
|
||||
const { handler, cleanup } = setup()
|
||||
try {
|
||||
const res = fakeRes()
|
||||
await handler(req('POST', '/sidebar/bundle/xlsx.js'), res as unknown as ServerResponse)
|
||||
await handler(req('POST', '/sidebar/bundle/editor.js'), res as unknown as ServerResponse)
|
||||
expect(res.status).toBe(405)
|
||||
} finally {
|
||||
cleanup()
|
||||
@@ -135,7 +137,7 @@ describe('/sidebar/bundle route', () => {
|
||||
try {
|
||||
const handler = createBundleRouteHandler(() => false, dir)
|
||||
const res = fakeRes()
|
||||
await handler(req('GET', '/sidebar/bundle/xlsx.js'), res as unknown as ServerResponse)
|
||||
await handler(req('GET', '/sidebar/bundle/editor.js'), res as unknown as ServerResponse)
|
||||
expect(res.status).toBe(403)
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
@@ -143,6 +145,6 @@ describe('/sidebar/bundle route', () => {
|
||||
})
|
||||
|
||||
it('exports the chunk allowlist (mirror of src/client/chunk-loader.ts)', () => {
|
||||
expect([...CHUNK_NAMES]).toEqual(['docx', 'xlsx', 'pptx', 'terminal', 'editor'])
|
||||
expect([...CHUNK_NAMES]).toEqual(['terminal', 'editor'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,7 +15,7 @@ import { CHUNK_EXTERNALS } from '../src/client/chunk-loader.ts'
|
||||
|
||||
const g = globalThis as Record<string, unknown>
|
||||
|
||||
const CHUNKS = ['docx', 'xlsx', 'pptx', 'terminal', 'editor']
|
||||
const CHUNKS = ['terminal', 'editor']
|
||||
|
||||
describe('built chunk artifacts', () => {
|
||||
it('each chunk assigns its global registry slot when executed as a script', () => {
|
||||
|
||||
+21
-21
@@ -61,29 +61,29 @@ beforeEach(() => {
|
||||
describe('test-registry path (vitest / jsdom-less environments)', () => {
|
||||
it('returns the registered chunk exports, loading once even for concurrent callers', async () => {
|
||||
let calls = 0
|
||||
registerChunkForTests('docx', async () => {
|
||||
registerChunkForTests('editor', async () => {
|
||||
calls += 1
|
||||
return { DocxView: 'docx-view' }
|
||||
return { TextEditor: 'text-editor' }
|
||||
})
|
||||
const [a, b, c] = await Promise.all([loadChunk('docx'), loadChunk('docx'), loadChunk('docx')])
|
||||
expect(a).toEqual({ DocxView: 'docx-view' })
|
||||
const [a, b, c] = await Promise.all([loadChunk('editor'), loadChunk('editor'), loadChunk('editor')])
|
||||
expect(a).toEqual({ TextEditor: 'text-editor' })
|
||||
expect(b).toBe(a)
|
||||
expect(c).toBe(a)
|
||||
expect(calls).toBe(1)
|
||||
// Memoized after resolution too.
|
||||
expect(await loadChunk('docx')).toBe(a)
|
||||
expect(await loadChunk('editor')).toBe(a)
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
it('a failed load clears the cache so the next call retries', async () => {
|
||||
let calls = 0
|
||||
registerChunkForTests('pptx', async () => {
|
||||
registerChunkForTests('terminal', async () => {
|
||||
calls += 1
|
||||
if (calls === 1) throw new Error('boom')
|
||||
return { PptxView: 'pptx-view' }
|
||||
return { TerminalView: 'terminal-view' }
|
||||
})
|
||||
await expect(loadChunk('pptx')).rejects.toThrow('boom')
|
||||
await expect(loadChunk('pptx')).resolves.toEqual({ PptxView: 'pptx-view' })
|
||||
await expect(loadChunk('terminal')).rejects.toThrow('boom')
|
||||
await expect(loadChunk('terminal')).resolves.toEqual({ TerminalView: 'terminal-view' })
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -94,15 +94,15 @@ describe('production path (script injection + global registry + externals requir
|
||||
const loaded: string[] = []
|
||||
setChunkScriptLoaderForTests(async (src) => {
|
||||
loaded.push(src)
|
||||
simulateScript('xlsx', (require) => ({ XlsxView: `view:${String(require('react'))}` }))
|
||||
simulateScript('editor', (require) => ({ TextEditor: `view:${String(require('react'))}` }))
|
||||
})
|
||||
const exports = await loadChunk('xlsx')
|
||||
expect(loaded).toEqual(['/sidebar/bundle/xlsx.js'])
|
||||
expect(exports).toEqual({ XlsxView: 'view:[object Object]' })
|
||||
const exports = await loadChunk('editor')
|
||||
expect(loaded).toEqual(['/sidebar/bundle/editor.js'])
|
||||
expect(exports).toEqual({ TextEditor: 'view:[object Object]' })
|
||||
// Externals resolved through the module system's seed branch, once.
|
||||
expect(modules.import).toHaveBeenCalledTimes(CHUNK_EXTERNALS.length)
|
||||
// Memoized: no second script injection, no second externals resolution.
|
||||
await loadChunk('xlsx')
|
||||
await loadChunk('editor')
|
||||
expect(loaded).toHaveLength(1)
|
||||
expect(modules.import).toHaveBeenCalledTimes(CHUNK_EXTERNALS.length)
|
||||
})
|
||||
@@ -123,9 +123,9 @@ describe('production path (script injection + global registry + externals requir
|
||||
it('a chunk requiring an unresolvable externals spec fails loudly', async () => {
|
||||
installModuleSystem()
|
||||
setChunkScriptLoaderForTests(async () => {
|
||||
simulateScript('docx', (require) => { require('not-in-the-table'); return {} })
|
||||
simulateScript('editor', (require) => { require('not-in-the-table'); return {} })
|
||||
})
|
||||
await expect(loadChunk('docx')).rejects.toThrow('missed the module table')
|
||||
await expect(loadChunk('editor')).rejects.toThrow('missed the module table')
|
||||
})
|
||||
|
||||
it('a script load failure rejects without materializing', async () => {
|
||||
@@ -149,14 +149,14 @@ describe('production path (script injection + global registry + externals requir
|
||||
let calls = 0
|
||||
setChunkScriptLoaderForTests(async () => {
|
||||
calls += 1
|
||||
simulateScript('pptx', () => {
|
||||
simulateScript('editor', () => {
|
||||
if (calls === 1) throw new Error('materialize boom')
|
||||
return { PptxView: 'pptx-view' }
|
||||
return { TextEditor: 'text-editor' }
|
||||
})
|
||||
})
|
||||
await expect(loadChunk('pptx')).rejects.toThrow('materialize boom')
|
||||
await expect(loadChunk('editor')).rejects.toThrow('materialize boom')
|
||||
// Retry: script re-injected, slot overwritten, materialization succeeds.
|
||||
await expect(loadChunk('pptx')).resolves.toEqual({ PptxView: 'pptx-view' })
|
||||
await expect(loadChunk('editor')).resolves.toEqual({ TextEditor: 'text-editor' })
|
||||
expect(calls).toBe(2)
|
||||
expect(modules.import).toHaveBeenCalled()
|
||||
})
|
||||
@@ -164,7 +164,7 @@ describe('production path (script injection + global registry + externals requir
|
||||
it('fails loudly when no module system is installed (before touching the network)', async () => {
|
||||
const loaded: string[] = []
|
||||
setChunkScriptLoaderForTests(async (src) => { loaded.push(src) })
|
||||
await expect(loadChunk('docx')).rejects.toThrow('client module system unavailable')
|
||||
await expect(loadChunk('editor')).rejects.toThrow('client module system unavailable')
|
||||
expect(loaded).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
@@ -67,11 +67,11 @@ describe('lazyChunkComponent', () => {
|
||||
|
||||
it('shows the failure reason with a retry that recovers', async () => {
|
||||
let fail = true
|
||||
registerChunkForTests('docx', async () => {
|
||||
registerChunkForTests('editor', async () => {
|
||||
if (fail) throw new Error('boom')
|
||||
return { DocxView: Marker }
|
||||
return { TextEditor: Marker }
|
||||
})
|
||||
const Wrapper = lazyChunkComponent<Record<string, never>>('docx', (mod) => mod.DocxView as ComponentType<Record<string, never>> | undefined)
|
||||
const Wrapper = lazyChunkComponent<Record<string, never>>('editor', (mod) => mod.TextEditor as ComponentType<Record<string, never>> | undefined)
|
||||
const { container, unmount } = mount(createElement(Wrapper, {}))
|
||||
await act(async () => {})
|
||||
expect(container.textContent).toContain('boom')
|
||||
@@ -87,8 +87,8 @@ describe('lazyChunkComponent', () => {
|
||||
|
||||
it('props flow through to the chunk component', async () => {
|
||||
const Recorder = (props: { label: string }): ReactNode => createElement('div', { 'data-testid': 'rec', 'data-label': props.label })
|
||||
registerChunkForTests('pptx', async () => ({ PptxView: Recorder }))
|
||||
const Wrapper = lazyChunkComponent<{ label: string }>('pptx', (mod) => mod.PptxView as ComponentType<{ label: string }> | undefined)
|
||||
registerChunkForTests('terminal', async () => ({ TerminalView: Recorder }))
|
||||
const Wrapper = lazyChunkComponent<{ label: string }>('terminal', (mod) => mod.TerminalView as ComponentType<{ label: string }> | undefined)
|
||||
const { container, unmount } = mount(createElement(Wrapper, { label: 'hello' }))
|
||||
await act(async () => {})
|
||||
expect(container.querySelector('[data-testid="rec"]')?.getAttribute('data-label')).toBe('hello')
|
||||
@@ -99,7 +99,7 @@ describe('lazyChunkComponent', () => {
|
||||
describe('built-in descriptor contract (render-prop functions)', () => {
|
||||
it('every heavy built-in viewer component is callable as a plain function without a chunk (returns an element)', () => {
|
||||
const viewers = builtinViewers()
|
||||
for (const id of ['docx', 'xlsx', 'pptx', 'markdown', 'html', 'code']) {
|
||||
for (const id of ['markdown', 'html', 'code']) {
|
||||
const descriptor = viewers.find(viewer => viewer.id === id)
|
||||
expect(descriptor, id).toBeDefined()
|
||||
// No chunk registered: calling must not throw — it returns the lazy
|
||||
|
||||
@@ -65,7 +65,7 @@ function bundleId(file: string): string {
|
||||
}
|
||||
|
||||
/** The lazy chunk bundle names (mirror of src/bundle-route.ts CHUNK_NAMES). */
|
||||
const CHUNK_FILES = ['docx', 'xlsx', 'pptx', 'terminal', 'editor'].map(name => `lib/client-${name}.js`)
|
||||
const CHUNK_FILES = ['terminal', 'editor'].map(name => `lib/client-${name}.js`)
|
||||
|
||||
/** The global registry slot a built chunk script assigns (its factory key). */
|
||||
function chunkSlot(file: string): string {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Built-in plugin catalog data-integrity tests (both files: the TAB
|
||||
* catalog in plugins-tabs.ts and the FILE-PREVIEWER catalog in
|
||||
* plugins-viewers.ts). Every entry must be installable as data — a unique
|
||||
* id (npm package name), a GitHub URL, a non-empty localized description
|
||||
* (string or () => string), and an install script that mentions the dsh
|
||||
* plugin CLI. The catalogs are the discovery surface of the two "add
|
||||
* plugin" modals; a malformed entry would break the install flow, so the
|
||||
* shape is pinned here.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { builtinTabPlugins } from '../src/client/plugins-tabs.ts'
|
||||
import { builtinViewerPlugins } from '../src/client/plugins-viewers.ts'
|
||||
import type { PluginEntry } from '../src/client/plugins-shared.ts'
|
||||
|
||||
/** Resolve an i18n-friendly string-or-function value (mirror of textOf). */
|
||||
function textOf(value: string | (() => string)): string {
|
||||
return typeof value === 'function' ? value() : value
|
||||
}
|
||||
|
||||
const catalogs: Array<[string, readonly PluginEntry[]]> = [
|
||||
['tab catalog', builtinTabPlugins],
|
||||
['viewer catalog', builtinViewerPlugins],
|
||||
]
|
||||
|
||||
describe('builtin plugin catalogs', () => {
|
||||
it('the viewer catalog has the seeded office plugin, the tab catalog does not', () => {
|
||||
const ids = (list: readonly PluginEntry[]): string[] => list.map(p => p.id)
|
||||
expect(ids(builtinViewerPlugins)).toContain('@huanlin/dsh-plugin-better-sidebar-plugin-office')
|
||||
expect(ids(builtinTabPlugins)).not.toContain('@huanlin/dsh-plugin-better-sidebar-plugin-office')
|
||||
})
|
||||
|
||||
for (const [name, list] of catalogs) {
|
||||
describe(name, () => {
|
||||
it('every entry has a unique id (the npm package name)', () => {
|
||||
const ids = list.map(p => p.id)
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
for (const id of ids) {
|
||||
expect(id.length).toBeGreaterThan(0)
|
||||
expect(id).not.toContain(' ')
|
||||
}
|
||||
})
|
||||
|
||||
it('every entry has a name, a GitHub URL, and an install script', () => {
|
||||
for (const entry of list) {
|
||||
expect(entry.name.length).toBeGreaterThan(0)
|
||||
expect(entry.url.startsWith('https://github.com/')).toBe(true)
|
||||
expect(entry.install.length).toBeGreaterThan(0)
|
||||
expect(entry.install).toContain('dsh plugin')
|
||||
}
|
||||
})
|
||||
|
||||
it('every description resolves to a non-empty localized string', () => {
|
||||
for (const entry of list) {
|
||||
expect(textOf(entry.description).length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('every install script starts at the DSH home (cd ~/.dsh) as the modal promises', () => {
|
||||
for (const entry of list) {
|
||||
expect(entry.install.startsWith('cd ~/.dsh')).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -119,6 +119,20 @@ describe('SideCardSection declarative inventory', () => {
|
||||
expect(html.match(/aria-label="[^"]*Feature settings"/g)?.length).toBe(1)
|
||||
})
|
||||
|
||||
it('renders the two dashed "add plugin" cards (tab grid + viewer grid)', () => {
|
||||
const { store, service } = mount()
|
||||
const html = renderSection(store, service)
|
||||
// The tab grid's dashed card (tab registration).
|
||||
expect(html).toContain('Add tab plugins')
|
||||
expect(html).toContain('Register a new sidebar page')
|
||||
// The viewer grid's dashed card (file-previewer registration).
|
||||
expect(html).toContain('Add preview plugins')
|
||||
expect(html).toContain('Register a file-type preview')
|
||||
// The add cards are plain buttons (open the modals), never switches:
|
||||
// they carry no aria-pressed, so the pressed-card counts stay untouched.
|
||||
expect(pressedCount(html, 'true')).toBe(3)
|
||||
})
|
||||
|
||||
it('a disabled feature renders pressed=false', () => {
|
||||
const { store, service } = mount()
|
||||
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { subagent: false }, viewersEnabled: { image: false } })
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
import { loadPrefs, type SidebarSettingsClient } from '../src/client/prefs.ts'
|
||||
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
|
||||
import { extOf, languageKeyForExt } from '../src/client/lang.ts'
|
||||
import { officeKindForExt } from '../src/client/office-types.ts'
|
||||
import { isPdfExt } from '../src/client/pdf-types.ts'
|
||||
import { isImageExt } from '../src/client/image-types.ts'
|
||||
import { relativeTo } from '../src/client/paths.ts'
|
||||
@@ -1088,28 +1087,6 @@ describe('editor language mapping', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('office preview kind', () => {
|
||||
it('routes docx/xlsx to their renderers', () => {
|
||||
expect(officeKindForExt('.docx')).toBe('docx')
|
||||
expect(officeKindForExt('.xlsx')).toBe('xlsx')
|
||||
})
|
||||
|
||||
it('routes pptx to its renderer and legacy OLE formats to download-only', () => {
|
||||
expect(officeKindForExt('.pptx')).toBe('pptx')
|
||||
expect(officeKindForExt('.doc')).toBe('download-only')
|
||||
expect(officeKindForExt('.xls')).toBe('download-only')
|
||||
expect(officeKindForExt('.ppt')).toBe('download-only')
|
||||
})
|
||||
|
||||
it('returns null for non-Office extensions and empty input', () => {
|
||||
expect(officeKindForExt('.txt')).toBeNull()
|
||||
expect(officeKindForExt('.md')).toBeNull()
|
||||
expect(officeKindForExt('.png')).toBeNull()
|
||||
expect(officeKindForExt('.pdf')).toBeNull()
|
||||
expect(officeKindForExt('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pdf preview kind', () => {
|
||||
it('routes only .pdf to the browser-native preview', () => {
|
||||
expect(isPdfExt('.pdf')).toBe(true)
|
||||
|
||||
+11
-35
@@ -28,10 +28,10 @@
|
||||
* factory}) with the (require) => exports CJS closure shape.
|
||||
*
|
||||
* Lazy chunks (lib/client-<name>.js): the heavy preview/terminal libraries
|
||||
* (Univer, docx-preview, pptx-renderer, CodeMirror, xterm — tens of MB)
|
||||
* build as five standalone chunk bundles (src/client/chunks/<name>.tsx),
|
||||
* shared by both channels. Each script assigns its factory to the
|
||||
* plugin-owned global registry (globalThis.__dshChunks__) and is fetched by
|
||||
* (CodeMirror, xterm) build as two standalone chunk bundles
|
||||
* (src/client/chunks/<name>.tsx), shared by both channels. Each script
|
||||
* assigns its factory to the plugin-owned global registry
|
||||
* (globalThis.__dshChunks__) and is fetched by
|
||||
* the client on first use from the plugin's own /sidebar/bundle route —
|
||||
* chunks deliberately do NOT go through the module loader (see
|
||||
* src/client/chunk-loader.ts). `codeSplitting: false` keeps every chunk a
|
||||
@@ -55,22 +55,6 @@ const NODE_BUILTINS = new Set([
|
||||
...builtinModules.map(id => `node:${id}`),
|
||||
])
|
||||
|
||||
/**
|
||||
* Browser-only standalone entries for dependencies whose package `browser`
|
||||
* remaps Rolldown does not currently honor after CJS lowering. Without these
|
||||
* aliases nanoid/SheetJS/JSZip leave Node builtin require() calls in the
|
||||
* client factory, which the DSH module table correctly refuses.
|
||||
*/
|
||||
const DOCX_PREVIEW_ENTRY = require.resolve('docx-preview')
|
||||
const JSZIP_BROWSER_ENTRY = resolvePath(
|
||||
dirname(require.resolve('jszip/package.json', { paths: [dirname(DOCX_PREVIEW_ENTRY)] })),
|
||||
'dist/jszip.min.js',
|
||||
)
|
||||
const XLSX_BROWSER_ENTRY = resolvePath(
|
||||
dirname(require.resolve('xlsx/package.json')),
|
||||
'dist/xlsx.full.min.js',
|
||||
)
|
||||
|
||||
/** Module specifiers the web shell shares into the frozen module table (the official PLATFORM_MODULES list, plus the runtime/client exemption). */
|
||||
const CLIENT_EXTERNALS = [
|
||||
'react',
|
||||
@@ -147,18 +131,14 @@ function clientBundle(pluginId: string, entryFile: string): UserConfig {
|
||||
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'production'),
|
||||
'import.meta.env.MODE': JSON.stringify(process.env.NODE_ENV ?? 'production'),
|
||||
'import.meta.env': JSON.stringify({ MODE: process.env.NODE_ENV ?? 'production' }),
|
||||
// pptx-renderer only uses this to auto-discover its optional PDF.js
|
||||
// fallback. PptxView passes pdfjs:false, so browser CJS has no resolver.
|
||||
// No bundled chunk uses import.meta.resolve; keep the stub so a stray
|
||||
// reference cannot resolve to Node's loader (browser CJS has none).
|
||||
'import.meta.resolve': 'undefined',
|
||||
},
|
||||
alias: {
|
||||
jszip: JSZIP_BROWSER_ENTRY,
|
||||
xlsx: XLSX_BROWSER_ENTRY,
|
||||
},
|
||||
// CJS output otherwise makes some transitive packages (notably nanoid
|
||||
// through Univer Core) resolve their Node entry even though this bundle
|
||||
// runs in the browser. Keep browser conditional exports authoritative for
|
||||
// both source import() and generated require() edges.
|
||||
// CJS output otherwise makes some transitive packages resolve their
|
||||
// Node entry even though this bundle runs in the browser. Keep browser
|
||||
// conditional exports authoritative for both source import() and
|
||||
// generated require() edges.
|
||||
inputOptions: {
|
||||
resolve: {
|
||||
conditionNames: ['browser', 'import', 'require', 'default'],
|
||||
@@ -218,10 +198,6 @@ function chunkBundle(name: string): UserConfig {
|
||||
'import.meta.env': JSON.stringify({ MODE: process.env.NODE_ENV ?? 'production' }),
|
||||
'import.meta.resolve': 'undefined',
|
||||
},
|
||||
alias: {
|
||||
jszip: JSZIP_BROWSER_ENTRY,
|
||||
xlsx: XLSX_BROWSER_ENTRY,
|
||||
},
|
||||
inputOptions: {
|
||||
resolve: {
|
||||
conditionNames: ['browser', 'import', 'require', 'default'],
|
||||
@@ -311,7 +287,7 @@ function makeCssPlugin(pluginId: string): BuildPlugin {
|
||||
}
|
||||
|
||||
/** The lazy chunk names (keep in sync with src/bundle-route.ts CHUNK_NAMES). */
|
||||
const CHUNKS = ['docx', 'xlsx', 'pptx', 'terminal', 'editor']
|
||||
const CHUNKS = ['terminal', 'editor']
|
||||
|
||||
export default [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user