Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b5834f74a | ||
|
|
a18e0f000d | ||
|
|
2e94b56c36 | ||
|
|
69f4349047 | ||
|
|
e46250ae4d | ||
|
|
e7d2300cf8 | ||
|
|
233201f7f8 | ||
|
|
b07c7116c8 | ||
|
|
0079ca83a3 | ||
|
|
85b09d888e | ||
|
|
0b40f74ce8 | ||
|
|
4099700bfa | ||
|
|
3728e9036b | ||
|
|
fac59ae2f3 |
@@ -2,6 +2,16 @@
|
||||
|
||||
> 本文档收录 dsh-better-sidebar 的完整发布历史(最新版摘要见 [README](README.md);同步发布于 [GitHub Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases))。
|
||||
|
||||
### v0.22.1
|
||||
|
||||
> 📦 **正式版**(npm `latest`):仅支持 **DSH 0.1.7-rc.1+**(peer 下限 `^0.1.7-rc.1`,CI 钉 `@deepseek-ai/dsh@0.1.7-rc.1`)——**支持线不变**,0.21.1 / 0.22.0 的用户直接升级即可;**DSH 0.1.6-alpha.2 及更早请继续固定 v0.19.1**。这是一次补丁版:两个缺陷在真机上可稳定复现,而当时的单元测试**全绿**。
|
||||
|
||||
- 🐛 **`files` 接管 id 被孤儿化 → `native register files error: … already registered` 刷屏 + 文件树落到宿主空态(需刷新页面才恢复)**(社区 issue #770 / #771 / #766)。根因链由官方桌面壳日志逐字确认(`desktop.frontdesk.log` 2026-09-27 15:51:39.338):客户端条目替换(插件市场更新 / Plugins 页禁用→启用 / HMR 重打)期间,插件 teardown 逐个注销内置描述符 → `service.notify()`(同步内联)驱动 `sync()` 在**已经 inactive** 的插件上下文上运行;清理循环把**不是描述符**的 `files` 接管释放后又**在同一轮里重建**它——`tabs.register` 建在**宿主**上下文上(宿主上下文仍活着)因此照样取走 id,紧随的 `ctx.slots.inject` 建在插件上下文上抛 `cannot create effect on inactive context`,`live.set(FILES_KIND, …)` 永不执行,disposer 丢失 → 该 id 在**整个页面生命周期内不可再注册**。修复(社区 PR #777,@yanzhaohui1999):① 清理循环**跳过 `FILES_KIND`**——接管的寿命只由编辑器类型的 `wantsFiles/hasFiles` 开关与 seat disposer 决定;② **任何在宿主取走 id 之后失败的注册都回滚释放**该 type 与已建好的槽位,失败只留一个「下次通知可重试」的状态,绝不留下无处释放的 id;③ teardown / 清理循环逐个安全释放(一个释放抛错不再中断其余)。
|
||||
- 🐛 **macOS 桌面版窗口拖拽「拖一次就失效」并且双击标题栏缩放也失效**(issue #772)。插件宿主是直挂 `body` 的子元素,宿主的 `html[data-platform=darwin] body > :not(#root) { -webkit-app-region: no-drag }`(选择器含 id,只有 `!important` 能压过)命中它,而 app-region **无视 `pointer-events`**——铺满视口的面板层把下面每条 `[data-window-drag]` 拖拽带一起抵消。修复(社区 PR #773 合并;随后 #786 补齐):`[data-dsh-better-sidebar]`、`[data-dsh-panel-host]`(`> *` 保持 `no-drag`)与放大视图 `.mermaidModal` 用**中性值** `-webkit-app-region: initial !important` 退出计算——`initial` 的计算值 `none` **不**扣减拖拽区,而字面量 `none` 不是中性值(它计算成 `no-drag`);交互弹层(`.selectionPopup`、`FloatingWindow`、`AnchoredPopover`)**不**加 `initial`,否则按下会变成拖窗(重演 #103/#111)。
|
||||
- ✅ **守护**:`tests/native-registration.spec.ts` 把两条不变量钉在注册表的**事件日志**上(在未修复代码上 4/4 红,且「失败后仍能重新注册」不再是非判别断言);槽位失败判据改为 `name::key`,让 `registerSlots` 的**部分回滚**释放路径真正被执行;新增**部署级回归门** `tests/e2e/native-reload.e2e.ts`(`utimesSync` 已安装的 `lib/client.js` → 页面内条目替换 → 断言无 `native register … error`、无诊断条、`files` 接管重新可用;npm 0.22.0 上连续 3 次运行全红、修复版连续 3 次全绿(该用例只有 1 个 test,重复跑三次))。拖拽契约由 `tests/panel-host-css.spec.ts` 在 Linux 的 `pnpm test` 里钉形状(无 macOS runner),并由挂载 lane 的**真实级联探针**按宿主规则读计算值(面板宿主/放大视图在未修复产物上都判红)。
|
||||
- 📐 **基线不变**:`@deepseek-ai/dsh-*` 仍钉 `0.1.7-rc.1`,peer 下限 `^0.1.7-rc.1`;无公共 API / 契约 / 词典 / chunk 改动。事故记录与「为什么否决 #766 的吸收方案」见 [docs/plans/2026-09-28-native-files-takeover-reload-leak.md](./docs/plans/2026-09-28-native-files-takeover-reload-leak.md)。
|
||||
- 🧪 **验证**:`pnpm test` 122 files / 1293 passed / 9 skipped;`pnpm test:mount` 与 `DSH_CMD=… pnpm test:mount:aggregate` 绿;打包产物里 `app-region:initial!important` ×2、`no-drag` ×2。
|
||||
|
||||
### v0.22.0
|
||||
|
||||
> 📦 **正式版**(npm `latest`):仅支持 **DSH 0.1.7-rc.1+**(peer 下限 `^0.1.7-rc.1`,CI 钉 `@deepseek-ai/dsh@0.1.7-rc.1`)——本版没有动支持线,0.21.1 的用户直接升级即可。**DSH 0.1.6-alpha.2 及更早仍请固定 v0.19.1**。主内容是把任务管理页从「子代理拓扑」重做成**工作流图**,并在随后几轮里按真机反馈打磨;期间 DSH 0.1.7 删掉了 Agent Teams 的 Remote 方法,团队与后台任务两个数据面随之改写。
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
|
||||
> Full release history of dsh-better-sidebar (see the [README](README_EN.md) for the latest release; also mirrored on [GitHub Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases)).
|
||||
|
||||
### v0.22.1
|
||||
|
||||
> 📦 **Stable release** (npm `latest`): DSH **0.1.7-rc.1+** only (peer floor `^0.1.7-rc.1`, CI pins `@deepseek-ai/dsh@0.1.7-rc.1`) — the support line is **unchanged**, so 0.21.1 / 0.22.0 users can upgrade straight away; **hosts on DSH 0.1.6-alpha.2 or earlier still pin v0.19.1**. A patch release: both defects reproduced reliably on a real host while the unit suite stayed **green**.
|
||||
|
||||
- 🐛 **The `files` takeover's tab-type id could be orphaned → `native register files error: … already registered` spam plus the Files window falling back to the host's empty state until a page refresh** (community issues #770 / #771 / #766). The chain was confirmed verbatim from the desktop shell's own log (`desktop.frontdesk.log`, 2026-09-27 15:51:39.338): during an in-page client entry replacement (plugin-market update, Plugins page disable→enable, HMR rebundle) the plugin's teardown unregisters each built-in descriptor one by one → `service.notify()` (synchronous, inline) drives `sync()` on an **already inactive** plugin context; the drop loop released the `files` takeover — which is **not a descriptor** — and re-created it **in the same pass**: `tabs.register` lives on the **host** context (still alive) so it took the id anyway, while the `ctx.slots.inject` right after it threw `cannot create effect on inactive context`, `live.set(FILES_KIND, …)` never ran and the disposer was lost — the id became **unregistrable for the rest of the page's life**. Fix (community PR #777, @yanzhaohui1999): ① the drop loop **skips `FILES_KIND`** — the takeover lives and dies by the editor type's `wantsFiles/hasFiles` switch and the seat disposer; ② **any registration that fails after the host took the id releases that type and the slots already installed**, leaving only a failure the next notification retries; ③ teardown / drop loops release entry by entry, so one throwing release no longer aborts the rest.
|
||||
- 🐛 **macOS desktop: window drag worked once and then stopped, and the double-click-title zoom was gone** (issue #772). The plugin host is a direct `body` child, so the shell's `html[data-platform=darwin] body > :not(#root) { -webkit-app-region: no-drag }` (an id-bearing selector — only `!important` wins) applied to it, and app-region **ignores `pointer-events`** — the viewport-sized panel layer cancelled every `[data-window-drag]` strip beneath it. Fix (community PR #773 merged; #786 completed it): `[data-dsh-better-sidebar]`, `[data-dsh-panel-host]` (with `> *` staying `no-drag`) and the zoom modal `.mermaidModal` opt out with the **neutral** `-webkit-app-region: initial !important` — `initial` computes to `none`, which does **not** subtract from the drag region, whereas the literal keyword `none` is not neutral (it computes to `no-drag`). Interactive popups (`.selectionPopup`, `FloatingWindow`, `AnchoredPopover`) deliberately do **not** get `initial`, or a press would start a window drag instead (the #103/#111 regression).
|
||||
- ✅ **Guards**: `tests/native-registration.spec.ts` pins both invariants to the registry's **event log** (4/4 red on the unfixed code, and "a failed registration can still be retried" is no longer a non-discriminating assertion); slot failures are keyed by `name::key` so `registerSlots`' partial-release path is actually exercised; a **deployment-level regression gate** `tests/e2e/native-reload.e2e.ts` was added (`utimesSync` the installed `lib/client.js` → in-page entry replacement → assert no `native register … error`, no diagnostic strip, the Files takeover usable again; red on 3 consecutive runs against npm 0.22.0, green on 3 consecutive runs of the fixed build (it is ONE case, run repeatedly)). The drag contract is shape-pinned by `tests/panel-host-css.spec.ts` on Linux `pnpm test` (there is no macOS runner) and value-pinned by a real cascade probe in the mount lane that reads computed styles against the shell's own rules (both the panel host and the zoom modal red the unfixed artifact).
|
||||
- 📐 **Baseline unchanged**: `@deepseek-ai/dsh-*` still pinned to `0.1.7-rc.1`, peer floor `^0.1.7-rc.1`; no public API, contract, dictionary or chunk changes. Incident write-up (and why #766's "absorb" approach was rejected): [docs/plans/2026-09-28-native-files-takeover-reload-leak.md](./docs/plans/2026-09-28-native-files-takeover-reload-leak.md).
|
||||
- 🧪 **Verification**: `pnpm test` 122 files / 1293 passed / 9 skipped; `pnpm test:mount` and `DSH_CMD=… pnpm test:mount:aggregate` green; the packed bundle carries `app-region:initial!important` twice and `no-drag` twice.
|
||||
|
||||
### v0.22.0
|
||||
|
||||
> 📦 **Stable release** (npm `latest`): DSH **0.1.7-rc.1+** only (peer floor `^0.1.7-rc.1`, CI pins `@deepseek-ai/dsh@0.1.7-rc.1`) — the support line is unchanged, so v0.21.1 users can upgrade straight away. **Hosts on DSH 0.1.6-alpha.2 or earlier still pin v0.19.1.** The headline is the Tasks page rebuilt from a "subagent topology" into a **workflow graph**, then polished over several rounds of real-host feedback; along the way DSH 0.1.7 deleted the Agent Teams Remote methods, so both the team and background-job data planes were rewritten.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
> [!IMPORTANT]
|
||||
> **v0.19.0 起接入 DSH 原生侧边栏**:右列就是 DSH 自己的右侧栏,插件把每个 tab 类型注册为原生 tab(不再自绘右侧面板),只保留自绘的底部工作台与开放给所有插件的 `ctx.betterSidebar` 服务。
|
||||
>
|
||||
> **v0.21.1 起要求 DSH `0.1.7-rc.1+`**(peer 下限 `^0.1.7-rc.1`;本版 v0.22.0 即 npm `latest`)。DSH 0.1.7 自带完整文档预览,插件把只读预览(表格 / PDF / 图片 / Office)整体让给内置,只保留 Markdown / HTML 与可编辑的代码编辑器。**0.1.6-alpha.2 及更早的用户请固定 `dsh-better-sidebar@0.19.1`**;按 DSH 版本选插件版本的对照表见[安装](#-安装)。
|
||||
> **v0.21.1 起要求 DSH `0.1.7-rc.1+`**(peer 下限 `^0.1.7-rc.1`;本版 v0.22.1 即 npm `latest`)。DSH 0.1.7 自带完整文档预览,插件把只读预览(表格 / PDF / 图片 / Office)整体让给内置,只保留 Markdown / HTML 与可编辑的代码编辑器。**0.1.6-alpha.2 及更早的用户请固定 `dsh-better-sidebar@0.19.1`**;按 DSH 版本选插件版本的对照表见[安装](#-安装)。
|
||||
|
||||
<!-- Hero -->
|
||||
<div align="center">
|
||||
@@ -14,7 +14,7 @@
|
||||
<a href="https://github.com/omdsh-dev/DSH-better-sidebar/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/omdsh-dev/DSH-better-sidebar" /></a>
|
||||
<a href="https://opensource.org/licenses/MIT"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-yellow.svg" /></a>
|
||||
<a href="https://dshfind.com/zh/plugins/omdsh-dev/DSH-better-sidebar?ref=badge"><img alt="dshfind" src="https://dshfind.com/api/badge/omdsh-dev/DSH-better-sidebar?lang=zh" /></a><br /><br />
|
||||
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本(v0.22.0):0.1.7-rc.1+" src="https://img.shields.io/badge/DSH-0.1.7--rc.1%2B-4d6bfe" /></a>
|
||||
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本(v0.22.1):0.1.7-rc.1+" src="https://img.shields.io/badge/DSH-0.1.7--rc.1%2B-4d6bfe" /></a>
|
||||
<a href="https://github.com/topics/dsh-better-sidebar"><img alt="插件生态:GitHub topic dsh-better-sidebar" src="https://img.shields.io/badge/%E6%8F%92%E4%BB%B6%E7%94%9F%E6%80%81-topic%20dsh--better--sidebar-4d6bfe" /></a><br /><br />
|
||||
<img alt="文件管理" src="https://img.shields.io/badge/-文件管理-4d6bfe" /> <img alt="编辑预览" src="https://img.shields.io/badge/-编辑预览-4d6bfe" /> <img alt="底部工作台" src="https://img.shields.io/badge/-底部工作台-4d6bfe" /> <img alt="文件变动" src="https://img.shields.io/badge/-文件变动-4d6bfe" /> <img alt="后台任务" src="https://img.shields.io/badge/-后台任务-4d6bfe" /> <img alt="侧边对话" src="https://img.shields.io/badge/-侧边对话-4d6bfe" /> <img alt="插件接入" src="https://img.shields.io/badge/-插件接入-4d6bfe" /><br /><br />
|
||||
<b>右侧栏 + 底部面板双工作台</b>,并把 <code>ctx.betterSidebar</code> 服务开放给所有插件——<br />
|
||||
@@ -62,15 +62,15 @@
|
||||
**前置**:已装好 DSH(`dsh web` 能正常运行),Node.js ≥ 20、pnpm ≥ 10。
|
||||
|
||||
**支持的 DSH 版本**:
|
||||
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本(v0.22.0):0.1.7-rc.1+" src="https://img.shields.io/badge/DSH-0.1.7--rc.1%2B-4d6bfe" /></a>
|
||||
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本(v0.22.1):0.1.7-rc.1+" src="https://img.shields.io/badge/DSH-0.1.7--rc.1%2B-4d6bfe" /></a>
|
||||
|
||||
> 📌 **通道与支持线**:`v0.22.0` 是**正式版**(npm `latest`),仅适配 DSH **0.1.7-rc.1+**。**装 DSH 请写精确版本号**:`npm i -g @deepseek-ai/dsh@0.1.7-rc.1`(rc.1 走 npm `next` 通道)。**DSH 0.1.6-alpha.2 及更早的用户请固定 `dsh-better-sidebar@0.19.1`**——0.1.7 的破坏面(设置服务重写、图标导出改名、会话格式 v3→v4)大到本版不写兼容层。
|
||||
> 📌 **通道与支持线**:`v0.22.1` 是**正式版**(npm `latest`),仅适配 DSH **0.1.7-rc.1+**。**装 DSH 请写精确版本号**:`npm i -g @deepseek-ai/dsh@0.1.7-rc.1`(rc.1 走 npm `next` 通道)。**DSH 0.1.6-alpha.2 及更早的用户请固定 `dsh-better-sidebar@0.19.1`**——0.1.7 的破坏面(设置服务重写、图标导出改名、会话格式 v3→v4)大到本版不写兼容层。
|
||||
|
||||
> 🧭 **按你的 DSH 版本选插件版本**:
|
||||
>
|
||||
> | 你的 DSH 版本 | 安装命令 | 版本 / peer 声明 |
|
||||
> | --- | --- | --- |
|
||||
> | **0.1.7-rc.1+**(含之后的 0.1.7 正式版) | `dsh plugin --profile web add dsh-better-sidebar@latest` | **0.22.0**,`^0.1.7-rc.1` |
|
||||
> | **0.1.7-rc.1+**(含之后的 0.1.7 正式版) | `dsh plugin --profile web add dsh-better-sidebar@latest` | **0.22.1**,`^0.1.7-rc.1` |
|
||||
> | 0.1.7-alpha.1 / 0.1.7-alpha.2 | **没有可装版本**——先把 DSH 升到 rc.1,再跑上一行:<br>`npm i -g @deepseek-ai/dsh@0.1.7-rc.1` | — |
|
||||
> | 0.1.6-alpha.2 及更早、`0.1.5-rc.*`(含 npm `latest` 的 0.1.5-rc.3) | `dsh plugin --profile web add dsh-better-sidebar@0.19.1` | **0.19.1**,`^0.1.5-rc.1` |
|
||||
> | `0.1.5-alpha.2` | `dsh plugin --profile web add dsh-better-sidebar@0.19.0-alpha.1` | `^0.1.5-alpha.2` |
|
||||
@@ -144,7 +144,7 @@ dsh plugin --profile web add dsh-better-sidebar@latest
|
||||
5. 硬刷新浏览器(Cmd/Ctrl+Shift+R)即可看到效果(client 改动无需重启 DSH;host 半改动才需重启)
|
||||
```
|
||||
|
||||
更新:`git pull && pnpm install && pnpm build` → 硬刷新浏览器即可(client 改动热加载生效,无需重启 DSH;host 半改动才需重启)。切回 npm 通道时,把依赖改回 npm 上的对应版本(稳定线 `"^0.19.1"`;本线 `"^0.22.0"`)再 `pnpm install`。
|
||||
更新:`git pull && pnpm install && pnpm build` → 硬刷新浏览器即可(client 改动热加载生效,无需重启 DSH;host 半改动才需重启)。切回 npm 通道时,把依赖改回 npm 上的对应版本(稳定线 `"^0.19.1"`;本线 `"^0.22.1"`)再 `pnpm install`。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -190,7 +190,15 @@ dsh registry enable dsh-external/dsh-better-sidebar
|
||||
|
||||
## 🆕 最近更新
|
||||
|
||||
**支持的 DSH 版本**:<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本(v0.22.0):0.1.7-rc.1+" src="https://img.shields.io/badge/DSH-0.1.7--rc.1%2B-4d6bfe" /></a> · 完整发布历史见 [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases)
|
||||
**支持的 DSH 版本**:<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本(v0.22.1):0.1.7-rc.1+" src="https://img.shields.io/badge/DSH-0.1.7--rc.1%2B-4d6bfe" /></a> · 完整发布历史见 [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases)
|
||||
|
||||
### v0.22.1
|
||||
|
||||
> 📦 **正式版**(npm `latest`):支持线**不变**——仍仅支持 **DSH 0.1.7-rc.1+**(peer 下限 `^0.1.7-rc.1`,CI 钉 `@deepseek-ai/dsh@0.1.7-rc.1`),0.21.1 / 0.22.0 的用户直接升级即可。修掉两个**真机可复现、单测却全绿**的缺陷;**DSH 0.1.6-alpha.2 及更早请继续固定 v0.19.1**。
|
||||
|
||||
- 🐛 **`files` 接管被孤儿化 → 报错刷屏 + 文件树空态**(社区 #770 / #771,官方桌面壳日志实证):客户端条目替换(插件市场更新 / Plugins 页禁用→启用 / HMR 重打)时,`sync()` 的清理循环会把**不属于描述符**的 `files` 接管释放掉、又在同一轮里重建——而重建发生在**已经 inactive** 的插件上下文上:`tabs.register` 建在宿主上下文上照样取走了 id,紧随的 `ctx.slots.inject` 却抛 `cannot create effect on inactive context`,于是 disposer 丢失、该 id 在**整个页面生命周期内不可再注册**(表现就是 `native register files error: … already registered` 刷屏 + 文件树落到宿主空态,只有刷新页面才恢复)。现在清理循环**跳过 `FILES_KIND`**(接管的寿命只由编辑器类型开关与 seat disposer 决定),并且**任何在宿主取走 id 之后失败的注册都会回滚释放**(含已建好的槽位),失败只留一个「下次通知可重试」的状态。修复取自社区 PR #777(@yanzhaohui1999)。
|
||||
- 🖥️ **macOS 桌面版窗口拖拽 / 双击标题栏缩放失效**(#772):插件宿主是直挂 `body` 的子元素,宿主的 `html[data-platform=darwin] body > :not(#root) { -webkit-app-region: no-drag }` 命中它,而 app-region **无视 `pointer-events`**——铺满视口的面板层把下面每条拖拽带一起抵消(拖第一次还行、之后全失效)。现在 `[data-dsh-better-sidebar]` / `[data-dsh-panel-host]` / 放大视图 `.mermaidModal` 都用中性值 `initial !important` 退出计算,层内的面板与控件保持 `no-drag`(点击不被吞);合并社区 PR #773 并补齐放大视图这最后一个铺满视口的 body 直挂层。
|
||||
- ✅ **守住它们**:新增单元用例把「接管不得被通知拆建」「注册失败必须回滚已占用 id 与已建槽位」钉在注册表**事件日志**上(未修复代码上 4/4 红),并新增**部署级回归门** `tests/e2e/native-reload.e2e.ts`(在 npm 0.22.0 上连续 3 次运行全红、修复版连续 3 次全绿(1 个用例重复跑三次));拖拽契约由单元用例 + 挂载 lane 的**真实级联探针**(按宿主规则读计算值)守护。验证:`pnpm test` 122 files / 1293 passed / 9 skipped,`pnpm test:mount` 与 `test:mount:aggregate` 绿。事故记录见 [docs/plans/2026-09-28-native-files-takeover-reload-leak.md](./docs/plans/2026-09-28-native-files-takeover-reload-leak.md)。
|
||||
|
||||
### v0.22.0
|
||||
|
||||
@@ -207,19 +215,7 @@ dsh registry enable dsh-external/dsh-better-sidebar
|
||||
- 🐛 **真机抓到、单测全绿的四个缺陷**:逐节点折叠按钮点了没反应(被自动折叠的守卫卡住);「待命」卡片从不画折叠按钮;认领后标签错显「阻塞」;队列任务上「完成」必失败(需先认领)。
|
||||
- 🎨 **窄屏与手机设置**:按原生右侧栏窄宽重新定档卡片与行距;设置页新增**手机**分组——窄屏(≤768px)不自动弹出新任务页、任务页默认树状图。
|
||||
|
||||
### v0.21.1
|
||||
|
||||
> 📦 **正式版**(npm `latest`):仅支持 **DSH 0.1.7-rc.1+**(peer 下限 `^0.1.7-rc.1`,CI 钉 `@deepseek-ai/dsh@0.1.7-rc.1`)。**DSH 0.1.6-alpha.2 及更早的用户请固定在 v0.19.1**——0.1.7 动了设置服务、图标具名导出与会话格式三处硬契约,本版不写运行时兼容层。⚠️ **上一版 v0.20.0 从未发布到 npm**:它的终端 / 浏览器让出也一并落在本版,npm 上从 0.19.1 直接到本版。
|
||||
|
||||
- 🗂️ **只读文件预览整体让给 DSH 的文档预览**:DSH 0.1.7 的 `ui-sidebar-documentpreview` 自带表格 / PDF / 图片 / Office 渲染(宿主侧 Office→PDF 转换、电子表格 worker 表格、图片 / PDF 缩放、按目录自动刷新),所以插件删掉了自己的 `image` / `pdf` / `binary-download` 三个 viewer,并在 `editor.canOpen` 里**拒绝认领**这些扩展名——`xlsx xls csv tsv fods pdf png jpg jpeg gif webp svg bmp ico doc docx ppt pptx`——把文件地址交回宿主。**rc.1 收回其中 9 个**:`xlsb` / `xlt` / `xltx` / `xltm` / `ots` / `dot` / `dotx` / `avif` / `ods` 宿主其实**没有渲染器**(点开只有「暂不支持预览」),而它们在让出之前是走插件兜底显示下载面板的,属于我们上一版自己引入的回归;现由插件的 `code` catch-all 重新认领。`fods` 继续让出(宿主会用纯文本显示这段扁平 XML,比下载面板有用)。**插件仍保留三件宿主没有的**:Markdown(自带渲染器)、HTML(自带沙箱预览 + `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` 两个安全开关)、以及**可编辑**的文本 / 代码编辑器(内置那几个是只读预览);未知二进制(`.zip` / `.wasm`)仍走代码编辑器判 binary 后的下载面板,功能不回归。
|
||||
- 🔗 **外链接管收敛**:删掉按协议分流的三个外链接管设置项(20 份词典的相关词条一并删除)。现在插件**只认领有 tab 类型通过 `urlTarget` 明确声明认领的链接**,其余一律放行、由宿主决定(DSH 0.1.7 新增用户设置 `linkOpening`,决定正文链接进侧栏还是新标签页);**一个都没认领到时不阻止默认行为**;认领成功但目标类型在打开那一刻已不可用(插件卸载 / 被关)时兜底 `window.open(url, '_blank', 'noopener,noreferrer')`——顺手修掉了上一版留下的真实回归:插件自绘 markdown(侧边对话转录 / 编辑器预览 / diff 面板)里的 http 链接点了没反应。另外宿主的 `browser` kind **在 Web profile 已不再挂载**(0.1.7 只在 desktop profile 挂载它)。
|
||||
- ⚙️ **设置接入面重写 + 用户偏好的自动回迁**:DSH 0.1.7 删除了插件可注册的设置命名空间,改为**按插件 Loader 行的 entry id 找表单**(`SettingsForms`:只剩 `describe` / `update` / `replace` / `mutate` / `configure`)。插件偏好因此落在 **profile 的 cordis patch 文档**里(即本插件的挂载行),不再是 `~/.dsh/settings.yaml`;schema 来自插件模块导出的 `Config`(本版把用户偏好并进 `Config`,并给每个偏好字段标 `meta.volatile = true`——**这一个标记就是「改设置实时生效、不重挂插件」的全部机制**)。**用户设置不会丢**:插件首次启动时会把旧 `settings.yaml` / `settings.yaml.imported` 里的 `dsh-better-sidebar` 段一次性回迁(只在该行还没有任何用户值时执行,且只迁移当前 schema 仍声明的字段)。entry id 是**运行时自发现**的(本包默认 `better-sidebar`,聚合包挂载时会是别的 id),不硬编码。
|
||||
- 🔄 **文件树实时刷新**:插件接管了内置「文件」页,宿主自己的按目录 watch 覆盖不到它——本版新增 `/sidebar/ws/fs-watch`:客户端上报**已展开**的目录,宿主按目录 `fs.watch`(150ms 去抖、每连接 64 个句柄上限、路径仍走 `fs.tree` 同一道 workspace fence),改动后只重列那一层、折叠即退订。此前文件树会一直陈旧到手动刷新。
|
||||
- 🐛 **会话跟随修好了**:插件此前读的是一个**不存在的 `SessionListState.current` 字段**(插件的类型镜像自己造了它,编译期一直放行),导致「按会话持久化」实际没绑上、窄屏 park 门控恒假。现在改用 DSH 0.1.7 的 `ctx.sidebarRight.mounted`(只在该列真正换成另一个会话时才变化)。
|
||||
- 🖥️ **模型侧代价不变**:插件原有的 8 个 `terminal_*` 工具(默认关)已在上一版删除,上游等价物 `@deepseek-ai/dsh-tool-terminal` **仍未被任何 shipped bundle 默认挂载**,需要持久终端时请在 profile 的 `cordis.patch.yml` 里自行插入一行 `tool-terminal`(否则模型只有一次性 `bash` / `pwsh`)。
|
||||
- 📐 **基线**:`@deepseek-ai/dsh-*` 全部钉 `0.1.7-rc.1`,`@deepseek-ai/cordis` peer 下限 `^4.0.3`;`ui-primitives` 图标具名导出整族改名(`Icon<Name><14|16>` → `Icon<Name>Regular` / `Medium`,26 个具名导入随之适配);会话格式 v3→v4(sidechat 边界注入改用 `plugin:dsh-better-sidebar`,tool 结果消息改 `role: 'tool'` 顶层形状,解析器同时接受新旧两种形状以兼容历史日志)。
|
||||
|
||||
> 📜 **更早版本**:完整发布历史见 [CHANGELOG.md](./CHANGELOG.md)(v0.20.0 → v0.12.3)与 [GitHub Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases)。
|
||||
> 📜 **更早版本**:完整发布历史见 [CHANGELOG.md](./CHANGELOG.md)(v0.21.1 → v0.12.3)与 [GitHub Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases)。
|
||||
|
||||
## ⌨️ 快捷键
|
||||
|
||||
|
||||
+17
-21
@@ -3,7 +3,7 @@
|
||||
> [!IMPORTANT]
|
||||
> **Built on DSH's native sidebar API** (since v0.19.0): the right column *is* DSH's own sidebar — the plugin registers every tab type as a native tab (no right panel of its own anymore) and keeps only its self-drawn bottom workbench and the `ctx.betterSidebar` service open to every plugin.
|
||||
>
|
||||
> **Since v0.21.1 the host support floor is DSH `0.1.7-rc.1+`** (peer floor `^0.1.7-rc.1`; v0.22.0 *is* npm's `latest`). DSH 0.1.7 ships a complete document preview of its own, so the plugin hands every read-only preview (spreadsheets / PDF / images / Office) back to the built-in and keeps only Markdown / HTML and the editable code editor. **Hosts on 0.1.6-alpha.2 or earlier should pin `dsh-better-sidebar@0.19.1`** — the DSH-to-plugin version table is in [Installation](#-installation).
|
||||
> **Since v0.21.1 the host support floor is DSH `0.1.7-rc.1+`** (peer floor `^0.1.7-rc.1`; v0.22.1 *is* npm's `latest`). DSH 0.1.7 ships a complete document preview of its own, so the plugin hands every read-only preview (spreadsheets / PDF / images / Office) back to the built-in and keeps only Markdown / HTML and the editable code editor. **Hosts on 0.1.6-alpha.2 or earlier should pin `dsh-better-sidebar@0.19.1`** — the DSH-to-plugin version table is in [Installation](#-installation).
|
||||
|
||||
<!-- Hero -->
|
||||
<div align="center">
|
||||
@@ -14,7 +14,7 @@
|
||||
<a href="https://github.com/omdsh-dev/DSH-better-sidebar/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/omdsh-dev/DSH-better-sidebar" /></a>
|
||||
<a href="https://opensource.org/licenses/MIT"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-yellow.svg" /></a>
|
||||
<a href="https://dshfind.com/en/plugins/omdsh-dev/DSH-better-sidebar?ref=badge"><img alt="dshfind" src="https://dshfind.com/api/badge/omdsh-dev/DSH-better-sidebar?lang=en" /></a><br /><br />
|
||||
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions (v0.22.0): 0.1.7-rc.1+" src="https://img.shields.io/badge/DSH-0.1.7--rc.1%2B-4d6bfe" /></a>
|
||||
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions (v0.22.1): 0.1.7-rc.1+" src="https://img.shields.io/badge/DSH-0.1.7--rc.1%2B-4d6bfe" /></a>
|
||||
<a href="https://github.com/topics/dsh-better-sidebar"><img alt="Plugin ecosystem: GitHub topic dsh-better-sidebar" src="https://img.shields.io/badge/plugin%20ecosystem-topic%20dsh--better--sidebar-4d6bfe" /></a><br /><br />
|
||||
<img alt="File management" src="https://img.shields.io/badge/-File%20management-4d6bfe" /> <img alt="Edit & preview" src="https://img.shields.io/badge/-Edit%20%26%20preview-4d6bfe" /> <img alt="Bottom workbench" src="https://img.shields.io/badge/-Bottom%20workbench-4d6bfe" /> <img alt="Changes" src="https://img.shields.io/badge/-Changes-4d6bfe" /> <img alt="Background tasks" src="https://img.shields.io/badge/-Background%20tasks-4d6bfe" /> <img alt="Side Chat" src="https://img.shields.io/badge/-Side%20Chat-4d6bfe" /> <img alt="Plugin integration" src="https://img.shields.io/badge/-Plugin%20integration-4d6bfe" /><br /><br />
|
||||
<b>A dual workbench (right sidebar + bottom panel)</b> that opens its <code>ctx.betterSidebar</code> service to every plugin —<br />
|
||||
@@ -62,15 +62,15 @@ What this plugin adds on top of DSH's stock sidebar:
|
||||
**Prerequisites**: DSH installed (`dsh web` boots), Node.js ≥ 20, pnpm ≥ 10.
|
||||
|
||||
**Supported DSH versions**:
|
||||
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions (v0.22.0): 0.1.7-rc.1+" src="https://img.shields.io/badge/DSH-0.1.7--rc.1%2B-4d6bfe" /></a>
|
||||
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions (v0.22.1): 0.1.7-rc.1+" src="https://img.shields.io/badge/DSH-0.1.7--rc.1%2B-4d6bfe" /></a>
|
||||
|
||||
> 📌 **Channel and support line**: `v0.22.0` is the **stable release** (npm `latest`) and targets DSH **0.1.7-rc.1+** only. **Pin the DSH version exactly**: `npm i -g @deepseek-ai/dsh@0.1.7-rc.1` (rc.1 rides npm's `next` dist-tag). **Hosts on DSH 0.1.6-alpha.2 or earlier should pin `dsh-better-sidebar@0.19.1`** — 0.1.7's breakage (the settings-service rewrite, the icon-export renames, session format v3→v4) is large enough that this version ships no compatibility layer.
|
||||
> 📌 **Channel and support line**: `v0.22.1` is the **stable release** (npm `latest`) and targets DSH **0.1.7-rc.1+** only. **Pin the DSH version exactly**: `npm i -g @deepseek-ai/dsh@0.1.7-rc.1` (rc.1 rides npm's `next` dist-tag). **Hosts on DSH 0.1.6-alpha.2 or earlier should pin `dsh-better-sidebar@0.19.1`** — 0.1.7's breakage (the settings-service rewrite, the icon-export renames, session format v3→v4) is large enough that this version ships no compatibility layer.
|
||||
|
||||
> 🧭 **Pick the plugin version that matches your DSH**:
|
||||
>
|
||||
> | Your DSH | Install command | Version / peer declared |
|
||||
> | --- | --- | --- |
|
||||
> | **0.1.7-rc.1+** (including a later 0.1.7 stable) | `dsh plugin --profile web add dsh-better-sidebar@latest` | **0.22.0**, `^0.1.7-rc.1` |
|
||||
> | **0.1.7-rc.1+** (including a later 0.1.7 stable) | `dsh plugin --profile web add dsh-better-sidebar@latest` | **0.22.1**, `^0.1.7-rc.1` |
|
||||
> | 0.1.7-alpha.1 / 0.1.7-alpha.2 | **nothing to install** — move DSH to rc.1 first, then run the row above:<br>`npm i -g @deepseek-ai/dsh@0.1.7-rc.1` | — |
|
||||
> | 0.1.6-alpha.2 and earlier, `0.1.5-rc.*` (including the 0.1.5-rc.3 that is npm's `latest`) | `dsh plugin --profile web add dsh-better-sidebar@0.19.1` | **0.19.1**, `^0.1.5-rc.1` |
|
||||
> | `0.1.5-alpha.2` | `dsh plugin --profile web add dsh-better-sidebar@0.19.0-alpha.1` | `^0.1.5-alpha.2` |
|
||||
@@ -106,7 +106,7 @@ If anything fails, check the troubleshooting table in the README at https://gith
|
||||
dsh plugin --profile web add dsh-better-sidebar@latest
|
||||
```
|
||||
|
||||
or bump the version in `~/.dsh/profiles/web/package.json` to the matching npm version (`"^0.22.0"`) and run `pnpm install`. Then hard-refresh the browser (Cmd/Ctrl+Shift+R) — client changes do not need a DSH restart.
|
||||
or bump the version in `~/.dsh/profiles/web/package.json` to the matching npm version (`"^0.22.1"`) and run `pnpm install`. Then hard-refresh the browser (Cmd/Ctrl+Shift+R) — client changes do not need a DSH restart.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -144,7 +144,7 @@ To debug local changes or track the dev branch, point the dependency at a local
|
||||
5. Restart DSH and hard-refresh
|
||||
```
|
||||
|
||||
Update: `git pull && pnpm install && pnpm build` → just hard-refresh the browser (client changes hot-reload; only host-half changes need a DSH restart). To switch back to the npm channel, restore the matching npm version (`"^0.22.0"`) and re-run `pnpm install`.
|
||||
Update: `git pull && pnpm install && pnpm build` → just hard-refresh the browser (client changes hot-reload; only host-half changes need a DSH restart). To switch back to the npm channel, restore the matching npm version (`"^0.22.1"`) and re-run `pnpm install`.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -199,7 +199,15 @@ WeChat / QQ group QR codes will live here. After uploading the QR images (drag t
|
||||
<a href="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0"><img width="33%" alt="Service API base screenshot" src="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0" /></a>
|
||||
</div>
|
||||
|
||||
**Supported DSH versions**: <a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions (v0.22.0): 0.1.7-rc.1+" src="https://img.shields.io/badge/DSH-0.1.7--rc.1%2B-4d6bfe" /></a> · full release history on the [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases) page
|
||||
**Supported DSH versions**: <a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions (v0.22.1): 0.1.7-rc.1+" src="https://img.shields.io/badge/DSH-0.1.7--rc.1%2B-4d6bfe" /></a> · full release history on the [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases) page
|
||||
|
||||
### v0.22.1
|
||||
|
||||
> 📦 **Stable release** (npm `latest`): the support line is **unchanged** — DSH **0.1.7-rc.1+** only (peer floor `^0.1.7-rc.1`, CI pins `@deepseek-ai/dsh@0.1.7-rc.1`), so 0.21.1 / 0.22.0 users can upgrade straight away. Two defects that were **reproducible on a real host while every unit test stayed green** are fixed; **hosts on DSH 0.1.6-alpha.2 or earlier still pin v0.19.1**.
|
||||
|
||||
- 🐛 **The `files` takeover could be orphaned → error spam + an empty tree** (community issues #770 / #771, proven from the desktop shell's own log): during an in-page client entry replacement (plugin-market update, Plugins page disable→enable, HMR rebundle), `sync()`'s drop loop released the `files` takeover — which is **not a descriptor** — and re-created it in the same pass. That re-creation ran on an **already inactive** plugin context: `tabs.register` lives on the HOST context and took the id anyway, while the `ctx.slots.inject` right after it threw `cannot create effect on inactive context`, so the disposer was lost and the id became **unregistrable for the rest of the page's life** (`native register files error: … already registered` spam plus the Files window falling back to the host's empty state until a page refresh). The drop loop now **skips `FILES_KIND`** (the takeover lives and dies by the editor-type switch and the seat disposer only), and **any registration that fails after the host took the id is rolled back** (including slots already installed), leaving only a failure the next notification can retry. Taken from community PR #777 (@yanzhaohui1999).
|
||||
- 🖥️ **macOS desktop: window drag / double-click-title zoom stopped working** (#772): the plugin host is a direct `body` child, so the shell's `html[data-platform=darwin] body > :not(#root) { -webkit-app-region: no-drag }` applied to it — and app-region **ignores `pointer-events`** — leaving the viewport-sized panel layer cancelling every drag strip beneath it (the first drag worked, later ones did not). `[data-dsh-better-sidebar]`, `[data-dsh-panel-host]` and the zoom modal `.mermaidModal` now opt out with the neutral `initial !important`, while panels and their controls stay `no-drag` so clicks are never swallowed. Merges community PR #773 and finishes the job for the zoom modal, the last viewport-sized body child.
|
||||
- ✅ **They stay fixed**: new unit cases pin "a notification must not tear the takeover down" and "a failed registration must release the type and the slots it installed" to the registry's **event log** (4/4 red on the unfixed code), plus a **deployment-level regression gate** `tests/e2e/native-reload.e2e.ts` (red on 3 consecutive runs against npm 0.22.0, green on 3 consecutive runs of the fixed build (one case, run repeatedly)). The drag contract is guarded by unit cases and by a real cascade probe in the mount lane that reads computed values against the shell's own rules. Verification: `pnpm test` 122 files / 1293 passed / 9 skipped, `pnpm test:mount` and `test:mount:aggregate` green. Incident write-up: [docs/plans/2026-09-28-native-files-takeover-reload-leak.md](./docs/plans/2026-09-28-native-files-takeover-reload-leak.md).
|
||||
|
||||
### v0.22.0
|
||||
|
||||
@@ -216,19 +224,7 @@ WeChat / QQ group QR codes will live here. After uploading the QR images (drag t
|
||||
- 🐛 **Four defects caught on a real host, all green in unit tests**: the per-node fold button did nothing (blocked by the automatic rule's guards); an idle card never drew a fold button; claiming a task mislabelled it "blocked"; and "complete" on a queued task always failed (a claim comes first).
|
||||
- 🎨 **Narrow panes and mobile settings**: card and row metrics re-tuned for the native right sidebar's narrow width; the settings page gained a **Mobile** group — on a narrow viewport (≤768px) the Tasks page no longer auto-opens and defaults to the tree view.
|
||||
|
||||
### v0.21.1
|
||||
|
||||
> 📦 **Stable release** (npm `latest`): supports **DSH 0.1.7-rc.1+** only (peer floor `^0.1.7-rc.1`, CI pins `@deepseek-ai/dsh@0.1.7-rc.1`). **Hosts on DSH 0.1.6-alpha.2 or earlier should stay on v0.19.1** — 0.1.7 moves three hard contracts (the settings service, the icon named exports and the session format) and this version writes no runtime compatibility layer. ⚠️ **The previous v0.20.0 was never published to npm**: its terminal / browser handover ships here too, so npm goes straight from 0.19.1 to this version.
|
||||
|
||||
- 🗂️ **Read-only file previews handed to DSH's document preview**: DSH 0.1.7's `ui-sidebar-documentpreview` ships its own spreadsheet / PDF / image / Office rendering (host-side Office→PDF conversion, worker-backed spreadsheet tables, image / PDF zoom, per-directory auto-refresh), so the plugin deleted its `image` / `pdf` / `binary-download` viewers and **refuses** those extensions in `editor.canOpen` — `xlsx xls csv tsv fods pdf png jpg jpeg gif webp svg bmp ico doc docx ppt pptx` — handing the address back to the host. **rc.1 takes nine of them back**: `xlsb` / `xlt` / `xltx` / `xltm` / `ots` / `dot` / `dotx` / `avif` / `ods` have **no host renderer at all** (opening one only said "preview is not available"), yet before the handover they reached the plugin's download pane — a regression we introduced ourselves in the previous version. The plugin's `code` catch-all claims them again. `fods` stays handed over (the host shows that flat XML as plain text, which beats a download pane). — handing the file address back to the host. **Three things the host does not have stay in the plugin**: Markdown (its own renderer), HTML (its own sandboxed preview plus the `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` safety switches), and the **editable** text / code editor (the built-in ones are read-only previews); unknown binaries (`.zip` / `.wasm`) still land on the code editor's download pane after the binary check, so nothing regresses.
|
||||
- 🔗 **External-link takeover narrowed**: the three protocol-routing external-link settings are gone (with their keys in all 20 locale dictionaries). The plugin now takes over **only links a tab type explicitly claims through `urlTarget`** and lets everything else through for the host to route (DSH 0.1.7 adds the user setting `linkOpening`, deciding whether prose links open in the sidebar or a new tab); **when nothing claims a link it does not preventDefault**; a successful claim whose target type is unavailable at open time falls back to `window.open(url, '_blank', 'noopener,noreferrer')` — which also fixes a real regression from the previous version: http links inside plugin-drawn markdown (Side Chat transcripts / editor previews / diff panes) did nothing when clicked. Separately, the host's `browser` kind is **no longer mounted in the Web profile** (0.1.7 mounts it in the desktop profile only).
|
||||
- ⚙️ **Settings surface rewritten + preferences imported automatically**: DSH 0.1.7 removed the registrable settings namespace in favour of **looking a form up by the plugin Loader row's entry id** (`SettingsForms`: only `describe` / `update` / `replace` / `mutate` / `configure` remain). Plugin preferences therefore live in the **profile's cordis patch document** (i.e. this plugin's mount row), not in `~/.dsh/settings.yaml`; the schema comes from the plugin module's exported `Config` (this version merges the user preferences into `Config` and marks every preference field `meta.volatile = true` — **that single flag is the entire "settings apply live, without remounting the plugin" mechanism**). **Your settings are not lost**: on first boot the plugin imports the `dsh-better-sidebar` section of the old `settings.yaml` / `settings.yaml.imported` once (only while that row still has no user values, and only fields the current schema still declares). The entry id is **discovered at runtime** (this bundle defaults to `better-sidebar`; an aggregate bundle mounts it under a different id) and never hardcoded.
|
||||
- 🔄 **Live-refreshing file tree**: the plugin takes over the built-in Files page, so the host's own per-directory watch cannot cover that tree — this version adds `/sidebar/ws/fs-watch`: the client reports the directories it has **expanded**, the host watches exactly those with `fs.watch` (150ms debounce, a 64-handle cap per connection, paths going through the same workspace fence as `fs.tree`), and a change re-lists just that level; collapsing unsubscribes. Before this, the tree stayed stale until a manual refresh.
|
||||
- 🐛 **Session following fixed**: the plugin used to read a **non-existent `SessionListState.current` field** (its own type mirror invented it, so the compiler never complained), which meant per-session persistence was never actually bound and the narrow-viewport park gate was always false. It now uses DSH 0.1.7's `ctx.sidebarRight.mounted` (set only when the column really switches to another session).
|
||||
- 🖥️ **The model-side cost is unchanged**: the plugin's own 8 `terminal_*` tools (off by default) were already removed in the previous version, and the upstream equivalent `@deepseek-ai/dsh-tool-terminal` is **still not mounted by any shipped bundle** — add a `tool-terminal` row to your profile's `cordis.patch.yml` when you need a persistent terminal (otherwise the model only has one-shot `bash` / `pwsh`).
|
||||
- 📐 **Baseline**: every `@deepseek-ai/dsh-*` pins `0.1.7-rc.1`, with the `@deepseek-ai/cordis` peer floor at `^4.0.3`; `ui-primitives` renamed its whole family of named icon exports (`Icon<Name><14|16>` → `Icon<Name>Regular` / `Medium`, 26 named imports adapted); session format v3→v4 (the Side Chat boundary injection now uses `plugin:dsh-better-sidebar`, and tool-result messages use the top-level `role: 'tool'` shape, with parsers accepting both old and new shapes for historical logs).
|
||||
|
||||
> 📜 **Earlier versions**: full release history in [CHANGELOG_EN.md](./CHANGELOG_EN.md) (v0.20.0 → v0.12.3) and on [GitHub Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases).
|
||||
> 📜 **Earlier versions**: full release history in [CHANGELOG_EN.md](./CHANGELOG_EN.md) (v0.21.1 → v0.12.3) and on [GitHub Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases).
|
||||
|
||||
## ⌨️ Keyboard Shortcuts
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 面向 **消费插件开发者**:如何让你的插件向 better-sidebar 注册新的侧边栏页面(tab)和文件类型预览器。
|
||||
>
|
||||
> 适用版本:**v0.4.0+**(`ctx.betterSidebar` 服务);声明式设置 **v0.4.1+**;text/number 设置行 **v0.11.0+**;badge/生命周期/定向打开/插件设置/版本探测 **v0.12.0+**;select 设置行(`settingSelect`)与外链认领(`urlTarget`)**v0.13.0+**;统一 `@deepseek-ai/cordis` 类型基底 **v0.15.2+**。当前版本 **v0.22.0**(npm dist-tag `latest`,peer 下限 `^0.1.7-rc.1`,仅支持 DSH **0.1.7-rc.1+**)。**v0.19.0 移除了自绘右侧面板与自由窗口**(见 §0、§11);**自 v0.20.0 开发线起**(**注意:0.20.0 从未发布到 npm,这些变更全部落在 v0.21.1**)插件**移除了自带的终端**(宿主 0.1.6 的 `ui-sidebar-terminal` 取代,见 §4.4)、**移除了终端固定(pin)**,并在 0.1.7 上**把浏览器视图与只读文件预览整体让给宿主**(`ui-sidebar-browser` / `ui-sidebar-documentpreview`,见 §4.4、§5.4)、**收敛了外链接管**(见 §4.1)、**重写了设置接入面**(`SettingsForms`,见 §8.2)、**给文件树加了实时刷新**(见 §10);同时**删除了轮尾产物行接管**(DSH 0.1.6 把 `conversation.chat.turnTail` 从 chain 改成只能追加的 list,替换语义不复存在)。
|
||||
> 适用版本:**v0.4.0+**(`ctx.betterSidebar` 服务);声明式设置 **v0.4.1+**;text/number 设置行 **v0.11.0+**;badge/生命周期/定向打开/插件设置/版本探测 **v0.12.0+**;select 设置行(`settingSelect`)与外链认领(`urlTarget`)**v0.13.0+**;统一 `@deepseek-ai/cordis` 类型基底 **v0.15.2+**。当前版本 **v0.22.1**(npm dist-tag `latest`,peer 下限 `^0.1.7-rc.1`,仅支持 DSH **0.1.7-rc.1+**)。**v0.19.0 移除了自绘右侧面板与自由窗口**(见 §0、§11);**自 v0.20.0 开发线起**(**注意:0.20.0 从未发布到 npm,这些变更全部落在 v0.21.1**)插件**移除了自带的终端**(宿主 0.1.6 的 `ui-sidebar-terminal` 取代,见 §4.4)、**移除了终端固定(pin)**,并在 0.1.7 上**把浏览器视图与只读文件预览整体让给宿主**(`ui-sidebar-browser` / `ui-sidebar-documentpreview`,见 §4.4、§5.4)、**收敛了外链接管**(见 §4.1)、**重写了设置接入面**(`SettingsForms`,见 §8.2)、**给文件树加了实时刷新**(见 §10);同时**删除了轮尾产物行接管**(DSH 0.1.6 把 `conversation.chat.turnTail` 从 chain 改成只能追加的 list,替换语义不复存在)。
|
||||
> 权威代码:`src/client/service.ts`(服务实现)、`src/client/builtins/`(内置 5 tab + 3 viewer 参考实现)、`lib/types/client/service.d.ts`(类型声明)。
|
||||
> 仓库开发规则(硬约束 / CI / 发版)见 [AGENTS.md](../AGENTS.md)。
|
||||
|
||||
@@ -997,6 +997,7 @@ interface SettingsDescriptor {
|
||||
- **迁移**:`titleBarScheme` 无默认值;旧文档已有值(`titleBarCompat === true` 或 `titleBarStripPx` 非 40)→ 迁 `custom`;干净文档 → `auto`。
|
||||
- **用户空间 CSS**:预设/自定义 css 注入 `<style data-dsh-preset-css|data-dsh-custom-css>` 到 head 末尾(后写胜出;覆盖 JS 内联需 `!important`),fiber 卸载即移除。稳定寻址面:`[data-dsh-toggle-cluster]` / `[data-dsh-panel]` / `[data-dsh-bottom-panel]`。
|
||||
- **拖拽区退出**:交互 chrome(`.toggleCluster` / `.toggleButton` / `.tabBar`)统一 `-webkit-app-region: no-drag`(无边框壳拖拽带吞点击,#103/#111)。
|
||||
- **拖拽区退出(视口层)**:宿主把每个**直挂 body 的子元素**都设成 `no-drag`(`html[data-platform=darwin] body > :not(#root)`,选择器含 id,只能靠 `!important` 压过),而 app-region **无视 `pointer-events`**——插件宿主就是这样一个 body 子元素,在 Electron 里该值还会传到它内部的**铺满视口的面板层**(#772 的 CDP 实测;挂载 lane 用的浏览器引擎不传播该属性,所以面板层自己也声明了同一条规则),于是它把下面每条 `[data-window-drag]` 拖拽带一起抵消(窗口拖一次就失效,同时丢掉 macOS 双击标题栏缩放,issue #772)。契约:装饰性的视口层用**中性值** `-webkit-app-region: initial !important` 退出计算(`initial` 的计算值 `none` 不扣减拖拽区;注意**字面量 `none` 不是中性值**——它计算成 `no-drag`,是扣减值),层内的面板/控件再声明 `no-drag` 保住点击;当前覆盖 `[data-dsh-better-sidebar]`、`[data-dsh-panel-host]`(`> *` 保持 `no-drag`)与 `.mermaidModal`(放大视图是第二个**持久**铺满视口的 body 直挂层)。**交互弹层不要加 `initial`**(`.selectionPopup` 是 `<button>`,`FloatingWindow` / `AnchoredPopover` 自带指针拖拽、不是遮罩层):宿主 `:is(button, a, input, …)` 已给它们 `no-drag`,反过来声明 `initial !important` 会让按下变成拖窗(重演 #103/#111)。放大视图是**遮罩层例外**——它是模态,只有其中的控件必须保持 `no-drag`;代价是它覆盖在顶部拖拽带上时,点背景会变成拖窗而不是关闭(为「放大图时窗口仍可拖」付的账)。**未纳入**:`FileTree` 的 `.uploadDropChatHint` 同样是 body 直挂的装饰层,但它只在 OS 文件拖拽悬停期间存在且 `pointer-events: none`,收益极小、暂不处理(`FloatingWindow` / `AnchoredPopover` 是交互层,按上面的判据本就该保持 `no-drag`)。形状由 `tests/panel-host-css.spec.ts` 在 Linux 的 `pnpm test` 里守护(无 macOS runner;`tests/theme.spec.ts` 只守颜色,故本轮不涉及),真实级联由挂载 lane 的探针按宿主规则断言计算值(面板宿主探针复现宿主两条 darwin 规则,放大视图探针直接用页面里宿主自己的规则)。
|
||||
- **z-index**:面板宿主层 25、按钮簇 45——低于 DSH ui-cordis 插件面板(30)与浮层栈(100/1000+),浮层天然盖住侧边栏。
|
||||
|
||||
### 12.2 注意事项
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# `files` 接管 id 泄漏:客户端条目替换后报错刷屏 + 文件树空态
|
||||
|
||||
日期:2026-09-28 分支:`fix/native-registration-leak-v2`(PR #777,已合并)→ 后续 `fix/native-leak-guard-tests`
|
||||
|
||||
关联 issue:[#771](https://github.com/omdsh-dev/DSH-better-sidebar/issues/771)(同串报错的独立复现与定位)、[#770](https://github.com/omdsh-dev/DSH-better-sidebar/issues/770)(同串报错,另一条 inject re-prove 猜测)、[#766](https://github.com/omdsh-dev/DSH-better-sidebar/pull/766)(「吸收」方案,已按本文结论关闭)
|
||||
|
||||
## 现象
|
||||
|
||||
```
|
||||
[dsh-better-sidebar] native register files error: sidebarRight: tab type id "dsh-better-sidebar:files" is already registered
|
||||
```
|
||||
|
||||
红色诊断条钉在页面左下角、触发时**同一毫秒刷出多条**;不致命,但 `files`(文件树)接管会**静默缺位**——原生右侧栏的「文件」入口渲染成宿主的空态,直到**刷新页面**才恢复。0.21.1 与 0.22.0(npm `latest`)都带此缺陷。
|
||||
|
||||
## 根因(真机日志 + 代码双向验证)
|
||||
|
||||
官方桌面壳日志 `~/Library/Application Support/dsh-tauri/logs/desktop.frontdesk.log:2370`(2026-09-27 15:51:39.338)第一条就是完整栈:
|
||||
|
||||
```
|
||||
native register files error: Error: cannot create effect on inactive context
|
||||
assertActive ← effect ← inject(slots) ← registerSlots ← registerFilesKind ← sync ← notify
|
||||
```
|
||||
|
||||
随后同一毫秒(.339–.340)**7 条** `already registered`(栈:`register ← registerFilesKind ← sync ← notify`),13 秒后新激活的 `_reload` 路径又报 2 条(该 burst 合计 9 条,与「7 个内置描述符注销 → 7 次 notify」一一对应)。链条:
|
||||
|
||||
1. `service.notify()` 是同步内联(`src/client/service.ts:696`),而 `registerNativeSurface` 把 `sync()` 订阅到它和 store 上(`src/client/native/index.ts:353`;本文行号按 v0.22.0,main 因 #777 的插入而顺移)。
|
||||
2. `sync()` 的清理循环把 `live` 里**两类归属不同的条目**一视同仁:描述符类型(键 = descriptor id)与 `files` 接管(键 = `FILES_KIND`,**永远不在 `wanted` 里**)。于是**每次**通知都会释放接管、并在同一轮里重建它。
|
||||
3. 插件 teardown 时,内置描述符逐个注销 → 每次注销 notify → `sync()` 在**已经 inactive** 的插件 ctx 上运行。此时 `tabs.register` 建在**宿主** ctx 上(宿主 ctx 仍活着)→ id 被取走且返回了 disposer;紧随的 `ctx.slots.inject` 建在**插件** ctx 上 → cordis `assertActive()` 抛 `INACTIVE_EFFECT`(`@deepseek-ai/cordis@4.0.4` `effect()` 首行)→ `live.set(FILES_KIND, …)` 永不执行 → **该 id 在本页生命周期内再也不能注册**。
|
||||
4. 之后每次 notify(同一 burst 里还有 6 次)都重试同一个被占用的 id → `already registered` 刷屏;新一次激活同样注册不上 → 文件树空态。
|
||||
|
||||
**两条一般化的不变量**(已写进 [AGENTS.md](../../AGENTS.md) §3.4 第 9 条):
|
||||
|
||||
- 清理循环必须**跳过 `FILES_KIND`**;接管只由编辑器类型的 `wantsFiles/hasFiles` 分支与 seat disposer 管理。
|
||||
- 「注册成功一半」是**可达状态**(宿主 ctx 与插件 ctx 的寿命不同),因此任何在宿主取走 id **之后**失败的注册必须**回滚释放该 type**(并释放已建槽位),插件侧只留一个下次通知可重试的失败。
|
||||
|
||||
## 修复
|
||||
|
||||
PR #777(贡献者 @yanzhaohui1999)落地上面两条:`sync()` 的清理循环跳过 `FILES_KIND`;`registerSlots` 失败时释放已建槽位;`registerDescriptor` / `registerFilesKind` 在槽位注册失败后回滚已占用的 type;teardown / 清理循环逐个安全释放(一个释放抛错不再中断其余)。
|
||||
|
||||
**否决备选方案**(PR #766 的「吸收」):把宿主抛出的 `already registered` 当作「宿主已持有该行」继续注册槽位。它能让日志安静,但会在 `live` 里记下一份**不拥有**的注册(空 disposer)——旧持有者随后释放该 id 时无人补注册,`files` 接管可能永久缺位;而且在 `tabs.register` 抛错(id 守卫,原子,未取走任何东西)与「取走后才失败」两种情形之间不做区分,掩盖了真正的账本错误。
|
||||
|
||||
## 验证
|
||||
|
||||
- **单测红→绿(实测)**:`tests/native-registration.spec.ts` 在 v0.22.0 源码上 4/4 红,在修复分支上 4/4 绿;`pnpm typecheck` / `pnpm lint` / `pnpm test` 全绿(修复分支的 base 早于 #781,故为 114 files / 1136 passed;合入 main 后为 122 files / 1293 passed / 9 skipped)。
|
||||
- **部署级红→绿(实测)**:`tests/e2e/native-reload.e2e.ts` 在 scratch profile 里对 npm 上的 `dsh-better-sidebar@0.22.0` 触发页面内条目替换(`utimesSync` 已安装的 `lib/client.js`),连续 3 次运行**全红**且每次都是本文那两条栈;换成本地打包的修复版连续 3 次**全绿**(该用例只有 1 个 test,重复跑三次)。该用例已随本 PR 进入挂载 lane(CI `plugin-mount` 绿),触发方式依赖钉住的宿主 0.1.7-rc.1 的 rev 规则(`mtime/ctime/size` 的 sha1)——pin 上调时需同步复核。
|
||||
- 首版用例的两处断言(「失败后还能重新注册」)写在**活着的 id 集合**上,在未修复代码上会**同样通过**(泄漏的 id 恰好构成同一个集合)。跟进 PR 把它们改写到注册表的**事件日志**上,并把槽位失败判据从 `key` 改成 `name::key` —— 两个槽位共用一个 `key`,按 `key` 失败时**第一个**槽就抛,回收循环虽然进了、但手里的 disposer 列表永远是空的(即 `registerSlots` 的部分回滚**释放动作**从未被执行);改后可以让某个描述符的**第二个**槽失败,回收列表非空、释放路径真正被覆盖,且 4 例在 v0.22.0 上仍全部红。
|
||||
|
||||
## 已知限制 / 未做
|
||||
|
||||
- **跨激活的瞬时重复**仍未消除:页面内条目替换时,新激活可能先于旧激活释放 id 而撞上 `already registered`(一次日志噪音,随后自愈)。要消掉它需要「吸收 + 旧持有者释放后补注册」的机制,与上面否决的方案同源,暂不做。
|
||||
- `disposeSafely` 的释放失败只写 `console.error`、**不弹诊断条**(teardown 阶段弹条是噪音;真被占住的 id 会在下一次注册尝试里经 `reportFailure` 显式报出)。理由写在源码注释里。
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "dsh-external/dsh-better-sidebar",
|
||||
"version": "0.22.0",
|
||||
"version": "0.22.1",
|
||||
"main": "./lib/index.js",
|
||||
"description": "更好用的右侧边栏——文件树、编辑器、文件变动、任务和侧边对话都收在这一栏里,每个会话互不干扰。暴露服务供其他插件注册侧边栏页面与文件预览器",
|
||||
"engines": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dsh-better-sidebar",
|
||||
"version": "0.22.0",
|
||||
"version": "0.22.1",
|
||||
"description": "Better Sidebar for DSH — file tree, editor, changes, tasks and side chat in one column, separate for every conversation. Also exposes a service other plugins use to register sidebar tabs and file viewers.",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
|
||||
+101
-29
@@ -156,6 +156,28 @@ interface Registration {
|
||||
readonly dispose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one registration disposer, reporting a failure instead of propagating
|
||||
* it: releasing a registration must never mask the error being handled, and
|
||||
* one broken entry must not leave the others registered.
|
||||
*
|
||||
* A release failure is logged, NOT routed through `reportFailure` (the visible
|
||||
* diagnostic strip), on purpose: both callers run while the surface is being
|
||||
* taken down — the drop loop and the seat teardown — where a strip would just
|
||||
* be noise, and an id that really stays taken cannot hide anyway: the next
|
||||
* registration attempt for it goes through the strip as
|
||||
* `register … error: … already registered`.
|
||||
* @param dispose - the disposer to run.
|
||||
* @param what - the registration's name, for the log line.
|
||||
*/
|
||||
function disposeSafely(dispose: () => void, what: string): void {
|
||||
try {
|
||||
dispose()
|
||||
} catch (error) {
|
||||
console.error(`[dsh-better-sidebar] ${what} release failed:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Everything the registrations need. */
|
||||
export interface NativeSurfaceDeps {
|
||||
readonly ctx: Context
|
||||
@@ -208,23 +230,44 @@ export function registerNativeSurface(deps: NativeSurfaceDeps): () => void {
|
||||
return address !== undefined && address.scope === 'session' ? address.sessionId : undefined
|
||||
}
|
||||
|
||||
/** Register the body + chip-title slots for one native implementation id. */
|
||||
/**
|
||||
* Register the body + chip-title slots for one native implementation id.
|
||||
*
|
||||
* A slot registration can fail long after the TYPE registration succeeded
|
||||
* (`ctx.effect` on a context that is already inactive, i.e. during a
|
||||
* plugin reload/disposal), so the disposers are collected as they are
|
||||
* handed out and the partial set is released on the way out: the caller
|
||||
* must be able to roll its type registration back WITHOUT losing track of
|
||||
* a slot that did register.
|
||||
*/
|
||||
const registerSlots = (
|
||||
id: string,
|
||||
injected: Omit<NativeBodyInjected, 'sessionId'>,
|
||||
params: Pick<NativeBodyInjected, 'paramsOf' | 'sessionIdOf'>,
|
||||
): Array<() => void> => [
|
||||
ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
|
||||
name: 'sidebar.right.pane.tab',
|
||||
key: id,
|
||||
inject: (sessionId: string) => ({ ...injected, ...params, sessionId }),
|
||||
}, NativeTabBody)),
|
||||
ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({
|
||||
name: 'sidebar.right.pane.tab.title',
|
||||
key: id,
|
||||
inject: () => ({ records, service, descriptorId: injected.descriptorId }),
|
||||
}, NativeTabTitle)),
|
||||
]
|
||||
): Array<() => void> => {
|
||||
// Collected AS THEY ARE HANDED OUT: a slot registration can fail long
|
||||
// after the TYPE registration succeeded (a plugin reload makes
|
||||
// `ctx.slots.inject` throw on the now-inactive context), and the caller
|
||||
// must then be able to release the type again WITHOUT losing track of a
|
||||
// slot that did register.
|
||||
const disposers: Array<() => void> = []
|
||||
try {
|
||||
disposers.push(ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
|
||||
name: 'sidebar.right.pane.tab',
|
||||
key: id,
|
||||
inject: (sessionId: string) => ({ ...injected, ...params, sessionId }),
|
||||
}, NativeTabBody)))
|
||||
disposers.push(ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({
|
||||
name: 'sidebar.right.pane.tab.title',
|
||||
key: id,
|
||||
inject: () => ({ records, service, descriptorId: injected.descriptorId }),
|
||||
}, NativeTabTitle)))
|
||||
} catch (error) {
|
||||
for (const dispose of disposers) disposeSafely(dispose, `native tab slot "${id}"`)
|
||||
throw error
|
||||
}
|
||||
return disposers
|
||||
}
|
||||
|
||||
/** One descriptor's native type + body + title, as one disposable. */
|
||||
const registerDescriptor = (descriptor: TabDescriptor): (() => void) => {
|
||||
@@ -271,14 +314,25 @@ export function registerNativeSurface(deps: NativeSurfaceDeps): () => void {
|
||||
}],
|
||||
}),
|
||||
})
|
||||
const slots = registerSlots(
|
||||
id,
|
||||
{ ctx, store, service, records, descriptorId: descriptor.id },
|
||||
isEditor ? { paramsOf: fileParamsOf, sessionIdOf: fileSessionIdOf } : {},
|
||||
)
|
||||
// The type is in the host's registry the moment `tabs.register`
|
||||
// returns; a slot failure must release it here, or the id stays taken
|
||||
// for the rest of the page's life (the host refuses a second
|
||||
// registration of the same id) and the tab kind renders the host's
|
||||
// "nothing can view this" face forever.
|
||||
let slots: Array<() => void>
|
||||
try {
|
||||
slots = registerSlots(
|
||||
id,
|
||||
{ ctx, store, service, records, descriptorId: descriptor.id },
|
||||
isEditor ? { paramsOf: fileParamsOf, sessionIdOf: fileSessionIdOf } : {},
|
||||
)
|
||||
} catch (error) {
|
||||
disposeSafely(disposeType, `native tab type "${id}"`)
|
||||
throw error
|
||||
}
|
||||
return () => {
|
||||
for (const dispose of slots.reverse()) dispose()
|
||||
disposeType()
|
||||
for (const dispose of slots.reverse()) disposeSafely(dispose, `native tab slot "${id}"`)
|
||||
disposeSafely(disposeType, `native tab type "${id}"`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,10 +358,16 @@ export function registerNativeSurface(deps: NativeSurfaceDeps): () => void {
|
||||
...guideIconOf(editor?.icon),
|
||||
}],
|
||||
})
|
||||
const slots = registerSlots(id, { ctx, store, service, records, descriptorId: EDITOR_KIND }, {})
|
||||
let slots: Array<() => void>
|
||||
try {
|
||||
slots = registerSlots(id, { ctx, store, service, records, descriptorId: EDITOR_KIND }, {})
|
||||
} catch (error) {
|
||||
disposeSafely(disposeType, `native tab type "${id}"`)
|
||||
throw error
|
||||
}
|
||||
return () => {
|
||||
for (const dispose of slots.reverse()) dispose()
|
||||
disposeType()
|
||||
for (const dispose of slots.reverse()) disposeSafely(dispose, `native tab slot "${id}"`)
|
||||
disposeSafely(disposeType, `native tab type "${id}"`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,14 +379,23 @@ export function registerNativeSurface(deps: NativeSurfaceDeps): () => void {
|
||||
wanted.set(descriptor.id, () => registerDescriptor(descriptor))
|
||||
}
|
||||
for (const [descriptorId, registration] of live) {
|
||||
if (wanted.has(descriptorId)) continue
|
||||
registration.dispose()
|
||||
// `files` is this module's OWN takeover, never a descriptor: it is
|
||||
// owned by the editor-type switch below. Re-creating it here on every
|
||||
// notification used to put its host-side type through a
|
||||
// tear-down/re-register window on each store commit — and a
|
||||
// re-registration attempted on an already-inactive context (a plugin
|
||||
// reload) left the type registered with nobody holding its disposer,
|
||||
// so the same id could never be registered again for the rest of the
|
||||
// page's life.
|
||||
if (descriptorId === FILES_KIND || wanted.has(descriptorId)) continue
|
||||
disposeSafely(() => registration.dispose(), `native tab type "${descriptorId}"`)
|
||||
live.delete(descriptorId)
|
||||
}
|
||||
for (const [descriptorId, create] of wanted) {
|
||||
if (live.has(descriptorId)) continue
|
||||
// One descriptor must not take the rest of the surface down with it:
|
||||
// report and continue so the remaining types still register.
|
||||
// report and continue so the remaining types still register. `create`
|
||||
// has already released whatever it managed to install.
|
||||
try {
|
||||
live.set(descriptorId, { dispose: create() })
|
||||
} catch (error) {
|
||||
@@ -345,17 +414,20 @@ export function registerNativeSurface(deps: NativeSurfaceDeps): () => void {
|
||||
}
|
||||
}
|
||||
if (!wantsFiles && hasFiles) {
|
||||
live.get(FILES_KIND)?.dispose()
|
||||
const takeover = live.get(FILES_KIND)
|
||||
live.delete(FILES_KIND)
|
||||
if (takeover !== undefined) disposeSafely(() => takeover.dispose(), 'the native "files" takeover')
|
||||
}
|
||||
}
|
||||
|
||||
const disposeSubscriptions = [service.subscribe(sync), store.subscribe(sync)]
|
||||
sync()
|
||||
return () => {
|
||||
for (const registration of live.values()) registration.dispose()
|
||||
for (const registration of live.values()) {
|
||||
disposeSafely(() => registration.dispose(), 'a native tab registration')
|
||||
}
|
||||
live.clear()
|
||||
for (const dispose of disposeSubscriptions.reverse()) dispose()
|
||||
for (const dispose of disposeSubscriptions.reverse()) disposeSafely(dispose, 'a native tab subscription')
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
|
||||
@@ -633,7 +633,7 @@ export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDes
|
||||
* The plugin version this service instance reports. Keep in lockstep with
|
||||
* `package.json`'s version — `tests/service.spec.ts` asserts the pair.
|
||||
*/
|
||||
export const SIDEBAR_SERVICE_VERSION = '0.22.0'
|
||||
export const SIDEBAR_SERVICE_VERSION = '0.22.1'
|
||||
|
||||
/**
|
||||
* Monotonic capability list consumers use to gate new API usage (features
|
||||
|
||||
@@ -63,6 +63,38 @@
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
/* Desktop drag regions ignore pointer-events: the shell's base stylesheet
|
||||
makes every direct body child `no-drag`, and the plugin HOST is one — in
|
||||
Electron that value also reaches the viewport-sized panel layer inside it
|
||||
(#772's CDP reading; the browser engine used by the mount lane does not
|
||||
propagate it, so the layer's own rule below is belt-and-braces), which is
|
||||
what cancelled every window-drag strip beneath it once the shell
|
||||
recomputed regions (window drags worked once, then stopped). Take the host
|
||||
and layer out of the app-region computation; `!important` because the
|
||||
shell's `body > :not(#root)` selector outranks ours. The panels themselves
|
||||
stay no-drag so their controls keep receiving clicks.
|
||||
The plugin's OTHER persistent viewport-sized body child — the mermaid zoom
|
||||
modal — carries the same reset on its own rule further down, or opening a
|
||||
diagram would blank window drag (and the macOS double-click-title zoom)
|
||||
while it is up. It is a modal SCRIM, so it does get the reset even though
|
||||
it is interactive: only its widgets (`<button>` …) need to stay no-drag,
|
||||
and the residual is that its backdrop inside the shell's top drag band
|
||||
starts a window drag instead of closing the modal — the deliberate trade
|
||||
for keeping the window draggable while a diagram is enlarged.
|
||||
Deliberately NOT applied to the other interactive popups (`.selectionPopup`
|
||||
is a `<button>`; FloatingWindow / AnchoredPopover own pointer drags and are
|
||||
not scrims): the shell's `:is(button, a, input, …)` rule already keeps those
|
||||
no-drag, and `initial !important` would outrank it and start a window drag on
|
||||
press instead — the click-swallow regression of issues #103/#111. */
|
||||
:global(html[data-platform] body > [data-dsh-better-sidebar]),
|
||||
:global([data-dsh-panel-host]) {
|
||||
-webkit-app-region: initial !important;
|
||||
}
|
||||
|
||||
:global([data-dsh-panel-host]) > * {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
:global([data-dsh-panel-host][data-dsh-panel-host-degraded]) {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -1698,6 +1730,15 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
|
||||
.mermaidModal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
/* Viewport-sized body child: the shell makes every direct body child
|
||||
`no-drag` (`html[data-platform=darwin] body > :not(#root)`, an
|
||||
id-bearing selector, hence `!important`), and app-region ignores
|
||||
pointer-events — without this reset the open modal cancels every
|
||||
window-drag strip under it plus the macOS double-click-title zoom.
|
||||
Same contract as the panel host rule above; `initial` computes to the
|
||||
neutral `none`, and the toolbar's buttons keep their own no-drag from
|
||||
the shell's `:is(button, …)` rule. */
|
||||
-webkit-app-region: initial !important;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -517,6 +517,28 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async
|
||||
const modal = page.locator('[data-mermaid-modal]')
|
||||
await pane.locator('[data-mermaid-diagram] svg').first().click()
|
||||
await expect(modal, 'clicking the diagram must open the zoom modal').toHaveCount(1, { timeout: 10_000 })
|
||||
// While it is up, the modal is the plugin's OTHER viewport-sized BODY CHILD:
|
||||
// without the app-region reset the shell's blanket `body > :not(#root)`
|
||||
// no-drag makes it cancel every window-drag strip (and the macOS
|
||||
// double-click-title zoom) until it closes — issue #772. Measured against the
|
||||
// SHELL's own darwin rules (present in this page's stylesheet, activated by
|
||||
// the platform attribute) rather than a copy: the modal must compute the
|
||||
// neutral `none`, while its toolbar buttons keep `no-drag` so a click on them
|
||||
// is never a window drag.
|
||||
const modalRegions = await page.evaluate(() => {
|
||||
const previous = document.documentElement.dataset.platform
|
||||
document.documentElement.dataset.platform = 'darwin'
|
||||
const modalElement = document.querySelector('[data-mermaid-modal]')
|
||||
const region = (element: Element | null): string => element === null
|
||||
? 'missing'
|
||||
: getComputedStyle(element).getPropertyValue('-webkit-app-region').trim()
|
||||
const result = { modal: region(modalElement), button: region(modalElement?.querySelector('button') ?? null) }
|
||||
if (previous === undefined) delete document.documentElement.dataset.platform
|
||||
else document.documentElement.dataset.platform = previous
|
||||
return result
|
||||
})
|
||||
expect(modalRegions.modal, 'the zoom modal must opt out of app-region computation').toBe('none')
|
||||
expect(modalRegions.button, 'its buttons must stay no-drag').toBe('no-drag')
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(modal, 'Esc must close the zoom modal').toHaveCount(0, { timeout: 10_000 })
|
||||
await assertNoCrash()
|
||||
@@ -615,6 +637,61 @@ test('conservative auto: URL stamps alone never modify the layout; plugin chrome
|
||||
return false
|
||||
})
|
||||
expect(hasNoDragRule, 'the bundle must ship the drag-region opt-out rule').toBe(true)
|
||||
// The plugin host is a direct body child, so on macOS it picks up the
|
||||
// shell's blanket `body > :not(#root) { no-drag }` and — because app-region
|
||||
// ignores pointer-events — the viewport-sized layer inside it cancelled every
|
||||
// window-drag strip beneath it (issue #772). A text match only proves the
|
||||
// declaration shipped, so this probe reproduces the SHELL's two darwin rules
|
||||
// verbatim, flips the platform attribute the shell stamps, and reads the
|
||||
// COMPUTED property. Two things to know when reading the expectations below:
|
||||
// the strip itself computes `drag` in every configuration (a fix validated
|
||||
// against the strip alone proves nothing), and `[data-dsh-panel-host]` is NOT
|
||||
// a body child (it lives inside the host), so its own value is pinned by the
|
||||
// unit guard rather than by this probe — see the `layer` expectation.
|
||||
const regions = await page.evaluate(() => {
|
||||
const style = document.createElement('style')
|
||||
style.textContent = [
|
||||
'html[data-platform=darwin] [data-window-drag]{-webkit-app-region:drag}',
|
||||
'html[data-platform=darwin] body>:not(#root){-webkit-app-region:no-drag}',
|
||||
].join('\n')
|
||||
const strip = document.createElement('div')
|
||||
strip.setAttribute('data-window-drag', '')
|
||||
strip.style.cssText = 'position:fixed;top:0;left:0;right:0;height:8px'
|
||||
const probe = document.createElement('div')
|
||||
document.head.appendChild(style)
|
||||
// INSIDE #root: the shell's real drag strip is a frame child, not a body
|
||||
// child — a body child would be caught by the blanket rule itself (it is
|
||||
// id-bearing, so it outranks the `[data-window-drag]` rule).
|
||||
const root = document.querySelector('#root')
|
||||
;(root ?? document.body).appendChild(strip)
|
||||
const host = document.querySelector('[data-dsh-better-sidebar]')
|
||||
const layer = document.querySelector('[data-dsh-panel-host]')
|
||||
// This stamp-only page has no session, so the panel host has no child of
|
||||
// its own; `probe` stands in for the workbench panel the same `> *`
|
||||
// selector targets in a real session.
|
||||
layer?.appendChild(probe)
|
||||
const previous = document.documentElement.dataset.platform
|
||||
document.documentElement.dataset.platform = 'darwin'
|
||||
const read = (element: Element | null): string => element === null
|
||||
? 'missing'
|
||||
: getComputedStyle(element).getPropertyValue('-webkit-app-region').trim()
|
||||
const result = { strip: read(strip), host: read(host), layer: read(layer), layerChild: read(probe) }
|
||||
probe.remove()
|
||||
strip.remove()
|
||||
style.remove()
|
||||
if (previous === undefined) delete document.documentElement.dataset.platform
|
||||
else document.documentElement.dataset.platform = previous
|
||||
return result
|
||||
})
|
||||
expect(regions.strip, 'the drag strip itself must keep computing `drag`').toBe('drag')
|
||||
expect(regions.host, 'the plugin host must not take the shell blanket no-drag').toBe('none')
|
||||
// NOT discriminating here, and kept as a regression nail only: the layer is
|
||||
// not a body child, and this engine does not propagate the property into it,
|
||||
// so it reads `none` pre- and post-fix. Its rule is pinned by
|
||||
// tests/panel-host-css.spec.ts; in Electron (#772's CDP reading) the layer
|
||||
// DOES end up no-drag through that propagation.
|
||||
expect(regions.layer, 'the panel layer must keep its own reset').toBe('none')
|
||||
expect(regions.layerChild, 'panels stay no-drag so their controls keep receiving clicks').toBe('no-drag')
|
||||
})
|
||||
|
||||
test('standard WCO geometry drives the strip reactively (issue #257)', async ({ page }) => {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* In-page CLIENT ENTRY REPLACEMENT ("plugin reload") — the deployment-level
|
||||
* guard for the `dsh-better-sidebar:files` orphaned-tab-type bug.
|
||||
*
|
||||
* Field symptom (issues #770/#771, reproduced on the official desktop shell):
|
||||
* a client-side replacement of the plugin entry — a plugin-market update, a
|
||||
* disable→enable in the Plugins page, an HMR rebundle — left the host's tab
|
||||
* type id `dsh-better-sidebar:files` registered with nobody holding its
|
||||
* disposer, so the plugin could never register it again: every later `sync()`
|
||||
* reported `native register files error: … already registered` and the Files
|
||||
* takeover rendered the host's "nothing here can view this kind of content"
|
||||
* face until a page refresh.
|
||||
*
|
||||
* Root cause (fixed in v0.22.1): `sync()`'s drop loop treated the plugin's own
|
||||
* `files` takeover like a service descriptor, so every notification tore it
|
||||
* down and re-created it in the same pass. During the reload the re-creation
|
||||
* ran on the dying plugin context: `tabs.register` (host context, still alive)
|
||||
* took the id and `ctx.slots.inject` (plugin context) then threw
|
||||
* `cannot create effect on inactive context`, losing the disposer.
|
||||
*
|
||||
* The trigger here is the same one the host's own HMR uses: the installed
|
||||
* client bundle is stat-polled, and a changed revision (`mtime`/`ctime`/`size`)
|
||||
* makes the host tear the entry's fiber down and re-import it IN PAGE — no
|
||||
* full page navigation, which is what made the old failure permanent.
|
||||
*/
|
||||
import { mkdirSync, utimesSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
import { PAGE_URL, createHostApi, hostRpc, sendFirstMessage } from './host'
|
||||
|
||||
/** Workspace the sidebar renders against (created by this lane's seeding). */
|
||||
const WORKSPACE_PATH = process.env.DSH_E2E_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-workspace')
|
||||
|
||||
/** A file seeded into the workspace, opened through the Files takeover. */
|
||||
const SEEDED_FILE = 'hello.txt'
|
||||
|
||||
/**
|
||||
* The installed client bundle the host's HMR stat-poll watches. Derived from
|
||||
* the profile the server was booted with (`DSH_HOME`), overridable for a
|
||||
* hand-run lane.
|
||||
*/
|
||||
const CLIENT_JS = process.env.DSH_E2E_PLUGIN_CLIENT_JS
|
||||
?? (process.env.DSH_HOME === undefined
|
||||
? undefined
|
||||
: join(process.env.DSH_HOME, 'profiles/web/node_modules/dsh-better-sidebar/lib/client.js'))
|
||||
|
||||
/** The strip `fail()` pins to <body>; the plugin's registration failures land here. */
|
||||
const REGISTRATION_ERROR = /native register .* error/
|
||||
|
||||
let api: APIRequestContext
|
||||
|
||||
test.beforeAll(async () => {
|
||||
mkdirSync(WORKSPACE_PATH, { recursive: true })
|
||||
writeFileSync(join(WORKSPACE_PATH, SEEDED_FILE), 'hello from the reload lane\n')
|
||||
api = await createHostApi()
|
||||
const workspace = await hostRpc<{ workspace: { workspaceId: string } }>(
|
||||
api, 'workspace.create', { path: WORKSPACE_PATH },
|
||||
)
|
||||
await hostRpc(api, 'session.create', { workspaceId: workspace.value.workspace.workspaceId })
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await api?.dispose()
|
||||
})
|
||||
|
||||
test('the native tab types survive an in-page client entry replacement', async ({ page }) => {
|
||||
// A hard failure, not a skip: this gate's whole value is that it cannot
|
||||
// pass quietly when the harness stops telling us where the bundle lives.
|
||||
if (CLIENT_JS === undefined) {
|
||||
throw new Error('DSH_HOME (or DSH_E2E_PLUGIN_CLIENT_JS) must point at the profile running this server')
|
||||
}
|
||||
const consoleErrors: string[] = []
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') consoleErrors.push(message.text())
|
||||
})
|
||||
|
||||
await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 })
|
||||
const sidebar = page.locator('[data-dsh-better-sidebar]')
|
||||
await expect(sidebar).toBeAttached({ timeout: 90_000 })
|
||||
|
||||
// A keyless boot stacks onboarding takeovers over the shell (versioned
|
||||
// welcome notice, then the provider-config dialog). Same bounded dismissal
|
||||
// loop as the mount lane: a masked click is retried, an absent takeover is
|
||||
// fine.
|
||||
try {
|
||||
await expect
|
||||
.poll(() => page.getByRole('button', { name: /^(Continue|Configure later)$/ }).count(), { timeout: 60_000 })
|
||||
.toBeGreaterThan(0)
|
||||
} catch {
|
||||
console.warn('[e2e] no onboarding takeover appeared; proceeding without dismissal')
|
||||
}
|
||||
for (let round = 0; round < 8; round++) {
|
||||
let dismissed = false
|
||||
for (const name of ['Continue', 'Configure later']) {
|
||||
const button = page.getByRole('button', { name, exact: true }).first()
|
||||
if ((await button.count()) === 0) continue
|
||||
try {
|
||||
await button.click({ timeout: 4_000 })
|
||||
dismissed = true
|
||||
await page.waitForTimeout(1_000)
|
||||
} catch {
|
||||
// Masked by the takeover stacked above it; the next round tries again.
|
||||
}
|
||||
}
|
||||
if (!dismissed) break
|
||||
}
|
||||
|
||||
// The session header (and with it the native right sidebar's controls) only
|
||||
// exists once the session has a turn — the mount lane does the same.
|
||||
await sendFirstMessage(page)
|
||||
await page.locator('[data-sidebar-right-expand]').first().click()
|
||||
const pane = page.locator('[data-sidebar-right-panel]')
|
||||
await expect(page.locator('[data-sidebar-right-guide]')).toBeVisible({ timeout: 30_000 })
|
||||
// The takeover is ALIVE before the reload: its guide row opens the plugin's
|
||||
// explorer and the seeded file is listed.
|
||||
await page.locator('[data-sidebar-right-guide-entry="files"]').click()
|
||||
await expect(pane.locator(`[role="button"][title$="${SEEDED_FILE}"]:visible`)).toHaveCount(1, { timeout: 30_000 })
|
||||
|
||||
// ── Trigger: bump the installed bundle's revision ─────────────────────
|
||||
// Tag the CURRENT host element so the replacement is observable: a disposal
|
||||
// removes it (`host.remove()`), and the new activation appends a fresh one.
|
||||
await page.evaluate(() => {
|
||||
document.querySelector('[data-dsh-better-sidebar]')?.setAttribute('data-e2e-generation', 'before-reload')
|
||||
})
|
||||
const now = new Date()
|
||||
utimesSync(CLIENT_JS!, now, new Date(now.getTime() + 2_000))
|
||||
await expect(
|
||||
page.locator('[data-dsh-better-sidebar][data-e2e-generation="before-reload"]'),
|
||||
'the host must replace the plugin entry in page (new activation)',
|
||||
).toHaveCount(0, { timeout: 60_000 })
|
||||
// Let the fresh activation finish its registrations.
|
||||
await expect(page.locator('[data-dsh-better-sidebar]')).toBeAttached({ timeout: 60_000 })
|
||||
await page.waitForTimeout(2_000)
|
||||
|
||||
// ── Assertions: no registration failure, takeover usable ──────────────
|
||||
const registrationErrors = consoleErrors.filter(message => REGISTRATION_ERROR.test(message))
|
||||
expect(registrationErrors, registrationErrors.join('\n')).toEqual([])
|
||||
await expect(
|
||||
page.locator('body > div', { hasText: 'native register files error' }),
|
||||
'the failure strip must not be pinned',
|
||||
).toHaveCount(0)
|
||||
// The Files takeover works again after the replacement: the guide row is
|
||||
// offered and the explorer still lists the seeded file.
|
||||
if (await page.locator('[data-sidebar-right-guide]').count() === 0) {
|
||||
await page.locator('[data-dockkit-add-tab]').first().click()
|
||||
}
|
||||
const filesEntry = page.locator('[data-sidebar-right-guide-entry="files"]')
|
||||
await expect(filesEntry, 'the Files guide row must be offered again').toHaveCount(1, { timeout: 30_000 })
|
||||
await filesEntry.click()
|
||||
// The explorer must really render again — assert the CONTENT first (a bare
|
||||
// "no unavailable marker" check passes on an empty pane), then the host's
|
||||
// "a kind with no registrant" fallback must be absent.
|
||||
await expect(pane.locator(`[role="button"][title$="${SEEDED_FILE}"]:visible`)).toHaveCount(1, { timeout: 30_000 })
|
||||
await expect(
|
||||
pane.locator('[data-sidebar-right-unavailable]'),
|
||||
'the host must not fall back to its "nothing can view this kind" face',
|
||||
).toHaveCount(0)
|
||||
})
|
||||
@@ -0,0 +1,241 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* The native tab registration LIFECYCLE (`src/client/native/index.ts`): what a
|
||||
* store/service notification may do to the host's tab-type registry, and what
|
||||
* a failed registration is allowed to leave behind.
|
||||
*
|
||||
* The failure this locks is a plugin RELOAD: cordis tears the fiber down, each
|
||||
* built-in descriptor is unregistered one by one, every unregistration notifies
|
||||
* the plugin's service, and `sync()` therefore runs again on a context that is
|
||||
* already inactive. A registration attempted there throws
|
||||
* (`ctx.effect` on an inactive context) — and if the host-side type was already
|
||||
* registered at that point, its disposer is lost, the id stays taken for the
|
||||
* rest of the page's life, and every later attempt is refused with
|
||||
* `tab type id "…" is already registered`. The kind then renders the host's
|
||||
* "nothing here can view this kind of content" face until a page refresh.
|
||||
*
|
||||
* Three properties keep that impossible, and all are asserted here:
|
||||
* 1. an unrelated notification never re-creates the `files` takeover (the
|
||||
* tear-down/re-register window the reload used to walk into);
|
||||
* 2. a registration that fails after its type was registered releases the
|
||||
* type again, so the id stays registrable and one broken descriptor cannot
|
||||
* take the others down with it;
|
||||
* 3. slots installed before the failing one are released too (the partial
|
||||
* release inside `registerSlots`).
|
||||
*
|
||||
* The assertions are written against the fake registry's EVENT LOG, not only
|
||||
* against the set of live ids: an orphaned type leaves the key set identical to
|
||||
* a healthy one, so a key-set assertion would pass on the unfixed code and pin
|
||||
* nothing.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { registerNativeSurface } from '../src/client/native/index.ts'
|
||||
import { createNativeTabRecords } from '../src/client/native/tab-adapter.tsx'
|
||||
import { createBetterSidebarService } from '../src/client/service.ts'
|
||||
import { createSidebarStore } from '../src/client/state.ts'
|
||||
|
||||
/**
|
||||
* The fake `sidebarRightTabs`, mirroring the host's two hard rules: one
|
||||
* registration per id, and the returned disposer is the only way to free it.
|
||||
*/
|
||||
function createRegistry() {
|
||||
const registered = new Map<string, () => void>()
|
||||
const events: string[] = []
|
||||
let failReleaseOf: string | undefined
|
||||
return {
|
||||
registered,
|
||||
events,
|
||||
failRelease(id: string): void { failReleaseOf = id },
|
||||
register(definition: { id: string }): () => void {
|
||||
if (registered.has(definition.id)) {
|
||||
throw new Error(`sidebarRight: tab type id "${definition.id}" is already registered`)
|
||||
}
|
||||
events.push(`register ${definition.id}`)
|
||||
// The host's disposer is IDEMPOTENT (a cordis effect disposer returns
|
||||
// the same task on a second call), so a repeated release must neither
|
||||
// log again nor pretend to free something twice.
|
||||
let released = false
|
||||
const dispose = (): void => {
|
||||
if (released) return
|
||||
if (failReleaseOf === definition.id) throw new Error(`cannot release ${definition.id}`)
|
||||
released = true
|
||||
registered.delete(definition.id)
|
||||
events.push(`release ${definition.id}`)
|
||||
}
|
||||
registered.set(definition.id, dispose)
|
||||
return dispose
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A fake client context: `slots.inject` runs its callback synchronously (the
|
||||
* real one resolves the declaration and calls `ctx.effect`, which throws while
|
||||
* `inactive`), a slot listed in `failSlots` throws from `slots.register`, and
|
||||
* `inject` hands back the registry while honouring the callback's own teardown.
|
||||
*
|
||||
* Slot failures are keyed by `name::key` — NOT by `key` alone. Both slots of a
|
||||
* descriptor share the same key, so a key-keyed failure can only ever fail the
|
||||
* FIRST slot; failing the second one for a single descriptor is the only shape
|
||||
* that exercises `registerSlots`' partial release (the first slot is already
|
||||
* installed when the second throws).
|
||||
*/
|
||||
function createHost() {
|
||||
const registry = createRegistry()
|
||||
/** Slot registrations and releases, in order (the partial-release witness). */
|
||||
const slotEvents: string[] = []
|
||||
// One mutable state object the ctx closures read: the test flips it after
|
||||
// the host was built.
|
||||
const state = {
|
||||
/** True while the plugin's context is being torn down (the reload case). */
|
||||
inactive: false,
|
||||
/** `name::key` slots whose registration throws. */
|
||||
failSlots: new Set<string>(),
|
||||
}
|
||||
const slotId = (options: { name: string; key?: string }): string => `${options.name}::${options.key ?? ''}`
|
||||
const ctx = {
|
||||
inject: (
|
||||
_deps: readonly string[],
|
||||
callback: (injected: { get: (name: string) => unknown }) => (() => void) | void,
|
||||
) => {
|
||||
const teardown = callback({ get: () => registry })
|
||||
return { dispose: () => { if (typeof teardown === 'function') teardown() } }
|
||||
},
|
||||
get: () => registry,
|
||||
slots: {
|
||||
inject: (_name: string, callback: () => Array<() => void> | (() => void)) => {
|
||||
if (state.inactive) throw new Error('cannot create effect on inactive context')
|
||||
return typeof callback === 'function' ? callback() : () => {}
|
||||
},
|
||||
register: (options: { name: string; key?: string }) => {
|
||||
const id = slotId(options)
|
||||
if (state.failSlots.has(id)) throw new Error(`slot registration for "${id}" failed`)
|
||||
slotEvents.push(`register ${id}`)
|
||||
return () => { slotEvents.push(`release ${id}`) }
|
||||
},
|
||||
},
|
||||
}
|
||||
return { registry, slotEvents, state, ctx }
|
||||
}
|
||||
|
||||
/** A store + service with two enabled descriptors, plus the record registry. */
|
||||
function createPlugins() {
|
||||
const store = createSidebarStore()
|
||||
store.setSession('s1')
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'editor', title: 'Files', component: () => null, icon: () => null })
|
||||
service.registerTab({ id: 'git', title: 'Changes', component: () => null })
|
||||
return { store, service, records: createNativeTabRecords() }
|
||||
}
|
||||
|
||||
/** Silence (and count) the failure logs the fixed code reports on purpose. */
|
||||
function silenceFailures(): { restore: () => void; messages: string[] } {
|
||||
const messages: string[] = []
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
|
||||
messages.push(args.map(String).join(' '))
|
||||
})
|
||||
return { restore: () => { spy.mockRestore() }, messages }
|
||||
}
|
||||
|
||||
/**
|
||||
* The phase labels a surface reports through the client's visible diagnostic
|
||||
* channel (`reportFailure` → `fail('native ' + phase, error)`).
|
||||
*/
|
||||
function collectReports(): { phases: string[]; report: (phase: string) => void } {
|
||||
const phases: string[] = []
|
||||
return { phases, report: (phase: string) => { phases.push(phase) } }
|
||||
}
|
||||
|
||||
describe('native tab registration lifecycle', () => {
|
||||
it('releases a type whose slot registration failed, and can register it again later', () => {
|
||||
const host = createHost()
|
||||
const { store, service, records } = createPlugins()
|
||||
const reports = collectReports()
|
||||
// The reload: the context goes inactive while notifications are still
|
||||
// arriving, so every slot registration fails after its type registered.
|
||||
host.state.inactive = true
|
||||
expect(() => registerNativeSurface({
|
||||
ctx: host.ctx as never, store, service, records, reportFailure: reports.report,
|
||||
})).not.toThrow()
|
||||
expect([...host.registry.registered.keys()], 'a failed registration leaves no type behind').toEqual([])
|
||||
// Each of them is reported through the visible channel, not swallowed.
|
||||
expect(reports.phases.sort()).toEqual(['register editor', 'register files', 'register git'])
|
||||
|
||||
// The context comes back (the reload's next activation): the same ids must
|
||||
// be registrable — no "already registered" from the orphan. The event log
|
||||
// is the discriminating assertion: a leaked type would keep the key set
|
||||
// looking correct while the retry throws into the same catch.
|
||||
host.state.inactive = false
|
||||
host.registry.events.length = 0
|
||||
store.setPrefs(store.getPrefs())
|
||||
expect(host.registry.events.sort()).toEqual([
|
||||
'register dsh-better-sidebar:editor',
|
||||
'register dsh-better-sidebar:files',
|
||||
'register dsh-better-sidebar:git',
|
||||
])
|
||||
})
|
||||
|
||||
it('never re-creates the "files" takeover on an unrelated notification', () => {
|
||||
const host = createHost()
|
||||
const { store, service, records } = createPlugins()
|
||||
const dispose = registerNativeSurface({ ctx: host.ctx as never, store, service, records })
|
||||
// The takeover is this module's own entry (never a descriptor), so no
|
||||
// store/service notification has any business touching it. Before the fix
|
||||
// every notification disposed and re-created it.
|
||||
host.registry.events.length = 0
|
||||
for (let round = 0; round < 3; round++) store.setPrefs(store.getPrefs())
|
||||
expect(host.registry.events, 'no notification may tear the "files" takeover down').toEqual([])
|
||||
expect(host.registry.registered.has('dsh-better-sidebar:files')).toBe(true)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('keeps the other tab types when one descriptor cannot register', () => {
|
||||
const host = createHost()
|
||||
const { store, service, records } = createPlugins()
|
||||
const reports = collectReports()
|
||||
// The SECOND slot of ONE descriptor: its type and its body slot are
|
||||
// already installed when this one throws.
|
||||
host.state.failSlots.add('sidebar.right.pane.tab.title::dsh-better-sidebar:git')
|
||||
const log = silenceFailures()
|
||||
try {
|
||||
expect(() => registerNativeSurface({
|
||||
ctx: host.ctx as never, store, service, records, reportFailure: reports.report,
|
||||
})).not.toThrow()
|
||||
expect([...host.registry.registered.keys()].sort()).toEqual([
|
||||
'dsh-better-sidebar:editor',
|
||||
'dsh-better-sidebar:files',
|
||||
])
|
||||
expect(reports.phases).toEqual(['register git'])
|
||||
// The body slot installed before the failure was released; the failing
|
||||
// one never registered, so there is nothing to release for it.
|
||||
expect(host.slotEvents.filter(event => event === 'release sidebar.right.pane.tab::dsh-better-sidebar:git'))
|
||||
.toHaveLength(1)
|
||||
expect(host.slotEvents.filter(event => event === 'release sidebar.right.pane.tab.title::dsh-better-sidebar:git'))
|
||||
.toEqual([])
|
||||
|
||||
// The broken descriptor is retried on the next sync instead of being
|
||||
// written off — and the retry REGISTERS it (event log), rather than
|
||||
// merely ending up with a key set a leaked type produces as well.
|
||||
host.state.failSlots.clear()
|
||||
host.registry.events.length = 0
|
||||
store.setPrefs(store.getPrefs())
|
||||
expect(host.registry.events).toEqual(['register dsh-better-sidebar:git'])
|
||||
} finally {
|
||||
log.restore()
|
||||
}
|
||||
})
|
||||
|
||||
it('releases every registration on teardown even when one release throws', () => {
|
||||
const host = createHost()
|
||||
const { store, service, records } = createPlugins()
|
||||
const log = silenceFailures()
|
||||
try {
|
||||
const dispose = registerNativeSurface({ ctx: host.ctx as never, store, service, records })
|
||||
host.registry.failRelease('dsh-better-sidebar:editor')
|
||||
expect(() => dispose()).not.toThrow()
|
||||
expect([...host.registry.registered.keys()]).toEqual(['dsh-better-sidebar:editor'])
|
||||
} finally {
|
||||
log.restore()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -44,4 +44,41 @@ describe('panel host layer css', () => {
|
||||
expect(degraded, 'the degraded-mode rule must exist').toBeDefined()
|
||||
expect(degraded).not.toMatch(/overflow\s*:/)
|
||||
})
|
||||
|
||||
it('keeps the app-region opt-out that un-breaks macOS window drag (issue #772)', () => {
|
||||
// The shell's `html[data-platform=darwin] body > :not(#root) { no-drag }`
|
||||
// carries an id in its specificity, so only `initial !important` wins;
|
||||
// `initial` computes to the NEUTRAL `none` (does not subtract from the
|
||||
// drag region) while `no-drag` is the subtracting value — and the literal
|
||||
// keyword `none` is NOT neutral (it computes to `no-drag`), which is
|
||||
// exactly why this guard pins `initial`. Hence the split: decorative
|
||||
// viewport-sized layers opt out, their panels stay no-drag so controls
|
||||
// keep receiving clicks. Dropping the `!important` silently re-breaks
|
||||
// window drag / double-click-title zoom on macOS (there is no macOS
|
||||
// runner to catch it), so pin the shape here where Linux `pnpm test`
|
||||
// fails instead.
|
||||
const text = stripComments(css)
|
||||
expect(text, 'the panel host must opt out of app-region computation').toMatch(
|
||||
/:global\(\[data-dsh-panel-host\]\)\s*\{[^}]*-webkit-app-region:\s*initial\s*!important/,
|
||||
)
|
||||
expect(text, 'panels must stay no-drag').toMatch(
|
||||
/:global\(\[data-dsh-panel-host\]\)\s*>\s*\*\s*\{[^}]*-webkit-app-region:\s*no-drag/,
|
||||
)
|
||||
expect(text, 'the mermaid zoom modal is the other persistent viewport-sized body child').toMatch(
|
||||
/\.mermaidModal\s*\{[^}]*-webkit-app-region:\s*initial\s*!important/,
|
||||
)
|
||||
// The interactive popups must NOT get `initial`: they are buttons/handles
|
||||
// the shell already keeps no-drag, and outranking that rule makes a press
|
||||
// start a window drag (the #103/#111 click-swallow class). Checked per
|
||||
// DECLARATION BLOCK, not per single-selector rule: a selector list
|
||||
// (`.selectionPopup, .other { … initial !important }`) would slip past a
|
||||
// simple "class immediately followed by {" pattern.
|
||||
const resets = [...text.matchAll(/([^{}]+)\{([^{}]*)\}/g)]
|
||||
.filter(([, , body]) => /-webkit-app-region:\s*initial/.test(body ?? ''))
|
||||
expect(resets.length, 'the file must declare the app-region opt-out').toBeGreaterThan(0)
|
||||
const resetSelectors = resets.map(([, selector]) => selector ?? '').join(',')
|
||||
for (const excluded of ['selectionPopup']) {
|
||||
expect(resetSelectors, `${excluded} must not opt out of app-region`).not.toContain(excluded)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user