Files
apes-sidebar/src/client/FileTree.tsx
T
Menghuan1918 98a446bbfc fix(sidebar): 点击展开时文件树整体重建(两处根因)+ v0.24.1
用户报告:「点击展开的时候,文件树会频繁整体刷新,看起来非常难受」。

根因 1(主):src/client/native/index.ts 的 sync() 把 files 接管项当成多余注册。
它按 KIND 记账、永远不在 wanted 里,于是每次 sync 都被 dispose + 重建;而
disposeSubscriptions 同时订阅了 service 与 store,**任何会话状态写入**(展开/收起、
切 tab、拖底部工作台、选中…)都会跑一遍 sync。替换 slot 注册 = 宿主换 slot entry →
插件 tab body 被 unmount/remount → 文件树丢失层缓存、滚动位置与目录 watcher,并重新
fs.trees([root, ...expanded]) 重列整棵可见树。机械证明(临时插桩,已回退):
  [probe-sync] enter live=[editor,git,subagent,sidechat,diff,files]
  [probe-registerSlots] slot inject ran for dsh-better-sidebar:files
  [probe-body] unmount tab3 kind=files
  [probe-body] mount tab3 kind=files
修法:wantsFiles 提前,删除循环里对 files 接管项 skip(禁用 editor 时仍照旧释放)。

根因 2(次):FileTree.tsx 的 retryDir 先把该层缓存删掉再请求,行被「Loading…」
占位替换后重建——目录有外部写入(构建 / 格式化 / 模型跑 bash)时整层闪空。
修法:复用既有 force 路径 loadLevels([dir], { force: true }),保留旧行直到新答案到达
(与 refreshTick 的强制刷新路径行为一致)。

判别性证据(回退任一修复即红)
- tests/native-surface.spec.ts:三次 store 写入不得产生任何注册/注销 → 回退后
  registered 6 → 12、disposed 全是 dsh-better-sidebar:files
- tests/file-tree-batch-load.spec.tsx:重列期间旧行仍在屏 → 回退后层被删空
- 新增真实宿主 lane tests/e2e/zz-expand-refresh.e2e.ts(quiescence 判据,无固定 sleep;
  zz- 前缀保证它最后跑,因为它会自建 workspace + session):A1 展开只请求被展开那一层、
  A2 tab body 存活(不重开 watch socket)、A3 屏上行数不归零;回退修复 1 后退出码 1
  (Error: expanding a folder must ask for that folder, not the whole visible set)

修复后实测(40 子项目录,同一探针/机器/工作区)
| 阶段 | fs.trees payload | 新开 ws | DOM +/- | 被删行 | body 替换 | 层内最小行数 |
| 展开 big | [big](原 [root, big]) | 0(原 1) | +41/-1 | 0(原 45) | 0(原是) | 40(原 40) |
| 外部写入 big | [big] | 0 | +1/-0(原 +41/-40) | 0 | 0 | 40(原 0) |
| 展开 bravo | [bravo] | 0 | +2/-1 | 0(原 45) | 0(原是) | 41 |

证伪记录:新建 fs.watch(dir,{persistent:false}) 在 macOS 上无改动时 0 事件(Node 微
探针两遍 + 8 轮真实时序 + 浏览器侧从未收到 {dir} 陈旧帧)→ src/fs-watch.ts 与
use-dir-watch.ts 一行未改。

验证:pnpm typecheck / lint / test(136 文件 1486 通过 9 跳过,基线 1484 + 新增 2)/ build
全绿;DSH_CMD='npx -y --package @deepseek-ai/dsh@0.2.0-rc.1 dsh' pnpm test:mount = 8/8。
版本 0.24.0 → 0.24.1(package.json / dsh.plugin.json / SIDEBAR_SERVICE_VERSION /
README×2 / CHANGELOG×2 同步)。
2026-09-28 22:33:30 +08:00

