/** * 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(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(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(null) const folderInputRef = useRef(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 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 (
{ setQuery(event.target.value) }} /> } onClick={() => { setRefreshTick(tick => tick + 1) }} /> } disabled={busy} onClick={() => { fileInputRef.current?.click() }} /> } disabled={busy} onClick={() => { folderInputRef.current?.click() }} /> { if (cwd !== undefined) startUpload(cwd, uploadItemsFromFiles(event.target.files ?? [])) event.target.value = '' }} /> { if (cwd !== undefined) startUpload(cwd, uploadItemsFromFiles(event.target.files ?? [])) event.target.value = '' }} />
{uploadStatus !== '' && (
{uploadStatus}
)} {/* 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. */} {/* The tree stays MOUNTED while searching: its level cache and the directory watcher survive a query, so clearing the box is free. */}
) }