b1af653bf6
fix(desktop): Harden local file tree paths (#43618)
* fix(desktop): Harden local file tree paths

Normalize Electron local path handling across file tree, preview, media, and git-root flows. Reject malformed and Windows device paths, recheck sensitive files after realpath resolution, and preserve external symlink traversal with stable renderer errors.

* fix(desktop): Address file tree review feedback
2026-06-11 10:05:59 -04:00

228 lines
6.2 KiB
TypeScript

import { useCallback, useRef, useState } from 'react'
import { type NodeApi, type NodeRendererProps, Tree, type TreeApi } from 'react-arborist'
import { PageLoader } from '@/components/page-loader'
import { Codicon } from '@/components/ui/codicon'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import type { TreeNode } from './use-project-tree'
const ROW_HEIGHT = 22
const INDENT = 10
interface ProjectTreeProps {
collapseNonce: number
cwd: string
data: TreeNode[]
onActivateFile: (path: string) => void
onActivateFolder: (path: string) => void
onLoadChildren: (id: string) => void | Promise<void>
onNodeOpenChange: (id: string, open: boolean) => void
onPreviewFile?: (path: string) => void
openState: Record<string, boolean>
}
export function ProjectTree({
collapseNonce,
cwd,
data,
onActivateFile,
onActivateFolder,
onLoadChildren,
onNodeOpenChange,
onPreviewFile,
openState
}: ProjectTreeProps) {
const containerRef = useRef<HTMLDivElement | null>(null)
const treeRef = useRef<TreeApi<TreeNode> | null>(null)
const [size, setSize] = useState({ height: 0, width: 0 })
const syncTreeSize = useCallback(() => {
const el = containerRef.current
if (!el) {
return
}
const { height, width } = el.getBoundingClientRect()
setSize(prev => {
if (prev.height === height && prev.width === width) {
return prev
}
return { height, width }
})
}, [])
useResizeObserver(syncTreeSize, containerRef)
const handleToggle = useCallback(
(id: string) => {
const node = treeRef.current?.get(id)
if (!node) {
return
}
onNodeOpenChange(id, node.isOpen)
if (node.isOpen && node.data?.isDirectory && node.data.children === undefined) {
void onLoadChildren(id)
}
},
[onLoadChildren, onNodeOpenChange]
)
const handleActivate = useCallback(
(node: NodeApi<TreeNode>) => {
if (node.data && !node.data.isDirectory) {
onPreviewFile?.(node.data.id)
}
},
[onPreviewFile]
)
return (
<div className="min-h-0 flex-1 overflow-hidden" ref={containerRef}>
{size.height > 0 && size.width > 0 ? (
<Tree<TreeNode>
childrenAccessor={node => (node?.isDirectory ? (node.children ?? []) : null)}
data={data}
disableDrag
disableDrop
disableEdit
height={size.height}
indent={INDENT}
initialOpenState={openState}
key={`${cwd}:${collapseNonce}`}
onActivate={handleActivate}
onToggle={handleToggle}
openByDefault={false}
padding={0}
ref={treeRef}
rowHeight={ROW_HEIGHT}
width={size.width}
>
{props => (
<ProjectTreeRow
{...props}
onAttachFile={onActivateFile}
onAttachFolder={onActivateFolder}
onPreviewFile={onPreviewFile}
/>
)}
</Tree>
) : (
<TreeSizingState />
)}
</div>
)
}
function TreeSizingState() {
const { t } = useI18n()
return <PageLoader aria-label={t.rightSidebar.loadingFiles} className="min-h-24 px-3" />
}
function ProjectTreeRow({
dragHandle,
node,
onAttachFile,
onAttachFolder,
onPreviewFile,
style
}: NodeRendererProps<TreeNode> & {
onAttachFile: (path: string) => void
onAttachFolder: (path: string) => void
onPreviewFile?: (path: string) => void
}) {
if (!node.data) {
return <div style={style} />
}
const isFolder = node.data.isDirectory
const isPlaceholder = Boolean(node.data.placeholder)
const isErrorPlaceholder = node.data.placeholder === 'error'
return (
<div
aria-expanded={isFolder ? node.isOpen : undefined}
aria-selected={node.isSelected}
className={cn(
'group/row flex h-full cursor-pointer select-none items-center gap-1 border border-transparent px-3 text-xs font-normal leading-(--file-tree-row-height) text-(--ui-text-secondary) transition-colors hover:bg-(--ui-row-hover-background) hover:text-foreground',
node.isSelected && 'bg-(--ui-row-active-background) text-foreground',
isPlaceholder && 'pointer-events-none italic text-muted-foreground/70'
)}
draggable={!isPlaceholder}
onClick={event => {
event.stopPropagation()
if (isPlaceholder) {
return
}
if (event.shiftKey) {
;(isFolder ? onAttachFolder : onAttachFile)(node.data.id)
return
}
if (isFolder) {
node.toggle()
} else {
node.select()
}
}}
onDoubleClick={event => {
event.stopPropagation()
if (!isFolder && !isPlaceholder) {
onPreviewFile?.(node.data.id)
}
}}
onDragStart={event => {
if (isPlaceholder) {
event.preventDefault()
return
}
const payload = JSON.stringify([{ isDirectory: isFolder, path: node.data.id }])
event.dataTransfer.effectAllowed = 'copy'
event.dataTransfer.setData('application/x-hermes-paths', payload)
event.dataTransfer.setData('text/plain', node.data.id)
}}
ref={dragHandle}
style={style}
>
{isFolder && !isPlaceholder && (
<span aria-hidden className="flex w-3 items-center justify-center">
<Codicon
className="text-(--ui-text-tertiary)"
name={node.isOpen ? 'chevron-down' : 'chevron-right'}
size="0.75rem"
/>
</span>
)}
{!isFolder && <span aria-hidden className="w-3 shrink-0" />}
<span aria-hidden className="flex w-3.5 items-center justify-center text-(--ui-text-tertiary)">
{isPlaceholder && !isErrorPlaceholder ? (
<Codicon name="loading" size="0.75rem" spinning />
) : isErrorPlaceholder ? (
<Codicon name="warning" size="0.75rem" />
) : isFolder ? (
<Codicon name={node.isOpen ? 'folder-opened' : 'folder'} size="0.875rem" />
) : (
<Codicon name="file" size="0.875rem" />
)}
</span>
<span className="min-w-0 flex-1 truncate">{node.data.name}</span>
</div>
)
}