/** * 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 { BetterSidebarService } from './service.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 /** The sidebar registry service (file-icon registrations; passed through to FileTree). */ service?: BetterSidebarService }) { const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, onPathRenamed, onPathDeleted, 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() 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) }} /> { 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}
)} {needle === '' ? ( ) : (
{error !== null &&
{error}
} {error === null && results === null &&
{t('loading')}
} {error === null && results !== null && results.matches.length === 0 && (
{t('editorSearchNoResults')}
)} {error === null && results !== null && results.matches.map(rel => ( ))} {error === null && results?.truncated === true && (
{t('editorSearchTruncated')}
)}
)} {upload !== null && ( )}
) }