Merge origin/main (v0.22.1 与三个热修 PR) into feat/files-changes-uiux

冲突面 11 个文件,逐项裁决如下:

1. src/client/native/index.ts —— **取 main 版**。main 的 v0.22.1 在同一处做了更强的修复
   (清理循环同样跳过 FILES_KIND,另加 `disposeSafely` 与「注册失败必须回滚已占用 id /
   已建槽位」),是我们那行守卫的超集。
   ⚠️ 但 main 分支从来没有过我们 task-19 的 `records.attachStore(store)` 调用,整文件
   采用会**静默丢掉**它(records 拿不到 store → `toggleExpanded` 直接 return → 点目录
   树毫无反应),且 jsdom 单测看不见(它们自己绑 records)。已按原注释恢复该调用,并在
   注释里记下这次合并教训。真实宿主 lane 证明:恢复前 `zz-expand-refresh` 红(请求为
   空、`big` 不展开),恢复后 9/9 全绿。
2. package.json / dsh.plugin.json / src/client/service.ts —— 版本相关冲突取我们的
   0.24.1(支持线 ^0.2.0-rc.1 不变)。
3. CHANGELOG / README(中英各一)—— 双方版本节全部保留:我们的 v0.24.1 / v0.24.0 +
   **新增缺失的 v0.23.0 节**(开发线,内容随 v0.24.1 发布)+ main 的 v0.22.1。
   同时修正两处事实错误:① 「0.1.7 线请固定 v0.23.0」→ **v0.22.1**(0.23.0 从未发布);
   ② 安装对照表里 0.1.7 行的安装命令同改。README 并补上 v0.23.0 摘要(含 ⚠️ 删除工作区
   路径检测这一安全相关的行为变更)。
4. docs/external-plugin-guide.md —— 版本行取我们的(0.2.0 线)。

