2026-04-22 17:16:56 +02:00
|
|
|
import {
|
|
|
|
|
memo,
|
2026-04-30 00:18:33 +02:00
|
|
|
type MouseEvent as ReactMouseEvent,
|
2026-04-22 17:16:56 +02:00
|
|
|
useCallback,
|
2026-04-30 00:18:33 +02:00
|
|
|
useMemo,
|
2026-04-22 17:16:56 +02:00
|
|
|
} from 'react'
|
|
|
|
|
import {
|
|
|
|
|
Plus,
|
|
|
|
|
} from '@phosphor-icons/react'
|
|
|
|
|
import { Button } from '@/components/ui/button'
|
2026-03-31 11:14:50 +02:00
|
|
|
import type { FolderNode, SidebarSelection } from '../types'
|
2026-04-22 17:16:56 +02:00
|
|
|
import { FolderContextMenu } from './folder-tree/FolderContextMenu'
|
|
|
|
|
import { FolderNameInput } from './folder-tree/FolderNameInput'
|
|
|
|
|
import { FolderTreeRow } from './folder-tree/FolderTreeRow'
|
|
|
|
|
import { useFolderContextMenu } from './folder-tree/useFolderContextMenu'
|
|
|
|
|
import { useFolderTreeDisclosure } from './folder-tree/useFolderTreeDisclosure'
|
|
|
|
|
import { SidebarGroupHeader } from './sidebar/SidebarGroupHeader'
|
2026-04-26 19:37:21 +02:00
|
|
|
import { translate, type AppLocale } from '../lib/i18n'
|
2026-04-27 01:45:16 +02:00
|
|
|
import type { FolderFileActions } from '../hooks/useFileActions'
|
2026-03-31 11:14:50 +02:00
|
|
|
|
|
|
|
|
interface FolderTreeProps {
|
|
|
|
|
folders: FolderNode[]
|
|
|
|
|
selection: SidebarSelection
|
|
|
|
|
onSelect: (selection: SidebarSelection) => void
|
2026-04-22 17:16:56 +02:00
|
|
|
onCreateFolder?: (name: string) => Promise<boolean> | boolean
|
|
|
|
|
onRenameFolder?: (folderPath: string, nextName: string) => Promise<boolean> | boolean
|
|
|
|
|
onDeleteFolder?: (folderPath: string) => void
|
2026-04-27 01:45:16 +02:00
|
|
|
folderFileActions?: FolderFileActions
|
2026-04-22 17:16:56 +02:00
|
|
|
renamingFolderPath?: string | null
|
|
|
|
|
onStartRenameFolder?: (folderPath: string) => void
|
|
|
|
|
onCancelRenameFolder?: () => void
|
2026-04-03 21:50:59 +02:00
|
|
|
collapsed?: boolean
|
2026-04-26 19:37:21 +02:00
|
|
|
locale?: AppLocale
|
2026-04-03 21:50:59 +02:00
|
|
|
onToggle?: () => void
|
2026-04-30 00:18:33 +02:00
|
|
|
vaultRootPath?: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface FolderTreeBodyProps extends Pick<
|
|
|
|
|
FolderTreeProps,
|
|
|
|
|
| 'locale'
|
|
|
|
|
| 'onCancelRenameFolder'
|
|
|
|
|
| 'onDeleteFolder'
|
|
|
|
|
| 'onRenameFolder'
|
|
|
|
|
| 'onSelect'
|
|
|
|
|
| 'onStartRenameFolder'
|
|
|
|
|
| 'renamingFolderPath'
|
|
|
|
|
| 'selection'
|
|
|
|
|
> {
|
|
|
|
|
displayedExpanded: Record<string, boolean>
|
|
|
|
|
displayedFolders: FolderNode[]
|
|
|
|
|
isCreating: boolean
|
|
|
|
|
onCancelCreateFolder: () => void
|
|
|
|
|
onCreateFolderSubmit: (value: string) => Promise<boolean>
|
|
|
|
|
rootPath?: string
|
|
|
|
|
sectionCollapsed: boolean
|
|
|
|
|
toggleFolder: (path: string) => void
|
|
|
|
|
onOpenMenu: (node: FolderNode, event: ReactMouseEvent<HTMLDivElement>) => void
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function vaultRootLabel(vaultRootPath: string, locale: AppLocale): string {
|
|
|
|
|
const trimmed = vaultRootPath.trim().replace(/[\\/]+$/g, '')
|
|
|
|
|
return trimmed.split(/[\\/]/).filter(Boolean).pop() || translate(locale, 'status.vault.default')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildRootNode(folders: FolderNode[], vaultRootPath: string | undefined, locale: AppLocale): FolderNode | null {
|
|
|
|
|
if (!vaultRootPath?.trim()) return null
|
|
|
|
|
return {
|
|
|
|
|
name: vaultRootLabel(vaultRootPath, locale),
|
|
|
|
|
path: '',
|
|
|
|
|
children: folders,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function useDisplayedFolders(folders: FolderNode[], expanded: Record<string, boolean>, vaultRootPath: string | undefined, locale: AppLocale) {
|
|
|
|
|
return useMemo(() => {
|
|
|
|
|
const rootNode = buildRootNode(folders, vaultRootPath, locale)
|
|
|
|
|
return {
|
|
|
|
|
displayedExpanded: rootNode ? { '': true, ...expanded } : expanded,
|
|
|
|
|
displayedFolders: rootNode ? [rootNode] : folders,
|
|
|
|
|
}
|
|
|
|
|
}, [expanded, folders, locale, vaultRootPath])
|
2026-03-31 11:14:50 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-22 17:16:56 +02:00
|
|
|
export const FolderTree = memo(function FolderTree({
|
|
|
|
|
folders,
|
|
|
|
|
selection,
|
|
|
|
|
onSelect,
|
|
|
|
|
onCreateFolder,
|
|
|
|
|
onRenameFolder,
|
|
|
|
|
onDeleteFolder,
|
2026-04-27 01:45:16 +02:00
|
|
|
folderFileActions,
|
2026-04-22 17:16:56 +02:00
|
|
|
renamingFolderPath,
|
|
|
|
|
onStartRenameFolder,
|
|
|
|
|
onCancelRenameFolder,
|
|
|
|
|
collapsed: externalCollapsed,
|
2026-04-26 19:37:21 +02:00
|
|
|
locale = 'en',
|
2026-04-22 17:16:56 +02:00
|
|
|
onToggle,
|
2026-04-30 00:18:33 +02:00
|
|
|
vaultRootPath,
|
2026-04-22 17:16:56 +02:00
|
|
|
}: FolderTreeProps) {
|
|
|
|
|
const {
|
|
|
|
|
closeCreateForm,
|
|
|
|
|
expanded,
|
|
|
|
|
handleToggleSection,
|
|
|
|
|
isCreating,
|
|
|
|
|
openCreateForm,
|
|
|
|
|
sectionCollapsed,
|
|
|
|
|
toggleFolder,
|
|
|
|
|
} = useFolderTreeDisclosure({
|
|
|
|
|
collapsed: externalCollapsed,
|
|
|
|
|
onToggle,
|
|
|
|
|
renamingFolderPath,
|
|
|
|
|
selection,
|
|
|
|
|
})
|
|
|
|
|
const {
|
|
|
|
|
closeContextMenu,
|
|
|
|
|
contextMenu,
|
2026-04-27 01:45:16 +02:00
|
|
|
handleCopyPathFromMenu,
|
2026-04-22 17:16:56 +02:00
|
|
|
handleDeleteFromMenu,
|
|
|
|
|
handleOpenMenu,
|
2026-04-27 01:45:16 +02:00
|
|
|
handleRevealFromMenu,
|
2026-04-22 17:16:56 +02:00
|
|
|
handleRenameFromMenu,
|
|
|
|
|
menuRef,
|
|
|
|
|
} = useFolderContextMenu({
|
|
|
|
|
onDeleteFolder,
|
2026-04-27 01:45:16 +02:00
|
|
|
folderFileActions,
|
2026-04-22 17:16:56 +02:00
|
|
|
onStartRenameFolder,
|
|
|
|
|
})
|
2026-03-31 11:14:50 +02:00
|
|
|
|
2026-04-22 17:16:56 +02:00
|
|
|
const handleCreateFolderSubmit = useCallback(async (value: string) => {
|
|
|
|
|
const nextName = value.trim()
|
|
|
|
|
if (!nextName || !onCreateFolder) {
|
|
|
|
|
closeCreateForm()
|
|
|
|
|
return true
|
2026-04-01 15:29:24 +02:00
|
|
|
}
|
2026-04-22 17:16:56 +02:00
|
|
|
|
|
|
|
|
const created = await onCreateFolder(nextName)
|
|
|
|
|
if (created) closeCreateForm()
|
|
|
|
|
return created
|
|
|
|
|
}, [closeCreateForm, onCreateFolder])
|
2026-04-01 15:29:24 +02:00
|
|
|
|
2026-04-27 01:45:16 +02:00
|
|
|
const handleCreateFolderClick = useCallback(() => {
|
|
|
|
|
closeContextMenu()
|
|
|
|
|
openCreateForm()
|
|
|
|
|
}, [closeContextMenu, openCreateForm])
|
|
|
|
|
|
2026-04-30 00:18:33 +02:00
|
|
|
const { displayedExpanded, displayedFolders } = useDisplayedFolders(folders, expanded, vaultRootPath, locale)
|
|
|
|
|
|
|
|
|
|
if (displayedFolders.length === 0 && !isCreating) return null
|
2026-03-31 11:14:50 +02:00
|
|
|
|
|
|
|
|
return (
|
2026-04-22 17:16:56 +02:00
|
|
|
<div className="border-b border-border" style={{ padding: '0 6px' }}>
|
2026-04-26 19:37:21 +02:00
|
|
|
<SidebarGroupHeader label={translate(locale, 'sidebar.group.folders')} collapsed={sectionCollapsed} onToggle={handleToggleSection}>
|
2026-04-01 15:29:24 +02:00
|
|
|
{onCreateFolder && (
|
2026-04-27 01:45:16 +02:00
|
|
|
<CreateFolderButton locale={locale} onCreate={handleCreateFolderClick} />
|
2026-04-01 15:29:24 +02:00
|
|
|
)}
|
2026-04-22 17:16:56 +02:00
|
|
|
</SidebarGroupHeader>
|
2026-04-30 00:18:33 +02:00
|
|
|
<FolderTreeBody
|
|
|
|
|
displayedExpanded={displayedExpanded}
|
|
|
|
|
displayedFolders={displayedFolders}
|
|
|
|
|
isCreating={isCreating}
|
|
|
|
|
locale={locale}
|
|
|
|
|
onCancelCreateFolder={closeCreateForm}
|
|
|
|
|
onCancelRenameFolder={onCancelRenameFolder}
|
|
|
|
|
onCreateFolderSubmit={handleCreateFolderSubmit}
|
|
|
|
|
onDeleteFolder={onDeleteFolder}
|
|
|
|
|
onOpenMenu={handleOpenMenu}
|
|
|
|
|
onRenameFolder={onRenameFolder}
|
|
|
|
|
onSelect={onSelect}
|
|
|
|
|
onStartRenameFolder={onStartRenameFolder}
|
|
|
|
|
renamingFolderPath={renamingFolderPath}
|
|
|
|
|
rootPath={vaultRootPath}
|
|
|
|
|
sectionCollapsed={sectionCollapsed}
|
|
|
|
|
selection={selection}
|
|
|
|
|
toggleFolder={toggleFolder}
|
|
|
|
|
/>
|
2026-04-22 17:16:56 +02:00
|
|
|
<FolderContextMenu
|
|
|
|
|
menu={contextMenu}
|
|
|
|
|
menuRef={menuRef}
|
|
|
|
|
onDelete={handleDeleteFromMenu}
|
2026-04-27 01:45:16 +02:00
|
|
|
onReveal={handleRevealFromMenu}
|
|
|
|
|
onCopyPath={handleCopyPathFromMenu}
|
2026-04-22 17:16:56 +02:00
|
|
|
onRename={handleRenameFromMenu}
|
2026-04-26 19:37:21 +02:00
|
|
|
locale={locale}
|
2026-04-22 17:16:56 +02:00
|
|
|
/>
|
2026-03-31 11:14:50 +02:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
})
|
2026-04-27 01:45:16 +02:00
|
|
|
|
2026-04-30 00:18:33 +02:00
|
|
|
function FolderTreeBody({
|
|
|
|
|
displayedExpanded,
|
|
|
|
|
displayedFolders,
|
|
|
|
|
isCreating,
|
|
|
|
|
locale = 'en',
|
|
|
|
|
onCancelCreateFolder,
|
|
|
|
|
onCancelRenameFolder,
|
|
|
|
|
onCreateFolderSubmit,
|
|
|
|
|
onDeleteFolder,
|
|
|
|
|
onOpenMenu,
|
|
|
|
|
onRenameFolder,
|
|
|
|
|
onSelect,
|
|
|
|
|
onStartRenameFolder,
|
|
|
|
|
renamingFolderPath,
|
|
|
|
|
rootPath,
|
|
|
|
|
sectionCollapsed,
|
|
|
|
|
selection,
|
|
|
|
|
toggleFolder,
|
|
|
|
|
}: FolderTreeBodyProps) {
|
|
|
|
|
if (sectionCollapsed) return null
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-col gap-0.5 pb-2">
|
|
|
|
|
{displayedFolders.map((node) => (
|
|
|
|
|
<FolderTreeRow
|
|
|
|
|
key={node.path}
|
|
|
|
|
depth={0}
|
|
|
|
|
expanded={displayedExpanded}
|
|
|
|
|
node={node}
|
|
|
|
|
onDeleteFolder={onDeleteFolder}
|
|
|
|
|
onOpenMenu={onOpenMenu}
|
|
|
|
|
onRenameFolder={onRenameFolder}
|
|
|
|
|
onSelect={onSelect}
|
|
|
|
|
onStartRenameFolder={onStartRenameFolder}
|
|
|
|
|
onToggle={toggleFolder}
|
|
|
|
|
onCancelRenameFolder={onCancelRenameFolder}
|
|
|
|
|
locale={locale}
|
|
|
|
|
renamingFolderPath={renamingFolderPath}
|
|
|
|
|
rootPath={rootPath}
|
|
|
|
|
selection={selection}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
{isCreating && (
|
|
|
|
|
<div style={{ paddingLeft: 8 }}>
|
|
|
|
|
<FolderNameInput
|
|
|
|
|
ariaLabel={translate(locale, 'sidebar.folder.newName')}
|
|
|
|
|
initialValue=""
|
|
|
|
|
placeholder={translate(locale, 'sidebar.folder.name')}
|
|
|
|
|
submitOnBlur={true}
|
|
|
|
|
testId="new-folder-input"
|
|
|
|
|
onCancel={onCancelCreateFolder}
|
|
|
|
|
onSubmit={onCreateFolderSubmit}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 01:45:16 +02:00
|
|
|
function CreateFolderButton({
|
|
|
|
|
locale,
|
|
|
|
|
onCreate,
|
|
|
|
|
}: {
|
|
|
|
|
locale: AppLocale
|
|
|
|
|
onCreate: () => void
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon-xs"
|
|
|
|
|
className="h-auto w-auto min-w-0 rounded-none p-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
|
|
|
|
|
data-testid="create-folder-btn"
|
|
|
|
|
title={translate(locale, 'sidebar.action.createFolder')}
|
|
|
|
|
aria-label={translate(locale, 'sidebar.action.createFolder')}
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
event.stopPropagation()
|
|
|
|
|
onCreate()
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Plus size={12} className="text-muted-foreground hover:text-foreground" />
|
|
|
|
|
</Button>
|
|
|
|
|
)
|
|
|
|
|
}
|