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)
This commit is contained in:
Menghuan1918
2026-09-28 20:37:56 +08:00
parent f29743f03f
commit 5c95002641
68 changed files with 1313 additions and 747 deletions
+88 -65
View File
@@ -49,11 +49,7 @@ import {
} from '@deepseek-ai/dsh-client-ui-primitives'
import { SiCursor, SiZedindustries } from 'react-icons/si'
import { VscFolderOpened, VscLinkExternal, VscPin, VscPinned } from 'react-icons/vsc'
import {
api, archiveBuild, archiveDownloadUrl, archiveStatus, downloadUrl, isOutsideWorkspaceMessage,
type FsEntry,
} from './api.ts'
import { FenceErrorNotice } from './FenceErrorNotice.tsx'
import { api, archiveBuild, archiveDownloadUrl, archiveStatus, downloadUrl, type FsEntry } from './api.ts'
import { builtinFileIcon, builtinFolderIcon } from './file-icons.tsx'
import { IconUploadOutline16, IconVscode16 } from './icons.tsx'
import { isImeComposition } from './ime-guard.ts'
@@ -63,7 +59,6 @@ import type { OpenWithTarget } from './open-with.ts'
import { relativeTo } from './paths.ts'
import { t } from './locales.ts'
import type { BetterSidebarService } from './service.ts'
import type { SidebarStore } from './state.ts'
import {
Chip, ConfirmDialog, IconButton, Notice, StatusBadge, useGitStatus,
type GitTone, type StatusTone,
@@ -406,8 +401,6 @@ const RootRow = memo(function RootRow(props: RootRowProps): ReactNode {
export function FileTree(props: {
sessionId: string
cwd: string | undefined
/** The sidebar store: the fence-refusal notice writes the `workspaceFence` pref through it. */
store: SidebarStore
expanded: string[]
/** Files highlighted by a "Show in folder" reveal (absolute paths). */
revealed: string[]
@@ -481,7 +474,7 @@ export function FileTree(props: {
// callbacks must not change identity when the caller re-renders).
// `onReferenceFile` is read through `propsRef` like the other row callbacks.
const {
sessionId, cwd, store, expanded, revealed, onOpenFileNewTab, onOpenFileSide,
sessionId, cwd, expanded, revealed, onOpenFileNewTab, onOpenFileSide,
openInApp, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin,
openWithShowPluginTargets,
onPathRenamed, onPathDeleted, refreshTick, onUploadRequest, busy, hidden, visible, service,
@@ -506,8 +499,6 @@ export function FileTree(props: {
const dirRowIcon = (path: string, open: boolean): ReactNode =>
service !== undefined ? service.folderIcon(path, open, 14) : builtinFolderIcon(open, 14)
const dataRef = useRef(data)
/** Every in-flight level fetch, keyed by directory (aborted on refresh/unmount). */
const controllersRef = useRef(new Map<string, AbortController>())
/** Bumped whenever the cache is wiped: a response from an older generation is dropped. */
const generationRef = useRef(0)
/** The row whose path was just copied ("copied" label replaces its button). */
@@ -531,6 +522,8 @@ export function FileTree(props: {
const [deletingSelected, setDeletingSelected] = useState(false)
/** The last mutation failure (dismissable strip above the tree). */
const [actionError, setActionError] = useState<string | null>(null)
/** A failed re-list that kept the previous rows on screen (hint line). */
const [loadError, setLoadError] = useState<string | null>(null)
/** The multi-selection (absolute paths) and its Shift anchor. */
const [selected, setSelected] = useState<Set<string>>(() => new Set())
const anchorRef = useRef<string | null>(null)
@@ -640,34 +633,72 @@ export function FileTree(props: {
}, [])
/**
* List one directory into the cache. Levels already loaded (including the
* empty placeholder of an in-flight fetch) are left alone; a stale response
* — the cache was wiped by a refresh tick, or a newer fetch for the same
* directory started — is dropped instead of overwriting fresher data.
* List a SET of directories into the cache with ONE `fs.trees` request.
*
* The visible set (the workspace root plus every expanded directory) is
* always loaded this way, so mounting the tree, a refresh tick and expanding
* a directory each cost exactly one POST — not one per level (the N+1 the
* file list was reported for). Levels already loaded (including the empty
* placeholder of an in-flight fetch) are left alone, so an expand only asks
* for what is genuinely missing; a stale response — the cache was wiped by a
* refresh tick — is dropped instead of overwriting fresher data.
*
* A level the host failed to read comes back with its own `error` and is
* stored on that level alone (the other levels render normally). When the
* WHOLE request fails, the levels that already had a listing keep it (plus a
* hint) and only the levels with nothing to show carry the message.
*
* `force` re-lists levels even when they are cached — the refresh tick's
* path — while leaving their current rows untouched until the answer lands.
*/
const loadDir = useCallback((dir: string) => {
if (dataRef.current[dir] !== undefined) return
const loadLevels = useCallback((paths: readonly string[], options?: { force?: boolean }) => {
const force = options?.force === true
const wanted = force
? [...new Set(paths)]
: paths.filter(path => dataRef.current[path] === undefined)
if (wanted.length === 0) return
const generation = generationRef.current
controllersRef.current.get(dir)?.abort()
const controller = new AbortController()
controllersRef.current.set(dir, controller)
storeLevel(dir, {})
api.fsTree({ sessionId, cwd }, dir, controller.signal).then((listing) => {
if (controller.signal.aborted || generation !== generationRef.current) return
storeLevel(dir, { entries: listing.entries, truncated: listing.truncated })
// A FORCED re-list (a refresh tick) keeps the listing on screen until the
// fresh one arrives: no blank frame, and a failed refresh degrades to the
// previous listing plus a hint instead of an empty tree.
if (!force) for (const path of wanted) storeLevel(path, {})
// The route takes no signal: the GENERATION counter is the staleness guard
// (a refresh tick or an unmount bumps it, so a late answer is dropped).
api.fsTrees({ sessionId, cwd }, wanted).then((result) => {
if (generation !== generationRef.current) return
setLoadError(null)
for (const level of result.levels) {
storeLevel(level.path, {
entries: level.entries,
truncated: level.truncated,
...(level.error !== undefined ? { error: level.error } : {}),
})
}
}).catch((error: unknown) => {
if (controller.signal.aborted || generation !== generationRef.current) return
storeLevel(dir, { error: error instanceof Error ? error.message : String(error) })
if (generation !== generationRef.current) return
const message = error instanceof Error ? error.message : String(error)
let keptListing = false
for (const path of wanted) {
const level = dataRef.current[path]
if (level?.entries !== undefined) {
keptListing = true
continue
}
storeLevel(path, { error: message })
}
// Levels that had a listing keep it; the hint explains why they are not
// fresher. With nothing to keep, the per-level rows already say it.
setLoadError(keptListing ? message : null)
})
}, [sessionId, cwd, storeLevel])
/** Drop one level from the cache and reload it (the fence notice's retry,
* a watch notice, or a landed mutation). */
/** Drop one level from the cache and reload it (a watch notice, or the
* parent of a landed mutation). */
const retryDir = useCallback((dir: string) => {
delete dataRef.current[dir]
setData({ ...dataRef.current })
loadDir(dir)
}, [loadDir])
loadLevels([dir])
}, [loadLevels])
/**
* Settle the tree after one rename/delete landed at `prefix`: drop every
@@ -712,38 +743,34 @@ export function FileTree(props: {
})
}
// The caller's refresh tick wipes the cache (declared BEFORE the load
// effect so the reload below sees the empty cache) and invalidates every
// in-flight response, so a slow answer for the old generation can never
// land on top of the fresh listing.
// The caller's refresh tick invalidates every in-flight response (a slow
// answer for the old generation can never land on top of the fresh listing)
// and flags the next load as FORCED: the visible levels are re-listed in one
// batch, but their current rows stay on screen until the answer arrives.
// Declared BEFORE the load effect so that effect sees the flag.
const lastTick = useRef(refreshTick)
const forceNextLoad = useRef(false)
useEffect(() => {
if (lastTick.current === refreshTick) return
lastTick.current = refreshTick
generationRef.current += 1
for (const controller of controllersRef.current.values()) controller.abort()
controllersRef.current.clear()
dataRef.current = {}
setData({})
forceNextLoad.current = true
}, [refreshTick])
useEffect(() => {
// Load the visible set; already-loaded levels (kept in the cache) are
// not refetched. Only the refresh tick wipes the cache. Every level
// starts its own fetch, so a tick reloads them all concurrently.
// Load the visible set in ONE batch request; already-loaded levels (kept
// in the cache) are not refetched, so this asks only for what is new — a
// refresh tick re-lists root + every expanded directory in one POST.
const root = cwd
if (root === undefined) return
loadDir(root)
for (const dir of expanded) loadDir(dir)
}, [cwd, expanded, refreshTick, loadDir])
const force = forceNextLoad.current
forceNextLoad.current = false
loadLevels([root, ...expanded], { force })
}, [cwd, expanded, refreshTick, loadLevels])
// A torn-down tree must not leave requests running against an unmounted
// component (their stores would be dropped anyway — this is lifecycle, not
// correctness, but it keeps the network honest).
useEffect(() => () => {
for (const controller of controllersRef.current.values()) controller.abort()
controllersRef.current.clear()
}, [])
// A torn-down tree must not write the answer of an in-flight batch into a
// dead component: bumping the generation makes every late response stale.
useEffect(() => () => { generationRef.current += 1 }, [])
// Live refresh: the host watches the folders this tree has expanded and
// names the one that changed, so exactly that level is dropped and
@@ -1502,23 +1529,14 @@ export function FileTree(props: {
)
}
if (level.error !== undefined) {
// The fence refusal becomes the friendly notice (reason + one-click
// global off + immediate retry of this directory), never the raw
// `path "..." is outside workspace` wire text.
// A level the host could not read: the raw message on the level that
// failed, never on its siblings (the batch reports errors per level).
return (
<>
{head}
{isOutsideWorkspaceMessage(level.error)
? (
<div style={{ paddingLeft: depth * 22 + 6 }}>
<FenceErrorNotice store={store} onDisabled={() => { retryDir(dir) }} />
</div>
)
: (
<div className={clsx(css.explorerRow, css.explorerError)} style={{ paddingLeft: depth * 22 + 6 }}>
{level.error}
</div>
)}
<div className={clsx(css.explorerRow, css.explorerError)} style={{ paddingLeft: depth * 22 + 6 }}>
{level.error}
</div>
</>
)
}
@@ -1617,6 +1635,11 @@ export function FileTree(props: {
strip (the two CAN coexist — a stale error stays readable while a
new attempt is in flight). `role="status"` makes it a polite live
region, so progress is announced without stealing focus. */}
{/* A refresh whose batch request failed: the rows above are the
PREVIOUS listing, so the hint explains why nothing changed. */}
{loadError !== null && (
<Notice kind="warn" tone="inline">{loadError}</Notice>
)}
{archiveBusy && (
<Notice kind="loading" tone="inline" role="status">
{archiveProgress === null