验证(合并后的树):pnpm typecheck / lint / test = **137 文件 / 1491 通过 / 9 跳过**
(含 main 新增的 native-registration.spec.ts);pnpm build ✓;
DSH_CMD='npx -y --package @deepseek-ai/dsh@0.2.0-rc.1 dsh' pnpm test:mount = **9/9**
(mount 5 + perf 2 + main 的 native-reload 1 + 我们的 zz-expand-refresh 1)。
This commit is contained in:
Menghuan1918
2026-09-28 23:13:36 +08:00
13 changed files with 799 additions and 86 deletions
+3 -3
View File
File diff suppressed because one or more lines are too long
+20 -1
View File
@@ -4,6 +4,8 @@
### v0.24.1
> 🎁 **本版包含从 v0.22.1 起的全部内容**(v0.23.0 的文件页 / 文件变动页 UI/UX 重构与性能打磨、v0.24.0 的 DSH 0.2.0-rc.1 支持线前移,以及本节的修复)。
>
> 🐞 **修复版**:文件树「一操作就整体刷新」的两处根因。① 原生承载面(`src/client/native/index.ts` 的 `sync()`)把 `files` 接管项当成多余注册——它按 KIND 记账、永远不在 `wanted` 里,于是**每次会话状态写入**都被 dispose + 重建;替换 slot 注册等于替换宿主的 slot entry,**整个插件 tab body 被卸载重挂**(展开/收起、切 tab、拖底部工作台都会触发)→ 文件树丢失层缓存、滚动位置与目录 watcher,并重新 `fs.trees([root, ...expanded])` 重列整棵可见树。② 目录实时刷新(`FileTree.tsx` 的 `retryDir`)**先把该层缓存删掉**再请求,行被占位替换后重建(构建 / 格式化 / 模型跑 bash 时整层闪空)。
- 🧪 判别性证据:真实挂载 lane 新增 `tests/e2e/zz-expand-refresh.e2e.ts`(展开只请求该层、tab body 存活、行数不归零;回退任一修复即红),配套两个 jsdom 单测(回退后 `registered` 6 → 12、`disposed` 全是 `dsh-better-sidebar:files`;另一条断言重列期间旧行仍在屏)
@@ -12,13 +14,30 @@
### v0.24.0
> 📦 **支持线前移**:仅适配 DSH **0.2.0-rc.1+**(peer 下限 `^0.2.0-rc.1`,CI 钉 `@deepseek-ai/dsh@0.2.0-rc.1`)。**0.1.7 线(含 npm `latest` 的 0.1.7-rc.2)请固定 v0.23.0**——宿主 0.2.0 的启动兼容性预检按 `semver.satisfies(宿主版本, peer范围, {includePrerelease:true})` 判定,caret 范围跨 minor 不成立(实测 `^0.1.7-rc.1` 对 `0.2.0-rc.1` 为 **false**,整行会被静默禁用),因此本版不保留任何 0.1.7 兼容分支。
> 📦 **支持线前移**:仅适配 DSH **0.2.0-rc.1+**(peer 下限 `^0.2.0-rc.1`,CI 钉 `@deepseek-ai/dsh@0.2.0-rc.1`)。**0.1.7 线(含 npm `latest` 的 0.1.7-rc.2)请固定 v0.22.1(0.1.7 线最后发布的版本)**——宿主 0.2.0 的启动兼容性预检按 `semver.satisfies(宿主版本, peer范围, {includePrerelease:true})` 判定,caret 范围跨 minor 不成立(实测 `^0.1.7-rc.1` 对 `0.2.0-rc.1` 为 **false**,整行会被静默禁用),因此本版不保留任何 0.1.7 兼容分支。
- 📦 **基线抬到 `0.2.0-rc.1`**:14 条 DSH peer + 27 个 `@deepseek-ai/*` devDependencies 同步钉版;`dsh.plugin.json` 的 `engines.dsh` 随之前移;挂载 lane(`scripts/e2e-common.sh`)与 CI 钉版同步。
- 🔍 **适配前的实测结论(纯增量,故无运行时改动)**:本插件消费的 19 个宿主包里**零个值导出被删除**;类型面仅 `dsh-client-ui-primitives`(`DisclosureRow`/`TextShimmer`/`Tooltip` 新增可选 prop、overlay 顶部内距)、`dsh-session`(新增 `ToolCallRecovery`)、`dsh-api-remotes`(新增 product-analytics remote)有变化;会话格式仍 **v4**(无迁移包)、`SUBAGENT_DESCRIPTOR_VERSION` 仍 **3**、`dsh/lib/bin.js`(`dsh plugin` / `dsh web` / 启动行 / `--version`)与 `dsh-client-modules` 运行时**逐字节相同**、web profile 的客户端 inject 列表相同、`ui-primitives` 的裸 import 集合相同(无需新增测试期依赖)。
- 🧩 **宿主 web profile 组成变化与本插件无关但已核对**:新增 desktop-only 的 `product-analytics` / `desktop-product-telemetry`(web profile 上 `disabled`);原先就 `disabled` 的 `time-context` / `schedule` / `ui-schedule` 三行移入可选的 `dsh-experimental-schedule-bundle`。「宿主 tab 只有 terminal、browser 缺席」的挂载断言不受影响。
- 🧪 **测试与规则**:`tests/market-manifest.spec.ts` 的 peer 形状断言改为钉当前基线 tuple,并把「caret 跨 minor 必失效」写进注释(这条与 §3.4 第 12 条里「includePrerelease 更宽松」的经验不冲突:宽松只发生在同一 minor 内)。
- ⚠️ **唯一残留**:可选集成 `@huanlin/dsh-plugin-better-locale`(未被任何源码/测试 import,仅结构化运行时探测)的 peer 钉在 `^0.1.0-rc.8`,其最新 0.4.3 同样钉 `^0.1.5-rc.2`——两者在 0.2.0 上都会被宿主预检禁用,故 `pnpm peers check` 会报它的 5 条 unmet peer。这是上游未适配 0.2.0 的结果,与本插件自身 14 条 peer 无关。
### v0.23.0
> 🧭 **开发线版本(从未发布到 npm,全部内容随 v0.24.1 一并发布)**:文件页与文件变动页的整体 UI/UX 重构,加上一轮性能与交互打磨。文件页新增 **Ctrl/Cmd 与 Shift 多选**(VS Code 语义)、批量操作条、**Git 变更着色**(与 VS Code 同色的新增/修改/删除/冲突,目录带汇总点)、**新建文件夹**、拖拽上传体验重做;**多选可右键「压缩并下载」**,打包在宿主侧成为带进度的任务(`archive.build` / `archive.status`,逐条目回调,4 并发 + 5 分钟 TTL + sessionId 绑定)。「打开方式」改为**双源并存**:宿主探测到的本机关联应用(资源管理器 / VS Code / Cursor / Zed / 自定义编辑器,含 SSH 远端)与插件自研目标,可用设置开关 `openWithPluginTargets` 强制并存;右键菜单收敛层级(一级只留默认动作,应用列表进子菜单)。文件变动页**重构为层级树**,Git 视角(工作区 / 暂存 / 历史)与 Agent 视角(会话内改动)各自保留,目录行可暂存/取消暂存。交互与性能:`/sidebar/api/fs.tree` 实测 **22.8ms → 3.8ms**(10k 条目,readdir 一次取回 + 只为 cap 建行 + 小写比较排序 + 1.5s 目录级缓存,由写操作与 fs-watch 失效),新增批量路由 `fs.trees`(挂载/刷新的 N+1 请求变 1 个),右键菜单打开不再重列目录、行不再随 git 轮询重渲染。修掉「在侧边打开」没有效果(原来只写未展开的底部工作台,现按承载面分流到宿主 `openResource(preferNewPane)` 真正分栏)。
- ⚠️ **行为变更(安全相关)**:**删除工作区路径检测**——插件 fs 路由(tree/read/write/rename/remove/mkdir/媒体/HTML/upload/archive)不再做包含性检查,可读写宿主用户能访问的任意路径(仅受 OS 权限约束),`workspaceFence` 设置与围栏提示一并移除。
- 🧩 抽出可复用的全局组件与共享 store(`IconButton` / `Chip` / `SectionHeader` / `Notice` / `ConfirmDialog` / `StatusBadge` + 共享 git 状态 store),皮肤契约(`tests/theme.spec.ts`)继续守护;ZIP 打包器自研(无第三方依赖)并修掉非 latin1 文件名的下载头 500(RFC 5987)。
- 🐞 同时修掉:关闭文件后返回文件树时展开状态被重置(原生承载面的展开集合原先按 tab 记录,现改为**每会话状态**的投影)。
### 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
+20 -1
View File
@@ -4,6 +4,8 @@
### v0.24.1
> 🎁 **This release carries everything since v0.22.1** (the Files/Changes UI-UX rebuild and performance work of v0.23.0, the DSH 0.2.0-rc.1 support-line move of v0.24.0, and the fixes below).
>
> 🐞 **Fix release** for the file tree rebuilding itself on every interaction. (1) The native surface (`sync()` in `src/client/native/index.ts`) treated the `files` takeover as a surplus registration — it is keyed by KIND and never appears in `wanted`, so **every session-state write** disposed and re-created it. Replacing a slot registration replaces the host's slot entry, which **unmounts and remounts the whole plugin tab body** (folder toggles, tab switches, bottom-workbench drags all trigger it): the explorer lost its level cache, scroll position and directory watcher, then re-listed the entire visible tree via `fs.trees([root, ...expanded])`. (2) The live-refresh path (`retryDir` in `FileTree.tsx`) **deleted a level from the cache** before refetching, so its rows were replaced by a placeholder and rebuilt (a build, a formatter or the model's own bash made entire folders blink).
- 🧪 Discriminating evidence: a new real-host lane `tests/e2e/zz-expand-refresh.e2e.ts` (a toggle asks only for the toggled level, the tab body survives, rows never drop to zero; reverting either fix turns it red) plus two jsdom guards (reverting the surface fix: `registered` 6 → 12 with every `disposed` entry being `dsh-better-sidebar:files`; the other asserts stale rows stay on screen during a re-list)
@@ -12,13 +14,30 @@
### v0.24.0
> 📦 **Support line moved**: DSH **0.2.0-rc.1+** only (peer floor `^0.2.0-rc.1`, CI pins `@deepseek-ai/dsh@0.2.0-rc.1`). **The 0.1.7 line (including npm `latest` = 0.1.7-rc.2) should pin v0.23.0** — the 0.2.0 host's startup compatibility preflight evaluates `semver.satisfies(hostVersion, peerRange, {includePrerelease:true})`, and a caret never spans a minor bump (measured: `^0.1.7-rc.1` vs `0.2.0-rc.1` is **false**, so the whole row is disabled silently). This version therefore keeps no 0.1.7 compatibility branch.
> 📦 **Support line moved**: DSH **0.2.0-rc.1+** only (peer floor `^0.2.0-rc.1`, CI pins `@deepseek-ai/dsh@0.2.0-rc.1`). **The 0.1.7 line (including npm `latest` = 0.1.7-rc.2) should pin v0.22.1 (the last published release on the 0.1.7 line)** — the 0.2.0 host's startup compatibility preflight evaluates `semver.satisfies(hostVersion, peerRange, {includePrerelease:true})`, and a caret never spans a minor bump (measured: `^0.1.7-rc.1` vs `0.2.0-rc.1` is **false**, so the whole row is disabled silently). This version therefore keeps no 0.1.7 compatibility branch.
- 📦 **Baseline lifted to `0.2.0-rc.1`**: 14 DSH peers and 27 `@deepseek-ai/*` devDependencies move together; `dsh.plugin.json`'s `engines.dsh` follows; the mount lane (`scripts/e2e-common.sh`) and CI pins follow.
- 🔍 **Measured before adapting (purely additive, hence no runtime change)**: across the 19 host packages this plugin consumes, **zero value exports were removed**; the only type-surface changes are `dsh-client-ui-primitives` (optional props on `DisclosureRow` / `TextShimmer` / `Tooltip`, overlay top inset), `dsh-session` (new `ToolCallRecovery`) and `dsh-api-remotes` (a new product-analytics remote); session format stays **v4** (no migration package), `SUBAGENT_DESCRIPTOR_VERSION` stays **3**, `dsh/lib/bin.js` (`dsh plugin` / `dsh web` / the ready line / `--version`) and the `dsh-client-modules` runtime are **byte-identical**, the web profile's client inject list is unchanged, and `ui-primitives`' bare imports are identical (no new test-time dependency).
- 🧩 **Host web-profile composition changes, checked though unrelated**: desktop-only `product-analytics` / `desktop-product-telemetry` rows are added (disabled on the web profile); the already-disabled `time-context` / `schedule` / `ui-schedule` rows move into the optional `dsh-experimental-schedule-bundle`. The mount assertion "the host owns exactly one tab entry (terminal) and browser is absent" is unaffected.
- 🧪 **Tests and rules**: the peer-shape assertion in `tests/market-manifest.spec.ts` now pins the current baseline tuple and records the "a caret never spans a minor bump" lesson (compatible with §3.4 item 12's "includePrerelease is more permissive" note — that permissiveness only holds inside one minor line).
- ⚠️ **Only residue**: the optional `@huanlin/dsh-plugin-better-locale` integration (imported by no source file or test; detected structurally at runtime) pins `^0.1.0-rc.8`, and even its newest 0.4.3 pins `^0.1.5-rc.2` — both fail the 0.2.0 preflight, so `pnpm peers check` reports its 5 unmet peers. That is upstream not having adapted to 0.2.0, unrelated to this plugin's own 14 peers.
### v0.23.0
> 🧭 **Development-line version (never published to npm; its content ships with v0.24.1)**: the Files and Changes pages rebuilt end to end, plus a round of performance and interaction polish. The Files page gains **Ctrl/Cmd and Shift multi-select** (VS Code semantics), a batch action bar, **Git change coloring** (VS Code's add/modify/delete/conflict tones, directories get a summary dot), **new-folder creation**, reworked drag-and-drop upload, and **"Zip and download" on a multi-selection** — archiving became a host-side task with progress (`archive.build` / `archive.status`, per-entry callbacks, 4 concurrent builds, 5-minute TTL, sessionId-bound). "Open with" now keeps **both sources side by side**: the local associated apps the host detects (file manager / VS Code / Cursor / Zed / custom editors, SSH remotes included) plus the plugin's own targets, with a setting (`openWithPluginTargets`) to force coexistence; the context menu was restructured (only default actions at level one, the app list moves into a submenu). The Changes page is **rebuilt as a hierarchy tree** with both the Git view (working tree / staged / history) and the Agent view (per-session edits), and directory rows stage/unstage. Interaction and performance: `/sidebar/api/fs.tree` measured **22.8ms → 3.8ms** (10k entries: one `readdir`, rows built only up to the cap, lowercase-comparison sort, 1.5s directory-level cache invalidated by writes and `fs-watch`), a new batch route `fs.trees` (mount/refresh goes from N+1 requests to one), opening the context menu no longer re-lists directories, and rows no longer re-render with every git poll. Also fixed: "Open to the Side" did nothing (it only wrote into the collapsed bottom workbench; it now routes by surface to the host's `openResource(preferNewPane)` and really splits).
- ⚠️ **Behavior change (security-relevant)**: the **workspace path fence was removed** — the plugin's fs routes (tree/read/write/rename/remove/mkdir/media/HTML/upload/archive) no longer enforce containment and can read or write any path the host user can reach (OS permissions are the only limit); the `workspaceFence` setting and its notice row are gone.
- 🧩 Reusable global components and shared stores were extracted (`IconButton` / `Chip` / `SectionHeader` / `Notice` / `ConfirmDialog` / `StatusBadge` plus a shared git-status store) under the skin contract (`tests/theme.spec.ts`); the ZIP writer is our own (no third-party dependency) and a non-latin1 download name no longer 500s (RFC 5987).
- 🐞 Also fixed: the tree's expansion was reset when a file preview was closed and the tree came back (the native surface kept the expansion set per tab; it is now projected from **per-session state**).
### 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
+19 -18
View File
@@ -3,7 +3,7 @@
> [!IMPORTANT]
> **v0.19.0 起接入 DSH 原生侧边栏**:右列就是 DSH 自己的右侧栏,插件把每个 tab 类型注册为原生 tab(不再自绘右侧面板),只保留自绘的底部工作台与开放给所有插件的 `ctx.betterSidebar` 服务。
>
> **v0.24.1 起要求 DSH `0.2.0-rc.1+`**(peer 下限 `^0.2.0-rc.1`)。0.2.0 对本插件所用的全部宿主 API 是**纯增量**(零导出删除、会话格式仍 v4、CLI 与客户端运行时未变),所以这一版没有运行时兼容分支,只把支持线整体前移。**DSH 0.1.7 线的用户请固定 `dsh-better-sidebar@0.23.0`——caret 范围跨 minor 不成立,`^0.1.7-rc.1` 在 0.2.0 宿主上会被启动预检静默禁用**;按 DSH 版本选插件版本的对照表见[安装](#-安装)。
> **v0.24.1 起要求 DSH `0.2.0-rc.1+`**(peer 下限 `^0.2.0-rc.1`)。0.2.0 对本插件所用的全部宿主 API 是**纯增量**(零导出删除、会话格式仍 v4、CLI 与客户端运行时未变),所以这一版没有运行时兼容分支,只把支持线整体前移。**DSH 0.1.7 线的用户请固定 `dsh-better-sidebar@0.22.1`——caret 范围跨 minor 不成立,`^0.1.7-rc.1` 在 0.2.0 宿主上会被启动预检静默禁用**;按 DSH 版本选插件版本的对照表见[安装](#-安装)。
<!-- Hero -->
<div align="center">
@@ -64,14 +64,14 @@
**支持的 DSH 版本**:
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本(v0.24.0):0.2.0-rc.1+" src="https://img.shields.io/badge/DSH-0.2.0--rc.1%2B-4d6bfe" /></a>
> 📌 **通道与支持线**:`v0.24.1` 适配 DSH **0.2.0-rc.1+**(0.2.0 首个候选版走 npm `next` 通道,`latest` 仍是 0.1.7-rc.2)。**装 DSH 请写精确版本号**:`npm i -g @deepseek-ai/dsh@0.2.0-rc.1`。**0.1.7 线的用户请固定 `dsh-better-sidebar@0.23.0`**:0.2.0 是宿主 minor 变更,`^0.1.7-rc.1` 这类 caret 范围在 0.2.0 上会被宿主的启动兼容性预检判定失败、整行静默禁用。
> 📌 **通道与支持线**:`v0.24.1` 适配 DSH **0.2.0-rc.1+**(0.2.0 首个候选版走 npm `next` 通道,`latest` 仍是 0.1.7-rc.2)。**装 DSH 请写精确版本号**:`npm i -g @deepseek-ai/dsh@0.2.0-rc.1`。**0.1.7 线的用户请固定 `dsh-better-sidebar@0.22.1`**:0.2.0 是宿主 minor 变更,`^0.1.7-rc.1` 这类 caret 范围在 0.2.0 上会被宿主的启动兼容性预检判定失败、整行静默禁用。
> 🧭 **按你的 DSH 版本选插件版本**:
>
> | 你的 DSH 版本 | 安装命令 | 版本 / peer 声明 |
> | --- | --- | --- |
> | **0.2.0-rc.1+**(含之后的 0.2.0 正式版) | `dsh plugin --profile web add dsh-better-sidebar@latest` | **0.24.1**,`^0.2.0-rc.1` |
> | **0.1.7-rc.1 ~ 0.1.7-rc.2**(含 0.1.7 正式版;npm `latest` 目前仍是 0.1.7-rc.2) | `dsh plugin --profile web add dsh-better-sidebar@0.23.0` | **0.23.0**,`^0.1.7-rc.1` |
> | **0.1.7-rc.1 ~ 0.1.7-rc.2**(含 0.1.7 正式版;npm `latest` 目前仍是 0.1.7-rc.2) | `dsh plugin --profile web add dsh-better-sidebar@0.22.1` | **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` |
@@ -145,7 +145,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>
@@ -197,14 +197,27 @@ dsh registry enable dsh-external/dsh-better-sidebar
> 🐞 **修复版**:修掉两处会让文件树「一操作就整体刷新」的缺陷——① 原生承载面在**每次会话状态写入**时都会重建 `files` 接管项的 slot 注册,宿主因此换掉 slot entry、把整个 tab body 卸载重挂(展开/收起、切 tab、拖底部工作台都会触发)→ 文件树丢失层缓存、滚动位置与目录 watcher 并重列整棵可见树;② 目录实时刷新的重列**先把该层缓存删掉**再请求,导致行被「Loading…」占位替换后重建(构建/格式化/模型跑 bash 时整层闪空)。现在展开只请求被展开那一层、tab body 不再重建、变更只增量更新行。
### v0.23.0
> 🧭 **开发线版本(从未发布到 npm,内容随 v0.24.1 一并发布)**:文件页与文件变动页的整体 UI/UX 重构。文件页新增 **Ctrl/Cmd 与 Shift 多选**、批量条、**Git 变更着色**、**新建文件夹**、拖拽上传重做,**多选可右键「压缩并下载」**(宿主侧带进度的打包任务);「打开方式」改为宿主本机关联应用与插件自研目标**双源并存**(可用设置 `openWithPluginTargets` 强制并存),右键菜单收敛层级;文件变动页**重构为层级树**(Git 视角 + Agent 视角,目录可暂存)。性能:`fs.tree` 实测 **22.8ms → 3.8ms**(10k 条目),新增批量路由 `fs.trees`(挂载/刷新从 N+1 请求变 1 个),菜单打开不再重列目录。⚠️ **行为变更(安全相关)**:**删除工作区路径检测**,插件 fs 路由可读写宿主用户能访问的任意路径(仅受 OS 权限约束)。详见 [CHANGELOG](./CHANGELOG.md#v0230)。
### v0.24.0
> 📦 **支持线前移**:仅适配 DSH **0.2.0-rc.1+**(peer 下限 `^0.2.0-rc.1`,CI 钉 `@deepseek-ai/dsh@0.2.0-rc.1`)。**0.1.7 线(含 npm `latest` 的 0.1.7-rc.2)请固定 v0.23.0**:caret 范围跨 minor 不成立,`^0.1.7-rc.1` 在 0.2.0 宿主上会被启动兼容性预检整行禁用(实测 `semver.satisfies('0.2.0-rc.1','^0.1.7-rc.1',{includePrerelease:true}) === false`)。
> 📦 **支持线前移**:仅适配 DSH **0.2.0-rc.1+**(peer 下限 `^0.2.0-rc.1`,CI 钉 `@deepseek-ai/dsh@0.2.0-rc.1`)。**0.1.7 线(含 npm `latest` 的 0.1.7-rc.2)请固定 v0.22.1(0.1.7 线最后发布的版本)**:caret 范围跨 minor 不成立,`^0.1.7-rc.1` 在 0.2.0 宿主上会被启动兼容性预检整行禁用(实测 `semver.satisfies('0.2.0-rc.1','^0.1.7-rc.1',{includePrerelease:true}) === false`)。
- 📦 **基线整体抬到 `0.2.0-rc.1`**:14 条 DSH peer 与 27 个 `@deepseek-ai/*` devDependencies 同步;`dsh.plugin.json` 的 `engines.dsh` 随之前移。
- 🔍 **实测确认是纯增量**:本插件用到的 19 个宿主包里**零个值导出被删除**;类型面只有 `ui-primitives`(`DisclosureRow` / `TextShimmer` / `Tooltip` 新增可选 prop、overlay 顶部内距)、`dsh-session`(新增 `ToolCallRecovery`)与 `dsh-api-remotes`(新增 product-analytics remote)变化;会话格式仍 v4、`SUBAGENT_DESCRIPTOR_VERSION` 仍 3、`dsh/lib/bin.js` 与 `dsh-client-modules` 运行时逐字未变。因此**没有为 0.1.7 保留任何兼容分支**。
- 🧪 **挂载 lane 与 CI 钉版同步到 `0.2.0-rc.1`**;`tests/market-manifest.spec.ts` 的 peer 形状规则改为钉当前基线 tuple,并记下「caret 跨 minor 必失效」这条教训。
- ⚠️ **生态连带**:`@huanlin/dsh-plugin-better-locale`(可选集成,非本插件依赖面)的 peer 钉在 `^0.1.x` 线上,在 0.2.0 上无法加载,其 5 条 unmet peer 是本次 `pnpm peers check` 唯一残留(上游未适配,与本插件的 14 条 peer 无关)。
**支持的 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
@@ -221,19 +234,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` 同一套词法解析),改动后只重列那一层、折叠即退订。此前文件树会一直陈旧到手动刷新。
- 🐛 **会话跟随修好了**:插件此前读的是一个**不存在的 `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)。
## ⌨️ 快捷键
+20 -19
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.24.1 the host support floor is DSH `0.2.0-rc.1+`** (peer floor `^0.2.0-rc.1`). Everything this plugin consumes from 0.2.0 is **purely additive** (zero removed exports, session format still v4, CLI and client runtime byte-identical), so this version carries no runtime compatibility branch — it only moves the support line. **Hosts on the 0.1.7 line should pin `dsh-better-sidebar@0.23.0` — a caret never spans a minor bump, and `^0.1.7-rc.1` is silently disabled by the 0.2.0 startup preflight**; the DSH-to-plugin version table is in [Installation](#-installation).
> **Since v0.24.1 the host support floor is DSH `0.2.0-rc.1+`** (peer floor `^0.2.0-rc.1`). Everything this plugin consumes from 0.2.0 is **purely additive** (zero removed exports, session format still v4, CLI and client runtime byte-identical), so this version carries no runtime compatibility branch — it only moves the support line. **Hosts on the 0.1.7 line should pin `dsh-better-sidebar@0.22.1` — a caret never spans a minor bump, and `^0.1.7-rc.1` is silently disabled by the 0.2.0 startup preflight**; the DSH-to-plugin version table is in [Installation](#-installation).
<!-- Hero -->
<div align="center">
@@ -64,14 +64,14 @@ What this plugin adds on top of DSH's stock sidebar:
**Supported DSH versions**:
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions (v0.24.0): 0.2.0-rc.1+" src="https://img.shields.io/badge/DSH-0.2.0--rc.1%2B-4d6bfe" /></a>
> 📌 **Channel and support line**: `v0.24.1` targets DSH **0.2.0-rc.1+** (0.2.0's first candidate rides npm's `next` dist-tag; `latest` is still 0.1.7-rc.2). **Pin the DSH version exactly**: `npm i -g @deepseek-ai/dsh@0.2.0-rc.1`. **Hosts on the 0.1.7 line should pin `dsh-better-sidebar@0.23.0`**: 0.2.0 is a host minor bump, and caret ranges such as `^0.1.7-rc.1` fail the host's startup compatibility preflight on 0.2.0, which disables the whole profile row silently.
> 📌 **Channel and support line**: `v0.24.1` targets DSH **0.2.0-rc.1+** (0.2.0's first candidate rides npm's `next` dist-tag; `latest` is still 0.1.7-rc.2). **Pin the DSH version exactly**: `npm i -g @deepseek-ai/dsh@0.2.0-rc.1`. **Hosts on the 0.1.7 line should pin `dsh-better-sidebar@0.22.1`**: 0.2.0 is a host minor bump, and caret ranges such as `^0.1.7-rc.1` fail the host's startup compatibility preflight on 0.2.0, which disables the whole profile row silently.
> 🧭 **Pick the plugin version that matches your DSH**:
>
> | Your DSH | Install command | Version / peer declared |
> | --- | --- | --- |
> | **0.2.0-rc.1+** (including a later 0.2.0 stable) | `dsh plugin --profile web add dsh-better-sidebar@latest` | **0.24.1**, `^0.2.0-rc.1` |
> | **0.1.7-rc.1 – 0.1.7-rc.2** (and a later 0.1.7 stable; npm `latest` is still 0.1.7-rc.2) | `dsh plugin --profile web add dsh-better-sidebar@0.23.0` | **0.23.0**, `^0.1.7-rc.1` |
> | **0.1.7-rc.1 – 0.1.7-rc.2** (and a later 0.1.7 stable; npm `latest` is still 0.1.7-rc.2) | `dsh plugin --profile web add dsh-better-sidebar@0.22.1` | **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` |
@@ -107,7 +107,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>
@@ -145,7 +145,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>
@@ -206,14 +206,27 @@ WeChat / QQ group QR codes will live here. After uploading the QR images (drag t
> 🐞 **Fix release**: two defects made the file tree rebuild itself on every interaction — (1) the native surface re-created the `files` takeover’s slot registration on EVERY session-state write (folder toggles, tab switches, bottom-workbench drags), so the host swapped the slot entry and unmounted/remounted the whole tab body: the explorer lost its level cache, scroll position and directory watcher and re-listed the entire visible tree; (2) the live-refresh re-list deleted a level from the cache before refetching, replacing its rows with a loading placeholder (a build, a formatter or the model’s own bash made whole folders blink). Now a toggle asks only for the toggled level, the tab body survives, and disk changes update rows in place.
### v0.23.0
> 🧭 **Development-line version (never published to npm; its content ships with v0.24.1)**: the Files and Changes pages rebuilt. The Files page gains **Ctrl/Cmd and Shift multi-select**, a batch bar, **Git change coloring**, **new-folder creation**, reworked drag-and-drop upload and **"Zip and download" on a multi-selection** (a host-side archiving task with progress); "Open with" keeps the host-detected local apps and the plugin's own targets **side by side** (setting `openWithPluginTargets`), and the context menu was restructured; the Changes page is **a hierarchy tree** again (Git view + Agent view, directory staging). Performance: `fs.tree` measured **22.8ms → 3.8ms** (10k entries) with the new batch route `fs.trees` (mount/refresh: N+1 requests → one), and opening the context menu no longer re-lists directories. ⚠️ **Behavior change (security-relevant)**: the **workspace path fence was removed** — the plugin's fs routes can read and write any path the host user can reach (OS permissions only). Details in [CHANGELOG_EN](./CHANGELOG_EN.md#v0230).
### v0.24.0
> 📦 **Support line moved**: DSH **0.2.0-rc.1+** only (peer floor `^0.2.0-rc.1`, CI pins `@deepseek-ai/dsh@0.2.0-rc.1`). **The 0.1.7 line (including npm `latest` = 0.1.7-rc.2) should pin v0.23.0**: a caret never spans a minor bump, so `^0.1.7-rc.1` fails the 0.2.0 host's startup compatibility preflight and the whole row is disabled (measured: `semver.satisfies('0.2.0-rc.1','^0.1.7-rc.1',{includePrerelease:true}) === false`).
> 📦 **Support line moved**: DSH **0.2.0-rc.1+** only (peer floor `^0.2.0-rc.1`, CI pins `@deepseek-ai/dsh@0.2.0-rc.1`). **The 0.1.7 line (including npm `latest` = 0.1.7-rc.2) should pin v0.22.1 (the last published release on the 0.1.7 line)**: a caret never spans a minor bump, so `^0.1.7-rc.1` fails the 0.2.0 host's startup compatibility preflight and the whole row is disabled (measured: `semver.satisfies('0.2.0-rc.1','^0.1.7-rc.1',{includePrerelease:true}) === false`).
- 📦 **Baseline lifted to `0.2.0-rc.1`**: 14 DSH peers and 27 `@deepseek-ai/*` devDependencies move together; `dsh.plugin.json`'s `engines.dsh` follows.
- 🔍 **Measured as purely additive**: across the 19 host packages this plugin consumes, **zero value exports were removed**; the only type-surface changes are `ui-primitives` (optional props on `DisclosureRow` / `TextShimmer` / `Tooltip`, plus the overlay top inset), `dsh-session` (new `ToolCallRecovery`) and `dsh-api-remotes` (a new product-analytics remote). Session format stays v4, `SUBAGENT_DESCRIPTOR_VERSION` stays 3, and `dsh/lib/bin.js` plus the `dsh-client-modules` runtime are byte-identical — so **no compatibility branch for 0.1.7 is kept**.
- 🧪 **Mount lane and CI pins follow to `0.2.0-rc.1`**; the peer-shape rule in `tests/market-manifest.spec.ts` now pins the current baseline tuple and records the "a caret never spans a minor bump" lesson.
- ⚠️ **Ecosystem side effect**: the optional `@huanlin/dsh-plugin-better-locale` integration pins the `^0.1.x` line and cannot load on 0.2.0; its 5 unmet peers are the only residue in `pnpm peers check` (upstream has not adapted; unrelated to this plugin's own 14 peers).
**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
@@ -230,19 +243,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 resolved by the same lexical rules 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
+1
View File
@@ -1035,6 +1035,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` 显式报出)。理由写在源码注释里。
+109 -44
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
@@ -188,6 +210,11 @@ export function registerNativeSurface(deps: NativeSurfaceDeps): () => void {
// Bind the store BEFORE any body can render: the per-session state is the
// authority for the explorer's expansion set, so the native surface and the
// workbench share one set per session and a closed tab cannot drop it.
// (Restored after the v0.22.1 merge: main's branch rewrote this function
// without the call, and taking that file wholesale silently disabled every
// folder toggle — the records then have no store to read from or write to,
// so `toggleExpanded` returns without touching anything. Only the real-host
// lane could see it; the jsdom specs bind the records themselves.)
records.attachStore(store)
// Wait for the tab-type REGISTRY (a service), not for the slot declaration:
// the native seat declares `sidebar.right.pane.tab` BEFORE it provides
@@ -212,23 +239,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) => {
@@ -275,14 +323,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}"`)
}
}
@@ -308,10 +367,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}"`)
}
}
@@ -322,36 +387,33 @@ export function registerNativeSurface(deps: NativeSurfaceDeps): () => void {
if (!service.isTabEnabled(descriptor.id)) continue
wanted.set(descriptor.id, () => registerDescriptor(descriptor))
}
// The built-in files kind follows the editor type's switch: with the
// editor disabled the plugin has no explorer to put there.
const wantsFiles = service.isTabEnabled(EDITOR_KIND)
for (const [descriptorId, registration] of live) {
if (wanted.has(descriptorId)) continue
// The `files` takeover is keyed by its KIND, not by a descriptor id, so
// it is never in `wanted` — and dropping it here would dispose it on
// EVERY sync. Replacing a slot registration replaces the host's slot
// entry, which unmounts the tab body it draws: the store notifies on
// each expand/collapse (and on every other state write), so the
// explorer was torn down and rebuilt — losing its level cache, its
// scroll position and its directory watcher, then re-listing the whole
// visible tree — every time a folder was toggled. Keep it while the
// editor type is on; the tail of this function owns its lifecycle.
if (descriptorId === FILES_KIND && wantsFiles) 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) {
reportFailure?.(`register ${descriptorId}`, error)
}
}
// The `files` takeover is a second registration of its own kind, so it
// is created and dropped here rather than by the loops above.
// The built-in files kind follows the editor type's switch: with the
// editor disabled the plugin has no explorer to put there.
const wantsFiles = service.isTabEnabled(EDITOR_KIND)
const hasFiles = live.has(FILES_KIND)
if (wantsFiles && !hasFiles) {
try {
@@ -361,17 +423,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 () => {
+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;
@@ -1777,6 +1809,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)
}
})
})