/** * The file explorer: 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, and the expansion * set lives in the per-session state. Clicking a file opens an editor tab. * * 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 to copy the relative or absolute path (with a brief * "copied" label replacing the button after a successful write); file rows * also offer a download action (the host serves raw bytes, binary-safe). */ import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react' import clsx from 'clsx' import { IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16, IconFolderClose16, IconFolderOpen16, IconRefreshOutline16, Menu, writeClipboard, } from '@deepseek-ai/dsh-client-ui-primitives' import { api, downloadUrl, type FsEntry } from './api.ts' import { relativeTo } from './paths.ts' import { t } from './locales.ts' import css from './sidebar.module.css' interface LevelData { entries?: FsEntry[] error?: string } /** Root label: the last path segment (mirror of the host rootLabel). */ 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) } /** How long the row's "copied" label stays after a successful write. */ const COPIED_MS = 1200 export function ExplorerView(props: { sessionId: string cwd: string | undefined expanded: string[] onToggle: (path: string) => void onOpenFile: (path: string) => void /** Insert `@` into the composer draft. */ onReferenceFile: (path: string) => void }) { const { sessionId, cwd, expanded, onToggle, onOpenFile, onReferenceFile } = props const [data, setData] = useState>({}) const dataRef = useRef(data) const [refreshTick, setRefreshTick] = useState(0) /** 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) 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]) useEffect(() => { // Load the visible set; already-loaded levels (kept in the cache) are // not refetched. Only the refresh button 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) } }} onContextMenu={(event) => { openRowMenu(event, entry.path, true) }} > {isOpen ? : } {entry.name} {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) } }} onContextMenu={(event) => { openRowMenu(event, entry.path, false) }} > {entry.name} {rowActions(entry)}
) }) } return (
{root === undefined ? t('noSession') : baseName(root)}
{root === undefined ? (
{t('noSession')}
) : ( <>
{ openRowMenu(event, root, true) }} > {baseName(root)} {copiedPath === root ? {t('copied')} : ( )}
{data[root] !== undefined && renderLevel(root, 1)} )}
{/* The one shared context menu, positioned at the right-click cursor (portal so the explorer's overflow clip cannot crop it). */} { setRowMenu(null) }} items={[ // Download applies to files only (the host route refuses directories). ...(rowMenu?.isDir === false ? [{ id: 'download', label: t('download'), 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 === 'download') { downloadFile(target.path) 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={} />
) }