refactor: consolidate vault ordering
This commit is contained in:
@@ -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<VaultOption>) => 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 (
|
||||
<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,
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
<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={() => setVaultPendingRemoval(null)}
|
||||
onConfirm={() => {
|
||||
if (vaultPendingRemoval) {
|
||||
onRemoveVault?.(vaultPendingRemoval.path)
|
||||
}
|
||||
setVaultPendingRemoval(null)
|
||||
setOpen(false)
|
||||
}}
|
||||
<VaultMenuRemoveConfirmDialog
|
||||
locale={locale}
|
||||
onRemoveVault={onRemoveVault}
|
||||
setOpen={setOpen}
|
||||
setVaultPendingRemoval={setVaultPendingRemoval}
|
||||
vaultPendingRemoval={vaultPendingRemoval}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user