Files
apes-sidebar/src/client/TreePanel.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

323 lines
13 KiB
TypeScript

/**
* The files window's tree surface: a global file-name search box on top
* (300ms debounce; an in-flight search is aborted by the next keystroke)
* over the shared controlled FileTree. The results panel and the tree are
* BOTH mounted: the query only parks one of them (`hidden`), so clearing the
* search is free — the tree keeps its level cache and the live directory
* watcher (the old conditional render dropped both and refetched the whole
* visible set on every query change). Owns its refresh tick: the icon next to
* the search input clears the tree cache. EditorHost docks it as the tab's
* right panel (wrapped in a drag-resize handle) and provides the file
* context-menu open escapes.
*
* Uploads (header pickers, the tree's drag-drop and "upload here" menu)
* all funnel through here: one session at a time, shown in a full-window
* progress overlay with cancel, followed by a tree refresh and a one-line
* hint under the search row (success fades, failures and cancels stay).
* OS file drags are shielded at the panel host (see Sidebar.tsx), so a
* drop over the file window uploads here and never reaches DSH's chat
* intake.
*/
import { useEffect, useRef, useState, type InputHTMLAttributes } from 'react'
import clsx from 'clsx'
import { IconFolderOpenRegular, IconRefreshOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import { api } from './api.ts'
import type { BetterSidebarService } from './service.ts'
import { FileTree } from './FileTree.tsx'
import { IconUploadOutline16 } from './icons.tsx'
import type { OpenInApp } from './open-in-app.ts'
import type { OpenWithTarget } from './open-with.ts'
import { t } from './locales.ts'
import { resolveSidebarPath } from './paths.ts'
import { IconButton } from './ui/index.ts'
import { UploadOverlay } from './UploadOverlay.tsx'
import {
summarizeResults, uploadHintText, uploadItemsFromFiles, uploadToDir,
UPLOAD_HINT_MS, type UploadItem,
} from './upload.ts'
import css from './sidebar.module.css'
/** One in-flight upload session (the overlay's progress source). */
interface UploadSession {
dir: string
done: number
total: number
/** Relative path of the file being uploaded ('' when none is in flight). */
current: string
controller: AbortController
}
export function TreePanel(props: {
sessionId: string
cwd: string | undefined
expanded: string[]
revealed: string[]
onToggle: (path: string) => void
onOpenFile: (path: string) => void
/** File context-menu "open in a new tab" (passed through to FileTree). */
onOpenFileNewTab?: (path: string) => void
/** File context-menu "open to the side" (passed through to FileTree). */
onOpenFileSide?: (path: string) => void
/** The host's open-in-app handle (passed through to FileTree; absent →
* the HOST half of the "打开方式" section is hidden). */
openInApp?: OpenInApp
/** The plugin's own open-with targets (passed through to FileTree; coexists
* with `openInApp`; absent → no plugin half). */
openWithTargets?: OpenWithTarget[]
openWithPinned?: string[]
openWithSsh?: boolean
onOpenWith?: (targetId: string, path: string) => void
onToggleOpenWithPin?: (targetId: string) => void
/** Show the plugin's own open-with targets even when the host lists local
* applications for the path (the `openWithPluginTargets` setting; passed
* through to FileTree). */
openWithShowPluginTargets?: boolean
onReferenceFile: (path: string, isDir: boolean) => void
/** A tree rename landed (passed through to FileTree for tab retargeting). */
onPathRenamed?: (oldPath: string, newPath: string) => void
/** A tree delete landed (passed through to FileTree for tab closing). */
onPathDeleted?: (path: string, isDir: boolean) => void
/** Whether the owning tab is on screen: a parked tab stops the tree's git
* polling (the shared status store pauses when nothing visible wants it). */
visible?: boolean
/** Full-window presentation: the panel fills its host instead of docking
* at a fixed width. */
full?: boolean
/** The sidebar registry service (file-icon registrations; passed through to FileTree). */
service?: BetterSidebarService
}) {
const {
sessionId, cwd, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide,
openInApp, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin,
openWithShowPluginTargets,
onReferenceFile, onPathRenamed, onPathDeleted, visible, full, service,
} = props
const [query, setQuery] = useState('')
const [results, setResults] = useState<{ matches: string[]; truncated: boolean } | null>(null)
const [error, setError] = useState<string | null>(null)
const [refreshTick, setRefreshTick] = useState(0)
// The tree caches loaded directories per refresh tick, so content changed
// outside DSH (another editor, a sync tool) stays stale until the manual
// refresh click. Re-focusing the window bumps the tick automatically, and
// integrations can force a refresh by dispatching a bubbling
// `dsh-sidebar:refresh-files` event on `window`.
useEffect(() => {
const bump = (): void => { setRefreshTick(tick => tick + 1) }
window.addEventListener('focus', bump)
window.addEventListener('dsh-sidebar:refresh-files', bump)
return () => {
window.removeEventListener('focus', bump)
window.removeEventListener('dsh-sidebar:refresh-files', bump)
}
}, [])
/** One-line upload status under the search row ('' hides the hint). */
const [uploadStatus, setUploadStatus] = useState('')
/** Whether the status line is a failure/cancel (error color, stays visible). */
const [uploadFailed, setUploadFailed] = useState(false)
/** The in-flight upload session (null → no overlay, buttons enabled). */
const [upload, setUpload] = useState<UploadSession | null>(null)
/** True between the cancel click and the session settling (button disabled). */
const [cancelling, setCancelling] = useState(false)
/** Set by cancelUpload; the settle path shows 'upload cancelled' instead of
* summarizing the partial results. */
const cancelledRef = useRef(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const folderInputRef = useRef<HTMLInputElement>(null)
/** Start one upload session into `dir` (absolute, inside the workspace). */
const startUpload = (dir: string, items: UploadItem[]): void => {
if (items.length === 0 || cwd === undefined || upload !== null) return
cancelledRef.current = false
const controller = new AbortController()
setUploadFailed(false)
setUploadStatus(uploadHintText(0, items.length, '', dir, t))
setUpload({ dir, done: 0, total: items.length, current: '', controller })
void uploadToDir({ sessionId, cwd }, dir, items, (done, total, current) => {
if (current !== '') setUploadStatus(uploadHintText(done, total, current, dir, t))
setUpload(session => session === null ? session : { ...session, done, total, current })
}, controller.signal).then((results) => {
setUpload(null)
setCancelling(false)
// Reload the tree whatever the outcome: files may have landed before a
// cancel, and failures leave whatever did succeed visible.
setRefreshTick(tick => tick + 1)
if (cancelledRef.current) {
setUploadStatus(t('uploadCancelled'))
setUploadFailed(true)
return
}
const status = summarizeResults(results, t)
setUploadStatus(status)
setUploadFailed(results.some(result => !result.ok))
// Success messages are transient; failures stay until the next action.
if (results.every(result => result.ok)) {
window.setTimeout(() => {
setUploadStatus(current => current === status ? '' : current)
}, UPLOAD_HINT_MS)
}
})
}
/** Cancel the in-flight upload (aborts the request; the host drops its temp). */
const cancelUpload = (): void => {
if (upload === null || cancelling) return
cancelledRef.current = true
setCancelling(true)
upload.controller.abort()
}
const folderInputProps = { webkitdirectory: '' } as InputHTMLAttributes<HTMLInputElement>
const needle = query.trim()
const searching = needle !== ''
useEffect(() => {
if (needle === '') {
setResults(null)
setError(null)
return
}
const controller = new AbortController()
const timer = window.setTimeout(() => {
api.fsSearch({ sessionId, cwd }, needle, controller.signal).then((found) => {
setResults(found)
setError(null)
}).catch((failure: unknown) => {
if (controller.signal.aborted) return
setResults(null)
setError(failure instanceof Error ? failure.message : String(failure))
})
}, 300)
return () => {
window.clearTimeout(timer)
controller.abort()
}
}, [sessionId, cwd, needle])
const busy = upload !== null
return (
<div className={clsx(css.editorTreePanel, full === true && css.editorTreePanelFull)}>
<div className={css.editorTreeSearch}>
<input
className={css.editorSearchInput}
value={query}
placeholder={t('editorSearchPlaceholder')}
spellCheck={false}
onChange={(event) => { setQuery(event.target.value) }}
/>
<IconButton
label={t('refresh')}
icon={<IconRefreshOutlineRegular size={14} />}
onClick={() => { setRefreshTick(tick => tick + 1) }}
/>
<IconButton
label={t('uploadFiles')}
icon={<IconUploadOutline16 size={14} />}
disabled={busy}
onClick={() => { fileInputRef.current?.click() }}
/>
<IconButton
label={t('uploadFolder')}
icon={<IconFolderOpenRegular size={14} />}
disabled={busy}
onClick={() => { folderInputRef.current?.click() }}
/>
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: 'none' }}
onChange={(event) => {
if (cwd !== undefined) startUpload(cwd, uploadItemsFromFiles(event.target.files ?? []))
event.target.value = ''
}}
/>
<input
ref={folderInputRef}
type="file"
multiple
{...folderInputProps}
style={{ display: 'none' }}
onChange={(event) => {
if (cwd !== undefined) startUpload(cwd, uploadItemsFromFiles(event.target.files ?? []))
event.target.value = ''
}}
/>
</div>
{uploadStatus !== '' && (
<div className={clsx(css.editorSearchHint, uploadFailed && css.editorError)} title={uploadStatus}>{uploadStatus}</div>
)}
{/* Search results: parked (not unmounted) while the query is empty. Its
contents render only for a live query — a parked panel must not put
state text (the loading line) into the panel's DOM. */}
<div
className={clsx(css.explorerBody, !searching && css.explorerHiddenPane)}
hidden={!searching}
>
{searching && (
<>
{error !== null && <div className={clsx(css.editorSearchHint, css.editorError)}>{error}</div>}
{error === null && results === null && <div className={css.editorSearchHint}>{t('loading')}</div>}
{error === null && results !== null && results.matches.length === 0 && (
<div className={css.editorSearchHint}>{t('editorSearchNoResults')}</div>
)}
{error === null && results !== null && results.matches.map(rel => (
<button
key={rel}
type="button"
className={css.editorSearchResult}
title={rel}
onClick={() => { onOpenFile(resolveSidebarPath(cwd, rel)) }}
>
{rel}
</button>
))}
{error === null && results?.truncated === true && (
<div className={css.editorSearchHint}>{t('editorSearchTruncated')}</div>
)}
</>
)}
</div>
{/* The tree stays MOUNTED while searching: its level cache and the
directory watcher survive a query, so clearing the box is free. */}
<FileTree
sessionId={sessionId}
cwd={cwd}
expanded={expanded}
revealed={revealed}
onToggle={onToggle}
onOpenFile={onOpenFile}
onOpenFileNewTab={onOpenFileNewTab}
onOpenFileSide={onOpenFileSide}
openInApp={openInApp}
openWithTargets={openWithTargets}
openWithPinned={openWithPinned}
openWithSsh={openWithSsh}
onOpenWith={onOpenWith}
onToggleOpenWithPin={onToggleOpenWithPin}
openWithShowPluginTargets={openWithShowPluginTargets}
onReferenceFile={onReferenceFile}
onPathRenamed={onPathRenamed}
onPathDeleted={onPathDeleted}
refreshTick={refreshTick}
onUploadRequest={startUpload}
busy={busy}
hidden={searching}
visible={visible !== false}
service={service}
/>
{upload !== null && (
<UploadOverlay
dir={upload.dir}
done={upload.done}
total={upload.total}
current={upload.current}
onCancel={cancelUpload}
cancelling={cancelling}
/>
)}
</div>
)
}