229 lines
6.0 KiB
TypeScript
229 lines
6.0 KiB
TypeScript
import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react'
|
|
import {
|
|
CaretDown,
|
|
CaretRight,
|
|
Folder,
|
|
FolderOpen,
|
|
PencilSimple,
|
|
Trash,
|
|
} from '@phosphor-icons/react'
|
|
import { Button } from '@/components/ui/button'
|
|
import { cn } from '@/lib/utils'
|
|
import type { FolderNode } from '../../types'
|
|
import { useFolderRowInteractions } from './useFolderRowInteractions'
|
|
|
|
interface FolderItemRowProps {
|
|
contentInset: number
|
|
depthIndent: number
|
|
isExpanded: boolean
|
|
isSelected: boolean
|
|
node: FolderNode
|
|
onDeleteFolder?: (folderPath: string) => void
|
|
onOpenMenu: (node: FolderNode, event: ReactMouseEvent<HTMLDivElement>) => void
|
|
onSelect: () => void
|
|
onStartRenameFolder?: (folderPath: string) => void
|
|
onToggle: (path: string) => void
|
|
}
|
|
|
|
export function FolderItemRow({
|
|
contentInset,
|
|
depthIndent,
|
|
isExpanded,
|
|
isSelected,
|
|
node,
|
|
onDeleteFolder,
|
|
onOpenMenu,
|
|
onSelect,
|
|
onStartRenameFolder,
|
|
onToggle,
|
|
}: FolderItemRowProps) {
|
|
const hasChildren = node.children.length > 0
|
|
const expandLabel = isExpanded ? `Collapse ${node.name}` : `Expand ${node.name}`
|
|
const hasActions = !!onStartRenameFolder || !!onDeleteFolder
|
|
const { handleRenameDoubleClick, handleSelectClick } = useFolderRowInteractions({
|
|
hasChildren,
|
|
onRenameFolder: onStartRenameFolder ? () => onStartRenameFolder(node.path) : undefined,
|
|
onSelect,
|
|
onToggle: () => onToggle(node.path),
|
|
})
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'group relative flex items-center gap-1 rounded transition-colors',
|
|
isSelected
|
|
? 'bg-[var(--accent-blue-light,rgba(0,100,255,0.08))] text-primary'
|
|
: 'text-foreground hover:bg-accent',
|
|
)}
|
|
style={{ paddingLeft: depthIndent, borderRadius: 4 }}
|
|
onContextMenu={(event) => {
|
|
onSelect()
|
|
onOpenMenu(node, event)
|
|
}}
|
|
>
|
|
<FolderToggleButton
|
|
expandLabel={expandLabel}
|
|
hasChildren={hasChildren}
|
|
isExpanded={isExpanded}
|
|
onToggle={() => onToggle(node.path)}
|
|
/>
|
|
<FolderSelectButton
|
|
contentInset={contentInset}
|
|
hasActions={hasActions}
|
|
hasChildren={hasChildren}
|
|
isExpanded={isExpanded}
|
|
isSelected={isSelected}
|
|
node={node}
|
|
onClick={handleSelectClick}
|
|
onDoubleClick={handleRenameDoubleClick}
|
|
/>
|
|
{hasActions && (
|
|
<div className="pointer-events-none absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100">
|
|
{onStartRenameFolder && (
|
|
<FolderActionButton
|
|
ariaLabel={`Rename ${node.name}`}
|
|
testId={`rename-folder-btn:${node.path}`}
|
|
title="Rename folder"
|
|
onClick={() => {
|
|
onSelect()
|
|
onStartRenameFolder(node.path)
|
|
}}
|
|
>
|
|
<PencilSimple size={12} />
|
|
</FolderActionButton>
|
|
)}
|
|
{onDeleteFolder && (
|
|
<FolderActionButton
|
|
ariaLabel={`Delete ${node.name}`}
|
|
testId={`delete-folder-btn:${node.path}`}
|
|
title="Delete folder"
|
|
destructive
|
|
onClick={() => {
|
|
onSelect()
|
|
onDeleteFolder(node.path)
|
|
}}
|
|
>
|
|
<Trash size={12} />
|
|
</FolderActionButton>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function FolderToggleButton({
|
|
expandLabel,
|
|
hasChildren,
|
|
isExpanded,
|
|
onToggle,
|
|
}: {
|
|
expandLabel: string
|
|
hasChildren: boolean
|
|
isExpanded: boolean
|
|
onToggle: () => void
|
|
}) {
|
|
if (!hasChildren) return null
|
|
|
|
return (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
className="h-6 w-4 shrink-0 p-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
|
|
onClick={(event) => {
|
|
event.stopPropagation()
|
|
onToggle()
|
|
}}
|
|
aria-label={expandLabel}
|
|
>
|
|
{isExpanded ? <CaretDown size={12} /> : <CaretRight size={12} />}
|
|
</Button>
|
|
)
|
|
}
|
|
|
|
function FolderActionButton({
|
|
ariaLabel,
|
|
children,
|
|
destructive = false,
|
|
onClick,
|
|
testId,
|
|
title,
|
|
}: {
|
|
ariaLabel: string
|
|
children: ReactNode
|
|
destructive?: boolean
|
|
onClick: () => void
|
|
testId: string
|
|
title: string
|
|
}) {
|
|
return (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
aria-label={ariaLabel}
|
|
title={title}
|
|
className={cn(
|
|
'h-5 w-5 rounded p-0 text-muted-foreground',
|
|
destructive ? 'hover:text-destructive' : 'hover:text-foreground',
|
|
)}
|
|
data-testid={testId}
|
|
onClick={(event) => {
|
|
event.stopPropagation()
|
|
onClick()
|
|
}}
|
|
>
|
|
{children}
|
|
</Button>
|
|
)
|
|
}
|
|
|
|
function FolderSelectButton({
|
|
contentInset,
|
|
hasActions,
|
|
hasChildren,
|
|
isExpanded,
|
|
isSelected,
|
|
node,
|
|
onClick,
|
|
onDoubleClick,
|
|
}: {
|
|
contentInset: number
|
|
hasActions: boolean
|
|
hasChildren: boolean
|
|
isExpanded: boolean
|
|
isSelected: boolean
|
|
node: FolderNode
|
|
onClick: (clickDetail: number) => void
|
|
onDoubleClick: () => void
|
|
}) {
|
|
return (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
className={cn(
|
|
'h-auto flex-1 justify-start gap-2 rounded text-left text-[13px] font-medium hover:bg-transparent',
|
|
isSelected ? 'text-primary hover:text-primary' : 'text-foreground hover:text-foreground',
|
|
)}
|
|
style={{
|
|
paddingTop: 6,
|
|
paddingBottom: 6,
|
|
paddingLeft: hasChildren ? 0 : contentInset,
|
|
paddingRight: hasActions ? 48 : 16,
|
|
}}
|
|
title={node.path}
|
|
onClick={(event) => onClick(event.detail)}
|
|
onDoubleClick={onDoubleClick}
|
|
data-testid={`folder-row:${node.path}`}
|
|
>
|
|
{isSelected || isExpanded ? (
|
|
<FolderOpen size={17} weight="fill" className="size-[17px] shrink-0" />
|
|
) : (
|
|
<Folder size={17} className="size-[17px] shrink-0" />
|
|
)}
|
|
<span className="truncate">{node.name}</span>
|
|
</Button>
|
|
)
|
|
}
|