From 0c8be628317e8b48e3ee1702b6f405fefab85dd9 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Fri, 15 May 2026 02:16:38 +0200 Subject: [PATCH] refactor: consolidate vault ordering --- src/components/status-bar/VaultMenu.tsx | 227 ++++++++++++++++++------ src/utils/vaultOrdering.test.ts | 17 +- src/utils/vaultOrdering.ts | 16 +- 3 files changed, 208 insertions(+), 52 deletions(-) diff --git a/src/components/status-bar/VaultMenu.tsx b/src/components/status-bar/VaultMenu.tsx index 8741fff6..799de544 100644 --- a/src/components/status-bar/VaultMenu.tsx +++ b/src/components/status-bar/VaultMenu.tsx @@ -1,11 +1,11 @@ import { Check, Cube, FolderOpen, GitBranch, Plus, Rocket, Warning as AlertTriangle, X } from '@phosphor-icons/react' -import { useMemo, useRef, useState } from '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, arrayMove, useSortable, verticalListSortingStrategy, + SortableContext, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { ActionTooltip } from '@/components/ui/action-tooltip' @@ -17,6 +17,7 @@ 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[] @@ -80,6 +81,32 @@ interface VaultAction { onClick: () => void } +interface VaultMenuInteractionOptions { + defaultPath: string + includedVaults: VaultOption[] + multiWorkspaceEnabled: boolean + onSetDefaultWorkspace?: (path: string) => void + onSwitchVault: (path: string) => void + onUpdateWorkspaceIdentity?: (path: string, patch: Partial) => void + setOpen: (open: boolean) => void +} + +interface MountToggleRequest { + canSetDefaultWorkspace: boolean + defaultPath: string + includedVaultCount: number + 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 @@ -261,6 +288,103 @@ function WorkspaceInitialsBadge({ vault }: { vault: VaultOption }) { ) } +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, + path, +}: MountToggleRequest): boolean { + return path === defaultPath && (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, + path, + }) + ), [defaultPath, includedVaults.length, 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, @@ -336,20 +460,11 @@ function VaultMenuItem({ ) } -function sortableVaultPaths(vaults: VaultOption[]): string[] { - return vaults.map((vault) => vault.path) -} - function reorderedVaultPaths(vaults: VaultOption[], event: DragEndEvent): string[] | null { const { active, over } = event - if (!over || active.id === over.id) return null + if (!over) return null - const paths = sortableVaultPaths(vaults) - const oldIndex = paths.indexOf(active.id as string) - const newIndex = paths.indexOf(over.id as string) - if (oldIndex === -1 || newIndex === -1) return null - - return arrayMove(paths, oldIndex, newIndex) + return reorderVaultPath(vaults, String(active.id), String(over.id)) } function SortableVaultMenuItem({ @@ -407,7 +522,7 @@ function renderVaultMenuItem({ function VaultMenuList(props: VaultMenuListProps) { const { onReorderVaults, vaults } = props - const vaultPaths = useMemo(() => sortableVaultPaths(vaults), [vaults]) + const vaultPaths = useMemo(() => vaultPathList(vaults), [vaults]) const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } })) const handleDragEnd = (event: DragEndEvent) => { @@ -489,6 +604,38 @@ function VaultMenuAction({ ) } +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 ( + + ) +} + function VaultMenuPopover({ actions, canRemove, @@ -590,29 +737,16 @@ export function VaultMenu(props: VaultMenuProps) { const triggerSize = compact ? 'icon-xs' : 'xs' const activeVaultLabel = activeVault?.label ?? translate(locale, 'status.vault.default') const menuMinWidth = multiWorkspaceEnabled ? 320 : 200 - const includedVaults = useMemo( - () => vaults.filter((vault) => ( - vault.available !== false - && (vault.path === defaultPath || vault.mounted !== false) - )), - [defaultPath, vaults], - ) - const disableMountToggleForPath = (path: string) => ( - path === defaultPath && (includedVaults.length <= 1 || !onSetDefaultWorkspace) - ) - const handleSelectVault = (path: string) => { - if (multiWorkspaceEnabled && onSetDefaultWorkspace) onSetDefaultWorkspace(path) - else onSwitchVault(path) - setOpen(false) - } - const handleMountedChange = (path: string, mounted: boolean) => { - if (!mounted && path === defaultPath) { - const nextDefaultPath = includedVaults.find((includedVault) => includedVault.path !== path)?.path - if (!nextDefaultPath) return - onSetDefaultWorkspace?.(nextDefaultPath) - } - onUpdateWorkspaceIdentity?.(path, { mounted }) - } + const includedVaults = useIncludedVaults(vaults, defaultPath) + const { disableMountToggleForPath, handleMountedChange, handleSelectVault } = useVaultMenuInteractions({ + defaultPath, + includedVaults, + multiWorkspaceEnabled, + onSetDefaultWorkspace, + onSwitchVault, + onUpdateWorkspaceIdentity, + setOpen, + }) useDismissibleLayer(open, menuRef, () => setOpen(false)) @@ -661,19 +795,12 @@ export function VaultMenu(props: VaultMenuProps) { vaults={vaults} /> )} - setVaultPendingRemoval(null)} - onConfirm={() => { - if (vaultPendingRemoval) { - onRemoveVault?.(vaultPendingRemoval.path) - } - setVaultPendingRemoval(null) - setOpen(false) - }} + ) diff --git a/src/utils/vaultOrdering.test.ts b/src/utils/vaultOrdering.test.ts index 39260a3b..f28db17b 100644 --- a/src/utils/vaultOrdering.test.ts +++ b/src/utils/vaultOrdering.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { VaultOption } from '../components/status-bar/types' -import { canMoveVaultPath, moveVaultPath, orderVaultsByPath } from './vaultOrdering' +import { canMoveVaultPath, moveVaultPath, orderVaultsByPath, reorderVaultPath, vaultPathList } from './vaultOrdering' const vaults: VaultOption[] = [ { label: 'Laputa', path: '/laputa' }, @@ -9,6 +9,10 @@ const vaults: VaultOption[] = [ ] describe('vaultOrdering', () => { + it('extracts vault paths in display order', () => { + expect(vaultPathList(vaults)).toEqual(['/laputa', '/research', '/archive']) + }) + it('orders vaults by a complete path list', () => { expect(orderVaultsByPath(vaults, ['/archive', '/laputa', '/research'])).toEqual([ vaults[2], @@ -27,6 +31,17 @@ describe('vaultOrdering', () => { expect(moveVaultPath(vaults, '/research', 'down')).toEqual(['/laputa', '/archive', '/research']) }) + it('reorders a dragged vault path to the hovered path index', () => { + expect(reorderVaultPath(vaults, '/laputa', '/archive')).toEqual(['/research', '/archive', '/laputa']) + expect(reorderVaultPath(vaults, '/archive', '/laputa')).toEqual(['/archive', '/laputa', '/research']) + }) + + it('ignores no-op or unknown drag reorder paths', () => { + expect(reorderVaultPath(vaults, '/research', '/research')).toBeNull() + expect(reorderVaultPath(vaults, '/missing', '/archive')).toBeNull() + expect(reorderVaultPath(vaults, '/archive', '/missing')).toBeNull() + }) + it('reports whether a vault can move in a direction', () => { expect(canMoveVaultPath(vaults, '/laputa', 'up')).toBe(false) expect(canMoveVaultPath(vaults, '/laputa', 'down')).toBe(true) diff --git a/src/utils/vaultOrdering.ts b/src/utils/vaultOrdering.ts index c00be274..4943dbbe 100644 --- a/src/utils/vaultOrdering.ts +++ b/src/utils/vaultOrdering.ts @@ -2,7 +2,7 @@ import type { VaultOption } from '../components/status-bar/types' export type VaultMoveDirection = 'up' | 'down' -function vaultPathList(vaults: VaultOption[]): string[] { +export function vaultPathList(vaults: VaultOption[]): string[] { return vaults.map((vault) => vault.path) } @@ -35,6 +35,20 @@ export function moveVaultPath(vaults: VaultOption[], path: string, direction: Va return nextPaths } +export function reorderVaultPath(vaults: VaultOption[], activePath: string, overPath: string): string[] | null { + if (activePath === overPath) return null + + const orderedPaths = vaultPathList(vaults) + const activeIndex = orderedPaths.indexOf(activePath) + const overIndex = orderedPaths.indexOf(overPath) + if (activeIndex === -1 || overIndex === -1) return null + + const nextPaths = [...orderedPaths] + const [movedPath] = nextPaths.splice(activeIndex, 1) + nextPaths.splice(overIndex, 0, movedPath) + return nextPaths +} + export function canMoveVaultPath(vaults: VaultOption[], path: string, direction: VaultMoveDirection): boolean { return moveVaultPath(vaults, path, direction) !== null }