Files
tolaria/src/components/status-bar/VaultMenu.tsx
2026-04-19 15:21:59 +02:00

267 lines
7.8 KiB
TypeScript

import { useMemo, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { AlertTriangle, Check, FolderOpen, GitBranch, Plus, Rocket, X } from 'lucide-react'
import { ActionTooltip } from '@/components/ui/action-tooltip'
import { Button } from '@/components/ui/button'
import type { VaultOption } from './types'
import { useDismissibleLayer } from './useDismissibleLayer'
interface VaultMenuProps {
vaults: VaultOption[]
vaultPath: string
onSwitchVault: (path: string) => void
onOpenLocalFolder?: () => void
onCreateEmptyVault?: () => void
onCloneVault?: () => void
onCloneGettingStarted?: () => void
onRemoveVault?: (path: string) => void
}
interface VaultMenuItemProps {
vault: VaultOption
isActive: boolean
canRemove: boolean
onSelect: () => void
onRemove?: () => void
}
interface VaultMenuActionProps {
icon: ReactNode
label: string
testId: string
accent?: boolean
onClick: () => void
}
interface VaultAction {
key: string
icon: ReactNode
label: string
testId: string
accent?: boolean
onClick: () => void
}
function buildVaultActions({
onCreateEmptyVault,
onCloneGettingStarted,
onCloneVault,
onOpenLocalFolder,
}: Pick<VaultMenuProps, 'onCreateEmptyVault' | 'onCloneGettingStarted' | 'onCloneVault' | 'onOpenLocalFolder'>): VaultAction[] {
const items: VaultAction[] = []
if (onCreateEmptyVault) {
items.push({
key: 'create-empty',
icon: <Plus size={12} />,
label: 'Create empty vault',
testId: 'vault-menu-create-empty',
accent: true,
onClick: onCreateEmptyVault,
})
}
if (onOpenLocalFolder) {
items.push({
key: 'open-local',
icon: <FolderOpen size={12} />,
label: 'Open local folder',
testId: 'vault-menu-open-local',
onClick: onOpenLocalFolder,
})
}
if (onCloneVault) {
items.push({
key: 'clone-git',
icon: <GitBranch size={12} />,
label: 'Clone Git repo',
testId: 'vault-menu-clone-git',
onClick: onCloneVault,
})
}
if (onCloneGettingStarted) {
items.push({
key: 'clone-getting-started',
icon: <Rocket size={12} />,
label: 'Clone Getting Started Vault',
testId: 'vault-menu-clone-getting-started',
accent: true,
onClick: onCloneGettingStarted,
})
}
return items
}
function VaultMenuIcon({ isActive, unavailable }: { isActive: boolean; unavailable: boolean }) {
if (isActive) return <Check size={12} />
if (unavailable) return <AlertTriangle size={12} style={{ color: 'var(--muted-foreground)' }} />
return <span style={{ width: 12 }} />
}
function VaultMenuItem({ vault, isActive, canRemove, onSelect, onRemove }: VaultMenuItemProps) {
const unavailable = vault.available === false
const removeLabel = `Remove ${vault.label} from list`
return (
<div className="group flex items-center gap-1 rounded-sm">
<Button
type="button"
variant="ghost"
size="xs"
disabled={unavailable}
onClick={onSelect}
aria-current={isActive ? 'true' : undefined}
title={unavailable ? `Vault not found: ${vault.path}` : vault.path}
data-testid={`vault-menu-item-${vault.label}`}
className={isActive
? 'w-full justify-between rounded-sm px-2 py-1 text-xs font-normal text-foreground hover:bg-[var(--hover)] hover:text-foreground'
: 'w-full justify-between rounded-sm px-2 py-1 text-xs font-normal text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground'
}
style={{
height: 'auto',
background: isActive ? 'var(--hover)' : 'transparent',
opacity: unavailable ? 0.45 : 1,
}}
>
<span className="flex items-center gap-1.5 truncate">
<VaultMenuIcon isActive={isActive} unavailable={unavailable} />
<span className="truncate">{vault.label}</span>
</span>
</Button>
{canRemove && onRemove && (
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={(event) => {
event.stopPropagation()
onRemove()
}}
title={removeLabel}
aria-label={removeLabel}
data-testid={`vault-menu-remove-${vault.label}`}
className="rounded-sm text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100"
>
<X size={10} />
</Button>
)}
</div>
)
}
function VaultMenuAction({ icon, label, testId, accent = false, onClick }: VaultMenuActionProps) {
return (
<Button
type="button"
variant="ghost"
size="xs"
onClick={onClick}
className="h-auto w-full justify-start rounded-sm px-2 py-1 text-xs font-normal"
style={{ color: accent ? 'var(--accent-blue)' : 'var(--muted-foreground)' }}
data-testid={testId}
>
{icon}
{label}
</Button>
)
}
export function VaultMenu({
vaults,
vaultPath,
onSwitchVault,
onOpenLocalFolder,
onCreateEmptyVault,
onCloneVault,
onCloneGettingStarted,
onRemoveVault,
}: VaultMenuProps) {
const [open, setOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const activeVault = vaults.find((vault) => vault.path === vaultPath)
const canRemove = !!onRemoveVault && vaults.length > 1
useDismissibleLayer(open, menuRef, () => setOpen(false))
const actions = useMemo<VaultAction[]>(() => {
return buildVaultActions({
onCreateEmptyVault,
onCloneGettingStarted,
onCloneVault,
onOpenLocalFolder,
})
}, [onCreateEmptyVault, onCloneGettingStarted, onCloneVault, onOpenLocalFolder])
return (
<div ref={menuRef} style={{ position: 'relative' }}>
<ActionTooltip copy={{ label: 'Switch vault' }} side="top">
<Button
type="button"
variant="ghost"
size="xs"
className={open
? 'h-auto gap-1 rounded-sm bg-[var(--hover)] px-1 py-0.5 text-[11px] font-medium text-foreground hover:bg-[var(--hover)]'
: 'h-auto gap-1 rounded-sm px-1 py-0.5 text-[11px] font-medium text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground'}
onClick={() => setOpen((value) => !value)}
aria-label="Switch vault"
data-testid="status-vault-trigger"
>
<FolderOpen size={13} />
{activeVault?.label ?? 'Vault'}
</Button>
</ActionTooltip>
{open && (
<div
style={{
position: 'absolute',
bottom: '100%',
left: 0,
marginBottom: 4,
background: 'var(--sidebar)',
border: '1px solid var(--border)',
borderRadius: 6,
padding: 4,
minWidth: 200,
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
zIndex: 1000,
}}
>
{vaults.map((vault) => (
<VaultMenuItem
key={vault.path}
vault={vault}
isActive={vault.path === vaultPath}
canRemove={canRemove}
onSelect={() => {
onSwitchVault(vault.path)
setOpen(false)
}}
onRemove={onRemoveVault ? () => {
onRemoveVault(vault.path)
setOpen(false)
} : undefined}
/>
))}
{actions.length > 0 && <div style={{ height: 1, background: 'var(--border)', margin: '4px 0' }} />}
{actions.map((action) => (
<VaultMenuAction
key={action.key}
icon={action.icon}
label={action.label}
testId={action.testId}
accent={action.accent}
onClick={() => {
action.onClick()
setOpen(false)
}}
/>
))}
</div>
)}
</div>
)
}