用户实测反馈:
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)
323 lines
13 KiB
TypeScript
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>
|
|
)
|
|
}
|