资源管理器补全:文件树右键菜单新增「重命名」(行内编辑:预填+全选、 Enter/失焦提交、Esc 取消、IME 组合键守卫)与「删除」(Modal 确认,文件/ 目录文案区分,永久删除——宿主无回收站)。根行(工作区根)不提供两项, 服务端 fs.rename/fs.remove 双保险拒绝;目标已存在 409;link-aware—— symlink 行只改/删链接本身(lstat 判定),fence 校验对解析后目标进行。 落定收尾:pruneTree 清旧前缀缓存 + 收起 stale expanded + 单层重载父目录; tab 调解走 service(rename→updateTab 改路径改标题、delete→closeTab 关 受影响 tab,覆盖两侧分栏/底栏/浮动窗口,经 tree-mutations.ts)。 二级菜单视觉关系(layout.css,仍限 data-dsh-sidebar-submenu 作用域): 父行在子菜单打开期间保持高亮(宿主已渲染 aria-expanded="true"), 卡片间距 10px→4px(悬停走廊同步收窄),翻转规则一并 4px。 i18n 新增 7 key × 20 语言文件(locales.spec 的 key-set-parity 门禁)。 测试:fs-operations 追加 12 例(rename/remove 全失败面);新增 file-tree-rename-delete.spec.tsx 8 例(菜单可见性/重命名流/删除流/错误条); menu-flip.spec.tsx 契约扩展覆盖 tether 规则。设计文档 docs/plans/2026-09-04-file-tree-rename-delete-design.md。
301 lines
12 KiB
TypeScript
301 lines
12 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 either the shared controlled FileTree (empty query) or the flat
|
|
* result list (relative paths; click opens through the caller's mode-aware
|
|
* open). 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 { IconFolderOpen16, IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
import { api } from './api.ts'
|
|
import type { SidebarStore } from './state.ts'
|
|
import { FileTree } from './FileTree.tsx'
|
|
import { IconUploadOutline16 } from './icons.tsx'
|
|
import type { OpenWithTarget } from './open-with.ts'
|
|
import { t } from './locales.ts'
|
|
import { resolveSidebarPath } from './produced-files.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
|
|
/** The sidebar store (passed through to the tree's fence-refusal notice). */
|
|
store: SidebarStore
|
|
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 "open with" menu surface (passed through to FileTree; absent →
|
|
* the whole section is hidden). */
|
|
openWithTargets?: OpenWithTarget[]
|
|
openWithPinned?: string[]
|
|
openWithSsh?: boolean
|
|
onOpenWith?: (targetId: string, path: string) => void
|
|
onToggleOpenWithPin?: (targetId: string) => void
|
|
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
|
|
/** Full-window presentation: the panel fills its host instead of docking
|
|
* at a fixed width. */
|
|
full?: boolean
|
|
}) {
|
|
const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, onPathRenamed, onPathDeleted, full } = 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()
|
|
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) }}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className={css.iconButton}
|
|
aria-label={t('refresh')}
|
|
title={t('refresh')}
|
|
onClick={() => { setRefreshTick(tick => tick + 1) }}
|
|
>
|
|
<IconRefreshOutline16 size={14} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={css.iconButton}
|
|
aria-label={t('uploadFiles')}
|
|
title={t('uploadFiles')}
|
|
disabled={busy}
|
|
onClick={() => { fileInputRef.current?.click() }}
|
|
>
|
|
<IconUploadOutline16 size={14} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={css.iconButton}
|
|
aria-label={t('uploadFolder')}
|
|
title={t('uploadFolder')}
|
|
disabled={busy}
|
|
onClick={() => { folderInputRef.current?.click() }}
|
|
>
|
|
<IconFolderOpen16 size={14} />
|
|
</button>
|
|
<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>
|
|
)}
|
|
{needle === '' ? (
|
|
<FileTree
|
|
sessionId={sessionId}
|
|
cwd={cwd}
|
|
store={store}
|
|
expanded={expanded}
|
|
revealed={revealed}
|
|
onToggle={onToggle}
|
|
onOpenFile={onOpenFile}
|
|
onOpenFileNewTab={onOpenFileNewTab}
|
|
onOpenFileSide={onOpenFileSide}
|
|
openWithTargets={openWithTargets}
|
|
openWithPinned={openWithPinned}
|
|
openWithSsh={openWithSsh}
|
|
onOpenWith={onOpenWith}
|
|
onToggleOpenWithPin={onToggleOpenWithPin}
|
|
onReferenceFile={onReferenceFile}
|
|
onPathRenamed={onPathRenamed}
|
|
onPathDeleted={onPathDeleted}
|
|
refreshTick={refreshTick}
|
|
onUploadRequest={startUpload}
|
|
busy={busy}
|
|
/>
|
|
) : (
|
|
<div className={css.explorerBody}>
|
|
{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>
|
|
)}
|
|
{upload !== null && (
|
|
<UploadOverlay
|
|
dir={upload.dir}
|
|
done={upload.done}
|
|
total={upload.total}
|
|
current={upload.current}
|
|
onCancel={cancelUpload}
|
|
cancelling={cancelling}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|