用户实测反馈:
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)
124 lines
4.9 KiB
TypeScript
124 lines
4.9 KiB
TypeScript
/**
|
||
* "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()
|
||
})
|
||
})
|