/** * 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; a stale response is dropped by generation guard + abort), * 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. * * Selection (VS Code semantics, no modifier = the old click semantics * untouched): Ctrl/Cmd+click toggles a row and sets the anchor, Shift+click * selects the visible range from the anchor, Escape / a blank click clears, * right-clicking outside the selection collapses it onto the row. A non-empty * selection shows the batch bar above the root row (copy paths / delete / * clear); the batch delete confirms once and removes sequentially. * * Git ink: rows read the shared `useGitStatus` store (the changes page reads * the same snapshot) — a changed file's name takes its tone and a letter * badge, a directory with a change below it is tinted without a letter, and a * non-repo silently renders plain. * * 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), the host's "open in app" section and * a download action (the host serves raw bytes, binary-safe); directory rows * offer "upload here" and "new folder" (an inline editor at the top of that * level); every row can copy the relative or absolute path (with a brief * "copied" label replacing the button after a successful write). * * Rows are memoized components (FileRow / DirRow) fed by stable callbacks and * per-row booleans, so a copy flash, a drag target or a selection change only * re-renders the rows it touches — not the whole tree. * * 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 { memo, useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' import { createPortal } from 'react-dom' import clsx from 'clsx' import { IconArchiveOutlineRegular, IconChevronRightOutlineRegular, IconCloseFillRegular, IconCodeOutlineRegular, IconCopyOutlineRegular, IconDownloadOutlineRegular, IconEditOutlineRegular, IconFolderOpenRegular, IconLinkOutlineRegular, IconPlusOutlineRegular, IconTrashOutlineRegular, Menu, type MenuEntry, type MenuItem, writeClipboard, } from '@deepseek-ai/dsh-client-ui-primitives' import { SiCursor, SiZedindustries } from 'react-icons/si' import { VscFolderOpened, VscLinkExternal, VscPin, VscPinned } from 'react-icons/vsc' import { api, archiveBuild, archiveDownloadUrl, archiveStatus, downloadUrl, type FsEntry } from './api.ts' import { builtinFileIcon, builtinFolderIcon } from './file-icons.tsx' import { IconUploadOutline16, IconVscode16 } from './icons.tsx' import { isImeComposition } from './ime-guard.ts' import { useSubmenuFlip } from './menu-flip.ts' import type { OpenInApp, OpenInAppEntry } from './open-in-app.ts' import type { OpenWithTarget } from './open-with.ts' import { relativeTo } from './paths.ts' import { t } from './locales.ts' import type { BetterSidebarService } from './service.ts' import { Chip, ConfirmDialog, IconButton, Notice, StatusBadge, useGitStatus, type GitTone, type StatusTone, } from './ui/index.ts' import { uploadItemsFromDrop, uploadItemsFromFiles, type UploadItem } from './upload.ts' import { useDirectoryWatch } from './use-dir-watch.ts' import { usePolling } from './use-polling.ts' import css from './sidebar.module.css' interface LevelData { entries?: FsEntry[] error?: string /** The host capped this level's listing (a huge directory). */ truncated?: boolean } /** 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) } /** Only OS file drags belong to the upload surface; in-app drags (tab reorder, * split zones) must pass through untouched to the pane's tab-drop handling * (mirror of Sidebar.tsx's panel-host shield gate). */ function isFileDrag(event: DragEvent): boolean { return event.dataTransfer?.types.includes('Files') ?? false } /** How long the row's "copied" label stays after a successful write. */ const COPIED_MS = 1200 /** One changed row's tooltip word (the badge's status vocabulary). */ function gitToneLabel(tone: GitTone): string { switch (tone) { case 'added': return t('gitStatusAdded') case 'deleted': return t('gitStatusDeleted') case 'untracked': return t('gitStatusUntracked') case 'renamed': return t('gitStatusRenamed') case 'conflict': return t('gitStatusConflict') // A copy is an added path from the tree's point of view, and the // `gitStatus*` vocabulary has no separate word for it. case 'copied': return t('gitStatusAdded') case 'modified': return t('gitStatusModified') } } /** The badge tone for one git tone (the kit has no separate 'copied' ink). */ function badgeToneOf(tone: GitTone): StatusTone { return tone === 'copied' ? 'added' : tone } /** The archive progress line's percentage (`0/0` — an empty selection — is 0%). */ function archivePercent(progress: { done: number; total: number }): number { if (progress.total <= 0) return 0 return Math.min(100, Math.round((progress.done / progress.total) * 100)) } /** * The drop overlay's hero art: an arrow rising out of a notched tray * (upload zone — the same glyph family as the toolbar's upload icon) and a * tilted pair of photo cards (chat zone). Hand-drawn, but every ink is a * theme token: the SVG paints `currentColor` and the classes below pick the * accent / tint / cut-out inks. */ const UploadDropIllustration = () => ( ) /** The chat zone's art: two tilted photo cards, each with its own * sun-over-mountains motif (the back card carries detail too, so it never * reads as a bare blob). Same token-only rule as the tray above. */ const ChatDropIllustration = () => ( ) /** The modifier subset row activation needs (clicks and Enter/Space share it). */ interface ActivateModifiers { ctrlKey: boolean metaKey: boolean shiftKey: boolean } /** * The stable per-row callback bundle. `FileRow` / `DirRow` are memoized, so * every handler here must keep its identity for the life of the tree; each * one reads the live props/state through the refs the tree maintains. */ interface RowActions { activate(event: ActivateModifiers, path: string, isDir: boolean): void contextMenu(event: MouseEvent, path: string, isDir: boolean): void reference(path: string, isDir: boolean): void dragOver(event: DragEvent, dir: string): void drop(event: DragEvent, path: string, isDir: boolean): void } /** One open-in-app entry's leading glyph: the host's icon route / data URL, * or a generic mark when the host has no icon for it. */ function AppGlyph(props: { entry: OpenInAppEntry }): ReactNode { const { icon } = props.entry if (icon === null || icon === '') return return } /** One file row's props. Everything is a primitive or a stable reference so * memo() actually bails out. */ interface FileRowProps { entry: FsEntry depth: number /** Registry revision: a fresh value re-resolves the row's icon. */ iconsVersion: number service: BetterSidebarService | undefined selected: boolean revealed: boolean /** A drag hovers this row's PARENT directory (upload target). */ dropTarget: boolean copied: boolean /** * The row's git state as PRIMITIVES, never the store's status object: the * shared store rebuilds its per-path objects on every poll, so passing the * object would re-render every changed row every 2.5s (`React.memo` compares * by reference) — exactly the "frequent refresh" the file list was reported * for. Two strings make the poll invisible to the rows. */ gitTone: GitTone | undefined gitLetter: string | undefined actions: RowActions } const FileRow = memo(function FileRow(props: FileRowProps): ReactNode { const { entry, depth, iconsVersion, service, selected, revealed, dropTarget, copied, gitTone, gitLetter, actions } = props // Referenced so a registry change re-renders the row with its new icon. void iconsVersion const icon = service !== undefined ? service.fileIcon(entry.path, 14) : builtinFileIcon(entry.path, 14) return (
{ actions.activate(event, entry.path, false) }} onKeyDown={(event: KeyboardEvent) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() actions.activate(event, entry.path, false) } }} onDragOver={(event) => { actions.dragOver(event, parentOf(entry.path)) }} onDrop={(event) => { actions.drop(event, entry.path, false) }} onContextMenu={(event) => { actions.contextMenu(event, entry.path, false) }} > {icon} {entry.name} {entry.isSymlink && } {gitTone !== undefined && gitLetter !== undefined && ( {gitLetter} )} {copied ? {t('copied')} : ( )}
) }) /** One directory row's props (same stability rules as {@link FileRowProps}). */ interface DirRowProps { entry: FsEntry depth: number expanded: boolean iconsVersion: number service: BetterSidebarService | undefined selected: boolean revealed: boolean dropTarget: boolean /** A change exists at or below this directory (tinted, never lettered). */ gitChanged: boolean copied: boolean actions: RowActions } const DirRow = memo(function DirRow(props: DirRowProps): ReactNode { const { entry, depth, expanded, iconsVersion, service, selected, revealed, dropTarget, gitChanged, copied, actions } = props void iconsVersion const icon = service !== undefined ? service.folderIcon(entry.path, expanded, 14) : builtinFolderIcon(expanded, 14) return (
{ actions.activate(event, entry.path, true) }} onKeyDown={(event: KeyboardEvent) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() actions.activate(event, entry.path, true) } }} onDragOver={(event) => { actions.dragOver(event, entry.path) }} onDrop={(event) => { actions.drop(event, entry.path, true) }} onContextMenu={(event) => { actions.contextMenu(event, entry.path, true) }} > {icon} {entry.name} {entry.isSymlink && } {copied ? {t('copied')} : ( )}
) }) /** * The workspace ROOT row (the session's own folder). Memoized like the other * rows: it has no name/selection churn, so a parent re-render (menu state, copy * flash, apps) must not re-resolve its glyph either. */ interface RootRowProps { path: string iconsVersion: number service: BetterSidebarService | undefined /** A drag hovers the workspace root (drop target). */ dropTarget: boolean /** A change exists at or below the root (tinted). */ gitChanged: boolean copied: boolean actions: RowActions } const RootRow = memo(function RootRow(props: RootRowProps): ReactNode { const { path, iconsVersion, service, dropTarget, gitChanged, copied, actions } = props void iconsVersion const icon = service !== undefined ? service.folderIcon(path, true, 14) : builtinFolderIcon(true, 14) return (
{ actions.dragOver(event, path) }} onDrop={(event) => { actions.drop(event, path, true) }} onContextMenu={(event) => { actions.contextMenu(event, path, true) }} > {icon} {baseName(path)} {copied ? {t('copied')} : ( )}
) }) export function FileTree(props: { sessionId: string cwd: string | undefined expanded: string[] /** Files highlighted by a "Show in folder" reveal (absolute paths). */ revealed: 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 /** * The host's open-in-app handle (EditorHost builds it and injects it). * Absent, or reporting the host cannot hand paths to the desktop, hides the * HOST half of the "打开方式" section (the plugin's own targets stay). */ openInApp?: OpenInApp /** * The PLUGIN's own "open with" menu: resolved external targets (already * SSH-filtered and in menu order). Absent → no plugin half. Coexists with * {@link openInApp} — the section lists both. */ openWithTargets?: OpenWithTarget[] /** Ids of targets pinned to the menu's top level (subset of the ids). */ openWithPinned?: string[] /** Whether the workspace is remote (appends the SSH hint to target labels). */ openWithSsh?: boolean /** Open one plugin target externally (reveal or URL — the caller decides). */ onOpenWith?: (targetId: string, path: string) => void /** Toggle one plugin target's pinned state (the submenu row's pushpin). */ onToggleOpenWithPin?: (targetId: string) => void /** Insert `@` into the composer draft (file vs directory). */ onReferenceFile: (path: string, isDir: boolean) => void /** A rename landed (old row path → new path): the caller retargets open tabs. */ onPathRenamed?: (oldPath: string, newPath: string) => void /** A delete landed: the caller closes tabs at or under the removed path. */ onPathDeleted?: (path: string, isDir: boolean) => 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 /** * Park the tree out of the layout WITHOUT unmounting it: the search results * panel takes the surface while the level cache (and the live watcher) stays * warm, so clearing the query is free. */ hidden?: boolean /** * Whether the owning tab is on screen. Defaults to true; a parked tab * passes false so the shared git-status store stops polling for rows * nobody can see (the workbench keeps every tab body mounted). */ visible?: boolean /** * Keep the plugin's own open-with targets in the "打开方式" submenu even when * the host reports local applications for the path. Default (false): the * host's own list wins, and the plugin's fixed targets (file manager / * VS Code / Cursor / Zed / custom editors) show only when the host cannot * offer any — so the menu is not two near-identical lists. The caller reads * the `openWithPluginTargets` plugin setting and passes it down. */ openWithShowPluginTargets?: boolean /** * The sidebar registry service: when present, externally registered file * icons (`registerFileIcon`) outrank the host's file-type artwork on file rows. * Absent → the built-ins alone (the host always passes it today). */ service?: BetterSidebarService }) { // `onToggle` / `onOpenFile` are read through `propsRef` (the stable row // callbacks must not change identity when the caller re-renders). // `onReferenceFile` is read through `propsRef` like the other row callbacks. const { sessionId, cwd, expanded, revealed, onOpenFileNewTab, onOpenFileSide, openInApp, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, openWithShowPluginTargets, onPathRenamed, onPathDeleted, refreshTick, onUploadRequest, busy, hidden, visible, service, } = props /** The live props for the stable callbacks below (identity churns per render). */ const propsRef = useRef(props) propsRef.current = props const [data, setData] = useState>({}) /** * Registry revision for the file-icon feature: bumps on ANY registry * change (register/dispose of tabs, viewers, or icons — one listener * set) so rows re-resolve their icons. Handed to the memoized rows as a * prop precisely so the bump reaches them. */ const [iconsVersion, setIconsVersion] = useState(0) useEffect( () => service?.subscribe(() => { setIconsVersion(version => version + 1) }), [service], ) /** One directory's leading glyph for the surfaces WITHOUT a memoized row * (the inline rename and new-folder editors). */ const dirRowIcon = (path: string, open: boolean): ReactNode => service !== undefined ? service.folderIcon(path, open, 14) : builtinFolderIcon(open, 14) const dataRef = useRef(data) /** Bumped whenever the cache is wiped: a response from an older generation is dropped. */ const generationRef = useRef(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) // The plugin submenu can tower past the viewport; publish its flip geometry // for layout.css while the row menu is open. useSubmenuFlip(rowMenu) /** The open-in-app rows for the open menu (null entries = listing failed). */ const [apps, setApps] = useState<{ path: string; entries: readonly OpenInAppEntry[] | null } | null>(null) /** The row being renamed inline: its path plus the edit buffer. */ const [renaming, setRenaming] = useState<{ path: string; value: string } | null>(null) /** The inline new-folder editor: the directory it inserts into plus the buffer. */ const [newFolder, setNewFolder] = useState<{ dir: string; value: string } | null>(null) /** The delete awaiting the confirmation modal's yes (single row). */ const [confirmDelete, setConfirmDelete] = useState<{ path: string; isDir: boolean; name: string } | null>(null) /** The batch delete awaiting the confirmation modal's yes. */ const [confirmDeleteSelected, setConfirmDeleteSelected] = useState(false) /** True while the batch delete walks its paths (locks the dialog). */ const [deletingSelected, setDeletingSelected] = useState(false) /** The last mutation failure (dismissable strip above the tree). */ const [actionError, setActionError] = useState(null) /** A failed re-list that kept the previous rows on screen (hint line). */ const [loadError, setLoadError] = useState(null) /** The multi-selection (absolute paths) and its Shift anchor. */ const [selected, setSelected] = useState>(() => new Set()) const anchorRef = useRef(null) /** Whether a dragged-over state exists at all (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 = useCallback((): 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 = useCallback((dir: string, transfer: DataTransfer | undefined): void => { if (propsRef.current.busy) return void uploadItemsFromDrop(transfer).then((items) => { if (items.length > 0) propsRef.current.onUploadRequest(dir, items) }) }, []) const handleBodyDrop = useCallback((event: DragEvent): void => { if (!isFileDrag(event)) return event.preventDefault() event.stopPropagation() resetDrop() const root = propsRef.current.cwd if (root !== undefined) reportDrop(root, event.dataTransfer) }, [reportDrop, resetDrop]) const handleDirDrop = useCallback((event: DragEvent, dir: string): void => { if (!isFileDrag(event)) return event.preventDefault() event.stopPropagation() resetDrop() reportDrop(dir, event.dataTransfer) }, [reportDrop, resetDrop]) const handleBodyDragEnter = useCallback((event: DragEvent): void => { if (!isFileDrag(event)) return event.preventDefault() event.stopPropagation() dropDepth.current += 1 if (propsRef.current.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 = useCallback((): void => { dropDepth.current = Math.max(0, dropDepth.current - 1) if (dropDepth.current > 0) return setDropOver(false) setDropTarget(null) setDropRect(null) }, []) const handleBodyDragOver = useCallback((event: DragEvent): void => { if (!isFileDrag(event)) return event.preventDefault() event.stopPropagation() event.dataTransfer.dropEffect = propsRef.current.busy ? 'none' : 'copy' if (propsRef.current.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 = useCallback((event: DragEvent, dir: string): void => { if (!isFileDrag(event)) return event.preventDefault() event.stopPropagation() event.dataTransfer.dropEffect = propsRef.current.busy ? 'none' : 'copy' if (propsRef.current.busy) return setDropTarget(dir) }, []) /** A row drop: a directory targets itself, a file its parent (VSCode semantics). */ const handleDrop = useCallback((event: DragEvent, path: string, isDir: boolean): void => { handleDirDrop(event, isDir ? path : parentOf(path)) }, [handleDirDrop]) const storeLevel = useCallback((path: string, level: LevelData) => { dataRef.current = { ...dataRef.current, [path]: level } setData(dataRef.current) }, []) /** * List a SET of directories into the cache with ONE `fs.trees` request. * * The visible set (the workspace root plus every expanded directory) is * always loaded this way, so mounting the tree, a refresh tick and expanding * a directory each cost exactly one POST — not one per level (the N+1 the * file list was reported for). Levels already loaded (including the empty * placeholder of an in-flight fetch) are left alone, so an expand only asks * for what is genuinely missing; a stale response — the cache was wiped by a * refresh tick — is dropped instead of overwriting fresher data. * * A level the host failed to read comes back with its own `error` and is * stored on that level alone (the other levels render normally). When the * WHOLE request fails, the levels that already had a listing keep it (plus a * hint) and only the levels with nothing to show carry the message. * * `force` re-lists levels even when they are cached — the refresh tick's * path — while leaving their current rows untouched until the answer lands. */ const loadLevels = useCallback((paths: readonly string[], options?: { force?: boolean }) => { const force = options?.force === true const wanted = force ? [...new Set(paths)] : paths.filter(path => dataRef.current[path] === undefined) if (wanted.length === 0) return const generation = generationRef.current // A FORCED re-list (a refresh tick) keeps the listing on screen until the // fresh one arrives: no blank frame, and a failed refresh degrades to the // previous listing plus a hint instead of an empty tree. if (!force) for (const path of wanted) storeLevel(path, {}) // The route takes no signal: the GENERATION counter is the staleness guard // (a refresh tick or an unmount bumps it, so a late answer is dropped). api.fsTrees({ sessionId, cwd }, wanted).then((result) => { if (generation !== generationRef.current) return setLoadError(null) for (const level of result.levels) { storeLevel(level.path, { entries: level.entries, truncated: level.truncated, ...(level.error !== undefined ? { error: level.error } : {}), }) } }).catch((error: unknown) => { if (generation !== generationRef.current) return const message = error instanceof Error ? error.message : String(error) let keptListing = false for (const path of wanted) { const level = dataRef.current[path] if (level?.entries !== undefined) { keptListing = true continue } storeLevel(path, { error: message }) } // Levels that had a listing keep it; the hint explains why they are not // fresher. With nothing to keep, the per-level rows already say it. setLoadError(keptListing ? message : null) }) }, [sessionId, cwd, storeLevel]) /** * Re-list one directory in place (a watch notice, or the parent of a landed * mutation). * * The re-list is FORCED but not preceded by dropping the level: a watch * notice usually lands while the directory is on screen with its rows, and * deleting the cached level first replaced every row with a "Loading…" * placeholder — the whole folder blinked and rebuilt on each disk change * (a build, a formatter, the model's own `bash`). The fresh listing simply * overwrites the old one when it arrives; a failed re-list keeps the rows it * has and adds the hint line. */ const retryDir = useCallback((dir: string) => { loadLevels([dir], { force: true }) }, [loadLevels]) /** * Settle the tree after one rename/delete landed at `prefix`: drop every * cached level at or under the old path, collapse the now-stale expanded * directories below it, and reload the immediate parent so the new shape * shows without a full refresh tick. */ const pruneTree = useCallback((prefix: string): void => { const parent = parentOf(prefix) const under = (key: string): boolean => key === prefix || key.startsWith(`${prefix}/`) || key.startsWith(`${prefix}\\`) for (const key of Object.keys(dataRef.current)) { if (under(key)) delete dataRef.current[key] } if (parent !== prefix) delete dataRef.current[parent] setData({ ...dataRef.current }) for (const dir of propsRef.current.expanded) { if (under(dir)) propsRef.current.onToggle(dir) } if (parent !== prefix) retryDir(parent) }, [retryDir]) /** Client-side twin of the server's name rule (the server re-validates). */ const validName = (name: string): boolean => name !== '' && name !== '.' && name !== '..' && !name.includes('/') && !name.includes('\\') /** Commit the inline rename: trim, no-op guard, then fs.rename + settle. */ const commitRename = (path: string, raw: string): void => { setRenaming(null) const name = raw.trim() if (cwd === undefined || name === baseName(path) || !validName(name)) { if (name !== baseName(path) && !validName(name)) setActionError(t('renameInvalid')) return } api.fsRename({ sessionId, cwd }, path, name) .then((result) => { setActionError(null) pruneTree(path) onPathRenamed?.(path, result.path) }) .catch((error: unknown) => { setActionError(error instanceof Error ? error.message : String(error)) }) } // The caller's refresh tick invalidates every in-flight response (a slow // answer for the old generation can never land on top of the fresh listing) // and flags the next load as FORCED: the visible levels are re-listed in one // batch, but their current rows stay on screen until the answer arrives. // Declared BEFORE the load effect so that effect sees the flag. const lastTick = useRef(refreshTick) const forceNextLoad = useRef(false) useEffect(() => { if (lastTick.current === refreshTick) return lastTick.current = refreshTick generationRef.current += 1 forceNextLoad.current = true }, [refreshTick]) useEffect(() => { // Load the visible set in ONE batch request; already-loaded levels (kept // in the cache) are not refetched, so this asks only for what is new — a // refresh tick re-lists root + every expanded directory in one POST. const root = cwd if (root === undefined) return const force = forceNextLoad.current forceNextLoad.current = false loadLevels([root, ...expanded], { force }) }, [cwd, expanded, refreshTick, loadLevels]) // A torn-down tree must not write the answer of an in-flight batch into a // dead component: bumping the generation makes every late response stale. useEffect(() => () => { generationRef.current += 1 }, []) // Live refresh: the host watches the folders this tree has expanded and // names the one that changed, so exactly that level is dropped and // re-listed instead of the whole tree. Without it a folder listed once when // it was opened stayed stale for the rest of the session. useDirectoryWatch({ sessionId, root: cwd, dirs: expanded, onStale: retryDir, }) // Bring a "Show in folder" reveal into view: the ancestors expand above // (revealPaths), but the row may not be scrolled into sight — a reveal on // a long tree should surface the highlighted file. Re-runs when the tree // data or reveal set changes (the row appears after its level loads). // Scrolls ONLY this tree's body: scrollIntoView would scroll every // scrollable ancestor, and the clipping panel host // ([data-dsh-panel-host]) is still programmatically scrollable — a deep // reveal shifted the whole panel, tab bar included, out of the viewport. useEffect(() => { if (revealed.length === 0 || propsRef.current.hidden === true) return const body = bodyRef.current if (body === null) return const row = body.querySelector('[data-dsh-revealed]') if (row === null) return const bodyTop = body.getBoundingClientRect().top const rowRect = row.getBoundingClientRect() const target = body.scrollTop + (rowRect.top + rowRect.height / 2) - (bodyTop + body.clientHeight / 2) const max = Math.max(body.scrollHeight - body.clientHeight, 0) body.scrollTo({ top: Math.min(Math.max(target, 0), max), behavior: 'smooth' }) }, [revealed, data]) /** 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) }) }, []) // ── Multi-selection ──────────────────────────────────────────────────── /** The selection's mirror for the stable callbacks (state itself re-renders). */ const selectedRef = useRef(selected) /** Path → whether the row is a directory (the delete callback needs it). */ const kindRef = useRef(new Map()) /** The visible rows in depth-first order: the Shift range's coordinate space. */ const visibleRowsRef = useRef<{ path: string; isDir: boolean }[]>([]) /** The expanded set for the stable callbacks. */ const expandedSetRef = useRef>(new Set()) const setSelection = useCallback((next: Set): void => { selectedRef.current = next setSelected(next) }, []) /** Ctrl/Cmd+click: toggle one row and move the Shift anchor onto it. */ const toggleSelect = useCallback((path: string, isDir: boolean): void => { const next = new Set(selectedRef.current) if (next.has(path)) { next.delete(path) } else { next.add(path) kindRef.current.set(path, isDir) } anchorRef.current = path setSelection(next) }, [setSelection]) /** Shift+click: select the visible range between the anchor and the row. */ const selectRange = useCallback((path: string, isDir: boolean): void => { const rows = visibleRowsRef.current const anchor = anchorRef.current const from = anchor === null ? -1 : rows.findIndex(row => row.path === anchor) const to = rows.findIndex(row => row.path === path) // No usable anchor (or a row that scrolled out of the tree): behave like // a plain Ctrl click, which also re-seats the anchor. if (from === -1 || to === -1) { toggleSelect(path, isDir) return } const low = Math.min(from, to) const high = Math.max(from, to) const next = new Set(selectedRef.current) for (let index = low; index <= high; index += 1) { const row = rows[index]! next.add(row.path) kindRef.current.set(row.path, row.isDir) } // The anchor stays put: a second Shift+click re-ranges from the same start. setSelection(next) }, [setSelection, toggleSelect]) const clearSelection = useCallback((): void => { if (selectedRef.current.size === 0) return anchorRef.current = null setSelection(new Set()) }, [setSelection]) const copySelectedPaths = useCallback((): void => { void writeClipboard([...selectedRef.current].join('\n')) }, []) /** Run the confirmed single-row delete: fs.remove + settle + close tabs. */ const performDelete = (target: { path: string; isDir: boolean }): void => { if (cwd === undefined) return api.fsRemove({ sessionId, cwd }, target.path) .then(() => { setActionError(null) pruneTree(target.path) onPathDeleted?.(target.path, target.isDir) anchorRef.current = null setSelection(new Set()) }) .catch((error: unknown) => { setActionError(error instanceof Error ? error.message : String(error)) }) } // ── Stable row actions ───────────────────────────────────────────────── const handleActivate = useCallback((event: ActivateModifiers, path: string, isDir: boolean): void => { if (event.ctrlKey || event.metaKey) { toggleSelect(path, isDir) return } if (event.shiftKey) { selectRange(path, isDir) return } // Plain click: the original semantics, plus dropping any selection. clearSelection() if (isDir) propsRef.current.onToggle(path) else propsRef.current.onOpenFile(path) }, [clearSelection, selectRange, toggleSelect]) const handleContextMenu = useCallback((event: MouseEvent, path: string, isDir: boolean): void => { event.preventDefault() event.stopPropagation() // VSCode semantics: right-clicking outside the selection collapses it // onto the row; right-clicking INSIDE it keeps the whole batch. if (!selectedRef.current.has(path)) { anchorRef.current = path kindRef.current.set(path, isDir) setSelection(new Set([path])) } // NOTE: the previous menu's app answer is deliberately KEPT — the render // only trusts it for the matching path, and dropping it here would cost an // extra render (and, on a reopen, a disabled "Loading…" frame). setRowMenu({ path, isDir, x: event.clientX, y: event.clientY }) }, [setSelection]) const handleReference = useCallback((path: string, isDir: boolean): void => { propsRef.current.onReferenceFile(path, isDir) }, []) const actions = useMemo(() => ({ activate: handleActivate, contextMenu: handleContextMenu, reference: handleReference, dragOver: handleRowDragOver, drop: handleDrop, }), [handleActivate, handleContextMenu, handleReference, handleRowDragOver, handleDrop]) // ── New folder ───────────────────────────────────────────────────────── const newFolderRef = useRef(newFolder) newFolderRef.current = newFolder /** Open the inline editor at the TOP of `dir`'s level (expanding it first). */ const startNewFolder = (dir: string): void => { const live = propsRef.current if (live.cwd !== undefined && dir !== live.cwd && !expandedSetRef.current.has(dir)) live.onToggle(dir) setNewFolder({ dir, value: '' }) } /** * Commit the inline new-folder name: Enter, blur, or the editor's own * cancel path. The ref guard makes the commit idempotent — a blur that * lands after Enter must not fire a second mkdir. */ const commitNewFolder = (dir: string, raw: string): void => { if (newFolderRef.current === null) return newFolderRef.current = null setNewFolder(null) const name = raw.trim() const live = propsRef.current if (live.cwd === undefined) return if (!validName(name)) { setActionError(t('newFolderInvalid')) return } api.fsMkdir({ sessionId: live.sessionId, cwd: live.cwd }, dir, name) .then(() => { setActionError(null) if (dir !== live.cwd && !expandedSetRef.current.has(dir)) live.onToggle(dir) retryDir(dir) }) .catch((error: unknown) => { setActionError(error instanceof Error ? error.message : String(error)) }) } const cancelNewFolder = (): void => { newFolderRef.current = null setNewFolder(null) } // ── Batch delete ─────────────────────────────────────────────────────── /** * Delete every selected row, ONE AT A TIME (the host refuses nothing here, * but a partial batch must be debuggable). The first failure stops the walk * and lands in the error strip; already-removed rows settle as they go. */ const performBatchDelete = (): void => { const live = propsRef.current if (live.cwd === undefined || deletingSelected) return const paths = [...selectedRef.current] setConfirmDeleteSelected(false) if (paths.length === 0) return const scope = { sessionId: live.sessionId, cwd: live.cwd } setDeletingSelected(true) void (async () => { const removed: string[] = [] for (const path of paths) { try { await api.fsRemove(scope, path) } catch (error: unknown) { setActionError(error instanceof Error ? error.message : String(error)) // Keep the rows that were NOT removed selected, so a retry is one click. const next = new Set(selectedRef.current) for (const done of removed) next.delete(done) setSelection(next) setDeletingSelected(false) return } setActionError(null) pruneTree(path) live.onPathDeleted?.(path, kindRef.current.get(path) ?? false) removed.push(path) } setDeletingSelected(false) clearSelection() })() } // ── Open in app ──────────────────────────────────────────────────────── /** Report one failed hand-off (open / reveal / app listing) in the strip. */ const reportOpenFailure = useCallback((path: string): void => { setActionError(t('openInAppFailed', { path })) }, []) /** * Whether the host can hand paths to a native desktop — probed ONCE per * handle, on mount. The adapter starts in its `null` (unprobed) state, and * only the probe publishes the answer, so the menu reads this state instead * of the tri-state getter: reading `available()` alone left the whole * section permanently invisible (nothing ever probed). */ const [appReady, setAppReady] = useState(() => openInApp?.available() === true) useEffect(() => { if (openInApp === undefined) { setAppReady(false) return } // An answer the handle already carries is authoritative; only the // unprobed (`null`) state needs the probe. const known = openInApp.available() if (known !== null) { setAppReady(known) return } let cancelled = false void openInApp.probe().then((ok) => { if (!cancelled) setAppReady(ok) }).catch(() => { if (!cancelled) setAppReady(false) }) return () => { cancelled = true } }, [openInApp]) /** * The host's application list, CACHED per path (the successful answers only; * a failed listing is retried on the next open). The list is a property of * the machine + the path, so reopening the same row's menu is instant: no * request, no disabled "Loading…" frame. A newly installed application shows * up on the next tree mount — the alternative (re-probing per open) is the * churn the menu was reported for. */ const appsCacheRef = useRef(new Map()) useEffect(() => { if (rowMenu === null || openInApp === undefined || !appReady) return const { path, isDir } = rowMenu const key = `${isDir ? 'd' : 'f'}\u0000${path}` const cached = appsCacheRef.current.get(key) if (cached !== undefined) { // Already answered for this path: publish it without a null frame (the // submenu never flashes its disabled "Loading…" row on a reopen). setApps({ path, entries: cached }) return } let cancelled = false const listing = isDir ? openInApp.directoryApps() : openInApp.fileApps(path) void listing.then((entries) => { if (cancelled) return if (entries !== null) appsCacheRef.current.set(key, entries) setApps({ path, entries }) if (entries === null) reportOpenFailure(path) }).catch(() => { if (cancelled) return setApps({ path, entries: null }) reportOpenFailure(path) }) return () => { cancelled = true } }, [rowMenu, openInApp, appReady, reportOpenFailure]) const openWithApp = useCallback((path: string, application?: string): void => { const handle = propsRef.current.openInApp if (handle === undefined) return const call = application === undefined ? handle.open(path) : handle.open(path, application) void call.then((ok) => { if (!ok) reportOpenFailure(path) }).catch(() => { reportOpenFailure(path) }) }, [reportOpenFailure]) const revealPath = useCallback((path: string): void => { const handle = propsRef.current.openInApp if (handle === undefined) return void handle.reveal(path).then((ok) => { if (!ok) reportOpenFailure(path) }).catch(() => { reportOpenFailure(path) }) }, [reportOpenFailure]) /** The menu label of one plugin open target: a locale key for the built-ins, * the user's own name for custom editors, plus the SSH hint in remote mode. */ const openWithLabelOf = (target: OpenWithTarget): string => { const name = target.nameKey !== undefined ? t(target.nameKey) : target.name return openWithSsh === true && !target.localOnly ? `${name}${t('openWithSshSuffix')}` : name } /** * The plugin's own open-with targets as SUBMENU rows: one row per target with * its brand mark, the SSH hint in remote mode, and the pin hot zone (main's * interaction). The built-in `explorer` target is dropped while the host is * ready — the host's own reveal row replaces it, and two identical "File * Manager" rows would be noise; without the host the target stays, so reveal * is never lost. * * Visibility (the user's "有本机检测到的关联应用时就不显示固定的 openwith * 项目"): when the host reports applications for THIS path, the plugin rows * only appear if the caller turned `openWithShowPluginTargets` on (the * `openWithPluginTargets` setting). An unavailable host, or an empty/failed * listing, keeps them — that is what makes a remote (SSH) session usable. */ const pluginTargetRows = (hostHasApps: boolean): MenuItem[] => { if (openWithTargets === undefined || onOpenWith === undefined) return [] if (hostHasApps && openWithShowPluginTargets !== true) return [] const targets = appReady ? openWithTargets.filter(target => target.id !== 'explorer') : openWithTargets const pinnedIds = openWithPinned ?? [] /** Brand marks for the built-ins (monochrome silhouettes, currentColor); * reveal gets the folder glyph, custom editors a generic code mark. * The compact menu's icon slot is 14px, so every mark renders at 14. */ const itemIcon = (target: OpenWithTarget): ReactNode => { if (target.kind === 'reveal') return if (target.id === 'vscode') return if (target.id === 'cursor') return if (target.id === 'zed') return return } return targets.map(target => { const pinnedNow = pinnedIds.includes(target.id) return { id: `open-with:${target.id}`, label: ( {openWithLabelOf(target)} {/* The pushpin: a PLAIN span, deliberately not a control. The Menu renders the row itself as `