From cbc28473a02830e0ba4504febe3a72335f567949 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Fri, 14 Aug 2026 22:30:29 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E8=AE=BE=E7=BD=AE=E9=A1=B5?= =?UTF-8?q?=E3=80=8C=E6=B7=BB=E5=8A=A0=E6=8F=92=E4=BB=B6=E3=80=8D=E6=8E=A8?= =?UTF-8?q?=E8=8D=90=E7=9B=AE=E5=BD=95=20+=20=E5=A4=8D=E5=88=B6=E5=AE=89?= =?UTF-8?q?=E8=A3=85=E5=91=BD=E4=BB=A4=EF=BC=9B=E7=A7=BB=E9=99=A4=E5=86=85?= =?UTF-8?q?=E7=BD=AE=20Office=20=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 设置页侧边栏内容/文件预览两个网格末尾各增虚线卡片,弹出对应 kind 的插件弹窗 - 弹窗:GitHub topic 按钮(新标签页)+ 推荐插件目录(plugins-tabs.ts / plugins-viewers.ts) - 每条目「跳转」(window.open 新标签页直达仓库)与「复制」(writeClipboard + 已复制反馈) - 不做终端预填流:不关闭弹窗、不打开终端、无失败路径(install/installNotice/installError* 文案删除) - 内置 Office 预览(docx/xlsx/pptx)整体移除,chunk 白名单 5→2(terminal/editor) - 文案「内置插件」→「推荐插件」;AGENTS.md/README/设计文档同步 --- AGENTS.md | 13 +- README.md | 14 +- README_EN.md | 14 +- docs/external-plugin-guide.md | 8 +- .../2026-08-14-add-plugins-modal-design.md | 69 +++++ scripts/package-registry.mjs | 6 - src/bundle-route.ts | 2 +- src/client/PptxView.tsx | 131 --------- src/client/SideCardSection.module.css | 205 ++++++++++++++ src/client/SideCardSection.tsx | 52 ++++ src/client/add-plugin-modal.tsx | 141 ++++++++++ src/client/builtins/viewers.tsx | 70 ++--- src/client/chunk-loader.ts | 12 +- src/client/chunks/docx.tsx | 8 - src/client/chunks/pptx.tsx | 8 - src/client/chunks/xlsx.tsx | 9 - src/client/docx-view.tsx | 113 -------- src/client/icons.tsx | 27 -- src/client/index.tsx | 11 +- src/client/locales.ts | 46 ++-- src/client/office-shared.tsx | 40 --- src/client/office-types.ts | 48 ---- src/client/office-view.tsx | 12 - src/client/plugins-shared.ts | 26 ++ src/client/plugins-tabs.ts | 15 + src/client/plugins-viewers.ts | 23 ++ src/client/sidebar.module.css | 146 +--------- src/client/xlsx-to-univer.ts | 260 ------------------ src/client/xlsx-view.tsx | 116 -------- tests/add-plugin-modal.spec.tsx | 167 +++++++++++ tests/builtins.spec.ts | 27 +- tests/bundle-route.spec.ts | 24 +- tests/chunk-artifact.spec.ts | 2 +- tests/chunk-loader.spec.ts | 42 +-- tests/lazy-chunk.spec.tsx | 12 +- tests/manifest-consistency.spec.ts | 2 +- tests/plugin-list.spec.ts | 66 +++++ tests/side-card-section.spec.tsx | 14 + tests/unit.spec.ts | 23 -- tsdown.config.ts | 46 +--- 40 files changed, 942 insertions(+), 1128 deletions(-) create mode 100644 docs/plans/2026-08-14-add-plugins-modal-design.md delete mode 100644 src/client/PptxView.tsx create mode 100644 src/client/add-plugin-modal.tsx delete mode 100644 src/client/chunks/docx.tsx delete mode 100644 src/client/chunks/pptx.tsx delete mode 100644 src/client/chunks/xlsx.tsx delete mode 100644 src/client/docx-view.tsx delete mode 100644 src/client/office-shared.tsx delete mode 100644 src/client/office-types.ts delete mode 100644 src/client/office-view.tsx create mode 100644 src/client/plugins-shared.ts create mode 100644 src/client/plugins-tabs.ts create mode 100644 src/client/plugins-viewers.ts delete mode 100644 src/client/xlsx-to-univer.ts delete mode 100644 src/client/xlsx-view.tsx create mode 100644 tests/add-plugin-modal.spec.tsx create mode 100644 tests/plugin-list.spec.ts diff --git a/AGENTS.md b/AGENTS.md index 9b0bdb8..fe8474f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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-.js`)中,经 `/sidebar/bundle` 路由按需下发;每个脚本把 factory 赋到插件自有全局注册表 `globalThis.__dshChunks__[]`,由 `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-.js`)中,经 `/sidebar/bundle` 路由按需下发;每个脚本把 factory 赋到插件自有全局注册表 `globalThis.__dshChunks__[]`,由 `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 的真实用法。 diff --git a/README.md b/README.md index 348ea04..2731356 100644 --- a/README.md +++ b/README.md @@ -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)无底部面板:进入窄屏时其标签页一次性并入右侧栏(迁移后回桌面仍保留在右侧栏),桌面端的底部面板只在宽视口下可用;移动端底部首展自动开终端不触发 diff --git a/README_EN.md b/README_EN.md index f015e02..5502e0b 100644 --- a/README_EN.md +++ b/README_EN.md @@ -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 `) 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 diff --git a/docs/external-plugin-guide.md b/docs/external-plugin-guide.md index 38efa29..eeac352 100644 --- a/docs/external-plugin-guide.md +++ b/docs/external-plugin-guide.md @@ -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;未注册 → ``)、`+` 菜单构建(order 排序 + available disabled + 禁用过滤) - **`src/client/SideCardSection.tsx`**:声明式设置页(注册表驱动清单 + `settings.toggles` 嵌套开关 + 开关持久化) diff --git a/docs/plans/2026-08-14-add-plugins-modal-design.md b/docs/plans/2026-08-14-add-plugins-modal-design.md new file mode 100644 index 0000000..9cb21df --- /dev/null +++ b/docs/plans/2026-08-14-add-plugins-modal-design.md @@ -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 更新到位 diff --git a/scripts/package-registry.mjs b/scripts/package-registry.mjs index 18f5862..3aa4c96 100644 --- a/scripts/package-registry.mjs +++ b/scripts/package-registry.mjs @@ -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', diff --git a/src/bundle-route.ts b/src/bundle-route.ts index da2c664..f031d2c 100644 --- a/src/bundle-route.ts +++ b/src/bundle-route.ts @@ -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). */ diff --git a/src/client/PptxView.tsx b/src/client/PptxView.tsx deleted file mode 100644 index 676ad0e..0000000 --- a/src/client/PptxView.tsx +++ /dev/null @@ -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(null) - const viewerRef = useRef(null) - const [load, setLoad] = useState({ 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 ( -
-
- - - {load.status === 'ready' ? `${slide + 1} / ${load.count}` : '– / –'} - - - - {t('downloadToView')} - -
-
-
- {load.status !== 'ready' && ( -
- {load.status === 'loading' &&
{t('loading')}
} - {load.status === 'error' &&
{load.message}
} -
- )} -
-
- ) -} diff --git a/src/client/SideCardSection.module.css b/src/client/SideCardSection.module.css index ed858c7..77ea75e 100644 --- a/src/client/SideCardSection.module.css +++ b/src/client/SideCardSection.module.css @@ -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,179 @@ 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; +} + +.pluginName { + min-width: 0; + font-size: 13px; + line-height: 20px; + font-weight: 600; + color: var(--dsw-alias-label-primary); + text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.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, diff --git a/src/client/SideCardSection.tsx b/src/client/SideCardSection.tsx index 45876ce..c0e4950 100644 --- a/src/client/SideCardSection.tsx +++ b/src/client/SideCardSection.tsx @@ -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(null) // Which feature's secondary settings popup is open (null = closed). const [settingsFor, setSettingsFor] = useState(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(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) { })} ))} + {/* 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. */} +
@@ -705,6 +727,21 @@ export function SideCardSection({ store, service }: SideCardSectionProps) { })} ))} + {/* The "add preview plugin" entry: dashed card opening the + FILE-PREVIEWER registration modal. */} + @@ -746,6 +783,21 @@ export function SideCardSection({ store, service }: SideCardSectionProps) { )} + {/* 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 && ( + { setAddPluginsOpen(null) }} + kind={addPluginsOpen} + /> + )} + {error !== null && (
{error} diff --git a/src/client/add-plugin-modal.tsx b/src/client/add-plugin-modal.tsx new file mode 100644 index 0000000..a2d3f3c --- /dev/null +++ b/src/client/add-plugin-modal.tsx @@ -0,0 +1,141 @@ +/** + * 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(null) + + /** Copy the entry's install script to the clipboard (best-effort) and + * flash the button's "已复制" label for a moment. Never closes anything, + * never throws outward. */ + const copy = (entry: PluginEntry): void => { + void writeClipboard(entry.install) + 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 (a button, so the + * sidebar link takeover cannot reroute it). */ + const jump = (entry: PluginEntry): void => { + window.open(entry.url, '_blank', 'noopener') + } + + return ( +
+ +
+ {t('addPluginsRecommended')} + {catalogOf(kind).length} +
+ {catalogOf(kind).length === 0 ? ( +
{t('addPluginsEmpty')}
+ ) : ( +
+ {catalogOf(kind).map(entry => ( +
+
+ + {entry.name} + + + + + +
+
+ {typeof entry.description === 'function' ? entry.description() : entry.description} +
+ {entry.install} +
+ ))} +
+ )} +
+ ) +} + +/** 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 ( + + {t('settingsDone')} + + )} + > + + + ) +} diff --git a/src/client/builtins/viewers.tsx b/src/client/builtins/viewers.tsx index 3ffd734..8e5f534 100644 --- a/src/client/builtins/viewers.tsx +++ b/src/client/builtins/viewers.tsx @@ -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('docx', (mod) => mod.DocxView as ComponentType | undefined) -const LazyXlsx = lazyChunkComponent('xlsx', (mod) => mod.XlsxView as ComponentType | undefined) -const LazyPptx = lazyChunkComponent('pptx', (mod) => mod.PptxView as ComponentType | undefined) const LazyTextEditor = lazyChunkComponent('editor', (mod) => mod.TextEditor as ComponentType | 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[] { ), }, - { - id: 'docx', - title: () => t('viewerDocx'), - icon: (size: number) => , - exts: ['docx'], - fetchStrategy: 'mediaUrl', - component: (props) => , - }, - { - id: 'xlsx', - title: () => t('viewerXlsx'), - icon: (size: number) => , - exts: ['xlsx'], - fetchStrategy: 'mediaUrl', - component: (props) => , - }, - { - id: 'pptx', - title: () => t('viewerPptx'), - icon: (size: number) => , - exts: ['pptx'], - fetchStrategy: 'mediaUrl', - component: (props) => , - }, { id: 'markdown', title: () => t('viewerMarkdown'), diff --git a/src/client/chunk-loader.ts b/src/client/chunk-loader.ts index 30d974c..b765af6 100644 --- a/src/client/chunk-loader.ts +++ b/src/client/chunk-loader.ts @@ -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-.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-.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 diff --git a/src/client/chunks/docx.tsx b/src/client/chunks/docx.tsx deleted file mode 100644 index 9ccd57a..0000000 --- a/src/client/chunks/docx.tsx +++ /dev/null @@ -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' diff --git a/src/client/chunks/pptx.tsx b/src/client/chunks/pptx.tsx deleted file mode 100644 index c908e1d..0000000 --- a/src/client/chunks/pptx.tsx +++ /dev/null @@ -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' diff --git a/src/client/chunks/xlsx.tsx b/src/client/chunks/xlsx.tsx deleted file mode 100644 index c990ad6..0000000 --- a/src/client/chunks/xlsx.tsx +++ /dev/null @@ -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' diff --git a/src/client/docx-view.tsx b/src/client/docx-view.tsx deleted file mode 100644 index b9314e0..0000000 --- a/src/client/docx-view.tsx +++ /dev/null @@ -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(null) - const wrapRef = useRef(null) - const [load, setLoad] = useState({ 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 ( -
-
- {load.status === 'loading' &&
{t('loading')}
} - {load.status === 'error' && } - {load.status !== 'error' && ( -
- )} -
-
- {t('zoomHint')} - { setZoom(Number(event.currentTarget.value)) }} - /> - {zoom}% -
-
- ) -} diff --git a/src/client/icons.tsx b/src/client/icons.tsx index c761e18..0fa8e55 100644 --- a/src/client/icons.tsx +++ b/src/client/icons.tsx @@ -83,33 +83,6 @@ export const IconPdfOutline16 = ({ size = 16, className }: IconProps) => ( ) -/** Word viewer glyph: a document frame with a "W". */ -export const IconDocxOutline16 = ({ size = 16, className }: IconProps) => ( - - - - - - -) - -/** Excel viewer glyph: a spreadsheet grid. */ -export const IconXlsxOutline16 = ({ size = 16, className }: IconProps) => ( - - - - - -) - -/** PowerPoint viewer glyph: a chart with rising bars. */ -export const IconPptxOutline16 = ({ size = 16, className }: IconProps) => ( - - - - -) - /** Markdown viewer glyph: the classic "M with a down arrow" badge. */ export const IconMarkdownOutline16 = ({ size = 16, className }: IconProps) => ( diff --git a/src/client/index.tsx b/src/client/index.tsx index b5fbdd3..e0950f3 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -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 { diff --git a/src/client/locales.ts b/src/client/locales.ts index 18d974d..6d5915c 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -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 = { 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 = { 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 = { 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', } /** diff --git a/src/client/office-shared.tsx b/src/client/office-shared.tsx deleted file mode 100644 index 3cd0e43..0000000 --- a/src/client/office-shared.tsx +++ /dev/null @@ -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 ( - - ) -} diff --git a/src/client/office-types.ts b/src/client/office-types.ts deleted file mode 100644 index 93a35df..0000000 --- a/src/client/office-types.ts +++ /dev/null @@ -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 -} diff --git a/src/client/office-view.tsx b/src/client/office-view.tsx deleted file mode 100644 index 559d042..0000000 --- a/src/client/office-view.tsx +++ /dev/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' diff --git a/src/client/plugins-shared.ts b/src/client/plugins-shared.ts new file mode 100644 index 0000000..f3f4c90 --- /dev/null +++ b/src/client/plugins-shared.ts @@ -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 +} diff --git a/src/client/plugins-tabs.ts b/src/client/plugins-tabs.ts new file mode 100644 index 0000000..372e5a4 --- /dev/null +++ b/src/client/plugins-tabs.ts @@ -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[] = [] diff --git a/src/client/plugins-viewers.ts b/src/client/plugins-viewers.ts new file mode 100644 index 0000000..db131ce --- /dev/null +++ b/src/client/plugins-viewers.ts @@ -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', + }, +] diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css index c111191..68f709d 100644 --- a/src/client/sidebar.module.css +++ b/src/client/sidebar.module.css @@ -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 { diff --git a/src/client/xlsx-to-univer.ts b/src/client/xlsx-to-univer.ts deleted file mode 100644 index 255e4ca..0000000 --- a/src/client/xlsx-to-univer.ts +++ /dev/null @@ -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> = {} - - 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 { - const cellData: IObjectMatrixPrimitiveType = {} - 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> { - if (!Array.isArray(cols)) return {} - const out: IObjectArrayPrimitiveType> = {} - 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> { - if (!Array.isArray(rows)) return {} - const out: IObjectArrayPrimitiveType> = {} - 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, -): { 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 } -} diff --git a/src/client/xlsx-view.tsx b/src/client/xlsx-view.tsx deleted file mode 100644 index 023583c..0000000 --- a/src/client/xlsx-view.tsx +++ /dev/null @@ -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