fix(sidebar): 点击展开时文件树整体重建(两处根因)+ v0.24.1

用户报告:「点击展开的时候,文件树会频繁整体刷新,看起来非常难受」。

根因 1(主):src/client/native/index.ts 的 sync() 把 files 接管项当成多余注册。
它按 KIND 记账、永远不在 wanted 里,于是每次 sync 都被 dispose + 重建;而
disposeSubscriptions 同时订阅了 service 与 store,**任何会话状态写入**(展开/收起、
切 tab、拖底部工作台、选中…)都会跑一遍 sync。替换 slot 注册 = 宿主换 slot entry →
插件 tab body 被 unmount/remount → 文件树丢失层缓存、滚动位置与目录 watcher,并重新
fs.trees([root, ...expanded]) 重列整棵可见树。机械证明(临时插桩,已回退):
  [probe-sync] enter live=[editor,git,subagent,sidechat,diff,files]
  [probe-registerSlots] slot inject ran for dsh-better-sidebar:files
  [probe-body] unmount tab3 kind=files
  [probe-body] mount tab3 kind=files
修法:wantsFiles 提前,删除循环里对 files 接管项 skip(禁用 editor 时仍照旧释放)。

根因 2(次):FileTree.tsx 的 retryDir 先把该层缓存删掉再请求,行被「Loading…」
占位替换后重建——目录有外部写入(构建 / 格式化 / 模型跑 bash)时整层闪空。
修法:复用既有 force 路径 loadLevels([dir], { force: true }),保留旧行直到新答案到达
(与 refreshTick 的强制刷新路径行为一致)。

判别性证据(回退任一修复即红)
- tests/native-surface.spec.ts:三次 store 写入不得产生任何注册/注销 → 回退后
  registered 6 → 12、disposed 全是 dsh-better-sidebar:files
- tests/file-tree-batch-load.spec.tsx:重列期间旧行仍在屏 → 回退后层被删空
- 新增真实宿主 lane tests/e2e/zz-expand-refresh.e2e.ts(quiescence 判据,无固定 sleep;
  zz- 前缀保证它最后跑,因为它会自建 workspace + session):A1 展开只请求被展开那一层、
  A2 tab body 存活(不重开 watch socket)、A3 屏上行数不归零;回退修复 1 后退出码 1
  (Error: expanding a folder must ask for that folder, not the whole visible set)

修复后实测(40 子项目录,同一探针/机器/工作区)
| 阶段 | fs.trees payload | 新开 ws | DOM +/- | 被删行 | body 替换 | 层内最小行数 |
| 展开 big | [big](原 [root, big]) | 0(原 1) | +41/-1 | 0(原 45) | 0(原是) | 40(原 40) |
| 外部写入 big | [big] | 0 | +1/-0(原 +41/-40) | 0 | 0 | 40(原 0) |
| 展开 bravo | [bravo] | 0 | +2/-1 | 0(原 45) | 0(原是) | 41 |

证伪记录:新建 fs.watch(dir,{persistent:false}) 在 macOS 上无改动时 0 事件(Node 微
探针两遍 + 8 轮真实时序 + 浏览器侧从未收到 {dir} 陈旧帧)→ src/fs-watch.ts 与
use-dir-watch.ts 一行未改。

