用户实测反馈:
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)
244 lines
9.1 KiB
TypeScript
244 lines
9.1 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 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')
|
||
})
|
||
})
|