1888 lines
82 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 `@<relative path>` 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 = () => (
<svg width="64" height="56" viewBox="0 0 64 56" fill="none" aria-hidden="true">
<g className={css.uploadDropArtPrimary}>
<path d="M32 28V11" stroke="currentColor" strokeWidth="5" strokeLinecap="round" />
<path d="M23 20l9-9 9 9" stroke="currentColor" strokeWidth="5" strokeLinecap="round" strokeLinejoin="round" />
</g>
<path
className={css.uploadDropArtAccent}
d="M10 40a4 4 0 0 1 4-4h7l3.2 4.6a5 5 0 0 0 4.1 2.2h7.4a5 5 0 0 0 4.1-2.2L43 36h7a4 4 0 0 1 4 4v2a10 10 0 0 1-10 10H20A10 10 0 0 1 10 42v-2z"
fill="currentColor"
/>
</svg>
)
/** 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 = () => (
<svg width="96" height="76" viewBox="0 0 96 76" fill="none" aria-hidden="true">
<g transform="rotate(-12 24 34)">
<rect className={css.uploadDropArtAccent} x="6" y="16" width="36" height="36" rx="10" fill="currentColor" />
<g className={css.uploadDropArtCut}>
<circle cx="16" cy="27" r="3.5" fill="currentColor" />
<path d="M11 44l8-9 6 6 4-4 8 9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
</g>
</g>
<g transform="rotate(8 61 35)">
<rect className={css.uploadDropArtPrimary} x="40" y="12" width="42" height="46" rx="10" fill="currentColor" />
<g className={css.uploadDropArtCut}>
<circle cx="55" cy="27" r="5" fill="currentColor" />
<path d="M46 50l10-13 7 8 6-6 9 11" stroke="currentColor" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round" />
</g>
</g>
</svg>
)
/** 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<HTMLDivElement>, path: string, isDir: boolean): void
reference(path: string, isDir: boolean): void
dragOver(event: DragEvent<HTMLDivElement>, dir: string): void
drop(event: DragEvent<HTMLDivElement>, 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 <IconCodeOutlineRegular size={14} />
return <img className={css.explorerAppIcon} src={icon} alt="" width={14} height={14} />
}
/** 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 (
<div
role="button"
tabIndex={0}
className={clsx(
css.explorerRow, entry.hidden && css.explorerHidden, entry.broken && css.explorerBroken,
selected && css.explorerRowSelected,
dropTarget && css.explorerRowDropTarget,
revealed && css.explorerRowRevealed,
)}
data-dsh-revealed={revealed ? 'true' : undefined}
data-dsh-selected={selected ? 'true' : undefined}
aria-pressed={selected}
style={{ paddingLeft: depth * 22 + 6 }}
title={entry.broken ? `${entry.path} — ${t('brokenSymlink')}` : entry.path}
onClick={(event) => { actions.activate(event, entry.path, false) }}
onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {
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}
<span className={clsx(css.explorerName, gitTone !== undefined && css.explorerGitName)} data-git-tone={gitTone}>
{entry.name}
</span>
{entry.isSymlink && <IconLinkOutlineRegular size={12} className={css.explorerSymlink} />}
{gitTone !== undefined && gitLetter !== undefined && (
<StatusBadge tone={badgeToneOf(gitTone)} title={gitToneLabel(gitTone)}>{gitLetter}</StatusBadge>
)}
{copied
? <span className={css.explorerCopied}>{t('copied')}</span>
: (
<button
type="button"
className={css.explorerRef}
aria-label={t('referenceFile')}
title={t('referenceFile')}
onClick={(event) => {
event.stopPropagation()
actions.reference(entry.path, false)
}}
>
{t('referenceFile')}
</button>
)}
</div>
)
})
/** 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 (
<div
role="button"
tabIndex={0}
className={clsx(
css.explorerRow, css.explorerDir, entry.hidden && css.explorerHidden,
selected && css.explorerRowSelected,
dropTarget && css.explorerRowDropTarget,
revealed && css.explorerRowRevealed,
)}
data-dsh-revealed={revealed ? 'true' : undefined}
data-dsh-selected={selected ? 'true' : undefined}
aria-pressed={selected}
style={{ paddingLeft: depth * 22 + 6 }}
onClick={(event) => { actions.activate(event, entry.path, true) }}
onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {
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}
<span className={clsx(css.explorerName, gitChanged && css.explorerDirChanged)}>{entry.name}</span>
{entry.isSymlink && <IconLinkOutlineRegular size={12} className={css.explorerSymlink} />}
{copied
? <span className={css.explorerCopied}>{t('copied')}</span>
: (
<button
type="button"
className={css.explorerRef}
aria-label={t('referenceFile')}
title={t('referenceFile')}
onClick={(event) => {
event.stopPropagation()
actions.reference(entry.path, true)
}}
>
{t('referenceFile')}
</button>
)}
</div>
)
})
/**
* 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 (
<div
className={clsx(css.explorerRow, dropTarget && css.explorerRowDropTarget)}
style={{ paddingLeft: 6 }}
onDragOver={(event) => { actions.dragOver(event, path) }}
onDrop={(event) => { actions.drop(event, path, true) }}
onContextMenu={(event) => { actions.contextMenu(event, path, true) }}
>
{icon}
<span className={clsx(css.explorerName, gitChanged && css.explorerDirChanged)}>{baseName(path)}</span>
{copied
? <span className={css.explorerCopied}>{t('copied')}</span>
: (
<button
type="button"
className={css.explorerRef}
aria-label={t('referenceFile')}
title={t('referenceFile')}
onClick={(event) => {
event.stopPropagation()
actions.reference(path, true)
}}
>
{t('referenceFile')}
</button>
)}
</div>
)
})
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 `@<relative path>` 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<Record<string, LevelData>>({})
/**
* 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<string | null>(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<string | null>(null)
/** A failed re-list that kept the previous rows on screen (hint line). */
const [loadError, setLoadError] = useState<string | null>(null)
/** The multi-selection (absolute paths) and its Shift anchor. */
const [selected, setSelected] = useState<Set<string>>(() => new Set())
const anchorRef = useRef<string | null>(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<string | null>(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<HTMLDivElement>(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<string | undefined>(undefined)
const fileInputRef = useRef<HTMLInputElement>(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<HTMLDivElement>): 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<HTMLDivElement>, dir: string): void => {
if (!isFileDrag(event)) return
event.preventDefault()
event.stopPropagation()
resetDrop()
reportDrop(dir, event.dataTransfer)
}, [reportDrop, resetDrop])
const handleBodyDragEnter = useCallback((event: DragEvent<HTMLDivElement>): 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<HTMLDivElement>): 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<HTMLDivElement>, 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<HTMLDivElement>, 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<HTMLElement>('[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<string, boolean>())
/** 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<Set<string>>(new Set())
const setSelection = useCallback((next: Set<string>): 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<HTMLDivElement>, 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<RowActions>(() => ({
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<string, readonly OpenInAppEntry[]>())
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 <VscFolderOpened size={14} />
if (target.id === 'vscode') return <IconVscode16 size={14} />
if (target.id === 'cursor') return <SiCursor size={14} />
if (target.id === 'zed') return <SiZedindustries size={14} />
return <IconCodeOutlineRegular size={14} />
}
return targets.map<MenuItem>(target => {
const pinnedNow = pinnedIds.includes(target.id)
return {
id: `open-with:${target.id}`,
label: (
<span className={css.openWithLabel}>
<span className={css.openWithName}>{openWithLabelOf(target)}</span>
{/* The pushpin: a PLAIN span, deliberately not a control. The
Menu renders the row itself as `<button role="menuitem">`, so a
nested button/imitation-button would be invalid markup — and
anything with `tabIndex` inside it is unreachable by keyboard
anyway (the row button swallows focus). It is therefore an
honest MOUSE hot zone: hovering the row reveals it, clicking it
pins/unpins without selecting the row (stopPropagation), and it
keeps a `title` so the affordance is still nameable on hover.
Keyboard/AT users have a real, reachable path to the same list:
the plugin settings panel's pinned-targets editor
(OpenWithSettings). Do not re-add role/tabIndex here without
also making the row itself non-interactive. */}
<span
className={clsx(css.openWithPin, pinnedNow && css.openWithPinActive)}
title={pinnedNow ? t('unpinOpenWith') : t('pinOpenWith')}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
onToggleOpenWithPin?.(target.id)
}}
>
{pinnedNow ? <VscPinned size={12} /> : <VscPin size={12} />}
</span>
</span>
),
icon: itemIcon(target),
}
})
}
/** The host's application list for one path (undefined while it is unknown). */
const hostEntries = (target: { path: string; isDir: boolean }): readonly OpenInAppEntry[] | null | undefined =>
apps !== null && apps.path === target.path ? apps.entries : undefined
/**
* The submenu behind the "打开方式" row — this menu's ONE place for "open with
* something else": the host's detected applications (the default handler is
* hoisted to level 1 for files), then, per the visibility rule above, the
* plugin's own fixed targets. A pending listing shows one disabled line; an
* empty/failed listing the disabled `openInAppEmpty` line.
*
* Title: the frozen shape asked for a `t('openInApp')` label, but that key no
* longer exists in the dictionaries (it was removed together with the
* standalone section it used to head), and `openWithMenu` is the one
* available synonym ("在应用中打开" / "Open with") — of the two near-duplicate
* labels the task allowed keeping one, so this is it.
*/
const openWithSubmenu = (target: { path: string; isDir: boolean }): MenuItem => {
const entries = hostEntries(target)
const hostHasApps = entries !== undefined && entries !== null && entries.length > 0
const rows: MenuItem[] = []
if (appReady) {
if (entries === undefined) rows.push({ id: 'open-in-app-loading', label: t('loading'), disabled: true })
else if (entries === null || entries.length === 0) {
rows.push({ id: 'open-in-app-empty', label: t('openInAppEmpty'), disabled: true })
} else {
for (const entry of entries) {
// The default handler is the level-1 row for files; directories have
// no default concept, so every catalogue app stays here.
if (!target.isDir && entry.isDefault) continue
rows.push({ id: `open-in-app:${entry.id}`, label: entry.name, icon: <AppGlyph entry={entry} /> })
}
}
}
// The primitive submenu takes MenuItem rows only (no separators), so the
// host and plugin groups are told apart by their icons.
rows.push(...pluginTargetRows(hostHasApps))
return {
id: 'open-with-menu',
// The primitives Menu renders no chevron for submenu parents — the
// trailing arrow is supplied inside the label (full-width flex row,
// right-aligned), matching how the submenu rows right-align the pin.
label: (
<span className={css.openWithLabel}>
<span className={css.openWithName}>{t('openWithMenu')}</span>
<IconChevronRightOutlineRegular size={14} className={css.openWithChevron} aria-hidden />
</span>
),
icon: <VscLinkExternal size={14} />,
submenu: rows,
}
}
/**
* The level-1 "default open" row for a file: the host's default handler, one
* click away without opening a submenu. Absent while the listing is unknown,
* when the host has no default, or for directories (no default concept).
*/
const defaultAppRow = (target: { path: string; isDir: boolean }): MenuEntry | null => {
if (!appReady || target.isDir) return null
const entries = hostEntries(target)
const fallback = entries?.find(entry => entry.isDefault)
if (fallback === undefined) return null
return { id: 'open-in-app:default', label: t('openInAppDefault'), icon: <AppGlyph entry={fallback} /> }
}
/** The "打开方式" group of the ONE row menu: the host default, the submenu and
* (host permitting) reveal. Empty when neither half can offer anything. */
const openWithSection = (target: { path: string; isDir: boolean }): MenuEntry[] => {
const fallback = defaultAppRow(target)
const entries = hostEntries(target)
const hostHasApps = entries !== undefined && entries !== null && entries.length > 0
const submenu = appReady || pluginTargetRows(hostHasApps).length > 0 ? openWithSubmenu(target) : null
if (fallback === null && submenu === null) return []
const rows: MenuEntry[] = []
if (fallback !== null) rows.push(fallback)
if (submenu !== null) rows.push(submenu)
if (appReady) {
rows.push(
{ id: 'open-in-app-sep', type: 'separator' },
{ id: 'reveal-in-file-manager', label: t('revealInFileManager'), icon: <IconFolderOpenRegular size={14} /> },
)
}
return rows
}
/**
* The selection's ZIP row: two or more selected rows archive together, and a
* LONE directory archives its own subtree. A lone FILE is skipped — it would
* only duplicate the plain download row.
*/
const zipEntries = (target: { path: string; isDir: boolean }): MenuEntry[] => {
const count = selected.size
if (count >= 2) {
return [{
id: 'archive-selection',
label: t('zipDownloadCount', { count }),
icon: <IconArchiveOutlineRegular size={14} />,
}]
}
if (count === 1 && target.isDir) {
return [{ id: 'archive-selection', label: t('zipDownload'), icon: <IconArchiveOutlineRegular size={14} /> }]
}
return []
}
/** 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()
}
/**
* Zip the selection through the host's ASYNC archive route:
*
* archiveBuild(scope, paths, name) → { id, entries }
* → poll archiveStatus(id) every 250ms (self-scheduling: one request in
* flight) while the job builds, driving the `zipProgress` line
* → state 'ready' → fetch(archiveDownloadUrl(scope, id)) → blob → hidden
* `<a download>` (the bytes are fetched explicitly because a bare anchor
* would save the route's JSON error envelope as a broken .zip)
* → any failure (build / status / download) → `zipFailed` in the strip.
*
* `archiveBusyRef` keeps a repeated pick from starting a second job (the
* visible progress line is the feedback), and `archiveProgress` carries the
* live `done/total` for the line and its percentage.
*/
const [archiveBusy, setArchiveBusy] = useState(false)
const [archiveProgress, setArchiveProgress] = useState<{ done: number; total: number } | null>(null)
const archiveBusyRef = useRef(false)
/** The job being polled (state so the poller starts/stops with it). */
const [archiveJobId, setArchiveJobId] = useState<string | null>(null)
const archiveJobRef = useRef<{ id: string; name: string } | null>(null)
/** End the job: drop the progress line and release the guard. */
const settleArchive = useCallback((): void => {
archiveBusyRef.current = false
archiveJobRef.current = null
setArchiveJobId(null)
setArchiveBusy(false)
setArchiveProgress(null)
}, [])
const failArchive = useCallback((message: string): void => {
setActionError(t('zipFailed', { message }))
settleArchive()
}, [settleArchive])
/** Save the finished archive as `name` through a hidden anchor. */
const saveArchive = useCallback((id: string, name: string): void => {
void fetch(archiveDownloadUrl({ sessionId, cwd }, id))
.then(async (response) => {
if (!response.ok) {
const envelope: { error?: { message?: string } } | null = await response.json().catch(() => null)
throw new Error(envelope?.error?.message ?? `HTTP ${response.status}`)
}
return await response.blob()
})
.then((blob) => {
setActionError(null)
const objectUrl = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = objectUrl
anchor.download = name
anchor.style.display = 'none'
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
// Revoke on the next task, not synchronously: some engines have not
// committed the download when click() returns, and revoking the URL
// under them loses the file. One tick is enough and keeps the blob
// from outliving the download.
window.setTimeout(() => { URL.revokeObjectURL(objectUrl) }, 0)
settleArchive()
})
.catch((error: unknown) => {
failArchive(error instanceof Error ? error.message : String(error))
})
}, [cwd, failArchive, sessionId, settleArchive])
/** The poller's callbacks, read through a ref so the polling TASK stays
* identity-stable (a churned task restarts the loop every render). */
const archiveHandlersRef = useRef({ fail: failArchive, save: saveArchive })
archiveHandlersRef.current = { fail: failArchive, save: saveArchive }
usePolling(archiveJobId !== null, useCallback(async (signal: AbortSignal): Promise<void> => {
const job = archiveJobRef.current
if (job === null) return
// The poller swallows a rejected task (it must keep the loop alive), so
// every failure is caught HERE and turned into the strip's `zipFailed`.
try {
const status = await archiveStatus(job.id)
// The transport may deliver a response after teardown; the signal is the
// only reliable staleness guard (the route call takes no signal).
if (signal.aborted) return
setArchiveProgress({ done: status.done, total: status.total })
if (status.state === 'error') {
archiveHandlersRef.current.fail(status.error ?? `HTTP ${status.state}`)
return
}
if (status.state === 'ready') archiveHandlersRef.current.save(job.id, job.name)
} catch (error: unknown) {
if (signal.aborted) return
archiveHandlersRef.current.fail(error instanceof Error ? error.message : String(error))
}
}, []), { intervalMs: 250, mode: 'self-scheduling', immediate: true })
const downloadArchive = (paths: readonly string[]): void => {
if (archiveBusyRef.current) return
const name = paths.length === 1 ? `${baseName(paths[0]!)}.zip` : 'archive.zip'
archiveBusyRef.current = true
setArchiveBusy(true)
setArchiveProgress(null)
void archiveBuild({ sessionId, cwd }, paths, name)
.then(({ id, entries }) => {
archiveJobRef.current = { id, name }
setArchiveProgress({ done: 0, total: entries })
setArchiveJobId(id)
})
.catch((error: unknown) => {
failArchive(error instanceof Error ? error.message : String(error))
})
}
const root = cwd
// Membership is tested per rendered row (deep trees run this thousands of
// times per render): includes() made every row O(expanded), the whole tree
// O(rows × expanded). One Set per render keeps it O(1) per row.
const expandedSet = useMemo(() => new Set(expanded), [expanded])
const revealedSet = useMemo(() => new Set(revealed), [revealed])
expandedSetRef.current = expandedSet
/** The tree's shared git status (the changes page reads the same snapshot);
* a parked tab unsubscribes from the poll. */
const gitStatus = useGitStatus({ sessionId, cwd }, { visible: visible !== false })
// The Shift range walks the rows the user can actually see: depth-first,
// expanded state decides. Recomputed with the level cache, never rendered.
const visibleRows = useMemo(() => {
const rows: { path: string; isDir: boolean }[] = []
const walk = (dir: string): void => {
const level = data[dir]
if (level?.entries === undefined) return
for (const entry of level.entries) {
rows.push({ path: entry.path, isDir: entry.isDir })
if (entry.isDir && expandedSet.has(entry.path)) walk(entry.path)
}
}
if (root !== undefined) walk(root)
return rows
}, [data, expandedSet, root])
visibleRowsRef.current = visibleRows
/**
* Focus + select an inline editor once, on mount. The callback identity
* must stay STABLE (useCallback []): an inline arrow re-runs on every
* render (detach null → attach el), and focus()/select() mid-keystroke
* would reselect the whole buffer while typing. A plain <input>, not the
* primitives Input — that one forwards no ref, and focus+select is the
* entire point here.
*/
const renameInputRef = useCallback((el: HTMLInputElement | null): void => {
if (el !== null) {
el.focus()
el.select()
}
}, [])
/** The inline rename editor replacing one row (dirs and files alike):
* same indent/icon/height for a seamless swap, Enter/blur commits,
* Escape cancels, IME composition keys never reach the handlers (the
* shared isImeComposition guard). */
const renderRenameRow = (entry: FsEntry, depth: number): ReactNode => (
<div
key={entry.path}
className={clsx(css.explorerRow, css.explorerRenaming)}
style={{ paddingLeft: depth * 22 + 6 }}
>
{entry.isDir
? dirRowIcon(entry.path, expandedSet.has(entry.path))
: service !== undefined ? service.fileIcon(entry.path, 14) : builtinFileIcon(entry.path, 14)}
<input
ref={renameInputRef}
className={css.explorerRenameInput}
value={renaming?.value ?? ''}
aria-label={t('rename')}
spellCheck={false}
onChange={(event) => {
setRenaming(prev => prev === null ? prev : { ...prev, value: event.target.value })
}}
onKeyDown={(event) => {
if (isImeComposition(event)) return
if (event.key === 'Enter') {
event.preventDefault()
commitRename(entry.path, renaming?.value ?? '')
} else if (event.key === 'Escape') {
event.preventDefault()
setRenaming(null)
}
}}
onBlur={() => { commitRename(entry.path, renaming?.value ?? '') }}
/>
</div>
)
/** The inline new-folder editor at the top of one level: the same
* interaction contract as the rename editor (Enter commits, Escape
* cancels, blur commits, IME guarded). */
const renderNewFolderRow = (dir: string, depth: number): ReactNode => (
<div className={clsx(css.explorerRow, css.explorerRenaming)} style={{ paddingLeft: depth * 22 + 6 }}>
{dirRowIcon(dir, true)}
<input
ref={renameInputRef}
className={css.explorerRenameInput}
value={newFolder?.value ?? ''}
placeholder={t('newFolderPlaceholder')}
aria-label={t('newFolder')}
spellCheck={false}
onChange={(event) => {
setNewFolder(prev => prev === null ? prev : { ...prev, value: event.target.value })
}}
onKeyDown={(event) => {
if (isImeComposition(event)) return
if (event.key === 'Enter') {
event.preventDefault()
commitNewFolder(dir, newFolder?.value ?? '')
} else if (event.key === 'Escape') {
event.preventDefault()
cancelNewFolder()
}
}}
onBlur={() => { commitNewFolder(dir, newFolder?.value ?? '') }}
/>
</div>
)
const renderLevel = (dir: string, depth: number): ReactNode => {
const level = data[dir]
const head = newFolder?.dir === dir ? renderNewFolderRow(dir, depth) : null
if (level === undefined) {
return (
<>
{head}
<div className={css.explorerRow} style={{ paddingLeft: depth * 22 + 6 }}>{t('loading')}</div>
</>
)
}
if (level.error !== undefined) {
// A level the host could not read: the raw message on the level that
// failed, never on its siblings (the batch reports errors per level).
return (
<>
{head}
<div className={clsx(css.explorerRow, css.explorerError)} style={{ paddingLeft: depth * 22 + 6 }}>
{level.error}
</div>
</>
)
}
const entries = level.entries ?? []
return (
<>
{head}
{entries.map((entry) => {
// The row being renamed renders as its editor (no button semantics:
// an editor is not a click target — and a nested interactive inside
// role="button" would be invalid anyway).
if (renaming?.path === entry.path) return renderRenameRow(entry, depth)
if (entry.isDir) {
const isOpen = expandedSet.has(entry.path)
return (
<div key={entry.path}>
<DirRow
entry={entry}
depth={depth}
expanded={isOpen}
iconsVersion={iconsVersion}
service={service}
selected={selected.has(entry.path)}
revealed={revealedSet.has(entry.path)}
dropTarget={dropTarget === entry.path}
gitChanged={gitStatus.dirHasChanges(entry.path)}
copied={copiedPath === entry.path}
actions={actions}
/>
{isOpen && renderLevel(entry.path, depth + 1)}
</div>
)
}
const git = gitStatus.statusOf(entry.path)
return (
<FileRow
key={entry.path}
entry={entry}
depth={depth}
iconsVersion={iconsVersion}
service={service}
selected={selected.has(entry.path)}
revealed={revealedSet.has(entry.path)}
dropTarget={dropTarget === parentOf(entry.path)}
gitTone={git?.tone}
gitLetter={git?.letter}
copied={copiedPath === entry.path}
actions={actions}
/>
)
})}
{/* The host capped this directory's listing: say so instead of
silently showing a partial tree. */}
{level.truncated === true && <Notice kind="hint">{t('filesTruncated')}</Notice>}
</>
)
}
return (
<div
ref={bodyRef}
className={clsx(css.explorerBody, hidden === true && css.explorerHiddenPane)}
hidden={hidden}
onDragEnter={handleBodyDragEnter}
onDragOver={handleBodyDragOver}
onDragLeave={handleBodyDragLeave}
onDrop={handleBodyDrop}
onClick={(event) => {
// A click on the body itself (below the last row) drops the selection.
if (event.target === event.currentTarget) clearSelection()
}}
onKeyDown={(event) => {
if (event.key === 'Escape') clearSelection()
}}
>
{root === undefined ? (
<div className={css.explorerEmpty}>{t('noSession')}</div>
) : (
<>
{/* The last mutation failure (rename/delete/mkdir/open-in-app):
dismissable, raw server text — the same show-the-truth policy the
fence notice uses. Any later action or a fresh attempt clears it. */}
{actionError !== null && (
<div className={css.explorerActionError} role="alert">
<span className={css.explorerActionErrorText}>{actionError}</span>
<IconButton
size="sm"
label={t('dismiss')}
icon={<IconCloseFillRegular size={14} />}
onClick={() => { setActionError(null) }}
/>
</div>
)}
{/* The archive is built host-side, in one pass, while the STATUS
poll drives `done/total`: the line sits right under the error
strip (the two CAN coexist — a stale error stays readable while a
new attempt is in flight). `role="status"` makes it a polite live
region, so progress is announced without stealing focus. */}
{/* A refresh whose batch request failed: the rows above are the
PREVIOUS listing, so the hint explains why nothing changed. */}
{loadError !== null && (
<Notice kind="warn" tone="inline">{loadError}</Notice>
)}
{archiveBusy && (
<Notice kind="loading" tone="inline" role="status">
{archiveProgress === null
? t('loading')
: `${t('zipProgress', { done: archiveProgress.done, total: archiveProgress.total })} · ${archivePercent(archiveProgress)}%`}
</Notice>
)}
<RootRow
path={root}
iconsVersion={iconsVersion}
service={service}
dropTarget={dropTarget === root}
gitChanged={gitStatus.dirHasChanges(root)}
copied={copiedPath === root}
actions={actions}
/>
{renderLevel(root, 1)}
{/* The selection bar closes the scroll container (sticky bottom): it
sits BELOW every row, never over them, and its appearance never
shifts the tree the way a top bar did. */}
{selected.size > 0 && (
<div className={css.explorerSelectionBar} role="toolbar" aria-label={t('filesSelected', { count: selected.size })}>
<span className={css.explorerSelectionText}>{t('filesSelected', { count: selected.size })}</span>
<span className={css.explorerSelectionActions}>
<Chip onClick={copySelectedPaths}>{t('copyPaths')}</Chip>
<Chip onClick={() => { setConfirmDeleteSelected(true) }}>{t('deleteSelected')}</Chip>
<Chip onClick={clearSelection}>{t('clearSelection')}</Chip>
</span>
</div>
)}
</>
)}
{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. A LIGHT mask dims
* the viewport: the dimmed conversation column still takes drops into
* the chat natively (this layer is pointer-inert), while the dashed
* 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.
*/
<>
<div className={css.uploadDropMask} />
<div
className={css.uploadDropZone}
style={{
top: dropRect.top + 2,
left: dropRect.left + 2,
width: dropRect.width - 4,
height: dropRect.height - 4,
}}
>
<div className={css.uploadDropHero}>
<UploadDropIllustration />
<div className={css.uploadDropZonePill}>
<IconUploadOutline16 size={14} />
<span className={css.uploadDropZoneText}>
{dropTarget !== null ? t('uploadTo', { dir: dropTarget }) : t('uploadDropHint')}
</span>
</div>
</div>
</div>
{/* 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 && (
<div className={css.uploadDropChatHint} style={{ width: dropRect.left }}>
<div className={css.uploadDropChatCard}>
<ChatDropIllustration />
<span>{t('uploadDropChat')}</span>
</div>
</div>
)}
</>,
document.body,
)}
{/*
The one shared context menu, positioned at the right-click cursor
(portal so the tree's overflow clip cannot crop it).
*/}
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: 'none' }}
onChange={(event) => {
const dir = pendingUploadDir.current ?? root
pendingUploadDir.current = undefined
if (dir !== undefined && !busy) onUploadRequest(dir, uploadItemsFromFiles(event.target.files ?? []))
event.target.value = ''
}}
/>
<Menu
open={rowMenu !== null}
onClose={() => { setRowMenu(null) }}
items={[
// 1-3: the "打开方式" group heads the menu — the host's default
// handler, the one submenu holding every application (host + the
// plugin's own, per the visibility rule) and the host's reveal.
...(rowMenu === null ? [] : openWithSection(rowMenu)),
// 4: the explicit open escapes (files only).
...(rowMenu?.isDir === false && onOpenFileNewTab !== undefined
? [{ id: 'open-new-tab', label: t('openFileNewTab'), icon: <IconCodeOutlineRegular size={14} /> }]
: []),
...(rowMenu?.isDir === false && onOpenFileSide !== undefined
? [{ id: 'open-side', label: t('openFileSide'), icon: <IconFolderOpenRegular size={14} /> }]
: []),
// Download applies to files only (the host route refuses directories).
...(rowMenu?.isDir === false
? [{ id: 'download', label: t('download'), icon: <IconDownloadOutlineRegular size={14} /> }]
: []),
// Directory actions: upload into, and create inside (the workspace
// root row included).
...(rowMenu?.isDir === true
? [{ id: 'upload-here', label: t('uploadHere'), icon: <IconUploadOutline16 size={14} /> }]
: []),
...(rowMenu?.isDir === true
? [{ id: 'new-folder', label: t('newFolder'), icon: <IconPlusOutlineRegular size={14} /> }]
: []),
// 5: ZIP of the current selection (≥2 rows, or one lone directory).
...(rowMenu === null ? [] : zipEntries(rowMenu)),
// 6: copy, then the mutations (never on the workspace root row).
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutlineRegular size={14} /> },
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutlineRegular size={14} /> },
...(rowMenu !== null && rowMenu.path !== cwd
? [
{ id: 'mutate-sep', type: 'separator' } as MenuEntry,
{ id: 'rename', label: t('rename'), icon: <IconEditOutlineRegular size={14} /> },
{ id: 'delete', label: t('delete'), icon: <IconTrashOutlineRegular size={14} />, danger: true },
]
: []),
]}
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 === 'open-in-app:default') {
openWithApp(target.path)
return
}
if (id.startsWith('open-in-app:')) {
openWithApp(target.path, id.slice('open-in-app:'.length))
return
}
// The plugin's own targets share one id space (pinned rows and
// submenu children alike), so the caller gets the target id + path.
if (id.startsWith('open-with:')) {
onOpenWith?.(id.slice('open-with:'.length), target.path)
return
}
if (id === 'reveal-in-file-manager') {
revealPath(target.path)
return
}
if (id === 'archive-selection') {
downloadArchive([...selected])
return
}
if (id === 'download') {
downloadFile(target.path)
return
}
if (id === 'upload-here') {
pendingUploadDir.current = target.path
fileInputRef.current?.click()
return
}
if (id === 'new-folder') {
startNewFolder(target.path)
return
} if (id === 'rename') {
setRenaming({ path: target.path, value: baseName(target.path) })
return
}
if (id === 'delete') {
setConfirmDelete({ path: target.path, isDir: target.isDir, name: baseName(target.path) })
return
}
copyPath(
id === 'relative' ? relativeTo(cwd ?? '', target.path) : target.path,
target.path,
)
}}
portal
compact
align="start"
getAnchorRect={() => (rowMenu === null ? null : new DOMRect(rowMenu.x, rowMenu.y, 0, 0))}
anchor={<span />}
/>
{/* The single-row delete confirmation: destructive and permanent (no host
trash), so it always lands here first — the shared kit dialog, the
same shape the git lens uses for discard/revert/cherry-pick. */}
<ConfirmDialog
open={confirmDelete !== null}
title={confirmDelete === null ? '' : t('deleteTitle', { name: confirmDelete.name })}
description={confirmDelete === null ? '' : t(confirmDelete.isDir ? 'deleteDescDir' : 'deleteDescFile')}
confirmLabel={t('delete')}
cancelLabel={t('cancel')}
danger
onConfirm={() => {
const pending = confirmDelete
if (pending === null) return
setConfirmDelete(null)
performDelete(pending)
}}
onClose={() => { setConfirmDelete(null) }}
/>
{/* The batch delete: one confirmation for the whole selection, then one
sequential fs.remove per row (the first failure stops the walk). */}
<ConfirmDialog
open={confirmDeleteSelected}
title={t('deleteSelectedTitle', { count: selected.size })}
description={t('deleteSelectedDesc')}
confirmLabel={t('deleteSelected')}
cancelLabel={t('cancel')}
danger
busy={deletingSelected}
onConfirm={performBatchDelete}
onClose={() => { setConfirmDeleteSelected(false) }}
/>
</div>
)
}