用户报告:「点击展开的时候,文件树会频繁整体刷新,看起来非常难受」。
根因 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 同步)。
275 lines
11 KiB
TypeScript
275 lines
11 KiB
TypeScript
/**
|
||
* 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')
|
||
})
|
||
})
|