2026-04-22 22:49:16 +02:00
|
|
|
import { memo, useCallback, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react'
|
2026-04-22 17:16:56 +02:00
|
|
|
import {
|
|
|
|
|
CaretDown,
|
|
|
|
|
CaretRight,
|
|
|
|
|
Folder,
|
|
|
|
|
FolderOpen,
|
2026-04-22 22:49:16 +02:00
|
|
|
PencilSimple,
|
|
|
|
|
Trash,
|
2026-04-22 17:16:56 +02:00
|
|
|
} from '@phosphor-icons/react'
|
|
|
|
|
import { Button } from '@/components/ui/button'
|
|
|
|
|
import { cn } from '@/lib/utils'
|
|
|
|
|
import type { FolderNode, SidebarSelection } from '../../types'
|
2026-04-22 23:56:00 +02:00
|
|
|
import { NoteDropTarget } from '../note-retargeting/NoteDropTarget'
|
|
|
|
|
import { useNoteRetargetingContext } from '../note-retargeting/noteRetargetingContext'
|
2026-04-22 17:16:56 +02:00
|
|
|
import { FolderNameInput } from './FolderNameInput'
|
|
|
|
|
|
|
|
|
|
interface FolderTreeRowProps {
|
|
|
|
|
depth: number
|
|
|
|
|
expanded: Record<string, boolean>
|
|
|
|
|
node: FolderNode
|
|
|
|
|
onDeleteFolder?: (folderPath: string) => void
|
|
|
|
|
onOpenMenu: (node: FolderNode, event: ReactMouseEvent<HTMLDivElement>) => void
|
|
|
|
|
onRenameFolder?: (folderPath: string, nextName: string) => Promise<boolean> | boolean
|
|
|
|
|
onSelect: (selection: SidebarSelection) => void
|
|
|
|
|
onStartRenameFolder?: (folderPath: string) => void
|
|
|
|
|
onToggle: (path: string) => void
|
|
|
|
|
onCancelRenameFolder?: () => void
|
|
|
|
|
renamingFolderPath?: string | null
|
|
|
|
|
selection: SidebarSelection
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function FolderRenameRow({
|
2026-04-23 00:21:20 +02:00
|
|
|
contentInset,
|
|
|
|
|
depthIndent,
|
2026-04-22 17:16:56 +02:00
|
|
|
node,
|
|
|
|
|
onCancelRenameFolder,
|
|
|
|
|
onRenameFolder,
|
|
|
|
|
}: {
|
2026-04-23 00:21:20 +02:00
|
|
|
contentInset: number
|
|
|
|
|
depthIndent: number
|
2026-04-22 17:16:56 +02:00
|
|
|
node: FolderNode
|
|
|
|
|
onCancelRenameFolder: () => void
|
|
|
|
|
onRenameFolder: (folderPath: string, nextName: string) => Promise<boolean> | boolean
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
2026-04-23 00:21:20 +02:00
|
|
|
<div style={{ paddingLeft: depthIndent }}>
|
2026-04-22 17:16:56 +02:00
|
|
|
<FolderNameInput
|
|
|
|
|
ariaLabel="Folder name"
|
|
|
|
|
initialValue={node.name}
|
|
|
|
|
placeholder="Folder name"
|
2026-04-23 00:21:20 +02:00
|
|
|
leftInset={contentInset}
|
2026-04-22 17:16:56 +02:00
|
|
|
selectTextOnFocus={true}
|
|
|
|
|
testId="rename-folder-input"
|
|
|
|
|
onCancel={onCancelRenameFolder}
|
|
|
|
|
onSubmit={(nextName) => onRenameFolder(node.path, nextName)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function FolderItemRow({
|
2026-04-23 00:21:20 +02:00
|
|
|
contentInset,
|
|
|
|
|
depthIndent,
|
2026-04-22 17:16:56 +02:00
|
|
|
isExpanded,
|
|
|
|
|
isSelected,
|
|
|
|
|
node,
|
2026-04-22 22:49:16 +02:00
|
|
|
onDeleteFolder,
|
2026-04-22 17:16:56 +02:00
|
|
|
onOpenMenu,
|
|
|
|
|
onSelect,
|
|
|
|
|
onStartRenameFolder,
|
|
|
|
|
onToggle,
|
|
|
|
|
}: {
|
2026-04-23 00:21:20 +02:00
|
|
|
contentInset: number
|
|
|
|
|
depthIndent: number
|
2026-04-22 17:16:56 +02:00
|
|
|
isExpanded: boolean
|
|
|
|
|
isSelected: boolean
|
|
|
|
|
node: FolderNode
|
2026-04-22 22:49:16 +02:00
|
|
|
onDeleteFolder?: (folderPath: string) => void
|
2026-04-22 17:16:56 +02:00
|
|
|
onOpenMenu: FolderTreeRowProps['onOpenMenu']
|
|
|
|
|
onSelect: () => void
|
|
|
|
|
onStartRenameFolder?: (folderPath: string) => void
|
|
|
|
|
onToggle: (path: string) => void
|
|
|
|
|
}) {
|
|
|
|
|
const hasChildren = node.children.length > 0
|
|
|
|
|
const expandLabel = isExpanded ? `Collapse ${node.name}` : `Expand ${node.name}`
|
2026-04-22 22:49:16 +02:00
|
|
|
const hasActions = !!onStartRenameFolder || !!onDeleteFolder
|
2026-04-22 17:16:56 +02:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
2026-04-22 23:56:00 +02:00
|
|
|
'group relative flex items-center gap-1 rounded transition-colors',
|
2026-04-22 17:16:56 +02:00
|
|
|
isSelected
|
|
|
|
|
? 'bg-[var(--accent-blue-light,rgba(0,100,255,0.08))] text-primary'
|
|
|
|
|
: 'text-foreground hover:bg-accent',
|
|
|
|
|
)}
|
2026-04-23 00:21:20 +02:00
|
|
|
style={{ paddingLeft: depthIndent, borderRadius: 4 }}
|
2026-04-22 17:16:56 +02:00
|
|
|
onContextMenu={(event) => {
|
|
|
|
|
onSelect()
|
|
|
|
|
onOpenMenu(node, event)
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<FolderToggleButton
|
|
|
|
|
expandLabel={expandLabel}
|
|
|
|
|
hasChildren={hasChildren}
|
|
|
|
|
isExpanded={isExpanded}
|
|
|
|
|
onToggle={() => onToggle(node.path)}
|
|
|
|
|
/>
|
|
|
|
|
<FolderSelectButton
|
2026-04-23 00:21:20 +02:00
|
|
|
contentInset={contentInset}
|
2026-04-22 22:49:16 +02:00
|
|
|
hasActions={hasActions}
|
2026-04-23 00:21:20 +02:00
|
|
|
hasChildren={hasChildren}
|
2026-04-22 17:16:56 +02:00
|
|
|
isExpanded={isExpanded}
|
|
|
|
|
isSelected={isSelected}
|
|
|
|
|
node={node}
|
|
|
|
|
onSelect={onSelect}
|
|
|
|
|
onStartRenameFolder={onStartRenameFolder}
|
|
|
|
|
/>
|
2026-04-22 22:49:16 +02:00
|
|
|
{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>
|
|
|
|
|
)}
|
2026-04-22 17:16:56 +02:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function FolderToggleButton({
|
|
|
|
|
expandLabel,
|
|
|
|
|
hasChildren,
|
|
|
|
|
isExpanded,
|
|
|
|
|
onToggle,
|
|
|
|
|
}: {
|
|
|
|
|
expandLabel: string
|
|
|
|
|
hasChildren: boolean
|
|
|
|
|
isExpanded: boolean
|
|
|
|
|
onToggle: () => void
|
|
|
|
|
}) {
|
2026-04-22 23:56:00 +02:00
|
|
|
if (!hasChildren) return null
|
|
|
|
|
|
2026-04-22 17:16:56 +02:00
|
|
|
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()
|
2026-04-22 23:56:00 +02:00
|
|
|
onToggle()
|
2026-04-22 17:16:56 +02:00
|
|
|
}}
|
2026-04-22 23:56:00 +02:00
|
|
|
aria-label={expandLabel}
|
2026-04-22 17:16:56 +02:00
|
|
|
>
|
2026-04-22 23:56:00 +02:00
|
|
|
{isExpanded ? <CaretDown size={12} /> : <CaretRight size={12} />}
|
2026-04-22 17:16:56 +02:00
|
|
|
</Button>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 22:49:16 +02:00
|
|
|
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>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 17:16:56 +02:00
|
|
|
function FolderSelectButton({
|
2026-04-23 00:21:20 +02:00
|
|
|
contentInset,
|
2026-04-22 22:49:16 +02:00
|
|
|
hasActions,
|
2026-04-23 00:21:20 +02:00
|
|
|
hasChildren,
|
2026-04-22 17:16:56 +02:00
|
|
|
isExpanded,
|
|
|
|
|
isSelected,
|
|
|
|
|
node,
|
|
|
|
|
onSelect,
|
|
|
|
|
onStartRenameFolder,
|
|
|
|
|
}: {
|
2026-04-23 00:21:20 +02:00
|
|
|
contentInset: number
|
2026-04-22 22:49:16 +02:00
|
|
|
hasActions: boolean
|
2026-04-23 00:21:20 +02:00
|
|
|
hasChildren: boolean
|
2026-04-22 17:16:56 +02:00
|
|
|
isExpanded: boolean
|
|
|
|
|
isSelected: boolean
|
|
|
|
|
node: FolderNode
|
|
|
|
|
onSelect: () => void
|
|
|
|
|
onStartRenameFolder?: (folderPath: string) => void
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
className={cn(
|
2026-04-23 00:21:20 +02:00
|
|
|
'h-auto flex-1 justify-start gap-2 rounded text-left text-[13px] font-medium hover:bg-transparent',
|
2026-04-22 23:56:00 +02:00
|
|
|
isSelected ? 'text-primary hover:text-primary' : 'text-foreground hover:text-foreground',
|
2026-04-22 17:16:56 +02:00
|
|
|
)}
|
2026-04-23 00:21:20 +02:00
|
|
|
style={{
|
|
|
|
|
paddingTop: 6,
|
|
|
|
|
paddingBottom: 6,
|
|
|
|
|
paddingLeft: hasChildren ? 0 : contentInset,
|
|
|
|
|
paddingRight: hasActions ? 48 : 16,
|
|
|
|
|
}}
|
2026-04-22 17:16:56 +02:00
|
|
|
title={node.path}
|
|
|
|
|
onClick={onSelect}
|
|
|
|
|
onDoubleClick={() => {
|
|
|
|
|
onSelect()
|
|
|
|
|
onStartRenameFolder?.(node.path)
|
|
|
|
|
}}
|
|
|
|
|
data-testid={`folder-row:${node.path}`}
|
|
|
|
|
>
|
|
|
|
|
{isSelected || isExpanded ? (
|
2026-04-23 00:21:20 +02:00
|
|
|
<FolderOpen size={17} weight="fill" className="size-[17px] shrink-0" />
|
2026-04-22 17:16:56 +02:00
|
|
|
) : (
|
2026-04-23 00:21:20 +02:00
|
|
|
<Folder size={17} className="size-[17px] shrink-0" />
|
2026-04-22 17:16:56 +02:00
|
|
|
)}
|
|
|
|
|
<span className="truncate">{node.name}</span>
|
|
|
|
|
</Button>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function FolderChildren({
|
|
|
|
|
depth,
|
|
|
|
|
expanded,
|
|
|
|
|
node,
|
|
|
|
|
onDeleteFolder,
|
|
|
|
|
onOpenMenu,
|
|
|
|
|
onRenameFolder,
|
|
|
|
|
onSelect,
|
|
|
|
|
onStartRenameFolder,
|
|
|
|
|
onToggle,
|
|
|
|
|
onCancelRenameFolder,
|
|
|
|
|
renamingFolderPath,
|
|
|
|
|
selection,
|
|
|
|
|
}: FolderTreeRowProps) {
|
|
|
|
|
const isExpanded = expanded[node.path] ?? false
|
|
|
|
|
const hasChildren = node.children.length > 0
|
|
|
|
|
if (!isExpanded || !hasChildren) return null
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="relative" style={{ paddingLeft: 15 }}>
|
|
|
|
|
<div
|
|
|
|
|
className="absolute top-0 bottom-0 bg-border"
|
|
|
|
|
style={{ left: 15 + depth * 16, width: 1, opacity: 0.3 }}
|
|
|
|
|
/>
|
|
|
|
|
{node.children.map((child) => (
|
|
|
|
|
<FolderTreeRow
|
|
|
|
|
key={child.path}
|
|
|
|
|
depth={depth + 1}
|
|
|
|
|
expanded={expanded}
|
|
|
|
|
node={child}
|
|
|
|
|
onDeleteFolder={onDeleteFolder}
|
|
|
|
|
onOpenMenu={onOpenMenu}
|
|
|
|
|
onRenameFolder={onRenameFolder}
|
|
|
|
|
onSelect={onSelect}
|
|
|
|
|
onStartRenameFolder={onStartRenameFolder}
|
|
|
|
|
onToggle={onToggle}
|
|
|
|
|
onCancelRenameFolder={onCancelRenameFolder}
|
|
|
|
|
renamingFolderPath={renamingFolderPath}
|
|
|
|
|
selection={selection}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const FolderTreeRow = memo(function FolderTreeRow({
|
|
|
|
|
depth,
|
|
|
|
|
expanded,
|
|
|
|
|
node,
|
|
|
|
|
onDeleteFolder,
|
|
|
|
|
onOpenMenu,
|
|
|
|
|
onRenameFolder,
|
|
|
|
|
onSelect,
|
|
|
|
|
onStartRenameFolder,
|
|
|
|
|
onToggle,
|
|
|
|
|
onCancelRenameFolder,
|
|
|
|
|
renamingFolderPath,
|
|
|
|
|
selection,
|
|
|
|
|
}: FolderTreeRowProps) {
|
|
|
|
|
const isExpanded = expanded[node.path] ?? false
|
|
|
|
|
const isRenaming = renamingFolderPath === node.path
|
|
|
|
|
const isSelected = selection.kind === 'folder' && selection.path === node.path
|
2026-04-23 00:21:20 +02:00
|
|
|
const depthIndent = depth * 16
|
|
|
|
|
const contentInset = 16
|
2026-04-22 23:56:00 +02:00
|
|
|
const noteRetargeting = useNoteRetargetingContext()
|
2026-04-22 17:16:56 +02:00
|
|
|
const selectFolder = useCallback(() => {
|
|
|
|
|
onSelect({ kind: 'folder', path: node.path })
|
|
|
|
|
}, [node.path, onSelect])
|
2026-04-22 23:56:00 +02:00
|
|
|
const row = (
|
|
|
|
|
<FolderItemRow
|
2026-04-23 00:21:20 +02:00
|
|
|
contentInset={contentInset}
|
|
|
|
|
depthIndent={depthIndent}
|
2026-04-22 23:56:00 +02:00
|
|
|
isExpanded={isExpanded}
|
|
|
|
|
isSelected={isSelected}
|
|
|
|
|
node={node}
|
|
|
|
|
onDeleteFolder={onDeleteFolder}
|
|
|
|
|
onOpenMenu={onOpenMenu}
|
|
|
|
|
onSelect={selectFolder}
|
|
|
|
|
onStartRenameFolder={onStartRenameFolder}
|
|
|
|
|
onToggle={onToggle}
|
|
|
|
|
/>
|
|
|
|
|
)
|
2026-04-22 17:16:56 +02:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
{isRenaming && onRenameFolder && onCancelRenameFolder ? (
|
|
|
|
|
<FolderRenameRow
|
2026-04-23 00:21:20 +02:00
|
|
|
contentInset={contentInset}
|
|
|
|
|
depthIndent={depthIndent}
|
2026-04-22 17:16:56 +02:00
|
|
|
node={node}
|
|
|
|
|
onCancelRenameFolder={onCancelRenameFolder}
|
|
|
|
|
onRenameFolder={onRenameFolder}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
2026-04-22 23:56:00 +02:00
|
|
|
noteRetargeting ? (
|
|
|
|
|
<NoteDropTarget
|
|
|
|
|
canAcceptNotePath={(notePath) => noteRetargeting.canDropNoteOnFolder(notePath, node.path)}
|
|
|
|
|
onDropNote={(notePath) => noteRetargeting.dropNoteOnFolder(notePath, node.path)}
|
|
|
|
|
>
|
|
|
|
|
{row}
|
|
|
|
|
</NoteDropTarget>
|
|
|
|
|
) : row
|
2026-04-22 17:16:56 +02:00
|
|
|
)}
|
|
|
|
|
<FolderChildren
|
|
|
|
|
depth={depth}
|
|
|
|
|
expanded={expanded}
|
|
|
|
|
node={node}
|
|
|
|
|
onDeleteFolder={onDeleteFolder}
|
|
|
|
|
onOpenMenu={onOpenMenu}
|
|
|
|
|
onRenameFolder={onRenameFolder}
|
|
|
|
|
onSelect={onSelect}
|
|
|
|
|
onStartRenameFolder={onStartRenameFolder}
|
|
|
|
|
onToggle={onToggle}
|
|
|
|
|
onCancelRenameFolder={onCancelRenameFolder}
|
|
|
|
|
renamingFolderPath={renamingFolderPath}
|
|
|
|
|
selection={selection}
|
|
|
|
|
/>
|
|
|
|
|
</>
|
|
|
|
|
)
|
|
|
|
|
})
|