-> 📌 **通道与支持线**:`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 版本**:
· 完整发布历史见 [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 @@

-

+
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**:

-> 📌 **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**:

· 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