用户实测反馈:
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)
200 lines
9.0 KiB
TypeScript
200 lines
9.0 KiB
TypeScript
import { afterEach, describe, expect, it } from 'vitest'
|
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join, resolve, sep } from 'node:path'
|
|
import { compareEntries, isWithin, invalidateDirectoryCache, listDirectory, parentOf, requireAbsolute, rootLabel } from '../src/fs-tree.ts'
|
|
import { isWin32 } from './platform.ts'
|
|
|
|
describe('fs-tree', () => {
|
|
it('sorts directories first, then names case-insensitively', () => {
|
|
const rows = [
|
|
{ name: 'b.txt', path: '/x/b.txt', isDir: false, hidden: false, isSymlink: false, broken: false },
|
|
{ name: 'A', path: '/x/A', isDir: true, hidden: false, isSymlink: false, broken: false },
|
|
{ name: 'a.txt', path: '/x/a.txt', isDir: false, hidden: false, isSymlink: false, broken: false },
|
|
{ name: '.hidden', path: '/x/.hidden', isDir: false, hidden: true, isSymlink: false, broken: false },
|
|
]
|
|
expect(rows.sort(compareEntries).map(row => row.name)).toEqual(['A', '.hidden', 'a.txt', 'b.txt'])
|
|
})
|
|
|
|
it('derives root labels and parents (POSIX-style)', () => {
|
|
// POSIX-style inputs behave identically on both platforms (win32 parses '/'
|
|
// as a separator), so these assertions are platform-independent.
|
|
expect(rootLabel('/Users/me/code')).toBe('code')
|
|
expect(rootLabel('/')).toBe('/')
|
|
expect(parentOf('/Users/me/code')).toBe('/Users/me')
|
|
expect(parentOf('/')).toBeUndefined()
|
|
})
|
|
|
|
it.skipIf(!isWin32)('derives root labels and parents for Windows drives', () => {
|
|
expect(rootLabel('C:\\')).toBe('C:\\')
|
|
expect(parentOf('C:\\')).toBeUndefined()
|
|
expect(rootLabel('C:\\Users\\me')).toBe('me')
|
|
expect(parentOf('C:\\Users\\me')).toBe('C:\\Users')
|
|
})
|
|
|
|
it('accepts POSIX absolute paths and rejects relative ones', () => {
|
|
// resolve() is platform-native: '/a/b' roots to the current drive on win32.
|
|
expect(requireAbsolute('/a/b')).toBe(resolve('/a/b'))
|
|
expect(() => requireAbsolute('a/b')).toThrow(/not an absolute path/)
|
|
expect(() => requireAbsolute('../a')).toThrow(/not an absolute path/)
|
|
})
|
|
|
|
// Windows-only path semantics — skipped (not silently passing) on POSIX:
|
|
// drive letters and UNC shares are absolute on win32, drive-relative 'C:foo'
|
|
// is not.
|
|
describe.skipIf(!isWin32)('win32 path semantics', () => {
|
|
it('accepts drive letters and normalizes their separators', () => {
|
|
expect(requireAbsolute('C:/proj')).toBe('C:\\proj')
|
|
expect(requireAbsolute('C:\\proj')).toBe('C:\\proj')
|
|
// A drive path resolves against its own drive, never a bare root.
|
|
expect(resolve('/a/b')).toMatch(/^[A-Za-z]:/)
|
|
})
|
|
|
|
it('accepts UNC network shares in both separator styles', () => {
|
|
expect(requireAbsolute('\\\\server\\share\\proj')).toBe('\\\\server\\share\\proj')
|
|
expect(requireAbsolute('//server/share/proj')).toBe('\\\\server\\share\\proj')
|
|
})
|
|
|
|
it('rejects drive-relative paths', () => {
|
|
expect(() => requireAbsolute('C:proj')).toThrow(/not an absolute path/)
|
|
})
|
|
})
|
|
|
|
// POSIX-only path semantics — the reverse branch of the win32 suite: forms
|
|
// the host can never access must be refused loudly instead of mangled.
|
|
describe.skipIf(isWin32)('POSIX path semantics', () => {
|
|
it('rejects Windows drive paths', () => {
|
|
expect(() => requireAbsolute('C:/proj')).toThrow(/not an absolute path/)
|
|
expect(() => requireAbsolute('C:\\proj')).toThrow(/not an absolute path/)
|
|
})
|
|
|
|
it('rejects backslash UNC paths (not absolute on POSIX)', () => {
|
|
expect(() => requireAbsolute('\\\\server\\share\\proj')).toThrow(/not an absolute path/)
|
|
})
|
|
})
|
|
|
|
it('isWithin tolerates separators and (on win32) letter case', () => {
|
|
expect(isWithin('/work/proj', '/work/proj/src/a.ts')).toBe(true)
|
|
expect(isWithin('/work/proj', '/work/proj')).toBe(true)
|
|
expect(isWithin('/work/proj', '/work/proj2/a.ts')).toBe(false)
|
|
expect(isWithin('/work/proj', '/other/a.ts')).toBe(false)
|
|
// Mixed separators normalize on every platform.
|
|
expect(isWithin('C:\\Users\\me', 'C:/Users/me/src/a.ts')).toBe(true)
|
|
// Case sensitivity follows the platform's filesystem semantics (the
|
|
// platform parameter makes both branches assertable on any host).
|
|
expect(isWithin('C:\\Users\\Me', 'c:/users/me/file.png', 'win32')).toBe(true)
|
|
expect(isWithin('/Users/Me', '/users/me/file.png', 'win32')).toBe(true)
|
|
expect(isWithin('/Users/Me', '/users/me/file.png', 'linux')).toBe(false)
|
|
expect(isWithin('/Users/Me', '/users/me/file.png', 'darwin')).toBe(false)
|
|
// Windows drive-root containment.
|
|
expect(isWithin('C:\\', 'C:\\Users\\me\\a.png', 'win32')).toBe(true)
|
|
expect(isWithin('c:\\users', 'C:/USERS/me/b.png', 'win32')).toBe(true)
|
|
// UNC network-share containment: the '//' share prefix must not defeat
|
|
// the prefix test, and a sibling share must stay outside. The platform
|
|
// parameter is injected, so these win32-semantics assertions run on
|
|
// every host without any platform guard.
|
|
expect(isWithin('\\\\server\\share\\proj', '\\\\server\\share\\proj\\src\\a.ts', 'win32')).toBe(true)
|
|
expect(isWithin('\\\\server\\share\\proj', '\\\\server\\share\\proj2\\a.ts', 'win32')).toBe(false)
|
|
expect(isWithin('\\\\server\\share\\proj', '\\\\other\\share\\a.ts', 'win32')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('listDirectory (capped rows + cache)', () => {
|
|
const roots: string[] = []
|
|
const tempDir = (): string => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-list-'))
|
|
roots.push(dir)
|
|
return dir
|
|
}
|
|
afterEach(() => {
|
|
invalidateDirectoryCache()
|
|
while (roots.length > 0) rmSync(roots.pop()!, { recursive: true, force: true })
|
|
})
|
|
|
|
it('caps the rows and flags truncated', async () => {
|
|
const dir = tempDir()
|
|
for (let index = 0; index < 25; index += 1) {
|
|
writeFileSync(join(dir, `file-${String(index).padStart(2, '0')}.txt`), 'x')
|
|
}
|
|
mkdirSync(join(dir, 'zz-dir'))
|
|
const listing = await listDirectory(dir, 10)
|
|
expect(listing.path).toBe(dir)
|
|
// The cap is the row budget of one level; `truncated` is true whenever the
|
|
// level held MORE rows than the budget (regardless of which ones survived).
|
|
expect(listing.entries).toHaveLength(10)
|
|
expect(listing.truncated).toBe(true)
|
|
for (const entry of listing.entries) {
|
|
expect(entry.path).toBe(`${dir}${sep}${entry.name}`)
|
|
expect(entry.hidden).toBe(entry.name.startsWith('.'))
|
|
}
|
|
})
|
|
|
|
it('sorts directories first within the returned rows', async () => {
|
|
const dir = tempDir()
|
|
mkdirSync(join(dir, 'Dir-A'))
|
|
mkdirSync(join(dir, 'dir-b'))
|
|
writeFileSync(join(dir, 'a.txt'), 'a')
|
|
writeFileSync(join(dir, 'B.txt'), 'b')
|
|
const listing = await listDirectory(dir, 10)
|
|
// Directories first, then case-insensitive names (the comparator's contract
|
|
// applied to real rows).
|
|
expect(listing.entries.map(entry => entry.name)).toEqual(['Dir-A', 'dir-b', 'a.txt', 'B.txt'])
|
|
expect(listing.truncated).toBe(false)
|
|
})
|
|
|
|
it('does not flag truncated when the level fits', async () => {
|
|
const dir = tempDir()
|
|
writeFileSync(join(dir, 'a.txt'), 'a')
|
|
const listing = await listDirectory(dir, 10)
|
|
expect(listing.entries.map(entry => entry.name)).toEqual(['a.txt'])
|
|
expect(listing.truncated).toBe(false)
|
|
})
|
|
|
|
it('composes row paths with the platform separator', async () => {
|
|
const dir = tempDir()
|
|
writeFileSync(join(dir, 'a.txt'), 'a')
|
|
const listing = await listDirectory(dir, 10)
|
|
expect(listing.entries[0]!.path).toBe(`${dir}${sep}a.txt`)
|
|
})
|
|
|
|
it('serves a repeat listing from the TTL cache and re-reads after invalidation', async () => {
|
|
const dir = tempDir()
|
|
writeFileSync(join(dir, 'a.txt'), 'a')
|
|
const first = await listDirectory(dir, 10)
|
|
// A file created behind the plugin's back is invisible until the cache
|
|
// expires or an invalidation arrives (the writers call it explicitly).
|
|
writeFileSync(join(dir, 'b.txt'), 'b')
|
|
const cached = await listDirectory(dir, 10)
|
|
expect(cached).toBe(first)
|
|
expect(cached.entries.map(entry => entry.name)).toEqual(['a.txt'])
|
|
invalidateDirectoryCache(dir)
|
|
const fresh = await listDirectory(dir, 10)
|
|
expect(fresh).not.toBe(first)
|
|
expect(fresh.entries.map(entry => entry.name)).toEqual(['a.txt', 'b.txt'])
|
|
})
|
|
|
|
it('keys the cache by cap as well as by directory', async () => {
|
|
const dir = tempDir()
|
|
for (let index = 0; index < 5; index += 1) writeFileSync(join(dir, `f${index}.txt`), 'x')
|
|
const capped = await listDirectory(dir, 2)
|
|
const full = await listDirectory(dir, 10)
|
|
expect(capped.entries).toHaveLength(2)
|
|
expect(full.entries).toHaveLength(5)
|
|
})
|
|
|
|
it('invalidateDirectoryCache() with no argument clears every level', async () => {
|
|
const dir = tempDir()
|
|
writeFileSync(join(dir, 'a.txt'), 'a')
|
|
const first = await listDirectory(dir, 10)
|
|
invalidateDirectoryCache()
|
|
const second = await listDirectory(dir, 10)
|
|
expect(second).not.toBe(first)
|
|
expect(second).toEqual(first)
|
|
})
|
|
|
|
it('throws fs-error for a missing directory', async () => {
|
|
const dir = tempDir()
|
|
await expect(listDirectory(join(dir, 'nope'), 10)).rejects.toMatchObject({ code: 'fs-error' })
|
|
})
|
|
})
|