Files
apes-sidebar/tests/file-tree-batch-load.spec.tsx
T
Menghuan1918 5c95002641 perf(sidebar): fs.tree 22.8ms→3.8ms + 删除工作区路径检测(放开权限)+ 树展开状态按会话
用户实测反馈:

1) `/sidebar/api/fs.tree` 性能极差 —— 先测后改(10k 条目目录、热缓存、中位数):
   - 冷调用 cap=1000:22.76ms → **3.77ms**;TTL 内重复:≈23ms → **0.000ms**
   - 改法:opendir for-await → readdir(withFileTypes) 一次取回;只为 cap 条建行
     (slice + 定长数组);路径用字符串拼接而非 path.join;排序弃 localeCompare
     (14.6ms/10k → 0.35ms)改小写比较 + 原名码点兜底;新增 1.5s 目录级缓存
     (键 绝对路径\0cap,上限 512),由 fs.write/rename/remove/mkdir/upload 与
     fs-watch 的变更通知显式失效
   - 契约逐字不变;新增 tests/fs-tree.spec.ts 6 例 + fs-watch 失效用例(判别性验证)

2) 删除工作区路径检测(默认不检测、放开权限):
   - path-security 变纯词法解析(realpath/包含性判定删除)、fs-operations 去 fence
     分支、所有 fs 路由不再读 fence、403 分支消失、config 删 workspaceFence 字段
     (schemastery 容忍未知键,旧 profile 不会加载失败)、settings 开关删除
   - ⚠️ 安全影响:插件 fs 路由(tree/read/write/rename/remove/mkdir/媒体/HTML/
     upload/archive)现在可读写宿主用户可访问的任意路径,只受 OS 权限约束;
     指南 §8.1 已加醒目声明
   - 死代码清理:FenceErrorNotice 组件 + EditorHost 分支 + 4 个词条(settingsFence*、
     fenceErrorReason、fenceDisableAction)+ isOutsideWorkspace* 助手 + TreePanel 的
     store prop + 相关 CSS 块

3) 批量列举路由 `fs.trees`(单层失败只在该层带 error)+ 客户端批量加载:
   挂载/刷新时的 N+1 请求变为 1 个(8 个展开目录:9 → 1,探针断言)
   refreshTick 不再先清缓存(旧行保留到新答案;失败保留旧行 + warn 提示)

4) 修复「关闭文件后返回文件树,展开状态被重置」:
   原生承载面的展开集合原来存在每个 tab 的记录里(drop 即丢),改为**每会话状态**
   的投影(ensure 从会话态读、toggleExpanded 写回会话态、registerNativeSurface 用
   attachStore 订阅);同一会话多 tab 共享、不同会话隔离;revealed 保持 per-tab

验证:typecheck / lint / test(136 文件 / 1484 通过 / 9 跳过)/ build 全绿;
DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount 7/7 通过;
版本镜像同步(dsh.plugin.json + SIDEBAR_SERVICE_VERSION)
2026-09-28 20:37:56 +08:00

244 lines
9.1 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 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')
})
})