864 lines
31 KiB
TypeScript
864 lines
31 KiB
TypeScript
/* eslint-disable max-lines -- Why: the row owns dense file-tree rendering plus its context menu, drag target, and inline-input sibling contract. */
|
|
import React, { useCallback, useRef } from 'react'
|
|
import { basename } from '@/lib/path'
|
|
import {
|
|
ChevronRight,
|
|
CircleSlash,
|
|
Copy,
|
|
Download,
|
|
ExternalLink,
|
|
Eye,
|
|
File,
|
|
FilePlus,
|
|
Files,
|
|
Folder,
|
|
FolderOpen,
|
|
FolderPlus,
|
|
Globe,
|
|
ListCollapse,
|
|
Link,
|
|
Loader2,
|
|
Pencil,
|
|
Search,
|
|
SquareTerminal,
|
|
Trash2
|
|
} from 'lucide-react'
|
|
import { toast } from 'sonner'
|
|
import {
|
|
ContextMenu,
|
|
ContextMenuContent,
|
|
ContextMenuItem,
|
|
ContextMenuSeparator,
|
|
ContextMenuShortcut,
|
|
ContextMenuTrigger
|
|
} from '@/components/ui/context-menu'
|
|
import { cn } from '@/lib/utils'
|
|
import { useAppStore } from '@/store'
|
|
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
|
|
import { detectLanguage } from '@/lib/language-detect'
|
|
import { getFileTypeIcon } from '@/lib/file-type-icons'
|
|
import { openFileInBrowserTab } from '@/lib/file-preview'
|
|
import {
|
|
encodeWorkspaceFilePaths,
|
|
WORKSPACE_FILE_PATH_MIME,
|
|
WORKSPACE_FILE_PATHS_MIME
|
|
} from '@/lib/workspace-file-drag'
|
|
import type { GitFileStatus } from '../../../../shared/types'
|
|
import { STATUS_LABELS } from './status-display'
|
|
import { RENAME_HOTSPOT_ATTR } from './file-explorer-dir-toggle-timing'
|
|
import type { TreeNode } from './file-explorer-types'
|
|
import { useFileExplorerRowDrag } from './useFileExplorerRowDrag'
|
|
import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard'
|
|
import { translate } from '@/i18n/i18n'
|
|
import { extractIpcErrorMessage } from '@/lib/ipc-error'
|
|
import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '@/components/tab-bar/SortableTab'
|
|
import { downloadRuntimeFile, type RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
|
|
|
|
const isMac = navigator.userAgent.includes('Mac')
|
|
const isLinux = navigator.userAgent.includes('Linux')
|
|
|
|
/** Platform-appropriate label: macOS → Finder, Windows → File Explorer, Linux → Files */
|
|
const revealLabel = isMac
|
|
? 'Reveal in Finder'
|
|
: isLinux
|
|
? 'Open Containing Folder'
|
|
: 'Reveal in File Explorer'
|
|
|
|
function stopRightButtonMenuSelection(event: React.PointerEvent): void {
|
|
if (event.button !== 2) {
|
|
return
|
|
}
|
|
// Why: Radix opens context menus under the pointer; on some macOS/Electron
|
|
// paths the right-button release lands on the first item and selects it.
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
}
|
|
|
|
export type InlineInput = {
|
|
parentPath: string
|
|
type: 'file' | 'folder' | 'rename'
|
|
depth: number
|
|
existingName?: string
|
|
existingPath?: string
|
|
operationOwner?: TreeNode['operationOwner']
|
|
}
|
|
|
|
// ─── Inline Input Row ────────────────────────────────────────────
|
|
|
|
export function InlineInputRow({
|
|
depth,
|
|
inlineInput,
|
|
onSubmit,
|
|
onCancel
|
|
}: {
|
|
depth: number
|
|
inlineInput: InlineInput
|
|
onSubmit: (value: string) => void
|
|
onCancel: () => void
|
|
}): React.JSX.Element {
|
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
const blurTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
const submitted = useRef(false)
|
|
// Grace period flag: when a menu (context or dropdown) closes, its focus
|
|
// management can momentarily steal focus from this input before the user
|
|
// has a chance to type. During the grace window we re-focus on blur instead
|
|
// of auto-submitting, which would dismiss the empty input.
|
|
const focusSettled = useRef(false)
|
|
const focusFrame = useRef<number | null>(null)
|
|
const settleTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
const refocusFrame = useRef<number | null>(null)
|
|
const inlineInputKey = [
|
|
inlineInput.type,
|
|
inlineInput.parentPath,
|
|
inlineInput.depth,
|
|
inlineInput.existingPath ?? '',
|
|
inlineInput.existingName ?? ''
|
|
].join('\0')
|
|
|
|
const cancelRefocusFrame = useCallback((): void => {
|
|
if (refocusFrame.current !== null) {
|
|
cancelAnimationFrame(refocusFrame.current)
|
|
refocusFrame.current = null
|
|
}
|
|
}, [])
|
|
|
|
const scheduleInputRefocus = useCallback((): void => {
|
|
cancelRefocusFrame()
|
|
refocusFrame.current = requestAnimationFrame(() => {
|
|
refocusFrame.current = null
|
|
inputRef.current?.focus()
|
|
})
|
|
}, [cancelRefocusFrame])
|
|
|
|
const clearInlineInputTimers = useCallback(() => {
|
|
if (focusFrame.current !== null) {
|
|
cancelAnimationFrame(focusFrame.current)
|
|
focusFrame.current = null
|
|
}
|
|
cancelRefocusFrame()
|
|
if (blurTimeout.current) {
|
|
clearTimeout(blurTimeout.current)
|
|
blurTimeout.current = null
|
|
}
|
|
if (settleTimer.current) {
|
|
clearTimeout(settleTimer.current)
|
|
settleTimer.current = null
|
|
}
|
|
}, [cancelRefocusFrame])
|
|
|
|
const setInputRef = useCallback(
|
|
(el: HTMLInputElement | null): void => {
|
|
inputRef.current = el
|
|
clearInlineInputTimers()
|
|
if (!el) {
|
|
return
|
|
}
|
|
|
|
submitted.current = false
|
|
focusSettled.current = false
|
|
|
|
// Schedule focus after any pending focus-restore from menu close
|
|
focusFrame.current = requestAnimationFrame(() => {
|
|
focusFrame.current = null
|
|
if (inputRef.current !== el) {
|
|
return
|
|
}
|
|
el.focus()
|
|
if (inlineInput.type === 'rename' && inlineInput.existingName) {
|
|
const dotIndex = inlineInput.existingName.lastIndexOf('.')
|
|
if (dotIndex > 0) {
|
|
el.setSelectionRange(0, dotIndex)
|
|
} else {
|
|
el.select()
|
|
}
|
|
}
|
|
// Allow enough time for the menu close focus management to finish
|
|
// before treating blur events as intentional user actions.
|
|
settleTimer.current = setTimeout(() => {
|
|
settleTimer.current = null
|
|
focusSettled.current = true
|
|
}, 200)
|
|
})
|
|
},
|
|
[clearInlineInputTimers, inlineInput.existingName, inlineInput.type]
|
|
)
|
|
|
|
const clearBlurTimeout = useCallback(() => {
|
|
if (blurTimeout.current) {
|
|
clearTimeout(blurTimeout.current)
|
|
blurTimeout.current = null
|
|
}
|
|
}, [])
|
|
|
|
const submit = useCallback(
|
|
(value: string) => {
|
|
if (submitted.current) {
|
|
return
|
|
}
|
|
submitted.current = true
|
|
clearBlurTimeout()
|
|
onSubmit(value)
|
|
},
|
|
[onSubmit, clearBlurTimeout]
|
|
)
|
|
|
|
return (
|
|
<div
|
|
className="flex items-center w-full h-[26px] px-2 gap-1"
|
|
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
|
>
|
|
<span className="size-3 shrink-0" />
|
|
{inlineInput.type === 'folder' ? (
|
|
<Folder className="size-3 shrink-0 text-muted-foreground" />
|
|
) : (
|
|
<File className="size-3 shrink-0 text-muted-foreground" />
|
|
)}
|
|
<input
|
|
key={inlineInputKey}
|
|
ref={setInputRef}
|
|
className="flex-1 min-w-0 bg-transparent text-xs text-foreground outline-none border border-ring rounded-sm px-1"
|
|
defaultValue={inlineInput.type === 'rename' ? inlineInput.existingName : ''}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
submit(e.currentTarget.value)
|
|
} else if (e.key === 'Escape') {
|
|
clearBlurTimeout()
|
|
submitted.current = true
|
|
onCancel()
|
|
}
|
|
}}
|
|
onFocus={clearBlurTimeout}
|
|
onBlur={(e) => {
|
|
// During the grace period after mount, menu close focus management
|
|
// may shift focus away before the user can type. Re-focus instead of
|
|
// dismissing the still-empty input. Past that window a blur is the
|
|
// user leaving, so commit like Finder does rather than clinging to
|
|
// the edit state — every row is itself a context-menu trigger, so
|
|
// relatedTarget can't tell an ordinary row click from Radix cleanup.
|
|
if (!focusSettled.current) {
|
|
scheduleInputRefocus()
|
|
return
|
|
}
|
|
const value = e.currentTarget.value
|
|
blurTimeout.current = setTimeout(() => {
|
|
blurTimeout.current = null
|
|
submit(value)
|
|
}, 150)
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── File / Folder Row with Context Menu ─────────────────────────
|
|
|
|
type FileExplorerRowProps = {
|
|
node: TreeNode
|
|
isExpanded: boolean
|
|
isLoading: boolean
|
|
isSelected: boolean
|
|
isFlashing: boolean
|
|
selectedPaths: Set<string>
|
|
nodeStatus: GitFileStatus | null
|
|
statusColor: string | null
|
|
isIgnored: boolean
|
|
deleteShortcutLabel: string
|
|
connectionId?: string | null
|
|
runtimeDownloadContext?: RuntimeFileOperationArgs | null
|
|
supportsFolderDownload?: boolean
|
|
canCollapseFolderSubtree: boolean
|
|
targetDir: string
|
|
targetDepth: number
|
|
selectionSize: number
|
|
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void
|
|
onDoubleClick: () => void
|
|
onViewFile: () => void
|
|
onContextMenuSelect: () => void
|
|
onCopyPaths: (pathKind: 'absolute' | 'relative') => void
|
|
onStartNew: (type: 'file' | 'folder', dir: string, depth: number) => void
|
|
onStartRename: (node: TreeNode) => void
|
|
onDuplicate: (node: TreeNode) => void
|
|
onAddFolderAsProject: () => void
|
|
canAddAsProject: boolean
|
|
onOpenInTerminal: () => void
|
|
onRequestDelete: () => void
|
|
onCollapseFolderSubtree: () => void
|
|
onFindInFolder: () => void
|
|
onMoveDrop: (sourcePath: string, destDir: string) => void
|
|
onDragTargetChange: (dir: string | null) => void
|
|
onDragSourceChange: (path: string | null) => void
|
|
onDragExpandDir: (dirPath: string) => void
|
|
onNativeDragTargetChange: (dir: string | null) => void
|
|
onNativeDragExpandDir: (dirPath: string) => void
|
|
}
|
|
|
|
export function shouldShowCollapseFolderAction(node: TreeNode, isExpanded: boolean): boolean {
|
|
return node.isDirectory && isExpanded
|
|
}
|
|
|
|
export function shouldShowFindInFolderAction(node: TreeNode): boolean {
|
|
return node.isDirectory
|
|
}
|
|
|
|
export function shouldShowOpenInTerminalAction(node: TreeNode): boolean {
|
|
return node.isDirectory
|
|
}
|
|
|
|
export function shouldShowViewFileAction(node: TreeNode): boolean {
|
|
return !node.isDirectory
|
|
}
|
|
|
|
export function shouldShowRemoteDownloadAction(
|
|
node: TreeNode,
|
|
connectionId?: string | null,
|
|
runtimeDownloadContext?: RuntimeFileOperationArgs | null,
|
|
// Why: fail closed — only show folder download when the connection explicitly
|
|
// advertises SFTP recursive transfer (system-SSH and unknown states stay off).
|
|
supportsFolderDownload = false
|
|
): boolean {
|
|
// Why: Desktop-only because download depends on Electron's native save/folder dialogs;
|
|
// runtime and system-SSH folders have no recursive transfer contract.
|
|
const hasDownloadCapability = node.isDirectory
|
|
? Boolean(connectionId && supportsFolderDownload)
|
|
: Boolean(connectionId || runtimeDownloadContext)
|
|
return (
|
|
hasDownloadCapability &&
|
|
(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ !== true
|
|
)
|
|
}
|
|
|
|
export function shouldShowCopyFileAction(
|
|
node: TreeNode,
|
|
connectionId?: string | null,
|
|
selectionSize = 1
|
|
): boolean {
|
|
// Why: remote directories would require recursive materialization semantics;
|
|
// keep this to a single concrete file reference until multi-file copy exists.
|
|
return (
|
|
(!connectionId || !node.isDirectory) &&
|
|
selectionSize === 1 &&
|
|
(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ !== true
|
|
)
|
|
}
|
|
|
|
function getLocalDownloadName(destinationPath: string, platform: NodeJS.Platform): string {
|
|
const lastSeparatorIndex =
|
|
platform === 'win32'
|
|
? Math.max(destinationPath.lastIndexOf('/'), destinationPath.lastIndexOf('\\'))
|
|
: destinationPath.lastIndexOf('/')
|
|
return destinationPath.slice(lastSeparatorIndex + 1)
|
|
}
|
|
|
|
export async function downloadRemoteFile(
|
|
node: TreeNode,
|
|
connectionIdOrRuntimeContext: string | RuntimeFileOperationArgs
|
|
): Promise<void> {
|
|
try {
|
|
const result =
|
|
typeof connectionIdOrRuntimeContext === 'string'
|
|
? node.isDirectory
|
|
? await window.api.fs.downloadFolder({
|
|
dirPath: node.path,
|
|
connectionId: connectionIdOrRuntimeContext
|
|
})
|
|
: await window.api.fs.downloadFile({
|
|
filePath: node.path,
|
|
connectionId: connectionIdOrRuntimeContext
|
|
})
|
|
: await downloadRuntimeFile(connectionIdOrRuntimeContext, node.path, node.name)
|
|
// Why: Suppress toasts when the user cancels the native save dialog per design.
|
|
if (result.canceled) {
|
|
return
|
|
}
|
|
// Why: POSIX permits backslashes in saved names; only Windows treats them as separators.
|
|
const savedName = getLocalDownloadName(
|
|
result.destinationPath,
|
|
window.api.platform.get().platform
|
|
)
|
|
toast.success(
|
|
node.isDirectory
|
|
? translate(
|
|
'auto.components.right.sidebar.FileExplorerRow.a4029c996b',
|
|
"Downloaded folder '{{value0}}'",
|
|
{ value0: savedName }
|
|
)
|
|
: translate(
|
|
'auto.components.right.sidebar.FileExplorerRow.bce4d4e44f',
|
|
"Downloaded '{{value0}}'",
|
|
{ value0: savedName }
|
|
),
|
|
{
|
|
action: {
|
|
label: translate('auto.components.right.sidebar.FileExplorerRow.1a3df04ae1', 'Open'),
|
|
onClick: () => {
|
|
void window.api.shell.openPath(result.destinationPath)
|
|
}
|
|
}
|
|
}
|
|
)
|
|
} catch (error) {
|
|
toast.error(
|
|
extractIpcErrorMessage(
|
|
error,
|
|
node.isDirectory
|
|
? translate(
|
|
'auto.components.right.sidebar.FileExplorerRow.f729bcd97d',
|
|
"Failed to download folder '{{value0}}'.",
|
|
{ value0: node.name }
|
|
)
|
|
: translate(
|
|
'auto.components.right.sidebar.FileExplorerRow.b3e288bf41',
|
|
"Failed to download '{{value0}}'.",
|
|
{ value0: node.name }
|
|
)
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
export async function copyFileToOsClipboard(
|
|
node: TreeNode,
|
|
connectionId?: string | null
|
|
): Promise<void> {
|
|
const failureMessage = translate(
|
|
'auto.components.right.sidebar.FileExplorerRow.b234ab25b4',
|
|
'Could not copy the file to the clipboard'
|
|
)
|
|
try {
|
|
const result = await window.api.ui.writeClipboardFile(
|
|
connectionId ? { filePath: node.path, connectionId } : node.path
|
|
)
|
|
if (!result.ok) {
|
|
toast.error(failureMessage)
|
|
}
|
|
} catch (error) {
|
|
toast.error(extractIpcErrorMessage(error, failureMessage))
|
|
}
|
|
}
|
|
|
|
export function FileExplorerRow({
|
|
node,
|
|
isExpanded,
|
|
isLoading,
|
|
isSelected,
|
|
isFlashing,
|
|
selectedPaths,
|
|
nodeStatus,
|
|
statusColor,
|
|
isIgnored,
|
|
deleteShortcutLabel,
|
|
connectionId,
|
|
runtimeDownloadContext,
|
|
supportsFolderDownload = false,
|
|
canCollapseFolderSubtree,
|
|
targetDir,
|
|
targetDepth,
|
|
selectionSize,
|
|
onClick,
|
|
onDoubleClick,
|
|
onViewFile,
|
|
onContextMenuSelect,
|
|
onCopyPaths,
|
|
onStartNew,
|
|
onStartRename,
|
|
onDuplicate,
|
|
onAddFolderAsProject,
|
|
canAddAsProject,
|
|
onOpenInTerminal,
|
|
onRequestDelete,
|
|
onCollapseFolderSubtree,
|
|
onFindInFolder,
|
|
onMoveDrop,
|
|
onDragTargetChange,
|
|
onDragSourceChange,
|
|
onDragExpandDir,
|
|
onNativeDragTargetChange,
|
|
onNativeDragExpandDir
|
|
}: FileExplorerRowProps): React.JSX.Element {
|
|
const openMarkdownPreview = useAppStore((s) => s.openMarkdownPreview)
|
|
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
|
|
const copyPathShortcutLabel = useShortcutLabel('fileExplorer.copyPath')
|
|
const copyRelativePathShortcutLabel = useShortcutLabel('fileExplorer.copyRelativePath')
|
|
const findInFolderShortcutLabel = useShortcutLabel('sidebar.search.toggle')
|
|
const FileIcon = getFileTypeIcon(node.relativePath || node.name)
|
|
const rowDropDir = node.isDirectory ? node.path : targetDir
|
|
const showRemoteDownloadAction = shouldShowRemoteDownloadAction(
|
|
node,
|
|
connectionId,
|
|
runtimeDownloadContext,
|
|
supportsFolderDownload
|
|
)
|
|
const showCopyFileAction = shouldShowCopyFileAction(node, connectionId, selectionSize)
|
|
const { setRowDragNode, handleDragOver, handleDragEnter, handleDragLeave, handleDrop } =
|
|
useFileExplorerRowDrag({
|
|
rowDropDir,
|
|
isDirectory: node.isDirectory,
|
|
nodePath: node.path,
|
|
isExpanded,
|
|
onDragTargetChange,
|
|
onDragExpandDir,
|
|
onNativeDragTargetChange,
|
|
onNativeDragExpandDir,
|
|
onMoveDrop
|
|
})
|
|
const handleOpenInOrcaBrowser = useCallback(() => {
|
|
if (!activeWorktreeId) {
|
|
return
|
|
}
|
|
const result = openFileInBrowserTab({ filePath: node.path, worktreeId: activeWorktreeId })
|
|
if (result.status === 'unsupported') {
|
|
toast.error(result.message)
|
|
}
|
|
}, [activeWorktreeId, node.path])
|
|
const handleDownload = useCallback(() => {
|
|
const downloadTarget = connectionId || runtimeDownloadContext
|
|
if (!downloadTarget) {
|
|
return
|
|
}
|
|
void downloadRemoteFile(node, downloadTarget)
|
|
}, [connectionId, node, runtimeDownloadContext])
|
|
const handleCopyFile = useCallback(() => {
|
|
void copyFileToOsClipboard(node, connectionId)
|
|
}, [connectionId, node])
|
|
|
|
return (
|
|
<ContextMenu
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
return
|
|
}
|
|
window.dispatchEvent(new Event(CLOSE_ALL_CONTEXT_MENUS_EVENT))
|
|
onContextMenuSelect()
|
|
}}
|
|
>
|
|
<ContextMenuTrigger asChild>
|
|
<button
|
|
data-file-explorer-row=""
|
|
data-selected={isSelected ? 'true' : undefined}
|
|
className={cn(
|
|
'flex w-full items-center gap-1 rounded-sm px-2 py-1 text-left text-xs transition-colors',
|
|
!isSelected && 'hover:bg-accent hover:text-foreground',
|
|
isSelected && 'text-accent-foreground',
|
|
isFlashing && 'bg-amber-400/20 ring-1 ring-inset ring-amber-400/70'
|
|
)}
|
|
style={{ paddingLeft: `${node.depth * 16 + 8}px` }}
|
|
ref={setRowDragNode}
|
|
data-native-file-drop-dir={rowDropDir}
|
|
// Why: marks this draggable row so the wheel-capture handler can rescue
|
|
// scroll Chromium swallows over draggable nodes (file-explorer-drag-scroll-marker).
|
|
data-explorer-draggable="true"
|
|
draggable
|
|
onDragStart={(event) => {
|
|
const paths =
|
|
selectedPaths.has(node.path) && selectedPaths.size > 1
|
|
? [...selectedPaths]
|
|
: [node.path]
|
|
event.dataTransfer.setData(WORKSPACE_FILE_PATH_MIME, node.path)
|
|
if (paths.length > 1) {
|
|
event.dataTransfer.setData(WORKSPACE_FILE_PATHS_MIME, encodeWorkspaceFilePaths(paths))
|
|
}
|
|
event.dataTransfer.effectAllowed = 'copyMove'
|
|
onDragSourceChange(node.path)
|
|
|
|
if (paths.length > 1) {
|
|
const MAX_SHOWN = 5
|
|
const btn = event.currentTarget
|
|
const rowW = btn.getBoundingClientRect().width
|
|
|
|
// Why: drag images are detached DOM nodes, so inline the same
|
|
// file glyph the real row renders.
|
|
const FILE_ICON =
|
|
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><polyline points="14 2 14 8 20 8"/></svg>'
|
|
|
|
const makeRow = (label: string, faded = false): HTMLDivElement => {
|
|
const row = document.createElement('div')
|
|
row.style.cssText = `display:flex;align-items:center;gap:4px;height:26px;padding:4px 8px;width:${rowW}px;box-sizing:border-box;font-size:12px;border-radius:2px;background:var(--accent);color:var(--accent-foreground);${faded ? 'opacity:0.6;' : ''}`
|
|
const spacer = document.createElement('span')
|
|
spacer.style.cssText = 'width:12px;height:12px;flex-shrink:0;'
|
|
row.appendChild(spacer)
|
|
const icon = document.createElement('span')
|
|
icon.style.cssText =
|
|
'width:12px;height:12px;flex-shrink:0;display:flex;align-items:center;color:var(--muted-foreground);'
|
|
icon.innerHTML = FILE_ICON
|
|
row.appendChild(icon)
|
|
const name = document.createElement('span')
|
|
name.style.cssText = 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'
|
|
name.textContent = label
|
|
row.appendChild(name)
|
|
return row
|
|
}
|
|
|
|
const ghost = document.createElement('div')
|
|
ghost.style.cssText =
|
|
'position:fixed;top:-9999px;left:-9999px;pointer-events:none;display:flex;flex-direction:column;gap:1px;'
|
|
|
|
for (const p of paths.slice(0, MAX_SHOWN)) {
|
|
ghost.appendChild(makeRow(basename(p)))
|
|
}
|
|
if (paths.length > MAX_SHOWN) {
|
|
ghost.appendChild(makeRow(`+${paths.length - MAX_SHOWN} more`, true))
|
|
}
|
|
|
|
document.body.appendChild(ghost)
|
|
event.dataTransfer.setDragImage(ghost, 12, 12)
|
|
setTimeout(() => document.body.removeChild(ghost), 0)
|
|
}
|
|
}}
|
|
onDragEnd={() => onDragSourceChange(null)}
|
|
onDragOver={handleDragOver}
|
|
onDragEnter={handleDragEnter}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
onClick={(e) => onClick(e)}
|
|
onDoubleClick={onDoubleClick}
|
|
>
|
|
{node.isDirectory ? (
|
|
<>
|
|
<ChevronRight
|
|
className={cn(
|
|
'size-3 shrink-0 text-muted-foreground transition-transform',
|
|
isExpanded && 'rotate-90'
|
|
)}
|
|
/>
|
|
{isLoading ? (
|
|
<Loader2 className="size-3 shrink-0 animate-spin text-muted-foreground" />
|
|
) : isExpanded ? (
|
|
<FolderOpen className="size-3 shrink-0 text-muted-foreground" />
|
|
) : (
|
|
<Folder className="size-3 shrink-0 text-muted-foreground" />
|
|
)}
|
|
</>
|
|
) : (
|
|
<>
|
|
<span className="size-3 shrink-0" />
|
|
{node.isSymlink ? (
|
|
<Link className="size-3 shrink-0 text-muted-foreground" />
|
|
) : (
|
|
<FileIcon className="size-3 shrink-0 text-muted-foreground" />
|
|
)}
|
|
</>
|
|
)}
|
|
<span
|
|
// Why: marks the rename hotspot so the row's click handler can hold
|
|
// back the directory toggle until the double-click window closes.
|
|
{...{ [RENAME_HOTSPOT_ATTR]: '' }}
|
|
className={cn(
|
|
'truncate',
|
|
isSelected && !nodeStatus && !isIgnored && 'text-accent-foreground',
|
|
// Why: italic glyphs overhang their advance width; truncate's
|
|
// overflow:hidden clips it, shaving the last char (".md" → ".ma").
|
|
// pr-0.5 reserves room for the slant so the final letter survives.
|
|
isIgnored && 'italic pr-0.5'
|
|
)}
|
|
style={
|
|
nodeStatus
|
|
? { color: statusColor ?? undefined }
|
|
: isIgnored
|
|
? { color: 'var(--git-decoration-ignored)' }
|
|
: undefined
|
|
}
|
|
onDoubleClick={(e) => {
|
|
// Why: scope rename to the filename text so "pin preview" and the
|
|
// directory toggle stay reachable on the icon and empty row area.
|
|
e.stopPropagation()
|
|
onStartRename(node)
|
|
}}
|
|
>
|
|
{node.name}
|
|
</span>
|
|
{nodeStatus ? (
|
|
<span
|
|
className="ml-auto shrink-0 text-[10px] font-semibold tracking-wide mr-2"
|
|
style={{ color: statusColor ?? undefined }}
|
|
>
|
|
{STATUS_LABELS[nodeStatus]}
|
|
</span>
|
|
) : isIgnored ? (
|
|
<CircleSlash
|
|
aria-label={translate(
|
|
'auto.components.right.sidebar.FileExplorerRow.e26010014a',
|
|
'Ignored by .gitignore'
|
|
)}
|
|
className="ml-auto size-3 shrink-0 mr-2"
|
|
style={{ color: 'var(--git-decoration-ignored)' }}
|
|
/>
|
|
) : null}
|
|
</button>
|
|
</ContextMenuTrigger>
|
|
<ContextMenuContent
|
|
className="w-64 bg-[rgba(255,255,255,0.82)] dark:bg-[rgba(0,0,0,0.72)]"
|
|
onPointerUpCapture={stopRightButtonMenuSelection}
|
|
onCloseAutoFocus={(e) => e.preventDefault()}
|
|
>
|
|
<ContextMenuItem onSelect={() => onStartNew('file', targetDir, targetDepth)}>
|
|
<FilePlus />
|
|
{translate('auto.components.right.sidebar.FileExplorerRow.37c875d827', 'New File')}
|
|
</ContextMenuItem>
|
|
<ContextMenuItem onSelect={() => onStartNew('folder', targetDir, targetDepth)}>
|
|
<FolderPlus />
|
|
{translate('auto.components.right.sidebar.FileExplorerRow.f61af83316', 'New Folder')}
|
|
</ContextMenuItem>
|
|
<ContextMenuSeparator />
|
|
{showCopyFileAction && (
|
|
<ContextMenuItem onSelect={handleCopyFile}>
|
|
<Copy />
|
|
{translate('auto.components.right.sidebar.FileExplorerRow.98a79948b3', 'Copy')}
|
|
</ContextMenuItem>
|
|
)}
|
|
<ContextMenuItem onSelect={() => onCopyPaths('absolute')}>
|
|
<Copy />
|
|
{selectionSize > 1
|
|
? translate('auto.components.right.sidebar.FileExplorerRow.f9d7ca753d', 'Copy Paths')
|
|
: translate('auto.components.right.sidebar.FileExplorerRow.b5d436aa30', 'Copy Path')}
|
|
{copyPathShortcutLabel !== 'Unassigned' ? (
|
|
<ContextMenuShortcut>{copyPathShortcutLabel}</ContextMenuShortcut>
|
|
) : null}
|
|
</ContextMenuItem>
|
|
<ContextMenuItem onSelect={() => onCopyPaths('relative')}>
|
|
<Copy />
|
|
{selectionSize > 1
|
|
? translate(
|
|
'auto.components.right.sidebar.FileExplorerRow.42e10cbf57',
|
|
'Copy Relative Paths'
|
|
)
|
|
: translate(
|
|
'auto.components.right.sidebar.FileExplorerRow.66a29dde82',
|
|
'Copy Relative Path'
|
|
)}
|
|
{copyRelativePathShortcutLabel !== 'Unassigned' ? (
|
|
<ContextMenuShortcut>{copyRelativePathShortcutLabel}</ContextMenuShortcut>
|
|
) : null}
|
|
</ContextMenuItem>
|
|
{!node.isDirectory && (
|
|
<ContextMenuItem onSelect={() => onDuplicate(node)}>
|
|
<Files />
|
|
{translate('auto.components.right.sidebar.FileExplorerRow.0fec99bfd7', 'Duplicate')}
|
|
</ContextMenuItem>
|
|
)}
|
|
{canAddAsProject && (
|
|
<ContextMenuItem onSelect={onAddFolderAsProject}>
|
|
<FolderPlus />
|
|
{translate(
|
|
'auto.components.right.sidebar.FileExplorerRow.1bb9be455c',
|
|
'Add as Project...'
|
|
)}
|
|
</ContextMenuItem>
|
|
)}
|
|
{shouldShowOpenInTerminalAction(node) && (
|
|
<ContextMenuItem onSelect={onOpenInTerminal}>
|
|
<SquareTerminal />
|
|
{translate(
|
|
'auto.components.right.sidebar.FileExplorerRow.e887fa4b2e',
|
|
'Open in Terminal'
|
|
)}
|
|
</ContextMenuItem>
|
|
)}
|
|
{shouldShowViewFileAction(node) && (
|
|
<ContextMenuItem onSelect={onViewFile}>
|
|
<File />
|
|
{translate('auto.components.right.sidebar.FileExplorerRow.1d8e182c32', 'View File')}
|
|
</ContextMenuItem>
|
|
)}
|
|
{!node.isDirectory && activeWorktreeId && (
|
|
<ContextMenuItem onSelect={handleOpenInOrcaBrowser}>
|
|
<Globe />
|
|
{translate(
|
|
'auto.components.right.sidebar.FileExplorerRow.dd112c81d2',
|
|
'Open in Orca Browser'
|
|
)}
|
|
</ContextMenuItem>
|
|
)}
|
|
{!node.isDirectory && activeWorktreeId && detectLanguage(node.path) === 'markdown' && (
|
|
<ContextMenuItem
|
|
onSelect={() =>
|
|
openMarkdownPreview({
|
|
filePath: node.path,
|
|
relativePath: node.relativePath,
|
|
worktreeId: activeWorktreeId,
|
|
language: 'markdown'
|
|
})
|
|
}
|
|
>
|
|
<Eye />
|
|
{translate(
|
|
'auto.components.right.sidebar.FileExplorerRow.d87a4c42e1',
|
|
'Open Markdown Preview'
|
|
)}
|
|
</ContextMenuItem>
|
|
)}
|
|
{showRemoteDownloadAction && (
|
|
<ContextMenuItem onSelect={handleDownload}>
|
|
<Download />
|
|
{node.isDirectory
|
|
? translate(
|
|
'auto.components.right.sidebar.FileExplorerRow.7ac885bd2f',
|
|
'Download Folder'
|
|
)
|
|
: translate('auto.components.right.sidebar.FileExplorerRow.c2112579f6', 'Download')}
|
|
</ContextMenuItem>
|
|
)}
|
|
{canCollapseFolderSubtree && shouldShowCollapseFolderAction(node, isExpanded) && (
|
|
<ContextMenuItem onSelect={onCollapseFolderSubtree}>
|
|
<ListCollapse />
|
|
{translate(
|
|
'auto.components.right.sidebar.FileExplorerRow.d6a25618aa',
|
|
'Collapse Folder'
|
|
)}
|
|
</ContextMenuItem>
|
|
)}
|
|
{shouldShowFindInFolderAction(node) && (
|
|
<ContextMenuItem onSelect={onFindInFolder}>
|
|
<Search />
|
|
{translate(
|
|
'auto.components.right.sidebar.FileExplorerRow.0df0e5abac',
|
|
'Find in Folder'
|
|
)}
|
|
{findInFolderShortcutLabel !== 'Unassigned' ? (
|
|
<ContextMenuShortcut>{findInFolderShortcutLabel}</ContextMenuShortcut>
|
|
) : null}
|
|
</ContextMenuItem>
|
|
)}
|
|
<ContextMenuItem
|
|
onSelect={() => {
|
|
const state = useAppStore.getState()
|
|
const activeWorktree = Object.values(state.worktreesByRepo)
|
|
.flat()
|
|
.find((worktree) => worktree.id === activeWorktreeId)
|
|
const activeRepo = activeWorktree
|
|
? state.repos.find((repo) => repo.id === activeWorktree.repoId)
|
|
: null
|
|
if (
|
|
isLocalPathOpenBlocked(state.settings, {
|
|
connectionId: activeRepo?.connectionId ?? null
|
|
})
|
|
) {
|
|
showLocalPathOpenBlockedToast()
|
|
return
|
|
}
|
|
window.api.shell.openPath(node.path)
|
|
}}
|
|
>
|
|
<ExternalLink />
|
|
{revealLabel}
|
|
</ContextMenuItem>
|
|
<ContextMenuSeparator />
|
|
<ContextMenuItem onSelect={() => onStartRename(node)}>
|
|
<Pencil />
|
|
{translate('auto.components.right.sidebar.FileExplorerRow.fc747429bf', 'Rename')}
|
|
<ContextMenuShortcut>
|
|
{isMac
|
|
? '↩'
|
|
: translate('auto.components.right.sidebar.FileExplorerRow.a06551beee', 'Enter')}
|
|
</ContextMenuShortcut>
|
|
</ContextMenuItem>
|
|
<ContextMenuItem variant="destructive" onSelect={onRequestDelete}>
|
|
<Trash2 />
|
|
{translate('auto.components.right.sidebar.FileExplorerRow.addc01145f', 'Delete')}
|
|
<ContextMenuShortcut>{deleteShortcutLabel}</ContextMenuShortcut>
|
|
</ContextMenuItem>
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
)
|
|
}
|