Compare commits

...
14 Commits
Author SHA1 Message Date
Atlas Zeng 9b5834f74a Merge pull request #787 from omdsh-dev/chore/release-0.22.1
chore(release): v0.22.1——修复 files 接管 id 孤儿化与 macOS 拖拽失效
2026-09-28 11:35:31 +08:00
Menghuan1918 a18e0f000d docs(release): 修正 v0.22.1 条目里的测试计数与「3/3」措辞(独立 review 发现)
- 单测计数:`122 files / 1292 passed / 9 skipped` → **1293**。1292 是 #785 当时的数字,
  本 commit 自己的 `make check` 两次都报 1293(`tests/` 与 main 逐字相同,122 个 spec +
  `it.each` 展开 = 1293)。涉及 CHANGELOG.md / CHANGELOG_EN.md / README.md / README_EN.md
  与事故文档。
- 「3/3 红 / 3/3 绿」措辞改为「连续 3 次运行全红 / 全绿」并注明该 lane 里只有 1 个用例、
  是重复跑三次——原措辞会被读成「3 个用例各红一次」。涉及上述四份发布文档 + 事故文档
  + AGENTS.md §2(同一句话在那边也有)。
- 其余 review 结论均已满足:版本三处锁步 + 两个守护用例绿、无遗留 0.22.0 引用、
  zh/en 段落一一对应、「最近更新」保持两条并把指针下移为 v0.21.1 → v0.12.3、
  release.yml 的 tag 校验与 `latest` dist-tag 都能过、lockfile 无需改动。
2026-09-28 11:30:29 +08:00
Menghuan1918 2e94b56c36 chore(release): v0.22.1——修复 files 接管 id 孤儿化与 macOS 拖拽失效
- package.json / dsh.plugin.json / SIDEBAR_SERVICE_VERSION → 0.22.1(三者锁步由
  tests/service.spec.ts 与 tests/manifest-consistency.spec.ts 断言)
- README / README_EN:新增 v0.22.1 条目、同步三处徽章与 latest/支持线表格、
  切回 npm 通道的示例版本号;「最近更新」保持两条(v0.22.1 + v0.22.0),
  更早版本指针区间随之下移为 v0.21.1 → v0.12.3
- docs/external-plugin-guide.md:当前版本行 → v0.22.1
- CHANGELOG / CHANGELOG_EN:补 v0.22.1 条目(两个缺陷的根因链、真机证据、
  修复要点与守卫清单)

支持线**不变**:仅 DSH 0.1.7-rc.1+(peer 下限 ^0.1.7-rc.1,CI 钉 0.1.7-rc.1);
0.21.1 / 0.22.0 用户直接升级;0.1.6-alpha.2 及更早仍固定 v0.19.1。

内容提要:① 修复客户端条目替换后 `dsh-better-sidebar:files` id 被孤儿化
(#777 + #785:清理循环跳过 FILES_KIND + 注册失败回滚已占用的 id 与槽位,
并新增部署级回归门 tests/e2e/native-reload.e2e.ts);② 修复 macOS 桌面版
窗口拖拽/双击缩放失效(#773 + #786:视口层用 initial !important 退出
app-region 计算,补齐放大视图,探针改用真实级联计算值)。

验证:make check 全绿(typecheck / lint / build / test 122 files 1293 passed
9 skipped / check:consumer-types);此外本地 pnpm test:mount 与
pnpm test:mount:aggregate 绿,npm 0.22.0 与修复版在 scratch profile 上的
红→绿对照见 PR #785 / #786。
2026-09-28 11:23:48 +08:00
Atlas Zeng 69f4349047 Merge pull request #786 from omdsh-dev/fix/desktop-app-region-rest
fix(desktop): 补齐 mermaid 放大视图的拖拽区退出 + 把 #772 的守卫换成真实级联探针
2026-09-28 11:22:22 +08:00
Atlas Zeng e46250ae4d Merge pull request #785 from omdsh-dev/fix/native-leak-guard-tests
fix(native): 钉牢 files 接管泄漏的守护断言 + 记录两条不变量
2026-09-28 11:17:35 +08:00
Menghuan1918 e7d2300cf8 fix(review): 按独立 review(Chromium 实测)修正 #786 的措辞、守卫与覆盖
reviewer 在本机跑 Chromium 逐条实测(同一批宿主规则 + 插件产物),结论:修复方向与形状正确
(`.mermaidModal` 预修复计算 `no-drag`、修复后 `none`;级联探针确实能把未修复产物判红),
但有几处措辞与守卫不到位。逐条处理:

