用户实测反馈:
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)
151 lines
6.3 KiB
TypeScript
151 lines
6.3 KiB
TypeScript
/**
|
|
* Directory-watch spec: the file tree's live refresh rests on this module, so
|
|
* the tests use real directories and the real `fs.watch` rather than a stub —
|
|
* the debounce, the handle accounting and the error path are the behaviour
|
|
* being pinned.
|
|
*
|
|
* Timing note: `fs.watch` delivery is neither prompt nor one-to-one with the
|
|
* writes that caused it (macOS FSEvents batches on its own schedule, and a
|
|
* loaded machine stretches it further). The assertions below therefore pin
|
|
* the PROPERTIES the tree depends on — a change is reported, a burst is
|
|
* folded, an unwatched directory stays silent — instead of an exact event
|
|
* count, and every wait is given a budget far above the module's 150 ms
|
|
* debounce.
|
|
*/
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { listDirectory } from '../src/fs-tree.ts'
|
|
import { createDirectoryWatchers, type DirectoryWatchEvent } from '../src/fs-watch.ts'
|
|
|
|
/** Generous delivery budget: the module debounces at 150 ms, the OS adds its own latency. */
|
|
const DELIVERY_TIMEOUT_MS = 15_000
|
|
/** Long enough for a second (tail) delivery to have surfaced, so "folded" means something. */
|
|
const SETTLE_MS = 1_000
|
|
|
|
/** One scratch tree with a directory to watch. */
|
|
const withScratch = (run: (dir: string) => Promise<void> | void): Promise<void> => {
|
|
const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-watch-'))
|
|
const dir = join(root, 'watched')
|
|
mkdirSync(dir)
|
|
return Promise.resolve(run(dir)).finally(() => { rmSync(root, { recursive: true, force: true }) })
|
|
}
|
|
|
|
/** Sleep for a fixed window. */
|
|
const settle = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms))
|
|
|
|
describe('directory watchers', () => {
|
|
it('drops the level cache for a changed directory before it pushes', async () => {
|
|
await withScratch(async (dir) => {
|
|
const events: DirectoryWatchEvent[] = []
|
|
// Warm the listing cache with the pre-change level…
|
|
const before = await listDirectory(dir, 100)
|
|
expect(before.entries).toEqual([])
|
|
const watchers = createDirectoryWatchers(event => events.push(event), () => {})
|
|
try {
|
|
expect(watchers.add(dir)).toBe(true)
|
|
writeFileSync(join(dir, 'new.txt'), 'x')
|
|
await vi.waitFor(() => { expect(events.length).toBeGreaterThan(0) }, { timeout: DELIVERY_TIMEOUT_MS })
|
|
// …then the re-list the client is about to send must NOT be answered
|
|
// from that warm entry (the watcher invalidates before pushing).
|
|
const after = await listDirectory(dir, 100)
|
|
expect(after).not.toBe(before)
|
|
expect(after.entries.map(entry => entry.name)).toEqual(['new.txt'])
|
|
} finally {
|
|
watchers.close()
|
|
}
|
|
})
|
|
})
|
|
|
|
it('folds a burst of changes into a single notice', async () => {
|
|
await withScratch(async (dir) => {
|
|
const events: DirectoryWatchEvent[] = []
|
|
const watchers = createDirectoryWatchers(event => events.push(event), () => {})
|
|
try {
|
|
expect(watchers.add(dir)).toBe(true)
|
|
// One save is several filesystem events; the tree re-lists the whole
|
|
// directory either way, so they must collapse. Twenty writes make the
|
|
// fold unambiguous even if the OS delivers one late tail batch.
|
|
for (let index = 0; index < 20; index++) writeFileSync(join(dir, `f${index}.txt`), 'x')
|
|
await vi.waitFor(() => { expect(events.length).toBeGreaterThan(0) }, { timeout: DELIVERY_TIMEOUT_MS })
|
|
await settle(SETTLE_MS)
|
|
expect(events.length).toBeLessThanOrEqual(2)
|
|
for (const event of events) expect(event).toEqual({ dir })
|
|
} finally {
|
|
watchers.close()
|
|
}
|
|
})
|
|
})
|
|
|
|
it('stops reporting once a directory is unwatched or closed', async () => {
|
|
await withScratch(async (dir) => {
|
|
const events: DirectoryWatchEvent[] = []
|
|
const watchers = createDirectoryWatchers(event => events.push(event), () => {})
|
|
watchers.add(dir)
|
|
watchers.remove(dir)
|
|
// Removing twice is a no-op, never a throw.
|
|
watchers.remove(dir)
|
|
writeFileSync(join(dir, 'a.txt'), 'x')
|
|
// The waiting budget matches the positive case: a silent assertion is
|
|
// only meaningful if an event had ample time to arrive.
|
|
await settle(SETTLE_MS)
|
|
expect(events).toEqual([])
|
|
// Closing the whole set is idempotent too.
|
|
watchers.close()
|
|
watchers.close()
|
|
})
|
|
})
|
|
|
|
it('treats a repeat add as already-watched', async () => {
|
|
await withScratch(async (dir) => {
|
|
const events: DirectoryWatchEvent[] = []
|
|
const watchers = createDirectoryWatchers(event => events.push(event), () => {})
|
|
try {
|
|
expect(watchers.add(dir)).toBe(true)
|
|
// A second add must not install a second watcher — that would double
|
|
// every notice for the same directory.
|
|
expect(watchers.add(dir)).toBe(true)
|
|
writeFileSync(join(dir, 'a.txt'), 'x')
|
|
await vi.waitFor(() => { expect(events.length).toBeGreaterThan(0) }, { timeout: DELIVERY_TIMEOUT_MS })
|
|
await settle(SETTLE_MS)
|
|
expect(events.length).toBeLessThanOrEqual(2)
|
|
} finally {
|
|
watchers.close()
|
|
}
|
|
})
|
|
})
|
|
|
|
it('caps the handles one connection may hold', async () => {
|
|
await withScratch(async (dir) => {
|
|
const root = join(dir, '..')
|
|
const watchers = createDirectoryWatchers(() => {}, () => {})
|
|
try {
|
|
// The cap is 64: the first 64 directories are watched, the rest are
|
|
// refused rather than throwing, and a refusal evicts nothing.
|
|
let accepted = 0
|
|
for (let index = 0; index < 70; index++) {
|
|
const child = join(root, `child-${index}`)
|
|
mkdirSync(child)
|
|
if (watchers.add(child)) accepted += 1
|
|
}
|
|
expect(accepted).toBe(64)
|
|
} finally {
|
|
watchers.close()
|
|
}
|
|
})
|
|
})
|
|
|
|
it('reports an unwatchable directory instead of throwing', async () => {
|
|
const failures: string[] = []
|
|
const watchers = createDirectoryWatchers(() => {}, (dir) => { failures.push(dir) })
|
|
try {
|
|
const missing = join(tmpdir(), 'dsh-sidebar-watch-does-not-exist-42')
|
|
expect(watchers.add(missing)).toBe(false)
|
|
expect(failures).toEqual([missing])
|
|
} finally {
|
|
watchers.close()
|
|
}
|
|
})
|
|
})
|