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:
@@ -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 兼容分支。
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 & 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
@@ -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
@@ -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
@@ -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])
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user