Files
apes-sidebar/tests/file-tree-batch-load.spec.tsx
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

275 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Level loading is BATCHED: the visible set (the workspace root plus every
* expanded directory) is listed with ONE `fs.trees` request on mount, on every
* refresh tick and when a directory expands — instead of one `fs.tree` POST
* per level (the N+1 the user's "频繁刷新" report pointed at).
*
* This spec is the probe AND the guard. It counts both routes, so running it
* before the batch change shows the N+1 shape and after it the single request:
* - 8 expanded directories: 9 × `fs.tree` → 1 × `fs.trees` (9 paths);
* - expanding one more directory lists ONLY that level (batch of 1);
* - a refresh tick re-lists the whole visible set in ONE request;
* - a watch notice still re-lists ONLY the stale level;
* - one level's `error` stays on that level; a whole-request failure keeps
* the cached levels and marks only the ones that had nothing.
*/
// @vitest-environment jsdom
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { FileTree } from '../src/client/FileTree.tsx'
import { setupReactAct } from './test-utils.ts'
setupReactAct()
// vitest 4.1.11+ follows the OS locale; pin en-US so the error copy is English.
beforeAll(() => {
Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true })
})
const fsTree = vi.hoisted(() => vi.fn())
const fsTrees = vi.hoisted(() => vi.fn())
vi.mock('../src/client/api.ts', () => ({
api: {
fsTree: (...args: unknown[]) => fsTree(...args),
fsTrees: (...args: unknown[]) => fsTrees(...args),
// The tree reads the shared git-status store; a non-repo answer keeps
// every row plain.
gitStatus: async () => ({ isRepo: false, entries: [] }),
},
downloadUrl: () => '/sidebar/file',
isOutsideWorkspaceMessage: () => false,
}))
const DIRS = ['/tmp/d1', '/tmp/d2', '/tmp/d3', '/tmp/d4', '/tmp/d5', '/tmp/d6', '/tmp/d7', '/tmp/d8']
/** One level's listing: the root lists the eight directories (so the expanded
* levels actually render) and every directory lists one file named after it. */
function levelOf(path: string): {
path: string; entries: { name: string; path: string; isDir: boolean }[]; truncated: boolean
} {
if (path === '/tmp') {
return {
path,
entries: DIRS.map(dir => ({ name: dir.slice('/tmp/'.length), path: dir, isDir: true })),
truncated: false,
}
}
const name = path.slice(path.lastIndexOf('/') + 1)
return { path, entries: [{ name: `${name}-file.ts`, path: `${path}/${name}-file.ts`, isDir: false }], truncated: false }
}
/** The smallest WebSocket the directory watcher talks to (see its spec). */
class FakeSocket {
static instances: FakeSocket[] = []
static readonly OPEN = 1
static readonly CONNECTING = 0
readyState = FakeSocket.CONNECTING
readonly url: string
onopen: (() => void) | null = null
onmessage: ((event: { data: string }) => void) | null = null
onclose: (() => void) | null = null
constructor(url: string) {
this.url = url
FakeSocket.instances.push(this)
}
send(): void {}
close(): void { this.readyState = 3 }
open(): void { this.readyState = FakeSocket.OPEN; this.onopen?.() }
frame(payload: unknown): void { this.onmessage?.({ data: JSON.stringify(payload) }) }
}
interface Harness {
container: HTMLDivElement
/** Re-render with a new expansion set; `refreshTick` only when bumping it. */
rerender: (expanded: string[], refreshTick?: number) => void
unmount: () => void
}
function mountTree(expanded: string[] = DIRS, refreshTick = 0): Harness {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
const render = (dirs: string[], tick: number): void => {
root.render(createElement(FileTree, {
sessionId: 's1',
cwd: '/tmp',
expanded: dirs,
revealed: [],
onToggle: () => {},
onOpenFile: () => {},
onReferenceFile: () => {},
refreshTick: tick,
onUploadRequest: () => {},
busy: false,
}))
}
let tick = refreshTick
act(() => { render(expanded, tick) })
return {
container,
rerender: (dirs: string[], nextTick?: number) => {
if (nextTick !== undefined) tick = nextTick
act(() => { render(dirs, tick) })
},
unmount: () => { act(() => { root.unmount() }); container.remove() },
}
}
/** Flush the batch promise chain. */
async function flush(): Promise<void> {
await act(async () => {
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
})
}
/** The paths a batch call was asked for. */
function batchPaths(index: number): string[] {
const call = fsTrees.mock.calls[index]
if (call === undefined) throw new Error(`no fs.trees call #${index}`)
return (call[1] as string[]).slice().sort()
}
let harness: Harness
beforeEach(() => {
FakeSocket.instances = []
vi.stubGlobal('WebSocket', FakeSocket)
fsTree.mockReset()
fsTree.mockImplementation(async (_scope: unknown, path: string) => levelOf(path))
fsTrees.mockReset()
fsTrees.mockImplementation(async (_scope: unknown, paths: readonly string[]) => ({
levels: paths.map(path => levelOf(path)),
}))
})
afterEach(() => {
harness.unmount()
document.body.innerHTML = ''
vi.unstubAllGlobals()
})
describe('FileTree level loading is batched', () => {
it('lists the root plus 8 expanded directories with ONE fs.trees request', async () => {
harness = mountTree()
await flush()
console.log('BATCH-PERF', JSON.stringify({ fsTree: fsTree.mock.calls.length, fsTrees: fsTrees.mock.calls.length }))
// The N+1 shape is gone: one batch for the whole visible set.
expect(fsTree).not.toHaveBeenCalled()
expect(fsTrees).toHaveBeenCalledTimes(1)
expect(batchPaths(0)).toEqual(['/tmp', ...DIRS].sort())
// Every level rendered (root + the 8 directories' children).
expect(harness.container.textContent).toContain('d1-file.ts')
expect(harness.container.textContent).toContain('d8-file.ts')
})
it('lists only the newly expanded directory (batch of 1)', async () => {
harness = mountTree(DIRS)
await flush()
fsTrees.mockClear()
harness.rerender([...DIRS, '/tmp/d9'])
await flush()
expect(fsTrees).toHaveBeenCalledTimes(1)
expect(batchPaths(0)).toEqual(['/tmp/d9'])
})
it('re-lists the whole visible set in ONE request on a refresh tick', async () => {
harness = mountTree(DIRS)
await flush()
fsTrees.mockClear()
harness.rerender(DIRS, 1)
await flush()
expect(fsTrees).toHaveBeenCalledTimes(1)
expect(batchPaths(0)).toEqual(['/tmp', ...DIRS].sort())
})
it('re-lists ONLY the stale level when the directory watcher reports one', async () => {
harness = mountTree(DIRS)
await flush()
const socket = FakeSocket.instances[0]
if (socket === undefined) throw new Error('no watch socket opened')
act(() => { socket.open() })
fsTrees.mockClear()
act(() => { socket.frame({ dir: '/tmp/d3' }) })
await flush()
// A stale notice drops exactly that level and re-lists it — never the
// whole visible set (the batch path is for mount/expand/refresh only).
expect(fsTrees).toHaveBeenCalledTimes(1)
expect(batchPaths(0)).toEqual(['/tmp/d3'])
})
it('keeps the stale level on screen until the fresh listing lands (no blank frame)', async () => {
harness = mountTree(DIRS)
await flush()
const socket = FakeSocket.instances[0]
if (socket === undefined) throw new Error('no watch socket opened')
act(() => { socket.open() })
// Hold the re-list open and answer with DIFFERENT content, so the swap is
// observable in both directions.
let release: (() => void) | undefined
fsTrees.mockImplementation(async (_scope: unknown, paths: readonly string[]) => {
await new Promise<void>((resolve) => { release = resolve })
return {
levels: paths.map(path => path === '/tmp/d3'
? { path, entries: [{ name: 'd3-fresh.ts', path: `${path}/d3-fresh.ts`, isDir: false }], truncated: false }
: levelOf(path)),
}
})
act(() => { socket.frame({ dir: '/tmp/d3' }) })
await flush()
// While the answer is in flight the level keeps the rows it had: dropping
// the cached level first replaced the whole folder with a loading row and
// rebuilt it — the "the tree blinks on every disk change" report.
expect(harness.container.textContent, 'the stale rows stay up during the re-list').toContain('d3-file.ts')
expect(harness.container.textContent, 'no placeholder replaces the level').not.toContain('Loading…')
release?.()
await flush()
// …and the fresh listing still lands.
expect(harness.container.textContent).toContain('d3-fresh.ts')
expect(harness.container.textContent).not.toContain('d3-file.ts')
})
it('keeps one level error on that level only', async () => {
fsTrees.mockImplementation(async (_scope: unknown, paths: readonly string[]) => ({
levels: paths.map(path => path === '/tmp/d3'
? { path, entries: [], truncated: false, error: 'cannot read d3' }
: levelOf(path)),
}))
harness = mountTree(DIRS)
await flush()
// The failing level shows its raw message; the others still render.
expect(harness.container.textContent).toContain('cannot read d3')
expect(harness.container.textContent).toContain('d1-file.ts')
expect(harness.container.textContent).toContain('d8-file.ts')
})
it('keeps the cached levels (plus a hint) when the whole batch request fails', async () => {
harness = mountTree(DIRS)
await flush()
// Everything is cached now: a failed refresh must not blank the tree — it
// keeps the previous listing and explains itself in a hint line.
fsTrees.mockRejectedValue(new Error('listing offline'))
harness.rerender(DIRS, 1)
await flush()
expect(harness.container.textContent).toContain('d1-file.ts')
expect(harness.container.textContent).toContain('d8-file.ts')
const hint = harness.container.querySelector<HTMLElement>('[data-kind="warn"]')
expect(hint?.textContent).toBe('listing offline')
// The stale rows are NOT replaced by per-level error rows.
expect(harness.container.querySelectorAll('[class*="explorerError"]')).toHaveLength(0)
})
it('shows the failure for levels that had nothing cached', async () => {
fsTrees.mockRejectedValue(new Error('listing offline'))
harness = mountTree(DIRS)
await flush()
// First load, nothing cached: the one request failed, so the root and the
// expanded levels carry the message.
expect(harness.container.textContent).toContain('listing offline')
})
})