验证:pnpm typecheck / lint / test(136 文件 1486 通过 9 跳过,基线 1484 + 新增 2)/ build
全绿;DSH_CMD='npx -y --package @deepseek-ai/dsh@0.2.0-rc.1 dsh' pnpm test:mount = 8/8。
版本 0.24.0 → 0.24.1(package.json / dsh.plugin.json / SIDEBAR_SERVICE_VERSION /
README×2 / CHANGELOG×2 同步)。
This commit is contained in:
Menghuan1918
2026-09-28 22:33:30 +08:00
parent b7825e1bd0
commit 98a446bbfc
12 changed files with 538 additions and 19 deletions
+8
View File
@@ -2,6 +2,14 @@
> 本文档收录 dsh-better-sidebar 的完整发布历史(最新版摘要见 [README](README.md);同步发布于 [GitHub Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases))。
### v0.24.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`;另一条断言重列期间旧行仍在屏)
- 📉 修复后实测(40 子项目录):展开 `fs.trees` payload 由 `[root, big]`(空缓存特征)变为 **`[big]`**,新开 WebSocket 1 → **0**,被删行 45 → **0**,外部写入由「删 40 建 40」变为 **只增 1 行**
- 🔧 `src/fs-watch.ts` / `use-dir-watch.ts` / `TreePanel.tsx` / `tab-adapter.tsx` 零改动(`fs.watch` 新建自身不投递事件,已实测证伪)
### 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 兼容分支。
+8
View File
@@ -2,6 +2,14 @@
> Full release history of dsh-better-sidebar (see the [README](README_EN.md) for the latest release; also mirrored on [GitHub Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases)).
### v0.24.1
> 🐞 **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)
- 📉 Measured after the fix (40-child directory): the toggle's `fs.trees` payload goes from `[root, big]` (the empty-cache signature) to **`[big]`**, new WebSockets 1 → **0**, removed rows 45 → **0**, and an external write goes from "remove 40, add 40" to **one added row**
- 🔧 `src/fs-watch.ts` / `use-dir-watch.ts` / `TreePanel.tsx` / `tab-adapter.tsx` untouched (`fs.watch` does not emit on creation — measured and ruled out)
### 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.
+8 -4
View File
@@ -3,7 +3,7 @@
> [!IMPORTANT]
> **v0.19.0 起接入 DSH 原生侧边栏**:右列就是 DSH 自己的右侧栏,插件把每个 tab 类型注册为原生 tab(不再自绘右侧面板),只保留自绘的底部工作台与开放给所有插件的 `ctx.betterSidebar` 服务。
>
> **v0.24.0 起要求 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.23.0`——caret 范围跨 minor 不成立,`^0.1.7-rc.1` 在 0.2.0 宿主上会被启动预检静默禁用**;按 DSH 版本选插件版本的对照表见[安装](#-安装)。
<!-- Hero -->
<div align="center">
@@ -14,7 +14,7 @@
<a href="https://github.com/omdsh-dev/DSH-better-sidebar/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/omdsh-dev/DSH-better-sidebar" /></a>
<a href="https://opensource.org/licenses/MIT"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-yellow.svg" /></a>
<a href="https://dshfind.com/zh/plugins/omdsh-dev/DSH-better-sidebar?ref=badge"><img alt="dshfind" src="https://dshfind.com/api/badge/omdsh-dev/DSH-better-sidebar?lang=zh" /></a><br /><br />
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本(v0.24.0):0.2.0-rc.1+" src="https://img.shields.io/badge/DSH-0.2.0--rc.1%2B-4d6bfe" /></a>
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本(v0.24.1):0.2.0-rc.1+" src="https://img.shields.io/badge/DSH-0.2.0--rc.1%2B-4d6bfe" /></a>
<a href="https://github.com/topics/dsh-better-sidebar"><img alt="插件生态:GitHub topic dsh-better-sidebar" src="https://img.shields.io/badge/%E6%8F%92%E4%BB%B6%E7%94%9F%E6%80%81-topic%20dsh--better--sidebar-4d6bfe" /></a><br /><br />
<img alt="文件管理" src="https://img.shields.io/badge/-文件管理-4d6bfe" /> <img alt="编辑预览" src="https://img.shields.io/badge/-编辑预览-4d6bfe" /> <img alt="底部工作台" src="https://img.shields.io/badge/-底部工作台-4d6bfe" /> <img alt="文件变动" src="https://img.shields.io/badge/-文件变动-4d6bfe" /> <img alt="后台任务" src="https://img.shields.io/badge/-后台任务-4d6bfe" /> <img alt="侧边对话" src="https://img.shields.io/badge/-侧边对话-4d6bfe" /> <img alt="插件接入" src="https://img.shields.io/badge/-插件接入-4d6bfe" /><br /><br />
<b>右侧栏 + 底部面板双工作台</b>,并把 <code>ctx.betterSidebar</code> 服务开放给所有插件——<br />
@@ -64,13 +64,13 @@
**支持的 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.0` 适配 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.23.0`**: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.0**,`^0.2.0-rc.1` |
> | **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-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` |
@@ -193,6 +193,10 @@ dsh registry enable dsh-external/dsh-better-sidebar
**支持的 DSH 版本**:<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本(v0.24.0):0.2.0-rc.1+" src="https://img.shields.io/badge/DSH-0.2.0--rc.1%2B-4d6bfe" /></a> · 完整发布历史见 [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases)
### v0.24.1
> 🐞 **修复版**:修掉两处会让文件树「一操作就整体刷新」的缺陷——① 原生承载面在**每次会话状态写入**时都会重建 `files` 接管项的 slot 注册,宿主因此换掉 slot entry、把整个 tab body 卸载重挂(展开/收起、切 tab、拖底部工作台都会触发)→ 文件树丢失层缓存、滚动位置与目录 watcher 并重列整棵可见树;② 目录实时刷新的重列**先把该层缓存删掉**再请求,导致行被「Loading…」占位替换后重建(构建/格式化/模型跑 bash 时整层闪空)。现在展开只请求被展开那一层、tab body 不再重建、变更只增量更新行。
### 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`)。
+8 -4
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.0 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.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).
<!-- Hero -->
<div align="center">
@@ -14,7 +14,7 @@
<a href="https://github.com/omdsh-dev/DSH-better-sidebar/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/omdsh-dev/DSH-better-sidebar" /></a>
<a href="https://opensource.org/licenses/MIT"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-yellow.svg" /></a>
<a href="https://dshfind.com/en/plugins/omdsh-dev/DSH-better-sidebar?ref=badge"><img alt="dshfind" src="https://dshfind.com/api/badge/omdsh-dev/DSH-better-sidebar?lang=en" /></a><br /><br />
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions (v0.24.0): 0.2.0-rc.1+" src="https://img.shields.io/badge/DSH-0.2.0--rc.1%2B-4d6bfe" /></a>
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions (v0.24.1): 0.2.0-rc.1+" src="https://img.shields.io/badge/DSH-0.2.0--rc.1%2B-4d6bfe" /></a>
<a href="https://github.com/topics/dsh-better-sidebar"><img alt="Plugin ecosystem: GitHub topic dsh-better-sidebar" src="https://img.shields.io/badge/plugin%20ecosystem-topic%20dsh--better--sidebar-4d6bfe" /></a><br /><br />
<img alt="File management" src="https://img.shields.io/badge/-File%20management-4d6bfe" /> <img alt="Edit &amp; preview" src="https://img.shields.io/badge/-Edit%20%26%20preview-4d6bfe" /> <img alt="Bottom workbench" src="https://img.shields.io/badge/-Bottom%20workbench-4d6bfe" /> <img alt="Changes" src="https://img.shields.io/badge/-Changes-4d6bfe" /> <img alt="Background tasks" src="https://img.shields.io/badge/-Background%20tasks-4d6bfe" /> <img alt="Side Chat" src="https://img.shields.io/badge/-Side%20Chat-4d6bfe" /> <img alt="Plugin integration" src="https://img.shields.io/badge/-Plugin%20integration-4d6bfe" /><br /><br />
<b>A dual workbench (right sidebar + bottom panel)</b> that opens its <code>ctx.betterSidebar</code> service to every plugin —<br />
@@ -64,13 +64,13 @@ 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.0` 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.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.
> 🧭 **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.0**, `^0.2.0-rc.1` |
> | **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-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` |
@@ -202,6 +202,10 @@ WeChat / QQ group QR codes will live here. After uploading the QR images (drag t
**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> · full release history on the [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases) page
### v0.24.1
> 🐞 **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.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`).
+1 -1
View File
@@ -1,6 +1,6 @@
{
"id": "dsh-external/dsh-better-sidebar",
"version": "0.24.0",
"version": "0.24.1",
"main": "./lib/index.js",
"description": "更好用的右侧边栏——文件树、编辑器、文件变动、任务和侧边对话都收在这一栏里,每个会话互不干扰。暴露服务供其他插件注册侧边栏页面与文件预览器",
"engines": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dsh-better-sidebar",
"version": "0.24.0",
"version": "0.24.1",
"description": "Better Sidebar for DSH — file tree, editor, changes, tasks and side chat in one column, separate for every conversation. Also exposes a service other plugins use to register sidebar tabs and file viewers.",
"type": "module",
"repository": {
+13 -5
View File
@@ -692,12 +692,20 @@ export function FileTree(props: {
})
}, [sessionId, cwd, storeLevel])
/** Drop one level from the cache and reload it (a watch notice, or the
* parent of a landed mutation). */
/**
* Re-list one directory in place (a watch notice, or the parent of a landed
* mutation).
*
* The re-list is FORCED but not preceded by dropping the level: a watch
* notice usually lands while the directory is on screen with its rows, and
* deleting the cached level first replaced every row with a "Loading…"
* placeholder — the whole folder blinked and rebuilt on each disk change
* (a build, a formatter, the model's own `bash`). The fresh listing simply
* overwrites the old one when it arrives; a failed re-list keeps the rows it
* has and adds the hint line.
*/
const retryDir = useCallback((dir: string) => {
delete dataRef.current[dir]
setData({ ...dataRef.current })
loadLevels([dir])
loadLevels([dir], { force: true })
}, [loadLevels])
/**
+15 -3
View File
@@ -322,8 +322,21 @@ 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()
live.delete(descriptorId)
}
@@ -337,9 +350,8 @@ export function registerNativeSurface(deps: NativeSurfaceDeps): () => void {
reportFailure?.(`register ${descriptorId}`, error)
}
}
// 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)
// The `files` takeover is a second registration of its own kind, so it
// is created and dropped here rather than by the loops above.
const hasFiles = live.has(FILES_KIND)
if (wantsFiles && !hasFiles) {
try {
+1 -1
View File
@@ -639,7 +639,7 @@ export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDes
* The plugin version this service instance reports. Keep in lockstep with
* `package.json`'s version — `tests/service.spec.ts` asserts the pair.
*/
export const SIDEBAR_SERVICE_VERSION = '0.24.0'
export const SIDEBAR_SERVICE_VERSION = '0.24.1'
/**
* Monotonic capability list consumers use to gate new API usage (features
+377
View File
@@ -0,0 +1,377 @@
/**
* Expand-refresh lane: a folder toggle must not rebuild the tree.
*
* Reported symptom: "expanding a folder makes the whole tree refresh over and
* over". Two independent defects produced it, and neither is visible in jsdom:
*
* 1. the plugin's native surface re-created the `files` takeover's slot
* registration on EVERY store write, and the store notifies on each
* expand/collapse. Replacing a slot registration replaces the host's slot
* entry, so the native tab BODY was unmounted and remounted: the explorer
* lost its level cache, its scroll position and its directory watcher, and
* re-listed the whole visible set every time a folder was toggled;
* 2. a live-refresh notice dropped the cached level before re-listing it, so
* the rows of a directory that changed on disk were replaced by a loading
* placeholder and rebuilt.
*
* This lane is the real-browser counterpart of the two unit guards
* (tests/native-surface.spec.ts, tests/file-tree-batch-load.spec.tsx): jsdom
* cannot see the host's slot machinery, so only a mounted `dsh web` can prove
* the body survives a toggle. It drives the plugin's own Files tab in DSH's
* native right Sidebar over a workspace with a 40-child directory and records,
* per phase, every `fs.trees`/`fs.tree` request with its payload paths, every
* `/sidebar/ws/fs-watch` frame, the tree-body replacements and an rAF census of
* the live rows.
*
* The gate:
* A1 the first request after a toggle names the TOGGLED level only
* (asking for [root, ...expanded] is the empty-cache signature of a
* fresh mount);
* A2 the tree body survives every toggle (no unmount/remount, no reopened
* watch socket);
* A3 rows that are on screen never drop to zero — not on a toggle and not
* when a directory changes behind the plugin.
*
* Phases wait for QUIESCENCE (no new request, frame or DOM batch for a quiet
* window) rather than a fixed sleep, so a slow machine widens the window
* instead of racing it.
*
* NOTE: this lane seeds its own workspace + session, and DSH renders the
* session that is current — so it runs LAST (the `zz-` prefix) for the same
* reason the mount lane runs first: a later lane's seeding must not change what
* an earlier lane's tree shows.
*/
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test, expect, type APIRequestContext, type Page } from '@playwright/test'
import { PAGE_URL, createHostApi, hostRpc, sendFirstMessage } from './host'
/** This lane's own workspace: a 40-child directory plus a small sibling. */
const WORKSPACE_PATH = process.env.DSH_E2E_EXPAND_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-expand-probe')
/** The big directory's name and its child count (the reader's scenario). */
const BIG = 'big'
const BIG_CHILDREN = 40
/** Quiescence: how long nothing may happen before a phase is read. */
const QUIET_MS = 500
/** Minimum reaction window: the watcher debounce (150ms) plus the round trip. */
const MIN_PHASE_MS = 800
const QUIET_MAX_MS = 4_000
let api: APIRequestContext
function seedWorkspace(): void {
// The lane owns this directory and every run starts from the same tree: a
// leftover file from an earlier run would already be listed, so the
// behind-the-plugin write below would change nothing on screen.
rmSync(WORKSPACE_PATH, { recursive: true, force: true })
mkdirSync(join(WORKSPACE_PATH, BIG), { recursive: true })
mkdirSync(join(WORKSPACE_PATH, 'bravo'), { recursive: true })
for (let i = 0; i < BIG_CHILDREN; i++) {
writeFileSync(join(WORKSPACE_PATH, BIG, `file-${String(i).padStart(2, '0')}.txt`), `child ${i}\n`)
}
writeFileSync(join(WORKSPACE_PATH, 'bravo', 'z.txt'), 'z\n')
writeFileSync(join(WORKSPACE_PATH, 'top.txt'), 'top\n')
}
test.beforeAll(async () => {
api = await createHostApi()
seedWorkspace()
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() })
async function dismissOnboarding(page: Page): Promise<void> {
try {
await expect
.poll(() => page.getByRole('button', { name: /^(Continue|Configure later)$/ }).count(), { timeout: 60_000 })
.toBeGreaterThan(0)
} catch { /* a build without onboarding proceeds straight to the tree */ }
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 */ }
}
if (!dismissed) break
}
}
/**
* Install the census: follow the pane's tree body (through a replacement, so a
* remount is recorded without zeroing the measurement), count — every frame —
* the live rows under the big directory, and log every document-level batch
* that removed a native tab host or a whole run of rows.
*/
const INSTALL_CENSUS = `(() => {
const bodies = () => [...document.querySelectorAll('[class*="explorerBody"]')]
const findTree = () => bodies().find(el => el.getBoundingClientRect().height > 0
&& [...el.querySelectorAll('[class*="explorerName"]')].some(span => span.textContent === '${BIG}'))
const first = findTree()
if (first === undefined) return 'no tree body'
first.setAttribute('data-expand-probe', 'target')
const state = {
changes: [], batches: [], added: 0, removed: 0,
replacedBodies: 0, killedRows: 0, marks: [], minBig: Infinity, sawBig: 0, emptyFrames: 0,
}
let seq = 0
const seen = new Map()
const census = () => {
const now = Math.round(performance.now())
const live = bodies().filter(el => el.getBoundingClientRect().height > 0 || el.querySelector('[class*="explorerRow"]'))
for (const el of live) if (!seen.has(el)) { seq += 1; seen.set(el, seq); state.marks.push({ t: now, ev: 'tree-body-mount', seq }) }
for (const [el, id] of [...seen]) if (!el.isConnected) { seen.delete(el); state.marks.push({ t: now, ev: 'tree-body-unmount', seq: id }) }
const current = first.isConnected ? first : findTree()
const bigRows = current === undefined ? -1 : current.querySelectorAll('[title*="/${BIG}/"]').length
const rows = current === undefined ? -1 : current.querySelectorAll('[class*="explorerRow"]').length
state.changes.push({ t: now, alive: first.isConnected ? 1 : 0, bodies: live.length, rows, bigRows })
// The blanking detector: the low only counts from the moment rows are up
// (an empty level before its first listing is not a blank frame). Collapsing
// the folder legitimately empties it, so only the phases that keep it open
// assert on this.
if (bigRows > 0) state.sawBig = 1
if (state.sawBig === 1 && bigRows >= 0) state.minBig = Math.min(state.minBig, bigRows)
if (state.sawBig === 1 && bigRows === 0) state.emptyFrames += 1
}
new MutationObserver(records => {
let added = 0, removed = 0, killedRows = 0, killedBodies = 0
for (const record of records) {
added += record.addedNodes.length
removed += record.removedNodes.length
for (const node of record.removedNodes) {
if (!(node instanceof Element)) continue
const cls = typeof node.className === 'string' ? node.className : ''
if (cls.includes('nativeTabHost')) killedBodies += 1
killedRows += node.querySelectorAll('[class*="explorerRow"]').length
}
}
state.added += added
state.removed += removed
state.killedRows += killedRows
state.replacedBodies += killedBodies
// A placeholder that is not itself a row marks the frame a level was blanked.
if (killedRows > 0) state.emptyFrames += 1
state.batches.push({ t: Math.round(performance.now()), added, removed, killedRows, killedBodies })
census()
}).observe(document.body, { childList: true, subtree: true })
const loop = () => { census(); requestAnimationFrame(loop) }
requestAnimationFrame(loop)
census()
window.__census = { state }
return 'installed'
})()`
test('a folder toggle re-lists that folder and nothing else', async ({ page }) => {
// ── Node-side recorders (armed before navigation: the socket opens on mount) ──
interface Req { t: number; kind: string; paths: string[] }
const requests: Req[] = []
interface Sock { id: number; sent: string[]; recv: string[] }
const sockets: Sock[] = []
const t0 = Date.now()
const rel = (): number => Date.now() - t0
page.on('request', (request) => {
const url = request.url()
const kind = url.includes('/sidebar/api/fs.trees') ? 'fs.trees'
: url.includes('/sidebar/api/fs.tree') ? 'fs.tree'
: /\/sidebar\/api\/git\./.test(url) ? 'git' : null
if (kind === null) return
let paths: string[] = []
const body = request.postData()
if (body !== null) {
try {
const parsed = JSON.parse(body) as { paths?: string[]; path?: string }
paths = parsed.paths ?? (parsed.path === undefined ? [] : [parsed.path])
} catch { /* non-JSON payload: leave the paths empty */ }
}
requests.push({ t: rel(), kind, paths })
})
page.on('websocket', (ws) => {
if (!ws.url().includes('/sidebar/ws/fs-watch')) return
const sock: Sock = { id: sockets.length + 1, sent: [], recv: [] }
sockets.push(sock)
ws.on('framesent', (frame) => sock.sent.push(String(frame.payload)))
ws.on('framereceived', (frame) => sock.recv.push(String(frame.payload)))
})
await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' })
await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 })
await expect(page.locator('[data-dsh-better-sidebar]')).toBeAttached({ timeout: 90_000 })
await dismissOnboarding(page)
await sendFirstMessage(page)
const pane = page.locator('[data-sidebar-right-panel]')
await page.locator('[data-sidebar-right-expand]').first().click()
await expect(pane).toBeVisible({ timeout: 90_000 })
const addTab = page.locator('[data-dockkit-add-tab]').first()
if (await page.locator('[data-sidebar-right-guide]').count() === 0) await addTab.click()
await page.locator('[data-sidebar-right-guide-entry="files"]').click()
const dirRow = (name: string) => pane.locator(`[class*="explorerDir"]:has([class*="explorerName"]:text-is("${name}"))`)
await expect(dirRow(BIG), 'the seeded big directory must be listed').toHaveCount(1, { timeout: 60_000 })
await expect(
pane.locator('[role="button"][title$="top.txt"]:visible'),
'the root level must be listed before the toggle',
).toHaveCount(1, { timeout: 60_000 })
const installed = await page.evaluate(INSTALL_CENSUS)
expect(installed, 'the tree body must exist for the census').toBe('installed')
/** A phase's readings, all read at once at the end of the phase. */
interface Phase {
phase: string
requests: string[]
sockets: number
wsFrames: number
added: number
removed: number
killedRows: number
treeBodyReplaced: number
emptyFrames: number
minBigRows: number
transitions: unknown[]
}
const phases: Phase[] = []
/** Counters the quiescence wait compares: requests, frames and DOM batches. */
const activity = (): number =>
requests.length + sockets.reduce((sum, s) => sum + s.sent.length + s.recv.length, 0) + domBatch
let domBatch = 0
const runPhase = async (phase: string, action: (() => Promise<void>) | null): Promise<Phase> => {
const reqBase = requests.length
const sockBase = sockets.length
await page.evaluate(() => {
const probe = (window as unknown as { __census?: { state: Record<string, unknown> } }).__census
if (probe === undefined) return
Object.assign(probe.state, {
changes: [], batches: [], marks: [], added: 0, removed: 0, replacedBodies: 0, killedRows: 0,
minBig: Infinity, sawBig: 0, emptyFrames: 0,
})
})
if (action !== null) await action()
// Wait for quiescence instead of a fixed sleep: the phase ends when nothing
// (network, socket, DOM) has moved for QUIET_MS, capped so a watcher loop
// cannot hang the lane — a loop is exactly what the gate must catch.
const started = Date.now()
let last = activity()
let quietSince = Date.now()
while (Date.now() - started < QUIET_MAX_MS) {
await page.waitForTimeout(50)
const seen = await page.evaluate(() => {
const probe = (window as unknown as { __census?: { state: { batches: unknown[] } } }).__census
return probe?.state.batches.length ?? 0
})
domBatch = seen
const now = activity()
if (now !== last) { last = now; quietSince = Date.now(); continue }
if (Date.now() - started >= MIN_PHASE_MS && Date.now() - quietSince >= QUIET_MS) break
}
const sample = await page.evaluate(() => {
const probe = (window as unknown as { __census?: { state: Record<string, unknown> } }).__census
if (probe === undefined) return null
const s = probe.state as unknown as {
changes: Array<{ t: number; alive: number; bodies: number; rows: number; bigRows: number }>
marks: Array<{ t: number; ev: string; seq: number }>
added: number; removed: number; replacedBodies: number; killedRows: number
minBig: number; emptyFrames: number
}
// Collapse the rAF census to its transitions (what changed on screen).
const transitions: typeof s.changes = []
let lastKey = ''
for (const entry of s.changes) {
const key = `${entry.alive}/${entry.bodies}/${entry.rows}/${entry.bigRows}`
if (key === lastKey) continue
lastKey = key
transitions.push(entry)
}
return {
added: s.added, removed: s.removed, killedRows: s.killedRows, emptyFrames: s.emptyFrames,
minBig: s.minBig === Infinity ? -1 : s.minBig,
remounts: s.marks.filter(mark => mark.ev === 'tree-body-unmount').length,
replacedBodies: s.replacedBodies,
transitions,
}
})
const phaseRequests = requests.slice(reqBase).filter(entry => entry.kind !== 'git')
.map(entry => `${entry.kind}[${entry.paths.map(path => path.split('/').pop()).join(',')}]`)
const phaseFrames = sockets.slice(sockBase)
.flatMap(sock => [...sock.sent.map(frame => `sent#${sock.id} ${frame}`), ...sock.recv.map(frame => `recv#${sock.id} ${frame}`)])
const result: Phase = {
phase,
requests: phaseRequests,
sockets: sockets.length - sockBase,
wsFrames: phaseFrames.length,
added: sample?.added ?? -1,
removed: sample?.removed ?? -1,
killedRows: sample?.killedRows ?? -1,
treeBodyReplaced: (sample?.replacedBodies ?? 0) > 0 || (sample?.remounts ?? 0) > 0 ? 1 : 0,
emptyFrames: sample?.emptyFrames ?? -1,
minBigRows: sample?.minBig ?? -1,
transitions: sample?.transitions ?? [],
}
phases.push(result)
console.log(`EXPAND_PHASE ${JSON.stringify(result)}`)
return result
}
// A: the reported gesture — expanding the 40-child directory.
const expand = await runPhase(`A-expand-${BIG}`, async () => {
await dirRow(BIG).click({ position: { x: 8, y: 8 } })
})
// B: idle with the directory open — a watcher feedback loop would show up
// here as requests arriving with nobody touching anything.
const idle = await runPhase('B-idle', null)
// C: a directory written BEHIND the plugin (a build, a formatter, the
// model's own bash): the host notices and the tree re-lists exactly that
// level, in place.
const landed = `landed-${Date.now()}.txt`
const external = await runPhase('C-external-write', async () => {
writeFileSync(join(WORKSPACE_PATH, BIG, landed), 'landed\n')
})
// D: expanding a second directory while the first stays open.
const sibling = await runPhase('D-expand-bravo', async () => {
await dirRow('bravo').click({ position: { x: 8, y: 8 } })
})
// E: collapsing the first one back.
const collapse = await runPhase(`E-collapse-${BIG}`, async () => {
await dirRow(BIG).click({ position: { x: 8, y: 8 } })
})
console.log(`EXPAND_SUMMARY ${JSON.stringify(phases.map(phase => ({
phase: phase.phase,
requests: phase.requests,
newSockets: phase.sockets,
wsFrames: phase.wsFrames,
domAdded: phase.added,
domRemoved: phase.removed,
rowsKilledWithSubtrees: phase.killedRows,
treeBodyReplaced: phase.treeBodyReplaced,
minBigRows: phase.minBigRows,
emptyFramesAfterRowsUp: phase.emptyFrames,
rowTransitions: phase.transitions,
})), null, 1)}`)
// A1: the toggle's own request names the toggled level only.
expect(
expand.requests[0],
'expanding a folder must ask for that folder, not the whole visible set',
).toBe(`fs.trees[${BIG}]`)
expect(sibling.requests[0], 'expanding a sibling must ask for the sibling').toBe('fs.trees[bravo]')
// A2: the tree body survives every toggle. A replaced body unmounts the
// explorer — level cache, scroll position and directory watcher with it —
// and a reopened socket re-announces its whole watch set.
for (const phase of [expand, sibling, collapse]) {
expect(phase.treeBodyReplaced, `${phase.phase}: the tab body must not be replaced`).toBe(0)
expect(phase.sockets, `${phase.phase}: the watch socket must not be re-opened`).toBe(0)
}
// A3: rows on screen never drop to zero.
expect(expand.emptyFrames, 'the expand must not blank the level').toBe(0)
expect(external.emptyFrames, 'a live refresh must not blank the level').toBe(0)
expect(external.minBigRows, 'the live refresh must keep the level on screen').toBe(BIG_CHILDREN)
// …and neither the idle folder nor the collapse re-lists anything.
expect(idle.requests, 'an idle open folder must not re-list').toEqual([])
expect(collapse.requests, 'collapsing must not re-list anything').toEqual([])
expect(collapse.killedRows, 'collapsing only removes the level it closes').toBe(0)
})
+31
View File
@@ -202,6 +202,37 @@ describe('FileTree level loading is batched', () => {
expect(batchPaths(0)).toEqual(['/tmp/d3'])
})
it('keeps the stale level on screen until the fresh listing lands (no blank frame)', async () => {
harness = mountTree(DIRS)
await flush()
const socket = FakeSocket.instances[0]
if (socket === undefined) throw new Error('no watch socket opened')
act(() => { socket.open() })
// Hold the re-list open and answer with DIFFERENT content, so the swap is
// observable in both directions.
let release: (() => void) | undefined
fsTrees.mockImplementation(async (_scope: unknown, paths: readonly string[]) => {
await new Promise<void>((resolve) => { release = resolve })
return {
levels: paths.map(path => path === '/tmp/d3'
? { path, entries: [{ name: 'd3-fresh.ts', path: `${path}/d3-fresh.ts`, isDir: false }], truncated: false }
: levelOf(path)),
}
})
act(() => { socket.frame({ dir: '/tmp/d3' }) })
await flush()
// While the answer is in flight the level keeps the rows it had: dropping
// the cached level first replaced the whole folder with a loading row and
// rebuilt it — the "the tree blinks on every disk change" report.
expect(harness.container.textContent, 'the stale rows stay up during the re-list').toContain('d3-file.ts')
expect(harness.container.textContent, 'no placeholder replaces the level').not.toContain('Loading…')
release?.()
await flush()
// …and the fresh listing still lands.
expect(harness.container.textContent).toContain('d3-fresh.ts')
expect(harness.container.textContent).not.toContain('d3-file.ts')
})
it('keeps one level error on that level only', async () => {
fsTrees.mockImplementation(async (_scope: unknown, paths: readonly string[]) => ({
levels: paths.map(path => path === '/tmp/d3'
+67
View File
@@ -399,6 +399,73 @@ describe('registerNativeSurface lifecycle (service-driven registration)', () =>
dispose()
})
it('re-registers NOTHING on a store change (a folder toggle must not replace the explorer body)', () => {
// Regression (reported as "expanding a folder makes the whole tree
// refresh"): the surface kept its registrations in step by diffing them on
// every service AND store notification, and the built-in `files` takeover
// is keyed by its KIND — never by a descriptor id — so the "drop what is
// no longer wanted" loop read it as a stray registration and disposed it on
// EVERY state write. Disposing and re-creating a slot registration replaces
// the host's slot entry, which unmounts the tab body it draws: toggling a
// folder tore down the explorer (level cache, scroll position and the
// directory watcher all live in that component) and remounted it, so the
// tree blanked, re-requested [root, ...expanded] and rebuilt every row.
// The store notifies on each toggle, so this churned on every click.
const store = createSidebarStore()
store.setSession('s1')
const service = createBetterSidebarService(store)
service.registerTab({ id: 'editor', title: () => 'Files', component: () => null })
service.registerTab({ id: 'git', title: () => 'Changes', component: () => null })
const records = createNativeTabRecords()
/** Every slot registration, in order (the unregister keys land in `disposed`). */
const registered: string[] = []
const disposed: string[] = []
const registry = {
register: (definition: { id: string; kind: string; title: (address: string) => string }) =>
() => { disposed.push(definition.id) },
}
const ctx = {
inject: (_deps: readonly string[], callback: (injected: { get: (name: string) => unknown }) => void) => {
callback({ get: () => registry })
return { dispose: () => { /* the surface owns the rest */ } }
},
get: () => registry,
slots: {
inject: (_key: string, callback: () => () => void) => callback(),
register: (options: { name: string; key?: string }) => {
const key = options.key ?? options.name
registered.push(key)
return () => { disposed.push(key) }
},
},
}
const dispose = registerNativeSurface({ ctx: ctx as never, store, service, records })
const mounted = [...registered]
/** The takeover's slot registrations: its body + its chip title, one pair. */
const filesSlots = (): number => registered.filter(key => key === 'dsh-better-sidebar:files').length
expect(filesSlots(), 'the takeover registers its body + chip slots').toBe(2)
expect(disposed).toEqual([])
// A folder toggle: only state, no registry and no settings change. The
// native surface must not touch a single registration.
store.reduce(state => toggleExpanded(state, '/work/big'))
store.reduce(state => toggleExpanded(state, '/work/big/child'))
// Any other state write behaves the same (the workbench height, a tab
// switch, the selection — all of them notify the same subscribers).
store.update(state => { state.bottomOpen = !state.bottomOpen })
expect({ registered, disposed }).toEqual({ registered: mounted, disposed: [] })
// The takeover still follows the editor type's own switch: disabling the
// editor releases it, re-enabling brings it back exactly once.
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { editor: false } })
expect(disposed).toContain('dsh-better-sidebar:files')
expect(registered).toEqual(mounted)
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { editor: true } })
expect(filesSlots(), 'the re-enabled takeover registers again — exactly one pair').toBe(4)
dispose()
})
it('claims exactly the extensions DSH has no preview for (the nine restored formats included)', () => {
// DSH 0.1.7 handed every read-only preview to `ui-sidebar-documentpreview`,
// and `canOpen` returning false is what gives the address away. The host's