Files
apes-sidebar/tests/e2e/zz-expand-refresh.e2e.ts
T
Menghuan1918 98a446bbfc 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 同步)。
2026-09-28 22:33:30 +08:00

378 lines
18 KiB
TypeScript

/**
* 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)
})