809 lines
24 KiB
TypeScript
809 lines
24 KiB
TypeScript
import { Check, Cube, FolderOpen, GitBranch, Plus, Rocket, Warning as AlertTriangle, X } from '@phosphor-icons/react'
|
|
import { useCallback, useMemo, useRef, useState } from 'react'
|
|
import type { ReactNode } from 'react'
|
|
import {
|
|
DndContext, PointerSensor, closestCenter, type DragEndEvent, useSensor, useSensors,
|
|
} from '@dnd-kit/core'
|
|
import {
|
|
SortableContext, useSortable, verticalListSortingStrategy,
|
|
} from '@dnd-kit/sortable'
|
|
import { CSS } from '@dnd-kit/utilities'
|
|
import { ActionTooltip } from '@/components/ui/action-tooltip'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Checkbox } from '@/components/ui/checkbox'
|
|
import { ConfirmDeleteDialog } from '../ConfirmDeleteDialog'
|
|
import { WorkspaceInitialsBadge } from '../WorkspaceInitialsBadge'
|
|
import { translate, type AppLocale, type TranslationKey } from '../../lib/i18n'
|
|
import { trackEvent } from '../../lib/telemetry'
|
|
import type { VaultOption } from './types'
|
|
import { useDismissibleLayer } from './useDismissibleLayer'
|
|
import { workspaceAliasFromOption, workspaceIdentityFromVault } from '../../utils/workspaces'
|
|
import { reorderVaultPath, vaultPathList } from '../../utils/vaultOrdering'
|
|
|
|
interface VaultMenuProps {
|
|
vaults: VaultOption[]
|
|
vaultPath: string
|
|
defaultWorkspacePath?: string | null
|
|
onSwitchVault: (path: string) => void
|
|
onSetDefaultWorkspace?: (path: string) => void
|
|
onOpenVaultSettings?: () => void
|
|
onOpenLocalFolder?: () => void
|
|
onCreateEmptyVault?: () => void
|
|
onCloneVault?: () => void
|
|
onCloneGettingStarted?: () => void
|
|
onRemoveVault?: (path: string) => void
|
|
onReorderVaults?: (orderedPaths: string[]) => void
|
|
multiWorkspaceEnabled?: boolean
|
|
onUpdateWorkspaceIdentity?: (path: string, patch: Partial<VaultOption>) => void
|
|
compact?: boolean
|
|
locale?: AppLocale
|
|
}
|
|
|
|
interface VaultMenuItemProps {
|
|
vault: VaultOption
|
|
isActive: boolean
|
|
canRemove: boolean
|
|
disableMountToggle: boolean
|
|
locale: AppLocale
|
|
multiWorkspaceEnabled: boolean
|
|
onSelect: () => void
|
|
onMountedChange?: (path: string, mounted: boolean) => void
|
|
onRequestRemove?: () => void
|
|
}
|
|
|
|
interface VaultMenuListProps {
|
|
canRemove: boolean
|
|
defaultPath: string
|
|
disableMountToggleForPath: (path: string) => boolean
|
|
locale: AppLocale
|
|
multiWorkspaceEnabled: boolean
|
|
onMountedChange: (path: string, mounted: boolean) => void
|
|
onRemoveVault?: (path: string) => void
|
|
onReorderVaults?: (orderedPaths: string[]) => void
|
|
onSelectVault: (path: string) => void
|
|
setVaultPendingRemoval: (vault: VaultOption) => void
|
|
vaults: VaultOption[]
|
|
}
|
|
|
|
interface VaultMenuActionProps {
|
|
icon: ReactNode
|
|
labelKey: TranslationKey
|
|
testId: string
|
|
accent?: boolean
|
|
onClick: () => void
|
|
}
|
|
|
|
interface VaultAction {
|
|
key: string
|
|
icon: ReactNode
|
|
labelKey: TranslationKey
|
|
testId: string
|
|
accent?: boolean
|
|
onClick: () => void
|
|
}
|
|
|
|
interface VaultMenuInteractionOptions {
|
|
defaultPath: string
|
|
includedVaults: VaultOption[]
|
|
multiWorkspaceEnabled: boolean
|
|
onSetDefaultWorkspace?: (path: string) => void
|
|
onSwitchVault: (path: string) => void
|
|
onUpdateWorkspaceIdentity?: (path: string, patch: Partial<VaultOption>) => void
|
|
setOpen: (open: boolean) => void
|
|
}
|
|
|
|
interface MountToggleRequest {
|
|
canSetDefaultWorkspace: boolean
|
|
defaultPath: string
|
|
includedVaultCount: number
|
|
isMounted: boolean
|
|
path: string
|
|
}
|
|
|
|
interface VaultPathSelection extends VaultMenuInteractionOptions {
|
|
path: string
|
|
}
|
|
|
|
interface VaultMountChangeRequest extends VaultMenuInteractionOptions {
|
|
mounted: boolean
|
|
path: string
|
|
}
|
|
|
|
function getVaultTriggerClassName(open: boolean, compact: boolean) {
|
|
if (compact) {
|
|
return open
|
|
? 'h-6 w-6 rounded-sm bg-[var(--hover)] p-0 text-foreground hover:bg-[var(--hover)]'
|
|
: 'h-6 w-6 rounded-sm p-0 text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground'
|
|
}
|
|
|
|
return open
|
|
? 'h-auto gap-1 rounded-sm bg-[var(--hover)] px-1 py-0.5 text-[12px] font-medium text-foreground hover:bg-[var(--hover)]'
|
|
: 'h-auto gap-1 rounded-sm px-1 py-0.5 text-[12px] font-medium text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground'
|
|
}
|
|
|
|
function buildVaultActions({
|
|
multiWorkspaceEnabled,
|
|
onCreateEmptyVault,
|
|
onCloneGettingStarted,
|
|
onCloneVault,
|
|
onOpenLocalFolder,
|
|
}: Pick<VaultMenuProps, 'multiWorkspaceEnabled' | 'onCreateEmptyVault' | 'onCloneGettingStarted' | 'onCloneVault' | 'onOpenLocalFolder'>): VaultAction[] {
|
|
const items: VaultAction[] = []
|
|
|
|
if (onCreateEmptyVault) {
|
|
items.push({
|
|
key: 'create-empty',
|
|
icon: <Plus size={12} />,
|
|
labelKey: 'status.vault.createEmpty',
|
|
testId: 'vault-menu-create-empty',
|
|
accent: !multiWorkspaceEnabled,
|
|
onClick: onCreateEmptyVault,
|
|
})
|
|
}
|
|
|
|
if (onOpenLocalFolder) {
|
|
items.push({
|
|
key: 'open-local',
|
|
icon: <FolderOpen size={12} />,
|
|
labelKey: 'status.vault.openLocal',
|
|
testId: 'vault-menu-open-local',
|
|
onClick: onOpenLocalFolder,
|
|
})
|
|
}
|
|
|
|
if (onCloneVault) {
|
|
items.push({
|
|
key: 'clone-git',
|
|
icon: <GitBranch size={12} />,
|
|
labelKey: 'status.vault.cloneGit',
|
|
testId: 'vault-menu-clone-git',
|
|
onClick: onCloneVault,
|
|
})
|
|
}
|
|
|
|
if (onCloneGettingStarted) {
|
|
items.push({
|
|
key: 'clone-getting-started',
|
|
icon: <Rocket size={12} />,
|
|
labelKey: 'status.vault.cloneGettingStarted',
|
|
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 workspaceMountLabel(locale: AppLocale, vault: VaultOption): string {
|
|
return translate(locale, 'status.vault.includeWorkspace', { label: vault.label })
|
|
}
|
|
|
|
function WorkspaceMountCheckbox({
|
|
checked,
|
|
disabled,
|
|
locale,
|
|
onMountedChange,
|
|
vault,
|
|
}: {
|
|
checked: boolean
|
|
disabled: boolean
|
|
locale: AppLocale
|
|
onMountedChange?: (path: string, mounted: boolean) => void
|
|
vault: VaultOption
|
|
}) {
|
|
return (
|
|
<Checkbox
|
|
checked={checked}
|
|
disabled={disabled || !onMountedChange}
|
|
aria-label={workspaceMountLabel(locale, vault)}
|
|
className="ml-1"
|
|
onCheckedChange={(checked) => {
|
|
if (typeof checked !== 'boolean') return
|
|
onMountedChange?.(vault.path, checked)
|
|
trackEvent('workspace_mount_changed', {
|
|
workspace_alias: workspaceAliasFromOption(vault),
|
|
mounted: checked ? 1 : 0,
|
|
})
|
|
}}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function vaultMenuItemClassName(isActive: boolean, multiWorkspaceEnabled: boolean): string {
|
|
return [
|
|
'min-w-0 max-w-[190px] flex-none justify-start rounded-sm px-2 font-normal',
|
|
multiWorkspaceEnabled ? 'py-1.5 text-[12px]' : 'py-1 text-xs',
|
|
isActive
|
|
? 'text-foreground hover:bg-[var(--hover)] hover:text-foreground'
|
|
: 'text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground',
|
|
].filter(Boolean).join(' ')
|
|
}
|
|
|
|
function vaultMenuItemStyle(unavailable: boolean) {
|
|
return {
|
|
height: 'auto',
|
|
background: 'transparent',
|
|
opacity: unavailable ? 0.45 : 1,
|
|
}
|
|
}
|
|
|
|
function VaultMenuItemButton({
|
|
vault,
|
|
isActive,
|
|
locale,
|
|
multiWorkspaceEnabled,
|
|
onSelect,
|
|
}: Pick<VaultMenuItemProps, 'vault' | 'isActive' | 'locale' | 'multiWorkspaceEnabled' | 'onSelect'>) {
|
|
const unavailable = vault.available === false
|
|
return (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="xs"
|
|
disabled={unavailable}
|
|
onClick={onSelect}
|
|
aria-current={isActive ? 'true' : undefined}
|
|
title={unavailable ? translate(locale, 'status.vault.notFound', { path: vault.path }) : vault.path}
|
|
data-testid={`vault-menu-item-label-${vault.label}`}
|
|
className={vaultMenuItemClassName(isActive, multiWorkspaceEnabled)}
|
|
style={vaultMenuItemStyle(unavailable)}
|
|
>
|
|
<span className="flex min-w-0 items-center gap-1.5">
|
|
{!multiWorkspaceEnabled && <VaultMenuIcon isActive={isActive} unavailable={unavailable} />}
|
|
<span className="truncate">{vault.label}</span>
|
|
</span>
|
|
</Button>
|
|
)
|
|
}
|
|
|
|
function DefaultVaultLabel({ isDefault, locale }: { isDefault: boolean; locale: AppLocale }) {
|
|
if (!isDefault) return null
|
|
|
|
return (
|
|
<span className="text-[10px] font-medium text-muted-foreground" data-testid="vault-menu-default-label">
|
|
{translate(locale, 'workspace.manager.default')}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function VaultWorkspaceInitialsBadge({ vault }: { vault: VaultOption }) {
|
|
const workspace = workspaceIdentityFromVault(vault)
|
|
|
|
return (
|
|
<WorkspaceInitialsBadge
|
|
workspace={workspace}
|
|
ariaLabel={`Vault ${workspace.label}`}
|
|
testId={`vault-menu-workspace-badge-${vault.label}`}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function isIncludedVault(vault: VaultOption, defaultPath: string): boolean {
|
|
return vault.available !== false && (vault.path === defaultPath || vault.mounted !== false)
|
|
}
|
|
|
|
function useIncludedVaults(vaults: VaultOption[], defaultPath: string): VaultOption[] {
|
|
return useMemo(() => vaults.filter((vault) => isIncludedVault(vault, defaultPath)), [defaultPath, vaults])
|
|
}
|
|
|
|
function nextIncludedVaultPath(includedVaults: VaultOption[], currentPath: string): string | null {
|
|
return includedVaults.find((vault) => vault.path !== currentPath)?.path ?? null
|
|
}
|
|
|
|
function shouldDisableMountToggle({
|
|
canSetDefaultWorkspace,
|
|
defaultPath,
|
|
includedVaultCount,
|
|
isMounted,
|
|
path,
|
|
}: MountToggleRequest): boolean {
|
|
return path === defaultPath
|
|
&& isMounted
|
|
&& (includedVaultCount <= 1 || !canSetDefaultWorkspace)
|
|
}
|
|
|
|
function selectVaultPath({
|
|
path,
|
|
multiWorkspaceEnabled,
|
|
onSetDefaultWorkspace,
|
|
onSwitchVault,
|
|
setOpen,
|
|
}: VaultPathSelection): void {
|
|
if (multiWorkspaceEnabled && onSetDefaultWorkspace) onSetDefaultWorkspace(path)
|
|
else onSwitchVault(path)
|
|
setOpen(false)
|
|
}
|
|
|
|
function applyMountedChange({
|
|
defaultPath,
|
|
includedVaults,
|
|
mounted,
|
|
onSetDefaultWorkspace,
|
|
onUpdateWorkspaceIdentity,
|
|
path,
|
|
}: VaultMountChangeRequest): void {
|
|
if (!mounted && path === defaultPath) {
|
|
const nextDefaultPath = nextIncludedVaultPath(includedVaults, path)
|
|
if (!nextDefaultPath) return
|
|
onSetDefaultWorkspace?.(nextDefaultPath)
|
|
}
|
|
onUpdateWorkspaceIdentity?.(path, { mounted })
|
|
}
|
|
|
|
function useVaultMenuInteractions({
|
|
defaultPath,
|
|
includedVaults,
|
|
multiWorkspaceEnabled,
|
|
onSetDefaultWorkspace,
|
|
onSwitchVault,
|
|
onUpdateWorkspaceIdentity,
|
|
setOpen,
|
|
}: VaultMenuInteractionOptions) {
|
|
const disableMountToggleForPath = useCallback((path: string) => (
|
|
shouldDisableMountToggle({
|
|
canSetDefaultWorkspace: !!onSetDefaultWorkspace,
|
|
defaultPath,
|
|
includedVaultCount: includedVaults.length,
|
|
isMounted: includedVaults.find((vault) => vault.path === path)?.mounted !== false,
|
|
path,
|
|
})
|
|
), [defaultPath, includedVaults, onSetDefaultWorkspace])
|
|
|
|
const handleSelectVault = useCallback((path: string) => {
|
|
selectVaultPath({
|
|
defaultPath,
|
|
includedVaults,
|
|
multiWorkspaceEnabled,
|
|
onSetDefaultWorkspace,
|
|
onSwitchVault,
|
|
onUpdateWorkspaceIdentity,
|
|
path,
|
|
setOpen,
|
|
})
|
|
}, [defaultPath, includedVaults, multiWorkspaceEnabled, onSetDefaultWorkspace, onSwitchVault, onUpdateWorkspaceIdentity, setOpen])
|
|
|
|
const handleMountedChange = useCallback((path: string, mounted: boolean) => {
|
|
applyMountedChange({
|
|
defaultPath,
|
|
includedVaults,
|
|
mounted,
|
|
multiWorkspaceEnabled,
|
|
onSetDefaultWorkspace,
|
|
onSwitchVault,
|
|
onUpdateWorkspaceIdentity,
|
|
path,
|
|
setOpen,
|
|
})
|
|
}, [defaultPath, includedVaults, multiWorkspaceEnabled, onSetDefaultWorkspace, onSwitchVault, onUpdateWorkspaceIdentity, setOpen])
|
|
|
|
return { disableMountToggleForPath, handleMountedChange, handleSelectVault }
|
|
}
|
|
|
|
function VaultMenuRemoveButton({
|
|
locale,
|
|
onRequestRemove,
|
|
vault,
|
|
}: Pick<VaultMenuItemProps, 'locale' | 'onRequestRemove' | 'vault'>) {
|
|
if (!onRequestRemove) return null
|
|
|
|
const removeLabel = translate(locale, 'status.vault.remove', { label: vault.label })
|
|
return (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
onClick={(event) => {
|
|
event.stopPropagation()
|
|
onRequestRemove()
|
|
}}
|
|
title={removeLabel}
|
|
aria-label={removeLabel}
|
|
data-testid={`vault-menu-remove-${vault.label}`}
|
|
className="ml-0.5 h-6 w-6 shrink-0 rounded-sm text-muted-foreground opacity-0 pointer-events-none transition-opacity hover:bg-[var(--hover)] hover:text-foreground focus-visible:opacity-100 focus-visible:pointer-events-auto group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto"
|
|
>
|
|
<X size={10} />
|
|
</Button>
|
|
)
|
|
}
|
|
|
|
function VaultMenuItem({
|
|
vault,
|
|
isActive,
|
|
canRemove,
|
|
disableMountToggle,
|
|
locale,
|
|
multiWorkspaceEnabled,
|
|
onSelect,
|
|
onMountedChange,
|
|
onRequestRemove,
|
|
}: VaultMenuItemProps) {
|
|
const unavailable = vault.available === false
|
|
|
|
return (
|
|
<div
|
|
className="group relative flex w-full items-center rounded-sm hover:bg-[var(--hover)]"
|
|
data-testid={`vault-menu-item-${vault.label}`}
|
|
onClick={(event) => {
|
|
if (event.target === event.currentTarget) onSelect()
|
|
}}
|
|
>
|
|
{multiWorkspaceEnabled && (
|
|
<WorkspaceMountCheckbox
|
|
checked={vault.mounted !== false}
|
|
disabled={unavailable || disableMountToggle}
|
|
locale={locale}
|
|
onMountedChange={onMountedChange}
|
|
vault={vault}
|
|
/>
|
|
)}
|
|
<VaultMenuItemButton
|
|
vault={vault}
|
|
isActive={isActive}
|
|
locale={locale}
|
|
multiWorkspaceEnabled={multiWorkspaceEnabled}
|
|
onSelect={onSelect}
|
|
/>
|
|
{canRemove && <VaultMenuRemoveButton locale={locale} onRequestRemove={onRequestRemove} vault={vault} />}
|
|
{multiWorkspaceEnabled && (
|
|
<span className="ml-auto flex shrink-0 items-center gap-1.5 pl-2 pr-1">
|
|
<DefaultVaultLabel isDefault={isActive} locale={locale} />
|
|
<VaultWorkspaceInitialsBadge vault={vault} />
|
|
</span>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function reorderedVaultPaths(vaults: VaultOption[], event: DragEndEvent): string[] | null {
|
|
const { active, over } = event
|
|
if (!over) return null
|
|
|
|
return reorderVaultPath(vaults, String(active.id), String(over.id))
|
|
}
|
|
|
|
function SortableVaultMenuItem({
|
|
children,
|
|
id,
|
|
}: {
|
|
children: ReactNode
|
|
id: string
|
|
}) {
|
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id })
|
|
|
|
return (
|
|
<div
|
|
ref={setNodeRef}
|
|
style={{
|
|
cursor: isDragging ? 'grabbing' : 'grab',
|
|
opacity: isDragging ? 0.55 : 1,
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
}}
|
|
{...attributes}
|
|
{...listeners}
|
|
>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function renderVaultMenuItem({
|
|
canRemove,
|
|
defaultPath,
|
|
disableMountToggleForPath,
|
|
locale,
|
|
multiWorkspaceEnabled,
|
|
onMountedChange,
|
|
onRemoveVault,
|
|
onSelectVault,
|
|
setVaultPendingRemoval,
|
|
vault,
|
|
}: Omit<VaultMenuListProps, 'onReorderVaults' | 'vaults'> & { vault: VaultOption }) {
|
|
return (
|
|
<VaultMenuItem
|
|
vault={vault}
|
|
isActive={vault.path === defaultPath}
|
|
canRemove={canRemove && vault.path !== defaultPath}
|
|
disableMountToggle={disableMountToggleForPath(vault.path)}
|
|
locale={locale}
|
|
multiWorkspaceEnabled={multiWorkspaceEnabled}
|
|
onSelect={() => onSelectVault(vault.path)}
|
|
onMountedChange={onMountedChange}
|
|
onRequestRemove={onRemoveVault ? () => setVaultPendingRemoval(vault) : undefined}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function VaultMenuList(props: VaultMenuListProps) {
|
|
const { onReorderVaults, vaults } = props
|
|
const vaultPaths = useMemo(() => vaultPathList(vaults), [vaults])
|
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }))
|
|
|
|
const handleDragEnd = (event: DragEndEvent) => {
|
|
const reordered = reorderedVaultPaths(vaults, event)
|
|
if (reordered) onReorderVaults?.(reordered)
|
|
}
|
|
|
|
if (!onReorderVaults || vaults.length < 2) {
|
|
return vaults.map((vault) => (
|
|
<div key={vault.path}>
|
|
{renderVaultMenuItem({ ...props, vault })}
|
|
</div>
|
|
))
|
|
}
|
|
|
|
return (
|
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
|
<SortableContext items={vaultPaths} strategy={verticalListSortingStrategy}>
|
|
{vaults.map((vault) => (
|
|
<SortableVaultMenuItem key={vault.path} id={vault.path}>
|
|
{renderVaultMenuItem({ ...props, vault })}
|
|
</SortableVaultMenuItem>
|
|
))}
|
|
</SortableContext>
|
|
</DndContext>
|
|
)
|
|
}
|
|
|
|
function VaultMenuHeader({
|
|
locale,
|
|
onOpenVaultSettings,
|
|
}: {
|
|
locale: AppLocale
|
|
onOpenVaultSettings?: () => void
|
|
}) {
|
|
return (
|
|
<div className="flex items-center justify-between gap-3 px-2 py-1.5">
|
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
|
{translate(locale, 'status.vault.availableHeader')}
|
|
</span>
|
|
{onOpenVaultSettings && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="xs"
|
|
className="h-auto rounded-sm px-1 py-0.5 text-[11px] font-medium text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground"
|
|
onClick={onOpenVaultSettings}
|
|
data-testid="vault-menu-manage-vaults"
|
|
>
|
|
{translate(locale, 'status.vault.manageWorkspaces')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function VaultMenuAction({
|
|
icon,
|
|
labelKey,
|
|
testId,
|
|
accent = false,
|
|
multiWorkspaceEnabled,
|
|
onClick,
|
|
locale = 'en',
|
|
}: VaultMenuActionProps & { locale?: AppLocale; multiWorkspaceEnabled: boolean }) {
|
|
return (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="xs"
|
|
onClick={onClick}
|
|
className={`h-auto w-full justify-start rounded-sm px-2 py-1 font-normal ${multiWorkspaceEnabled ? 'text-[12px]' : 'text-xs'}`}
|
|
style={{ color: accent ? 'var(--accent-blue)' : 'var(--muted-foreground)' }}
|
|
data-testid={testId}
|
|
>
|
|
{icon}
|
|
{translate(locale, labelKey)}
|
|
</Button>
|
|
)
|
|
}
|
|
|
|
function VaultMenuRemoveConfirmDialog({
|
|
locale,
|
|
onRemoveVault,
|
|
setOpen,
|
|
setVaultPendingRemoval,
|
|
vaultPendingRemoval,
|
|
}: {
|
|
locale: AppLocale
|
|
onRemoveVault?: (path: string) => void
|
|
setOpen: (open: boolean) => void
|
|
setVaultPendingRemoval: (vault: VaultOption | null) => void
|
|
vaultPendingRemoval: VaultOption | null
|
|
}) {
|
|
const closeDialog = () => setVaultPendingRemoval(null)
|
|
const confirmRemoval = () => {
|
|
if (vaultPendingRemoval) onRemoveVault?.(vaultPendingRemoval.path)
|
|
setVaultPendingRemoval(null)
|
|
setOpen(false)
|
|
}
|
|
|
|
return (
|
|
<ConfirmDeleteDialog
|
|
open={!!vaultPendingRemoval}
|
|
title={translate(locale, 'status.vault.removeConfirmTitle')}
|
|
message={translate(locale, 'status.vault.removeConfirmMessage', { label: vaultPendingRemoval?.label ?? '' })}
|
|
confirmLabel={translate(locale, 'status.vault.removeConfirmAction')}
|
|
onCancel={closeDialog}
|
|
onConfirm={confirmRemoval}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function VaultMenuPopover({
|
|
actions,
|
|
canRemove,
|
|
defaultPath,
|
|
disableMountToggleForPath,
|
|
locale,
|
|
menuMinWidth,
|
|
multiWorkspaceEnabled,
|
|
onMountedChange,
|
|
onOpenVaultSettings,
|
|
onRemoveVault,
|
|
onReorderVaults,
|
|
onSelectVault,
|
|
setOpen,
|
|
setVaultPendingRemoval,
|
|
vaults,
|
|
}: VaultMenuListProps & {
|
|
actions: VaultAction[]
|
|
menuMinWidth: number
|
|
onOpenVaultSettings?: () => void
|
|
setOpen: (open: boolean) => void
|
|
}) {
|
|
return (
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
bottom: '100%',
|
|
left: 0,
|
|
marginBottom: 4,
|
|
background: 'var(--sidebar)',
|
|
border: '1px solid var(--border)',
|
|
borderRadius: 6,
|
|
padding: 4,
|
|
minWidth: menuMinWidth,
|
|
boxShadow: '0 4px 12px var(--shadow-dialog)',
|
|
zIndex: 1000,
|
|
}}
|
|
data-testid="vault-menu-popover"
|
|
>
|
|
{multiWorkspaceEnabled && (
|
|
<>
|
|
<VaultMenuHeader
|
|
locale={locale}
|
|
onOpenVaultSettings={onOpenVaultSettings ? () => {
|
|
onOpenVaultSettings()
|
|
setOpen(false)
|
|
} : undefined}
|
|
/>
|
|
<div style={{ height: 1, background: 'var(--border)', margin: '2px 0 4px' }} />
|
|
</>
|
|
)}
|
|
<VaultMenuList
|
|
canRemove={canRemove}
|
|
defaultPath={defaultPath}
|
|
disableMountToggleForPath={disableMountToggleForPath}
|
|
locale={locale}
|
|
multiWorkspaceEnabled={multiWorkspaceEnabled}
|
|
onMountedChange={onMountedChange}
|
|
onRemoveVault={onRemoveVault}
|
|
onReorderVaults={onReorderVaults}
|
|
onSelectVault={onSelectVault}
|
|
setVaultPendingRemoval={setVaultPendingRemoval}
|
|
vaults={vaults}
|
|
/>
|
|
{actions.length > 0 && <div style={{ height: 1, background: 'var(--border)', margin: '4px 0' }} />}
|
|
{actions.map((action) => (
|
|
<VaultMenuAction
|
|
key={action.key}
|
|
icon={action.icon}
|
|
labelKey={action.labelKey}
|
|
testId={action.testId}
|
|
accent={action.accent}
|
|
multiWorkspaceEnabled={multiWorkspaceEnabled}
|
|
locale={locale}
|
|
onClick={() => {
|
|
action.onClick()
|
|
setOpen(false)
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function VaultMenu(props: VaultMenuProps) {
|
|
const {
|
|
vaults, vaultPath, onSwitchVault, onOpenLocalFolder, onCreateEmptyVault,
|
|
defaultWorkspacePath, onSetDefaultWorkspace, onOpenVaultSettings,
|
|
onCloneVault, onCloneGettingStarted, onRemoveVault, multiWorkspaceEnabled = false,
|
|
onReorderVaults, onUpdateWorkspaceIdentity, compact = false, locale = 'en',
|
|
} = props
|
|
const [open, setOpen] = useState(false)
|
|
const [vaultPendingRemoval, setVaultPendingRemoval] = useState<VaultOption | null>(null)
|
|
const menuRef = useRef<HTMLDivElement>(null)
|
|
const defaultPath = multiWorkspaceEnabled ? (defaultWorkspacePath ?? vaultPath) : vaultPath
|
|
const activeVault = vaults.find((vault) => vault.path === defaultPath)
|
|
const canRemove = !!onRemoveVault && vaults.length > 1
|
|
const triggerClassName = getVaultTriggerClassName(open, compact)
|
|
const triggerSize = compact ? 'icon-xs' : 'xs'
|
|
const activeVaultLabel = activeVault?.label ?? translate(locale, 'status.vault.default')
|
|
const menuMinWidth = multiWorkspaceEnabled ? 320 : 200
|
|
const includedVaults = useIncludedVaults(vaults, defaultPath)
|
|
const { disableMountToggleForPath, handleMountedChange, handleSelectVault } = useVaultMenuInteractions({
|
|
defaultPath,
|
|
includedVaults,
|
|
multiWorkspaceEnabled,
|
|
onSetDefaultWorkspace,
|
|
onSwitchVault,
|
|
onUpdateWorkspaceIdentity,
|
|
setOpen,
|
|
})
|
|
|
|
useDismissibleLayer(open, menuRef, () => setOpen(false))
|
|
|
|
const actions = useMemo<VaultAction[]>(() => {
|
|
return buildVaultActions({
|
|
multiWorkspaceEnabled,
|
|
onCreateEmptyVault,
|
|
onCloneGettingStarted,
|
|
onCloneVault,
|
|
onOpenLocalFolder,
|
|
})
|
|
}, [multiWorkspaceEnabled, onCreateEmptyVault, onCloneGettingStarted, onCloneVault, onOpenLocalFolder])
|
|
|
|
return (
|
|
<div ref={menuRef} style={{ position: 'relative' }}>
|
|
<ActionTooltip copy={{ label: translate(locale, 'status.vault.switch') }} side="top">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size={triggerSize}
|
|
className={triggerClassName}
|
|
onClick={() => setOpen((value) => !value)}
|
|
aria-label={translate(locale, 'status.vault.switch')}
|
|
data-testid="status-vault-trigger"
|
|
>
|
|
<Cube size={13} weight="regular" />
|
|
{compact ? null : <span className="max-w-32 truncate">{activeVaultLabel}</span>}
|
|
</Button>
|
|
</ActionTooltip>
|
|
{open && (
|
|
<VaultMenuPopover
|
|
actions={actions}
|
|
canRemove={canRemove}
|
|
defaultPath={defaultPath}
|
|
disableMountToggleForPath={disableMountToggleForPath}
|
|
locale={locale}
|
|
menuMinWidth={menuMinWidth}
|
|
multiWorkspaceEnabled={multiWorkspaceEnabled}
|
|
onMountedChange={handleMountedChange}
|
|
onOpenVaultSettings={onOpenVaultSettings}
|
|
onRemoveVault={onRemoveVault}
|
|
onReorderVaults={onReorderVaults}
|
|
onSelectVault={handleSelectVault}
|
|
setOpen={setOpen}
|
|
setVaultPendingRemoval={setVaultPendingRemoval}
|
|
vaults={vaults}
|
|
/>
|
|
)}
|
|
<VaultMenuRemoveConfirmDialog
|
|
locale={locale}
|
|
onRemoveVault={onRemoveVault}
|
|
setOpen={setOpen}
|
|
setVaultPendingRemoval={setVaultPendingRemoval}
|
|
vaultPendingRemoval={vaultPendingRemoval}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|