diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b1bc75..bb733d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 兼容分支。 diff --git a/CHANGELOG_EN.md b/CHANGELOG_EN.md index 8c43087..741fdd5 100644 --- a/CHANGELOG_EN.md +++ b/CHANGELOG_EN.md @@ -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. diff --git a/README.md b/README.md index 9aa14c0..177efae 100644 --- a/README.md +++ b/README.md @@ -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 版本选插件版本的对照表见[安装](#-安装)。
@@ -14,7 +14,7 @@ GitHub stars License: MIT dshfind

- 支持的 DSH 版本(v0.24.0):0.2.0-rc.1+ + 支持的 DSH 版本(v0.24.1):0.2.0-rc.1+ 插件生态:GitHub topic dsh-better-sidebar

文件管理 编辑预览 底部工作台 文件变动 后台任务 侧边对话 插件接入

右侧栏 + 底部面板双工作台,并把 ctx.betterSidebar 服务开放给所有插件——
@@ -64,13 +64,13 @@ **支持的 DSH 版本**: 支持的 DSH 版本(v0.24.0):0.2.0-rc.1+ -> 📌 **通道与支持线**:`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,再跑上一行:
`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 版本**:支持的 DSH 版本(v0.24.0):0.2.0-rc.1+ · 完整发布历史见 [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`)。 diff --git a/README_EN.md b/README_EN.md index e397827..ed600a0 100644 --- a/README_EN.md +++ b/README_EN.md @@ -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).
@@ -14,7 +14,7 @@ GitHub stars License: MIT dshfind

- Supported DSH versions (v0.24.0): 0.2.0-rc.1+ + Supported DSH versions (v0.24.1): 0.2.0-rc.1+ Plugin ecosystem: GitHub topic dsh-better-sidebar

File management Edit & preview Bottom workbench Changes Background tasks Side Chat Plugin integration

A dual workbench (right sidebar + bottom panel) that opens its ctx.betterSidebar service to every plugin —
@@ -64,13 +64,13 @@ What this plugin adds on top of DSH's stock sidebar: **Supported DSH versions**: Supported DSH versions (v0.24.0): 0.2.0-rc.1+ -> 📌 **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:
`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**: Supported DSH versions (v0.24.0): 0.2.0-rc.1+ · 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`). diff --git a/dsh.plugin.json b/dsh.plugin.json index 9b407f5..d007cc0 100644 --- a/dsh.plugin.json +++ b/dsh.plugin.json @@ -1,6 +1,6 @@ { "id": "dsh-external/dsh-better-sidebar", - "version": "0.24.0", + "version": "0.24.1", "main": "./lib/index.js", "description": "更好用的右侧边栏——文件树、编辑器、文件变动、任务和侧边对话都收在这一栏里,每个会话互不干扰。暴露服务供其他插件注册侧边栏页面与文件预览器", "engines": { diff --git a/package.json b/package.json index 393ba99..1f6c139 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/client/FileTree.tsx b/src/client/FileTree.tsx index d6117f6..222ad26 100644 --- a/src/client/FileTree.tsx +++ b/src/client/FileTree.tsx @@ -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]) /** diff --git a/src/client/native/index.ts b/src/client/native/index.ts index 7b90be2..f9c4b79 100644 --- a/src/client/native/index.ts +++ b/src/client/native/index.ts @@ -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 { diff --git a/src/client/service.ts b/src/client/service.ts index d16937b..182f1da 100644 --- a/src/client/service.ts +++ b/src/client/service.ts @@ -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 diff --git a/tests/e2e/zz-expand-refresh.e2e.ts b/tests/e2e/zz-expand-refresh.e2e.ts new file mode 100644 index 0000000..bfb8738 --- /dev/null +++ b/tests/e2e/zz-expand-refresh.e2e.ts @@ -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 { + 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) | null): Promise => { + const reqBase = requests.length + const sockBase = sockets.length + await page.evaluate(() => { + const probe = (window as unknown as { __census?: { state: Record } }).__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 } }).__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) +}) diff --git a/tests/file-tree-batch-load.spec.tsx b/tests/file-tree-batch-load.spec.tsx index b912263..c9e11c4 100644 --- a/tests/file-tree-batch-load.spec.tsx +++ b/tests/file-tree-batch-load.spec.tsx @@ -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((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' diff --git a/tests/native-surface.spec.ts b/tests/native-surface.spec.ts index 41356c1..9d61e3a 100644 --- a/tests/native-surface.spec.ts +++ b/tests/native-surface.spec.ts @@ -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