Files
apes-sidebar/tests/file-tree-reveal-scroll.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

124 lines
4.9 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.
/**
* "Show in folder" reveal scrolling: the tree body is the ONLY thing that
* may scroll. The reveal used native scrollIntoView, which scrolls every
* scrollable ancestor — and the clipping fixed panel host
* ([data-dsh-panel-host], overflow hidden at the time) is still
* programmatically scrollable, so a deep reveal shifted the whole panel,
* tab bar included, out of the viewport (reported on DSH 0.1.2-alpha.1).
* The regression guard: body.scrollTo receives the centered, clamped
* target; row.scrollIntoView is never called.
*/
// @vitest-environment jsdom
import { afterEach, beforeAll, 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()
// jsdom defines no scrollIntoView; counting calls needs a real function.
const scrollIntoView = vi.fn()
beforeAll(() => {
;(Element.prototype as unknown as { scrollIntoView: () => void }).scrollIntoView = scrollIntoView
})
afterEach(() => { scrollIntoView.mockClear() })
vi.mock('../src/client/api.ts', () => ({
api: {
fsTrees: async (_scope: unknown, paths: readonly string[]) => ({
levels: paths.map(path => ({ path, entries: [
{ name: 'src', path: '/tmp/src', isDir: true },
{ name: 'a.ts', path: '/tmp/a.ts', isDir: false },
], truncated: false })),
}),
// The tree reads the shared git-status store; a non-repo answer keeps
// every row plain (this spec is about the reveal scroll).
gitStatus: async () => ({ isRepo: false, entries: [] }),
},
downloadUrl: () => '/sidebar/file',
}))
interface TreeHarness {
/** Re-render with new props; the refresh tick reloads the level cache. */
rerender: (revealed: string[], refreshTick: number) => Promise<void>
body: HTMLElement
/** Pin one row's geometry (jsdom has no layout). */
layoutRow: (top: number, height: number) => void
scrollTo: ReturnType<typeof vi.fn>
unmount: () => void
}
async function mountTree(bodyScroll: { top: number; client: number; height: number }): Promise<TreeHarness> {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
const render = (revealed: string[], refreshTick: number): void => {
root.render(createElement(FileTree, {
sessionId: 's1',
cwd: '/tmp',
expanded: [],
revealed,
onToggle: () => {},
onOpenFile: () => {},
onReferenceFile: () => {},
refreshTick,
onUploadRequest: () => {},
busy: false,
}))
}
await act(async () => { render([], 0) })
const body = container.firstElementChild as HTMLElement
const scrollTo = vi.fn()
Object.defineProperty(body, 'scrollTo', { value: scrollTo })
Object.defineProperty(body, 'getBoundingClientRect', { value: () => ({ top: 100, height: bodyScroll.client }) })
Object.defineProperty(body, 'clientHeight', { value: bodyScroll.client })
Object.defineProperty(body, 'scrollHeight', { value: bodyScroll.height })
Object.defineProperty(body, 'scrollTop', { value: bodyScroll.top, writable: true })
return {
rerender: async (revealed, refreshTick) => { await act(async () => { render(revealed, refreshTick) }) },
body,
layoutRow: (top, height) => {
const row = body.querySelector('[data-dsh-revealed]')
expect(row).not.toBeNull()
Object.defineProperty(row as Element, 'getBoundingClientRect', { value: () => ({ top, height }) })
},
scrollTo,
unmount: () => { act(() => { root.unmount() }); container.remove() },
}
}
afterEach(() => { document.body.innerHTML = '' })
describe('FileTree reveal scrolling (show in folder)', () => {
it('centers the revealed row in the tree body only', async () => {
// Body: top 100, 300 tall, scrolled 40; row lands at 400, 20 tall →
// target = 40 + (400 + 10) − (100 + 150) = 200.
const tree = await mountTree({ top: 40, client: 300, height: 1000 })
await tree.rerender(['/tmp/a.ts'], 0)
tree.layoutRow(400, 20)
await tree.rerender(['/tmp/a.ts'], 1)
expect(scrollIntoView).not.toHaveBeenCalled()
expect(tree.scrollTo.mock.calls.map(call => call[0])).toContainEqual({ top: 200, behavior: 'smooth' })
tree.unmount()
})
it('clamps the target to the scrollable range', async () => {
// 5000px-tall scroller with 300 visible: max scrollTop 4700.
const tree = await mountTree({ top: 0, client: 300, height: 5000 })
await tree.rerender(['/tmp/a.ts'], 0)
tree.layoutRow(9000, 20)
await tree.rerender(['/tmp/a.ts'], 1)
expect(tree.scrollTo.mock.calls.map(call => call[0]?.top)).toContainEqual(4700)
tree.unmount()
})
it('scrolls nothing without a reveal', async () => {
const tree = await mountTree({ top: 0, client: 300, height: 1000 })
await tree.rerender([], 1)
expect(tree.scrollTo).not.toHaveBeenCalled()
tree.unmount()
})
})