1. **「app-region 会被继承」的说法在该引擎里不成立**:`[data-dsh-panel-host]` 不是 body
   子元素(DOM 是 `body > [data-dsh-better-sidebar] > [data-dsh-panel-host]`),lane 用的
   Chromium 不传播该属性(实测:`drag` 父元素里子元素读 `none`)。改写成「宿主是 body
   子元素;在 Electron 里该值会传到它内部的面板层(#772 的 CDP 实测),lane 的引擎不传播,
   所以面板层自己也声明同一条规则」——CSS 注释、指南 §12.1、探针注释三处同步。
2. **`regions.layer` 那条断言是真空断言**(修复前后都读 `none`):保留为回归钉子,但在
   注释里明确标注它不具判别力、面板层的规则由 `panel-host-css.spec.ts` 钉住。
3. **给修复本身补行为级守护**:放大视图已经在同一 lane 里被真实打开过,于是在那里直接对
   `[data-mermaid-modal]`(以及它的按钮)读计算值——断言用**页面里宿主自己的** darwin
   规则(不再是探针复制的副本)。实测判别力:npm 0.22.0 上 `Received: "no-drag"`、修复版
   `none`。
4. **「交互层不要加 initial」与放大视图的矛盾**:放大视图是**遮罩层例外**——模态语义下
   只有其中控件必须保持 `no-drag`;残余代价(背景落在顶部拖拽带上时点背景会变拖窗)已写进
   指南,避免后来者按字面判据「修」掉其中一边。
5. **`.selectionPopup` 的负向守卫可被选择器列表绕过**(reviewer 实测:`.selectionPopup,.x
   { initial !important }` 能通过):改成按**声明块**扫描——凡带 `-webkit-app-region:
   initial` 的块,其选择器文本都不得出现 `selectionPopup`。
6. **补上 `none` 关键字的坑**:字面量 `none` 计算成 `no-drag`(是扣减值),只有 `initial`
   /`unset`/`revert` 才是中性值——CSS 注释、指南与单元用例注释都已写明。
7. **未覆盖层给出结论**:`FileTree` 的 `.uploadDropChatHint` 是 body 直挂的装饰层,但只在
   OS 文件拖拽悬停期间存在且 `pointer-events:none`,收益极小而**有意不处理**(写进指南,
   不再让「覆盖清单」看起来是绝对集合)。
8. 顺带:`§12.2` 的同步集里 `tests/theme.spec.ts` 只守颜色(本轮无颜色改动),形状守护落在
   已解析该样式表的 `tests/panel-host-css.spec.ts` 上——指南那句已按此表述。

验证:typecheck / lint 通过;panel-host-css 4 例通过;本地挂载 lane 7 passed(含新的放大
视图计算值断言);对 npm 0.22.0 的判别力实测:宿主探针红(`host` = `no-drag`)、放大视图
断言红(`modal` = `no-drag`),修复版全绿。
2026-09-28 11:17:03 +08:00
Menghuan1918 233201f7f8 fix(review): 按独立 review 结论修正 PR C 的注释、文档与用例保真度
独立 review(对照 606339f / 4099700 静态复核 + 日志逐行核对)提出的问题,逐条修正:

1. **补回被我自己抹掉的源码注释**:上次验证「未修复代码上 4/4 红」时用了
   `git checkout 606339f -- src/client/native/index.ts`,随后 `git checkout HEAD --
   <同一文件>` 把**尚未提交**的 `disposeSafely` 注释一起还原掉了 —— PR 描述里承诺的
   「说明释放失败为何只走 console.error」因此在 diff 里根本不存在。已重新落盘。
2. **事故文档三处事实修正**:同一毫秒的 `already registered` 是 **7 条**(.339 一条 +
   .340 六条,合计 burst 9 条),不是 6;验证段的「114 files / 1136 passed」是修复分支
   (base 早于 #781)的数字,补上口径并给出合入 main 后的 122/1292;`index.ts:353` 标注
   为 v0.22.0 行号(main 上因 #777 顺移)。
3. **撤回一处措辞过头**:「部分回滚分支从未被执行」→ 回收集合虽然进了、但 disposer
   列表恒为空(释放动作未执行),改后能真正覆盖。
4. **e2e 门不再可能静默通过**:`test.skip(CLIENT_JS === undefined, …)` 改成硬失败——
   这个门的价值就在于 harness 以后不再导出 DSH_HOME 时必须报错而不是变成 skip。
   同时把「无 `[data-sidebar-right-unavailable]`」移到文件渲染断言**之后**(前者在空
   pane 上恒真,不是真空但也没重量)。
5. **测试替身补齐保真度**:假注册表的 disposer 改为幂等(真宿主的 disposer 是幂等的
   ——cordis effect 二次释放返回同一个 task),避免「二次 release 又被记一笔」这种与
   真实宿主不一致的行为被后续用例当成事实。
6. **AGENTS §2 的 HMR 机制加版本限定**:`mtime/ctime/size` 的 sha1 rev 是**钉住的宿主
   0.1.7-rc.1** 的行为;DSH 源码树新版本改成只比 `mtime+size` 并对内容取 sha1,pin 上调
   时该触发器必须同步复核(否则会红在替换断言上,而不是静默失效)。

验证:typecheck / lint 通过;pnpm test 122 files / 1292 passed / 9 skipped(重启前机器上有
两个 scratch dsh 服务时的 fs-watch 超时是负载抖动,单独跑 5/5 绿、清空负载后全量绿)。
2026-09-28 11:09:30 +08:00
Menghuan1918 b07c7116c8 fix(desktop): 补齐 mermaid 放大视图的拖拽区退出 + 把 #772 的守卫换成真实级联探针
#773 修掉了面板宿主层继承宿主 `body > :not(#root) { no-drag }` 导致的 macOS 拖拽
失效,但只覆盖了 `.mermaidModal` 之外的层——而放大视图是插件**第二个铺满视口的
body 直挂层**(`position:fixed; inset:0`,portal 到 body),打开期间会同样清空整窗
拖拽与 macOS 双击标题栏缩放。

- src/client/sidebar.module.css:`.mermaidModal` 加 `-webkit-app-region: initial
  !important`(与面板宿主同一条契约),并在面板宿主那段注释里写明「哪些交互层
  绝对不能加 initial」(`.selectionPopup` 是 `<button>`、FloatingWindow /
  AnchoredPopover 自带指针拖拽,宿主 `:is(button, …)` 已给它们 no-drag;反过来
  声明 `initial !important` 会让按下变成拖窗,重演 #103/#111)。
- tests/panel-host-css.spec.ts:新用例把形状钉在 Linux 的 `pnpm test` 里(宿主层
  与面板层必须是 `initial !important`、`> *` 必须是 `no-drag`、`.mermaidModal`
  同样退出、`.selectionPopup` 不得出现 `initial`)——没有 macOS runner,丢掉
  `!important` 只能靠这里才不会静默复发。
- tests/e2e/mount.e2e.ts:把 #773 的**文本正则**断言换成**真实级联探针**——逐字
  注入宿主两条 darwin 规则、翻转 `data-platform=darwin`,读取计算值并断言
  `[data-window-drag]` 仍 `drag`、宿主层与面板层为 `none`、`[data-dsh-panel-host]
  > *` 为 `no-drag`,随后还原。探针把 `[data-window-drag]` 放在 `#root` 内:直挂
  body 的元素会被宿主那条 id 级规则本身命中(这正是问题机制)。
- docs/external-plugin-guide.md §12.1:按仓库规则同步这条皮肤契约(含交互弹层
  的反例判据与守卫位置)。

实测(scratch profile + 真实 dsh web + Chromium):
- npm dsh-better-sidebar@0.22.0:探针红,`host` 计算值 `no-drag`(正是线上症状)
- 本分支打包版:探针绿(`strip=drag` / `host=none` / `layer=none` / `child=no-drag`)
- 本地挂载 lane:7 passed(mount + perf,含新探针)

验证:typecheck / lint 通过;panel-host-css + theme 用例通过;新产物
`app-region:initial!important` 2 处(面板宿主 + mermaid 模态)、`app-region:no-drag` 2 处。
2026-09-28 11:04:52 +08:00
Atlas Zeng 0079ca83a3 Merge pull request #773 from jorgitin02/fix/macos-window-drag-panel-host
fix(desktop): stop panel host from blocking macOS window drag
2026-09-28 11:00:44 +08:00
Menghuan1918 85b09d888e test(e2e): 页面内条目替换的部署级回归门(真机红→绿)
在 scratch profile 上用**已发布的 npm 0.22.0** 复现了线上那条报错链:页面内客户端
条目替换(宿主 HMR 的 500ms stat-poll 发现已安装 lib/client.js 的 rev 变化 →
teardown → 重新 import)期间,旧激活的 teardown 触发 sync(),让 files 接管在
inactive ctx 上重建 —— 控制台依次出现

  native register files error: ... cannot create effect on inactive context
    registerSlots ← registerFilesKind ← sync ← notify
  native register files error: ... tab type id "dsh-better-sidebar:files" is already registered
    register ← registerFilesKind ← sync ← notify

新用例 tests/e2e/native-reload.e2e.ts 把这条路径固化进挂载 lane(同一 lane 里
mount.e2e.ts 跑完后跑):utimesSync 已安装的 client bundle → 断言宿主元素被替换
(新激活)、无 native register … error、无诊断条、files 接管重新可用。
CLIENT_JS 从 e2e-mount.sh 已 export 的 DSH_HOME 推导,无需改脚本。

实测(同一台机器、同一份用例):
- npm dsh-better-sidebar@0.22.0:3/3 红(每次都是上面那两条栈)
- 本地打包的修复版:3/3 绿

AGENTS.md §2 补一条说明该门与触发方式。
2026-09-28 11:00:23 +08:00
Menghuan1918 0b40f74ce8 fix(native): 钉牢 files 接管泄漏的守护断言 + 记录两条不变量
#777 已修掉根因(清理循环跳过 FILES_KIND、注册失败回滚已占用的 id),但它的两处
断言写在「活着的 id 集合」上——泄漏的 id 恰好构成同一个集合,未修复代码上同样
通过;且槽位失败判据按 key 命中,而一个描述符的两个槽共用一个 key,所以
registerSlots 的「部分回滚」分支从未被执行。

- tests/native-registration.spec.ts:断言改写到注册表的事件日志(孤儿 id 会让键
  集合看起来完全正常);槽位失败判据改为 name::key,从而可以只让某个描述符的
  第二个槽失败、真正走到部分回滚;补 reportFailure 的 phase 断言。实测:在
  v0.22.0 源码上 4/4 红,在修复后的源码上 4/4 绿。
- src/client/native/index.ts:给 disposeSafely 补注释,说明释放失败只写
  console.error、不弹诊断条的理由(两个调用点都在 teardown 路径上;真被占住的
  id 会在下一次注册尝试里经 reportFailure 显式报出)。
- AGENTS.md:§3.4 第 9 条补上这两条不变量(含真机日志实证的链接),§6 把
  native-registration.spec.ts 纳入关键测试守护。
- docs/plans/2026-09-28-native-files-takeover-reload-leak.md:事故/证据/否决方案
  记录(含「吸收」方案为何被否)。

验证:pnpm typecheck / pnpm lint 通过;pnpm test 122 files / 1292 passed / 9 skipped。
2026-09-28 10:57:10 +08:00
Atlas Zeng 4099700bfa Merge pull request #777 from yanzhaohui1999/fix/native-registration-leak-v2
fix(native): 注册失败的 tab 类型必须释放,否则 id 永久占用(插件重载后文件树变空态)
2026-09-28 10:54:48 +08:00
ymac 3728e9036b fix(native): release a tab type whose slot registration failed
A plugin reload (or a disable/enable) tears the client fiber down while this
module's own service/store subscriptions are still live: unregistering each
built-in descriptor notifies the plugin's service, and every notification ran
`sync()` again. The `files` takeover is never a descriptor — its key is
`FILES_KIND` — so the drop loop released it every single time, and the block
below re-created it. Re-registering on the now-inactive context throws inside
`registerSlots` (`ctx.slots.inject` cannot create an effect on an inactive
context) *after* `tabs.register` already put the type into the host registry,
so that registration's disposer was lost with the exception.

The id then stayed taken for the rest of the page's life: every later attempt
was refused with

  sidebarRight: tab type id "dsh-better-sidebar:files" is already registered

and the kind rendered the host's "nothing here can view this kind of content
yet" face until a page refresh.

- `sync()` skips the `files` takeover in its drop loop: the takeover is owned
  by the editor-type switch, so an unrelated notification no longer puts its
  host-side type through a tear-down/re-register window;
- a registration that fails after its type was registered releases the type
  again (and whatever slot disposers it collected), so the same id stays
  registrable and a retry can succeed;
- one descriptor that cannot register no longer aborts the whole sync, so the
  remaining types are still brought up;
- teardown releases every registration even when one release throws.

Verified: tests/native-registration.spec.ts (new, 4 cases) is red 4/4 against
the unfixed code and green with the fix; tsc/eslint clean and the full unit
suite passes.
2026-09-27 14:21:25 +08:00
jorge guerreroandClaude Opus 5.5 fac59ae2f3 fix(desktop): stop panel host from blocking macOS window drag
The shell's base stylesheet makes every direct body child no-drag
(`html[data-platform=darwin] body > :not(#root)`). The viewport-sized
`[data-dsh-panel-host]` layer inherited it, and since app-region ignores
pointer-events it cancelled every window-drag strip beneath it once the
shell recomputed regions.

Take the host and layer out of the app-region computation (`!important`,
the shell selector carries an id's specificity) and keep the panels
themselves no-drag so their controls still receive clicks.

Fixes #772

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 21:45:48 -04:00
16 changed files with 769 additions and 77 deletions
+3 -3
View File
File diff suppressed because one or more lines are too long
+10
View File
@@ -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 方法,团队与后台任务两个数据面随之改写。
+10
View File
@@ -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.
+16 -20
View File
@@ -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
View File
@@ -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 &amp; 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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 () => {
+1 -1
View File
@@ -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
+41
View File
@@ -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;
+77
View File
@@ -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 }) => {
+160
View File
@@ -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)
})
+241
View File
@@ -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()
}
})
})
+37
View File
@@ -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)
}
})
})