267 lines
7.8 KiB
TypeScript
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>
|
|
)
|
|
}
|