/** * The controlled file tree behind the files window's tree panel (TreePanel * wraps it with the search box): a lazy VSCode-style tree rooted at the * session's working directory. Levels load on expansion (one API call per * directory), directories sort first, hidden entries render dimmed. The * expansion set lives in the per-session state (owned by the caller); the * caller also owns the refresh affordance — a `refreshTick` bump wipes the * level cache so the visible set reloads. * * Row actions: hovering a row reveals an @-reference button on the far * right (appends `@` to the composer draft), and right-click * opens a context menu: file rows offer the caller's open escapes * (new tab / to the side, only when the callbacks exist) and a download * action (the host serves raw bytes, binary-safe); directory rows offer * "upload here"; every row can copy the relative or absolute path (with a * brief "copied" label replacing the button after a successful write). * * Uploads start here (drag-drop or the context menu picker) but run in the * caller: every request is reported through `onUploadRequest(dir, items)` * (VSCode semantics — a drop on a file row targets its parent directory), * and `busy` gates new drags while one upload is in flight. */ import { useCallback, useEffect, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from 'react' import { createPortal } from 'react-dom' import clsx from 'clsx' import { IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16, IconFolderClose16, IconFolderOpen16, IconLinkOutline16, Menu, writeClipboard, } from '@deepseek-ai/dsh-client-ui-primitives' import { api, downloadUrl, type FsEntry } from './api.ts' import { IconUploadOutline16 } from './icons.tsx' import { relativeTo } from './paths.ts' import { t } from './locales.ts' import { uploadItemsFromDrop, uploadItemsFromFiles, type UploadItem } from './upload.ts' import css from './sidebar.module.css' interface LevelData { entries?: FsEntry[] error?: string } /** Root label: the last path segment (mirror of the host rootLabel). */ export function baseName(path: string): string { const trimmed = path.replace(/[\\/]+$/, '') const at = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\')) return at === -1 ? trimmed : trimmed.slice(at + 1) } /** The containing directory of an absolute row path (never the root edge here). */ function parentOf(path: string): string { const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) return at <= 0 ? path : path.slice(0, at) } /** How long the row's "copied" label stays after a successful write. */ const COPIED_MS = 1200 /** * The drop overlay's hero art: an arrow descending into a tray (upload * zone) and tilted photo cards (chat zone). Hand-drawn, colored in the * palette of DSH's own native drop illustration (#3964FE / #679EFE / * #9CE5ED) so the two zones read as one family; the drop overlay is this * flow's one brand moment, so it gets color the rest of the UI never does. */ const UploadDropIllustration = () => ( ) /** The chat zone's art: a tilted pair of photo cards (sun over mountains). */ const ChatDropIllustration = () => ( ) export function FileTree(props: { sessionId: string cwd: string | undefined expanded: string[] onToggle: (path: string) => void onOpenFile: (path: string) => void /** Context-menu "open in a new tab" (file rows; absent → no entry). */ onOpenFileNewTab?: (path: string) => void /** Context-menu "open to the side" (file rows; absent → no entry). */ onOpenFileSide?: (path: string) => void /** Insert `@` into the composer draft. */ onReferenceFile: (path: string) => void /** Bump to wipe the level cache and reload the visible set. */ refreshTick: number /** Upload into `dir` (absolute, inside the workspace); runs in the caller. */ onUploadRequest: (dir: string, items: UploadItem[]) => void /** True while an upload is in flight (drops are ignored). */ busy: boolean }) { const { sessionId, cwd, expanded, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, onReferenceFile, refreshTick, onUploadRequest, busy } = props const [data, setData] = useState>({}) const dataRef = useRef(data) /** The row whose path was just copied ("copied" label replaces its button). */ const [copiedPath, setCopiedPath] = useState(null) /** Open context menu: the row path (and whether it is a directory) plus the cursor position. */ const [rowMenu, setRowMenu] = useState<{ path: string; isDir: boolean; x: number; y: number } | null>(null) /** Whether a file drag hovers the tree (drives the portaled drop zone). */ const [dropOver, setDropOver] = useState(false) /** The directory a drag is hovering right now (null = body, drop to root). */ const [dropTarget, setDropTarget] = useState(null) /** * Enter/leave depth under the tree body. dragenter/dragleave fire per * element along the drag path (and bubble), so a counter — DSH InputBar's * own pattern — is the flicker-free signal; relatedTarget is unreliable * across engines for drag events. */ const dropDepth = useRef(0) /** Explorer body element; its viewport rect anchors the portaled drop zone. */ const bodyRef = useRef(null) /** The body's viewport rect captured at drag entry (null = not measured). */ const [dropRect, setDropRect] = useState<{ top: number; left: number; width: number; height: number } | null>(null) /** Context-menu "upload here" target directory. */ const pendingUploadDir = useRef(undefined) const fileInputRef = useRef(null) /** Reset all drag state (drop landed, the drag left, or a new drag begins). */ const resetDrop = (): void => { dropDepth.current = 0 setDropOver(false) setDropTarget(null) setDropRect(null) } /** * Drop handlers: always swallow the event (a dropped file must never open * in the browser), then report the target directory to the caller. A drop * ends the drag without further leave events, so the depth resets here. * The payload collection is async (dropped folders are traversed through * their entry handles — captured synchronously inside uploadItemsFromDrop * while the dataTransfer is still live), so the request rides a then. */ const reportDrop = (dir: string, data: DataTransfer | undefined): void => { if (busy) return void uploadItemsFromDrop(data).then((items) => { if (items.length > 0) onUploadRequest(dir, items) }) } const handleBodyDrop = (event: DragEvent): void => { event.preventDefault() event.stopPropagation() resetDrop() if (cwd !== undefined) reportDrop(cwd, event.dataTransfer) } const handleDirDrop = (event: DragEvent, dir: string): void => { event.preventDefault() event.stopPropagation() resetDrop() reportDrop(dir, event.dataTransfer) } const handleFileDrop = (event: DragEvent, path: string): void => { // VSCode semantics: dropping onto a file uploads into its directory. handleDirDrop(event, parentOf(path)) } const handleBodyDragEnter = (event: DragEvent): void => { event.preventDefault() event.stopPropagation() dropDepth.current += 1 if (busy) return // First entry: anchor the portaled drop zone to the body's rect. if (dropDepth.current === 1) { const rect = bodyRef.current?.getBoundingClientRect() setDropRect(rect === undefined ? null : { top: rect.top, left: rect.left, width: rect.width, height: rect.height }) } setDropOver(true) } const handleBodyDragLeave = (): void => { dropDepth.current = Math.max(0, dropDepth.current - 1) if (dropDepth.current > 0) return setDropOver(false) setDropTarget(null) setDropRect(null) } const handleBodyDragOver = (event: DragEvent): void => { event.preventDefault() event.stopPropagation() event.dataTransfer.dropEffect = busy ? 'none' : 'copy' if (busy) return // Rows stop propagation, so this only fires over non-row regions: the // drag targets the workspace root. dragover fires continuously, making // it the authoritative (flicker-free) place to clear the row target. setDropTarget(null) } const handleRowDragOver = (event: DragEvent, dir: string): void => { event.preventDefault() event.stopPropagation() event.dataTransfer.dropEffect = busy ? 'none' : 'copy' if (busy) return setDropTarget(dir) } const storeLevel = useCallback((path: string, level: LevelData) => { dataRef.current = { ...dataRef.current, [path]: level } setData(dataRef.current) }, []) const loadDir = useCallback((dir: string) => { if (dataRef.current[dir] !== undefined) return storeLevel(dir, {}) api.fsTree({ sessionId, cwd }, dir).then((listing) => { storeLevel(dir, { entries: listing.entries }) }).catch((error: unknown) => { storeLevel(dir, { error: error instanceof Error ? error.message : String(error) }) }) }, [sessionId, cwd, storeLevel]) // The caller's refresh tick wipes the cache (declared BEFORE the load // effect so the reload below sees the empty cache). const lastTick = useRef(refreshTick) useEffect(() => { if (lastTick.current === refreshTick) return lastTick.current = refreshTick dataRef.current = {} setData({}) }, [refreshTick]) useEffect(() => { // Load the visible set; already-loaded levels (kept in the cache) are // not refetched. Only the refresh tick wipes the cache. const root = cwd if (root === undefined) return loadDir(root) for (const dir of expanded) loadDir(dir) }, [cwd, expanded, refreshTick, loadDir]) /** Copy `text`; on success flip the row's copied label for a moment. */ const copyPath = useCallback((text: string, path: string): void => { void writeClipboard(text).then((ok) => { if (!ok) return setCopiedPath(path) window.setTimeout(() => { setCopiedPath(current => current === path ? null : current) }, COPIED_MS) }) }, []) /** The row's trailing actions: the @-reference button, or the copied label. */ const rowActions = (entry: FsEntry): ReactNode => { if (copiedPath === entry.path) { return {t('copied')} } return ( ) } const openRowMenu = (event: MouseEvent, path: string, isDir: boolean): void => { event.preventDefault() event.stopPropagation() setRowMenu({ path, isDir, x: event.clientX, y: event.clientY }) } /** Download a file through the host route (raw bytes, binary-safe). */ const downloadFile = (path: string): void => { const url = downloadUrl({ sessionId, cwd }, path) const anchor = document.createElement('a') anchor.href = url anchor.style.display = 'none' document.body.appendChild(anchor) anchor.click() anchor.remove() } const root = cwd const renderLevel = (dir: string, depth: number): ReactNode => { const level = data[dir] if (level === undefined) { return
{t('loading')}
} if (level.error !== undefined) { return (
{level.error}
) } const entries = level.entries ?? [] return entries.map(entry => { if (entry.isDir) { const isOpen = expanded.includes(entry.path) return (
{ onToggle(entry.path) }} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() onToggle(entry.path) } }} onDragOver={(event) => { handleRowDragOver(event, entry.path) }} onDrop={(event) => { handleDirDrop(event, entry.path) }} onContextMenu={(event) => { openRowMenu(event, entry.path, true) }} > {isOpen ? : } {entry.name} {entry.isSymlink && } {rowActions(entry)}
{isOpen && renderLevel(entry.path, depth + 1)}
) } return (
{ onOpenFile(entry.path) }} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() onOpenFile(entry.path) } }} onDragOver={(event) => { handleRowDragOver(event, parentOf(entry.path)) }} onDrop={(event) => { handleFileDrop(event, entry.path) }} onContextMenu={(event) => { openRowMenu(event, entry.path, false) }} > {entry.name} {entry.isSymlink && } {rowActions(entry)}
) }) } return (
{root === undefined ? (
{t('noSession')}
) : ( <>
{ handleRowDragOver(event, root) }} onDrop={(event) => { handleDirDrop(event, root) }} onContextMenu={(event) => { openRowMenu(event, root, true) }} > {baseName(root)} {copiedPath === root ? {t('copied')} : ( )}
{data[root] !== undefined && renderLevel(root, 1)} )} {dropOver && dropRect !== null && createPortal( /* * The sidebar's drop surface, portaled to document.body at z-1001 — * above DSH's own whole-page drop mask (z-1000, see InputBar's * document-level intake) so the two never compete. It dims the WHOLE * viewport (the giant box-shadow spread on the zone frame is the * mask; the zone rect itself stays clear), teaching the zone split: * the dimmed conversation column still takes drops into the chat * natively (this layer is pointer-inert), while the clear frame * marks the tree as the workspace-upload zone. Deliberate exception * to the "panel stays below the DSH float stack" rule: transient, * and the drop always lands on the element beneath. The hint pill * docks at the TOP edge of the zone — right under the search row, * the first thing the eye meets — keeping the rows aimable. */ <>
{dropTarget !== null ? t('uploadTo', { dir: dropTarget }) : t('uploadDropHint')}
{/* The left zone's invitation, centered in the space beside the tree; skipped when that space is too narrow to hold it. */} {dropRect.left >= 200 && (
{t('uploadDropChat')}
)} , document.body, )} {/* The one shared context menu, positioned at the right-click cursor (portal so the tree's overflow clip cannot crop it). */} { const dir = pendingUploadDir.current ?? root pendingUploadDir.current = undefined if (dir !== undefined && !busy) onUploadRequest(dir, uploadItemsFromFiles(event.target.files ?? [])) event.target.value = '' }} /> { setRowMenu(null) }} items={[ // The open escapes head the FILE menu (dirs only get copy). ...(rowMenu?.isDir === false && onOpenFileNewTab !== undefined ? [{ id: 'open-new-tab', label: t('openFileNewTab'), icon: }] : []), ...(rowMenu?.isDir === false && onOpenFileSide !== undefined ? [{ id: 'open-side', label: t('openFileSide'), icon: }] : []), // Download applies to files only (the host route refuses directories). ...(rowMenu?.isDir === false ? [{ id: 'download', label: t('download'), icon: }] : []), // Upload into a directory (incl. the workspace root row). ...(rowMenu?.isDir === true ? [{ id: 'upload-here', label: t('uploadHere'), icon: }] : []), { id: 'relative', label: t('copyRelative'), icon: }, { id: 'absolute', label: t('copyAbsolute'), icon: }, ]} onSelect={(id) => { const target = rowMenu if (target === null) return setRowMenu(null) if (id === 'open-new-tab') { onOpenFileNewTab?.(target.path) return } if (id === 'open-side') { onOpenFileSide?.(target.path) return } if (id === 'download') { downloadFile(target.path) return } if (id === 'upload-here') { pendingUploadDir.current = target.path fileInputRef.current?.click() return } copyPath( id === 'relative' ? relativeTo(cwd ?? '', target.path) : target.path, target.path, ) }} portal align="start" getAnchorRect={() => (rowMenu === null ? null : new DOMRect(rowMenu.x, rowMenu.y, 0, 0))} anchor={} />
) }