feat: support vault reordering
This commit is contained in:
@@ -127,6 +127,8 @@ files:
|
||||
settings.workspaces.labelTooltip: b1b87a2affcd504e54bf97100e3d9c34
|
||||
settings.workspaces.slugTooltip: daa690f5855e35d1cfa9dbffe1870d40
|
||||
settings.workspaces.color: cb5feb1b7314637725a2e73bdc9f7295
|
||||
settings.workspaces.moveUpAria: 2ade43872ef9432d14af83056304c6d7
|
||||
settings.workspaces.moveDownAria: ed1ef4a310797ac84894a224bf4c77a6
|
||||
settings.workspaces.removeAria: a5d2ceeeb76bfcb99015fc780ced07c2
|
||||
settings.appearance.title: a1c58e94227389415de133efdf78ea6e
|
||||
settings.appearance.description: ca7b75a4c10ab8a540707e3a96cd3592
|
||||
|
||||
@@ -1758,7 +1758,7 @@ function App() {
|
||||
</div>
|
||||
<UpdateBanner status={updateStatus} actions={updateActions} locale={appLocale} />
|
||||
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
|
||||
<StatusBar noteCount={visibleEntries.length} modifiedCount={gitModifiedCount} vaultPath={resolvedPath} defaultWorkspacePath={defaultWorkspacePath} vaults={vaultSwitcher.allVaults} multiWorkspaceEnabled={multiWorkspaceEnabled} onSwitchVault={vaultSwitcher.switchVault} onSetDefaultWorkspace={vaultSwitcher.setDefaultWorkspace} onOpenSettings={handleOpenSettings} onOpenVaultSettings={handleOpenVaultSettings} onOpenFeedback={openFeedback} onOpenDocs={openDocs} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} commitActionPending={commitFlow.isOpeningCommitDialog} onInitializeGit={openGitSetupDialog} isOffline={networkStatus.isOffline} isGitVault={isGitVault} isVaultReloading={vault.isReloading || isVaultContentLoading} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} themeMode={documentThemeMode} onZoomReset={zoom.zoomReset} onToggleThemeMode={settingsLoaded ? handleToggleThemeMode : undefined} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} onUpdateWorkspaceIdentity={vaultSwitcher.updateWorkspaceIdentity} aiFeaturesEnabled={aiFeaturesEnabled} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiFeaturesEnabled ? aiAgentsStatus : undefined} vaultAiGuidanceStatus={aiFeaturesEnabled ? vaultAiGuidanceStatus : undefined} defaultAiAgent={aiFeaturesEnabled ? aiAgentPreferences.defaultAiAgent : undefined} defaultAiTarget={aiFeaturesEnabled ? settings.default_ai_target ?? undefined : undefined} aiModelProviders={aiFeaturesEnabled ? settings.ai_model_providers ?? [] : []} onSetDefaultAiAgent={aiFeaturesEnabled ? aiAgentPreferences.setDefaultAiAgent : undefined} onSetDefaultAiTarget={aiFeaturesEnabled ? aiAgentPreferences.setDefaultAiTarget : undefined} onRestoreVaultAiGuidance={aiFeaturesEnabled ? () => { void restoreVaultAiGuidance() } : undefined} locale={appLocale} />
|
||||
<StatusBar noteCount={visibleEntries.length} modifiedCount={gitModifiedCount} vaultPath={resolvedPath} defaultWorkspacePath={defaultWorkspacePath} vaults={vaultSwitcher.allVaults} multiWorkspaceEnabled={multiWorkspaceEnabled} onSwitchVault={vaultSwitcher.switchVault} onSetDefaultWorkspace={vaultSwitcher.setDefaultWorkspace} onOpenSettings={handleOpenSettings} onOpenVaultSettings={handleOpenVaultSettings} onOpenFeedback={openFeedback} onOpenDocs={openDocs} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} commitActionPending={commitFlow.isOpeningCommitDialog} onInitializeGit={openGitSetupDialog} isOffline={networkStatus.isOffline} isGitVault={isGitVault} isVaultReloading={vault.isReloading || isVaultContentLoading} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} themeMode={documentThemeMode} onZoomReset={zoom.zoomReset} onToggleThemeMode={settingsLoaded ? handleToggleThemeMode : undefined} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} onReorderVaults={vaultSwitcher.reorderVaults} onUpdateWorkspaceIdentity={vaultSwitcher.updateWorkspaceIdentity} aiFeaturesEnabled={aiFeaturesEnabled} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiFeaturesEnabled ? aiAgentsStatus : undefined} vaultAiGuidanceStatus={aiFeaturesEnabled ? vaultAiGuidanceStatus : undefined} defaultAiAgent={aiFeaturesEnabled ? aiAgentPreferences.defaultAiAgent : undefined} defaultAiTarget={aiFeaturesEnabled ? settings.default_ai_target ?? undefined : undefined} aiModelProviders={aiFeaturesEnabled ? settings.ai_model_providers ?? [] : []} onSetDefaultAiAgent={aiFeaturesEnabled ? aiAgentPreferences.setDefaultAiAgent : undefined} onSetDefaultAiTarget={aiFeaturesEnabled ? aiAgentPreferences.setDefaultAiTarget : undefined} onRestoreVaultAiGuidance={aiFeaturesEnabled ? () => { void restoreVaultAiGuidance() } : undefined} locale={appLocale} />
|
||||
<GitSetupDialog open={shouldShowGitSetupDialog} onInitGit={handleInitGitRepo} onDismiss={dismissGitSetupDialog} />
|
||||
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
|
||||
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
||||
@@ -1808,7 +1808,7 @@ function App() {
|
||||
onCommit={conflictResolver.commitResolution}
|
||||
onClose={conflictFlow.handleCloseConflictResolver}
|
||||
/>
|
||||
<SettingsPanel open={dialogs.showSettings} initialSectionId={settingsInitialSectionId} settings={settings} aiAgentsStatus={aiAgentsStatus} locale={appLocale} systemLocale={systemLocale} vaults={vaultSwitcher.allVaults} defaultWorkspacePath={vaultSwitcher.defaultWorkspacePath} onSetDefaultWorkspace={vaultSwitcher.setDefaultWorkspace} onRemoveVault={vaultSwitcher.removeVault} onUpdateWorkspaceIdentity={vaultSwitcher.updateWorkspaceIdentity} isGitVault={isGitVault} onSave={saveSettings} onCopyMcpConfig={handleCopyMcpConfig} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
|
||||
<SettingsPanel open={dialogs.showSettings} initialSectionId={settingsInitialSectionId} settings={settings} aiAgentsStatus={aiAgentsStatus} locale={appLocale} systemLocale={systemLocale} vaults={vaultSwitcher.allVaults} defaultWorkspacePath={vaultSwitcher.defaultWorkspacePath} onSetDefaultWorkspace={vaultSwitcher.setDefaultWorkspace} onRemoveVault={vaultSwitcher.removeVault} onReorderVaults={vaultSwitcher.reorderVaults} onUpdateWorkspaceIdentity={vaultSwitcher.updateWorkspaceIdentity} isGitVault={isGitVault} onSave={saveSettings} onCopyMcpConfig={handleCopyMcpConfig} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
|
||||
<FeedbackDialog open={showFeedback} onClose={closeFeedback} />
|
||||
<McpSetupDialog open={showMcpSetupDialog} status={mcpStatus} busyAction={mcpDialogAction} manualConfigSnippet={mcpConfigSnippet} manualConfigLoading={mcpConfigLoading} manualConfigError={mcpConfigError} locale={appLocale} onClose={closeMcpSetupDialog} onConnect={handleConnectMcp} onCopyManualConfig={handleCopyMcpConfig} onDisconnect={handleDisconnectMcp} onLoadManualConfig={handleLoadMcpConfigSnippet} />
|
||||
<CloneVaultModal key={dialogs.showCloneVault ? 'clone-open' : 'clone-closed'} open={dialogs.showCloneVault} onClose={dialogs.closeCloneVault} onVaultCloned={vaultSwitcher.handleVaultCloned} />
|
||||
|
||||
@@ -235,6 +235,7 @@ describe('SettingsPanel', () => {
|
||||
|
||||
it('keeps vault identity management hidden until multiple vaults are enabled', () => {
|
||||
const onUpdateWorkspaceIdentity = vi.fn()
|
||||
const onReorderVaults = vi.fn()
|
||||
render(
|
||||
<SettingsPanel
|
||||
open={true}
|
||||
@@ -242,6 +243,7 @@ describe('SettingsPanel', () => {
|
||||
vaults={workspaceVaults}
|
||||
defaultWorkspacePath="/personal"
|
||||
onSave={onSave}
|
||||
onReorderVaults={onReorderVaults}
|
||||
onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
@@ -264,6 +266,9 @@ describe('SettingsPanel', () => {
|
||||
expect(screen.getAllByLabelText('The stable prefix used in cross-vault links and relationships. It is read-only for now to avoid breaking existing references.').length).toBeGreaterThan(0)
|
||||
expect(screen.getByRole('button', { name: 'Make Default' })).toHaveAttribute('data-variant', 'secondary')
|
||||
expect(within(screen.getByTestId('settings-workspace-row-personal')).getByRole('button', { name: 'Purple' }).getAttribute('style')).toContain('2px solid var(--foreground)')
|
||||
expect(screen.getByRole('button', { name: 'Move vault Personal Notes up' })).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Move vault Personal Notes down' }))
|
||||
expect(onReorderVaults).toHaveBeenCalledWith(['/team', '/personal'])
|
||||
|
||||
const nameInput = screen.getByLabelText('Vault name for Personal Notes')
|
||||
fireEvent.change(nameInput, {
|
||||
|
||||
@@ -93,9 +93,7 @@ interface SettingsPanelProps {
|
||||
onCopyMcpConfig?: () => void
|
||||
vaults?: VaultOption[]
|
||||
defaultWorkspacePath?: string | null
|
||||
onRemoveVault?: (path: string) => void
|
||||
onSetDefaultWorkspace?: (path: string) => void
|
||||
onUpdateWorkspaceIdentity?: (path: string, patch: Partial<VaultOption>) => void
|
||||
onRemoveVault?: (path: string) => void; onReorderVaults?: (orderedPaths: string[]) => void; onSetDefaultWorkspace?: (path: string) => void; onUpdateWorkspaceIdentity?: (path: string, patch: Partial<VaultOption>) => void
|
||||
isGitVault?: boolean
|
||||
explicitOrganizationEnabled?: boolean
|
||||
onSaveExplicitOrganization?: (enabled: boolean) => void
|
||||
@@ -174,9 +172,7 @@ interface SettingsBodyProps {
|
||||
setMultiWorkspaceEnabled: (value: boolean) => void
|
||||
vaults: VaultOption[]
|
||||
defaultWorkspacePath?: string | null
|
||||
onRemoveVault?: (path: string) => void
|
||||
onSetDefaultWorkspace?: (path: string) => void
|
||||
onUpdateWorkspaceIdentity?: (path: string, patch: Partial<VaultOption>) => void
|
||||
onRemoveVault?: (path: string) => void; onReorderVaults?: (orderedPaths: string[]) => void; onSetDefaultWorkspace?: (path: string) => void; onUpdateWorkspaceIdentity?: (path: string, patch: Partial<VaultOption>) => void
|
||||
explicitOrganization: boolean
|
||||
setExplicitOrganization: (value: boolean) => void
|
||||
crashReporting: boolean
|
||||
@@ -299,9 +295,7 @@ export function SettingsPanel({
|
||||
onCopyMcpConfig,
|
||||
vaults = [],
|
||||
defaultWorkspacePath = null,
|
||||
onRemoveVault,
|
||||
onSetDefaultWorkspace,
|
||||
onUpdateWorkspaceIdentity,
|
||||
onRemoveVault, onReorderVaults, onSetDefaultWorkspace, onUpdateWorkspaceIdentity,
|
||||
isGitVault = true,
|
||||
explicitOrganizationEnabled = true,
|
||||
onSaveExplicitOrganization,
|
||||
@@ -320,9 +314,7 @@ export function SettingsPanel({
|
||||
onCopyMcpConfig={onCopyMcpConfig}
|
||||
vaults={vaults}
|
||||
defaultWorkspacePath={defaultWorkspacePath}
|
||||
onRemoveVault={onRemoveVault}
|
||||
onSetDefaultWorkspace={onSetDefaultWorkspace}
|
||||
onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity}
|
||||
{...{ onRemoveVault, onReorderVaults, onSetDefaultWorkspace, onUpdateWorkspaceIdentity }}
|
||||
isGitVault={isGitVault}
|
||||
explicitOrganizationEnabled={explicitOrganizationEnabled}
|
||||
onSaveExplicitOrganization={onSaveExplicitOrganization}
|
||||
@@ -349,9 +341,7 @@ function SettingsPanelInner({
|
||||
onCopyMcpConfig,
|
||||
vaults,
|
||||
defaultWorkspacePath,
|
||||
onRemoveVault,
|
||||
onSetDefaultWorkspace,
|
||||
onUpdateWorkspaceIdentity,
|
||||
onRemoveVault, onReorderVaults, onSetDefaultWorkspace, onUpdateWorkspaceIdentity,
|
||||
isGitVault,
|
||||
explicitOrganizationEnabled,
|
||||
onSaveExplicitOrganization,
|
||||
@@ -454,9 +444,7 @@ function SettingsPanelInner({
|
||||
onCopyMcpConfig={onCopyMcpConfig}
|
||||
vaults={vaults ?? []}
|
||||
defaultWorkspacePath={defaultWorkspacePath}
|
||||
onRemoveVault={onRemoveVault}
|
||||
onSetDefaultWorkspace={onSetDefaultWorkspace}
|
||||
onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity}
|
||||
{...{ onRemoveVault, onReorderVaults, onSetDefaultWorkspace, onUpdateWorkspaceIdentity }}
|
||||
setThemeMode={handleThemeModeChange}
|
||||
setHideGitignoredFiles={handleGitignoredVisibilityChange}
|
||||
setAllNotesFileVisibility={handleAllNotesFileVisibilityChange}
|
||||
@@ -498,9 +486,7 @@ interface SettingsBodyFromDraftProps {
|
||||
onCopyMcpConfig?: () => void
|
||||
vaults: VaultOption[]
|
||||
defaultWorkspacePath?: string | null
|
||||
onRemoveVault?: (path: string) => void
|
||||
onSetDefaultWorkspace?: (path: string) => void
|
||||
onUpdateWorkspaceIdentity?: (path: string, patch: Partial<VaultOption>) => void
|
||||
onRemoveVault?: (path: string) => void; onReorderVaults?: (orderedPaths: string[]) => void; onSetDefaultWorkspace?: (path: string) => void; onUpdateWorkspaceIdentity?: (path: string, patch: Partial<VaultOption>) => void
|
||||
setThemeMode: (value: ThemeMode) => void
|
||||
setHideGitignoredFiles: (value: boolean) => void
|
||||
setAllNotesFileVisibility: (value: AllNotesFileVisibility) => void
|
||||
@@ -517,9 +503,7 @@ function SettingsBodyFromDraft({
|
||||
onCopyMcpConfig,
|
||||
vaults,
|
||||
defaultWorkspacePath,
|
||||
onRemoveVault,
|
||||
onSetDefaultWorkspace,
|
||||
onUpdateWorkspaceIdentity,
|
||||
onRemoveVault, onReorderVaults, onSetDefaultWorkspace, onUpdateWorkspaceIdentity,
|
||||
setThemeMode,
|
||||
setHideGitignoredFiles,
|
||||
setAllNotesFileVisibility,
|
||||
@@ -572,9 +556,7 @@ function SettingsBodyFromDraft({
|
||||
setMultiWorkspaceEnabled={(value) => updateDraft('multiWorkspaceEnabled', value)}
|
||||
vaults={vaults}
|
||||
defaultWorkspacePath={defaultWorkspacePath}
|
||||
onRemoveVault={onRemoveVault}
|
||||
onSetDefaultWorkspace={onSetDefaultWorkspace}
|
||||
onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity}
|
||||
{...{ onRemoveVault, onReorderVaults, onSetDefaultWorkspace, onUpdateWorkspaceIdentity }}
|
||||
explicitOrganization={draft.explicitOrganization}
|
||||
setExplicitOrganization={(value) => updateDraft('explicitOrganization', value)}
|
||||
crashReporting={draft.crashReporting}
|
||||
@@ -617,9 +599,7 @@ function SettingsSyncAndAppearanceSections({
|
||||
setMultiWorkspaceEnabled,
|
||||
vaults,
|
||||
defaultWorkspacePath,
|
||||
onRemoveVault,
|
||||
onSetDefaultWorkspace,
|
||||
onUpdateWorkspaceIdentity,
|
||||
onRemoveVault, onReorderVaults, onSetDefaultWorkspace, onUpdateWorkspaceIdentity,
|
||||
themeMode,
|
||||
setThemeMode,
|
||||
uiLanguage,
|
||||
@@ -646,9 +626,7 @@ function SettingsSyncAndAppearanceSections({
|
||||
enabled={multiWorkspaceEnabled}
|
||||
locale={locale}
|
||||
onEnabledChange={setMultiWorkspaceEnabled}
|
||||
onRemoveVault={onRemoveVault}
|
||||
onSetDefaultWorkspace={onSetDefaultWorkspace}
|
||||
onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity}
|
||||
{...{ onRemoveVault, onReorderVaults, onSetDefaultWorkspace, onUpdateWorkspaceIdentity }}
|
||||
vaults={vaults}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
@@ -95,6 +95,7 @@ interface StatusBarProps {
|
||||
buildNumber?: string
|
||||
onCheckForUpdates?: () => void
|
||||
onRemoveVault?: (path: string) => void
|
||||
onReorderVaults?: (orderedPaths: string[]) => void
|
||||
onUpdateWorkspaceIdentity?: (path: string, patch: Partial<VaultOption>) => void
|
||||
aiFeaturesEnabled?: boolean
|
||||
mcpStatus?: McpStatus
|
||||
@@ -148,6 +149,7 @@ function StatusBarPrimaryFromFooter({
|
||||
buildNumber,
|
||||
onCheckForUpdates,
|
||||
onRemoveVault,
|
||||
onReorderVaults,
|
||||
onUpdateWorkspaceIdentity,
|
||||
aiFeaturesEnabled = true,
|
||||
mcpStatus,
|
||||
@@ -198,6 +200,7 @@ function StatusBarPrimaryFromFooter({
|
||||
buildNumber={buildNumber}
|
||||
onCheckForUpdates={onCheckForUpdates}
|
||||
onRemoveVault={onRemoveVault}
|
||||
onReorderVaults={onReorderVaults}
|
||||
onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity}
|
||||
mcpStatus={aiFeaturesEnabled ? mcpStatus : undefined}
|
||||
onInstallMcp={onInstallMcp}
|
||||
|
||||
59
src/components/WorkspaceMoveButtons.tsx
Normal file
59
src/components/WorkspaceMoveButtons.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { CaretDown, CaretUp } from '@phosphor-icons/react'
|
||||
import { Button } from './ui/button'
|
||||
import { createTranslator, type AppLocale } from '../lib/i18n'
|
||||
import type { VaultOption } from './status-bar/types'
|
||||
import type { VaultMoveDirection } from '../utils/vaultOrdering'
|
||||
import type { workspaceIdentityFromVault } from '../utils/workspaces'
|
||||
|
||||
interface WorkspaceMoveButtonsProps {
|
||||
canMoveDown: boolean
|
||||
canMoveUp: boolean
|
||||
locale: AppLocale
|
||||
onMoveVault?: (path: string, direction: VaultMoveDirection) => void
|
||||
vault: VaultOption
|
||||
workspace: ReturnType<typeof workspaceIdentityFromVault>
|
||||
}
|
||||
|
||||
export function WorkspaceMoveButtons({
|
||||
canMoveDown,
|
||||
canMoveUp,
|
||||
locale,
|
||||
onMoveVault,
|
||||
vault,
|
||||
workspace,
|
||||
}: WorkspaceMoveButtonsProps) {
|
||||
const t = createTranslator(locale)
|
||||
const moveUpLabel = t('settings.workspaces.moveUpAria', { label: workspace.label })
|
||||
const moveDownLabel = t('settings.workspaces.moveDownAria', { label: workspace.label })
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => onMoveVault?.(vault.path, 'up')}
|
||||
disabled={!onMoveVault || !canMoveUp}
|
||||
aria-label={moveUpLabel}
|
||||
title={moveUpLabel}
|
||||
data-testid={`settings-workspace-move-up-${workspace.alias}`}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<CaretUp size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => onMoveVault?.(vault.path, 'down')}
|
||||
disabled={!onMoveVault || !canMoveDown}
|
||||
aria-label={moveDownLabel}
|
||||
title={moveDownLabel}
|
||||
data-testid={`settings-workspace-move-down-${workspace.alias}`}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<CaretDown size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
292
src/components/WorkspaceSettingsRows.tsx
Normal file
292
src/components/WorkspaceSettingsRows.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
import { Info, Trash as Trash2 } from '@phosphor-icons/react'
|
||||
import { type KeyboardEvent, useState } from 'react'
|
||||
import { AccentColorPicker } from './AccentColorPicker'
|
||||
import { Button } from './ui/button'
|
||||
import { Input } from './ui/input'
|
||||
import { SettingsGroupItem } from './SettingsControls'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'
|
||||
import { WorkspaceMoveButtons } from './WorkspaceMoveButtons'
|
||||
import { createTranslator, type AppLocale } from '../lib/i18n'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
import type { VaultOption } from './status-bar/types'
|
||||
import { canMoveVaultPath, moveVaultPath, type VaultMoveDirection } from '../utils/vaultOrdering'
|
||||
import { workspaceIdentityFromVault } from '../utils/workspaces'
|
||||
|
||||
interface WorkspaceSettingsRowsProps {
|
||||
defaultWorkspacePath?: string | null
|
||||
locale: AppLocale
|
||||
onRemoveVault?: (path: string) => void
|
||||
onReorderVaults?: (orderedPaths: string[]) => void
|
||||
onSetDefaultWorkspace?: (path: string) => void
|
||||
onUpdateWorkspaceIdentity?: (path: string, patch: Partial<VaultOption>) => void
|
||||
setVaultPendingRemoval: (vault: VaultOption) => void
|
||||
vaults: VaultOption[]
|
||||
}
|
||||
|
||||
function workspaceInputId(alias: string, field: 'name' | 'label' | 'slug'): string {
|
||||
return `settings-workspace-${alias}-${field}`
|
||||
}
|
||||
|
||||
function sanitizeWorkspaceShortLabel(value: string): string {
|
||||
return value.trim().toUpperCase().slice(0, 3)
|
||||
}
|
||||
|
||||
function WorkspaceFieldLabel({
|
||||
htmlFor,
|
||||
label,
|
||||
tooltip,
|
||||
}: {
|
||||
htmlFor: string
|
||||
label: string
|
||||
tooltip: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<label className="truncate text-[11px] font-medium text-muted-foreground" htmlFor={htmlFor}>
|
||||
{label}
|
||||
</label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="h-4 w-4 shrink-0 rounded-full p-0 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
aria-label={tooltip}
|
||||
>
|
||||
<Info size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start">{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceIdentityInputs({
|
||||
canEdit,
|
||||
locale,
|
||||
onUpdateWorkspaceIdentity,
|
||||
vault,
|
||||
}: Pick<WorkspaceSettingsRowsProps, 'locale' | 'onUpdateWorkspaceIdentity'> & {
|
||||
canEdit: boolean
|
||||
vault: VaultOption
|
||||
}) {
|
||||
const t = createTranslator(locale)
|
||||
const workspace = workspaceIdentityFromVault(vault)
|
||||
const nameId = workspaceInputId(workspace.alias, 'name')
|
||||
const labelId = workspaceInputId(workspace.alias, 'label')
|
||||
const slugId = workspaceInputId(workspace.alias, 'slug')
|
||||
const savedNameDraft = vault.label ?? ''
|
||||
const savedShortLabelDraft = sanitizeWorkspaceShortLabel(vault.shortLabel || workspace.shortLabel)
|
||||
const [nameDraft, setNameDraft] = useState(savedNameDraft)
|
||||
const [shortLabelDraft, setShortLabelDraft] = useState(savedShortLabelDraft)
|
||||
|
||||
const commitNameDraft = () => {
|
||||
if (!canEdit || nameDraft === savedNameDraft) return
|
||||
onUpdateWorkspaceIdentity?.(vault.path, { label: nameDraft })
|
||||
}
|
||||
|
||||
const commitShortLabelDraft = () => {
|
||||
const normalizedShortLabel = sanitizeWorkspaceShortLabel(shortLabelDraft)
|
||||
if (!canEdit || normalizedShortLabel === savedShortLabelDraft) return
|
||||
onUpdateWorkspaceIdentity?.(vault.path, { shortLabel: normalizedShortLabel })
|
||||
}
|
||||
|
||||
const blurOnEnter = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') event.currentTarget.blur()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(80px,0.28fr)_minmax(140px,0.55fr)]">
|
||||
<div className="grid gap-1.5">
|
||||
<WorkspaceFieldLabel htmlFor={nameId} label={t('settings.workspaces.name')} tooltip={t('settings.workspaces.nameTooltip')} />
|
||||
<Input
|
||||
id={nameId}
|
||||
value={nameDraft}
|
||||
onChange={(event) => setNameDraft(event.target.value)}
|
||||
onBlur={commitNameDraft}
|
||||
onKeyDown={blurOnEnter}
|
||||
aria-label={t('settings.workspaces.nameAria', { label: workspace.label })}
|
||||
disabled={!canEdit}
|
||||
className="h-8 bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<WorkspaceFieldLabel htmlFor={labelId} label={t('settings.workspaces.label')} tooltip={t('settings.workspaces.labelTooltip')} />
|
||||
<Input
|
||||
id={labelId}
|
||||
value={shortLabelDraft}
|
||||
maxLength={3}
|
||||
onChange={(event) => setShortLabelDraft(sanitizeWorkspaceShortLabel(event.target.value))}
|
||||
onBlur={commitShortLabelDraft}
|
||||
onKeyDown={blurOnEnter}
|
||||
aria-label={t('settings.workspaces.labelAria', { label: workspace.label })}
|
||||
disabled={!canEdit}
|
||||
className="h-8 bg-transparent uppercase"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<WorkspaceFieldLabel htmlFor={slugId} label={t('settings.workspaces.slug')} tooltip={t('settings.workspaces.slugTooltip')} />
|
||||
<Input
|
||||
id={slugId}
|
||||
value={vault.alias ?? workspace.alias}
|
||||
aria-label={t('settings.workspaces.slugAria', { label: workspace.label })}
|
||||
readOnly
|
||||
aria-readonly="true"
|
||||
className="h-8 cursor-default bg-muted/30 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceRowActions({
|
||||
canEdit,
|
||||
canMoveDown,
|
||||
canMoveUp,
|
||||
locale,
|
||||
onMoveVault,
|
||||
onSetDefaultWorkspace,
|
||||
onUpdateWorkspaceIdentity,
|
||||
onRemoveVault,
|
||||
workspace,
|
||||
vault,
|
||||
}: Pick<WorkspaceSettingsRowsProps, 'locale' | 'onRemoveVault' | 'onSetDefaultWorkspace' | 'onUpdateWorkspaceIdentity'> & {
|
||||
canEdit: boolean
|
||||
canMoveDown: boolean
|
||||
canMoveUp: boolean
|
||||
onMoveVault?: (path: string, direction: VaultMoveDirection) => void
|
||||
workspace: ReturnType<typeof workspaceIdentityFromVault>
|
||||
vault: VaultOption
|
||||
}) {
|
||||
const t = createTranslator(locale)
|
||||
const removeLabel = t('settings.workspaces.removeAria', { label: workspace.label })
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-3">
|
||||
<WorkspaceMoveButtons canMoveDown={canMoveDown} canMoveUp={canMoveUp} locale={locale} onMoveVault={onMoveVault} vault={vault} workspace={workspace} />
|
||||
<AccentColorPicker
|
||||
className="gap-1.5"
|
||||
disabled={!canEdit}
|
||||
selectedColor={vault.color ?? null}
|
||||
onSelectColor={(color) => onUpdateWorkspaceIdentity?.(vault.path, { color })}
|
||||
size={18}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
onSetDefaultWorkspace?.(vault.path)
|
||||
trackEvent('workspace_default_changed', { workspace_alias: workspace.alias })
|
||||
}}
|
||||
disabled={!onSetDefaultWorkspace || workspace.defaultForNewNotes}
|
||||
data-testid={`settings-workspace-default-${workspace.alias}`}
|
||||
>
|
||||
{workspace.defaultForNewNotes ? t('workspace.manager.default') : t('workspace.manager.makeDefault')}
|
||||
</Button>
|
||||
{onRemoveVault && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => onRemoveVault(vault.path)}
|
||||
disabled={workspace.defaultForNewNotes}
|
||||
aria-label={removeLabel}
|
||||
title={removeLabel}
|
||||
data-testid={`settings-workspace-remove-${workspace.alias}`}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceSettingsRow({
|
||||
defaultWorkspacePath,
|
||||
locale,
|
||||
onMoveVault,
|
||||
onSetDefaultWorkspace,
|
||||
onUpdateWorkspaceIdentity,
|
||||
onRemoveVault,
|
||||
vaults,
|
||||
vault,
|
||||
}: Pick<WorkspaceSettingsRowsProps, 'defaultWorkspacePath' | 'locale' | 'onRemoveVault' | 'onSetDefaultWorkspace' | 'onUpdateWorkspaceIdentity' | 'vaults'> & {
|
||||
onMoveVault?: (path: string, direction: VaultMoveDirection) => void
|
||||
vault: VaultOption
|
||||
}) {
|
||||
const workspace = workspaceIdentityFromVault(vault, { defaultWorkspacePath })
|
||||
const canEdit = !!onUpdateWorkspaceIdentity && vault.path !== '' && !vault.managedDefault
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 px-4 py-4" data-testid={`settings-workspace-row-${workspace.alias}`}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-x-4 gap-y-2">
|
||||
<div className="min-w-[220px] flex-1">
|
||||
<div className="truncate text-sm font-medium text-foreground">{workspace.label}</div>
|
||||
<div className="truncate text-[11px] text-muted-foreground">{workspace.path}</div>
|
||||
</div>
|
||||
<WorkspaceRowActions
|
||||
canEdit={canEdit}
|
||||
canMoveDown={canMoveVaultPath(vaults, vault.path, 'down')}
|
||||
canMoveUp={canMoveVaultPath(vaults, vault.path, 'up')}
|
||||
locale={locale}
|
||||
onMoveVault={onMoveVault}
|
||||
onSetDefaultWorkspace={onSetDefaultWorkspace}
|
||||
onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity}
|
||||
onRemoveVault={onRemoveVault}
|
||||
workspace={workspace}
|
||||
vault={vault}
|
||||
/>
|
||||
</div>
|
||||
<WorkspaceIdentityInputs canEdit={canEdit} locale={locale} onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity} vault={vault} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function moveVaultInList(
|
||||
vaults: VaultOption[],
|
||||
onReorderVaults: ((orderedPaths: string[]) => void) | undefined,
|
||||
path: string,
|
||||
direction: VaultMoveDirection,
|
||||
) {
|
||||
const orderedPaths = moveVaultPath(vaults, path, direction)
|
||||
if (orderedPaths) onReorderVaults?.(orderedPaths)
|
||||
}
|
||||
|
||||
export function WorkspaceSettingsRows({
|
||||
defaultWorkspacePath,
|
||||
locale,
|
||||
onRemoveVault,
|
||||
onReorderVaults,
|
||||
onSetDefaultWorkspace,
|
||||
onUpdateWorkspaceIdentity,
|
||||
setVaultPendingRemoval,
|
||||
vaults,
|
||||
}: WorkspaceSettingsRowsProps) {
|
||||
const moveVault = (path: string, direction: VaultMoveDirection) => {
|
||||
moveVaultInList(vaults, onReorderVaults, path, direction)
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsGroupItem testId="settings-workspace-list">
|
||||
<div className="-mx-4 divide-y divide-border">
|
||||
{vaults.map((vault) => (
|
||||
<WorkspaceSettingsRow
|
||||
key={vault.path}
|
||||
defaultWorkspacePath={defaultWorkspacePath}
|
||||
locale={locale}
|
||||
onMoveVault={onReorderVaults ? moveVault : undefined}
|
||||
onSetDefaultWorkspace={onSetDefaultWorkspace}
|
||||
onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity}
|
||||
onRemoveVault={onRemoveVault ? () => setVaultPendingRemoval(vault) : undefined}
|
||||
vaults={vaults}
|
||||
vault={vault}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SettingsGroupItem>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,11 @@
|
||||
import { Info, Trash as Trash2 } from '@phosphor-icons/react'
|
||||
import { type KeyboardEvent, useState } from 'react'
|
||||
import { Button } from './ui/button'
|
||||
import { Input } from './ui/input'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'
|
||||
import { useState } from 'react'
|
||||
import { TooltipProvider } from './ui/tooltip'
|
||||
import { ConfirmDeleteDialog } from './ConfirmDeleteDialog'
|
||||
import { SettingsGroup, SettingsGroupItem, SettingsSwitchRow } from './SettingsControls'
|
||||
import { SettingsGroup, SettingsSwitchRow } from './SettingsControls'
|
||||
import { createTranslator, type AppLocale } from '../lib/i18n'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
import type { VaultOption } from './status-bar/types'
|
||||
import { workspaceIdentityFromVault } from '../utils/workspaces'
|
||||
import { AccentColorPicker } from './AccentColorPicker'
|
||||
import { WorkspaceSettingsRows } from './WorkspaceSettingsRows'
|
||||
|
||||
interface WorkspaceSettingsSectionProps {
|
||||
defaultWorkspacePath?: string | null
|
||||
@@ -17,248 +13,19 @@ interface WorkspaceSettingsSectionProps {
|
||||
locale: AppLocale
|
||||
onEnabledChange: (enabled: boolean) => void
|
||||
onRemoveVault?: (path: string) => void
|
||||
onReorderVaults?: (orderedPaths: string[]) => void
|
||||
onSetDefaultWorkspace?: (path: string) => void
|
||||
onUpdateWorkspaceIdentity?: (path: string, patch: Partial<VaultOption>) => void
|
||||
vaults: VaultOption[]
|
||||
}
|
||||
|
||||
function workspaceInputId(alias: string, field: 'name' | 'label' | 'slug'): string {
|
||||
return `settings-workspace-${alias}-${field}`
|
||||
}
|
||||
|
||||
function sanitizeWorkspaceShortLabel(value: string): string {
|
||||
return value.trim().toUpperCase().slice(0, 3)
|
||||
}
|
||||
|
||||
function WorkspaceFieldLabel({
|
||||
htmlFor,
|
||||
label,
|
||||
tooltip,
|
||||
}: {
|
||||
htmlFor: string
|
||||
label: string
|
||||
tooltip: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<label className="truncate text-[11px] font-medium text-muted-foreground" htmlFor={htmlFor}>
|
||||
{label}
|
||||
</label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="h-4 w-4 shrink-0 rounded-full p-0 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
aria-label={tooltip}
|
||||
>
|
||||
<Info size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start">{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceIdentityInputs({
|
||||
canEdit,
|
||||
locale,
|
||||
onUpdateWorkspaceIdentity,
|
||||
vault,
|
||||
}: Pick<WorkspaceSettingsSectionProps, 'locale' | 'onUpdateWorkspaceIdentity'> & {
|
||||
canEdit: boolean
|
||||
vault: VaultOption
|
||||
}) {
|
||||
const t = createTranslator(locale)
|
||||
const workspace = workspaceIdentityFromVault(vault)
|
||||
const nameId = workspaceInputId(workspace.alias, 'name')
|
||||
const labelId = workspaceInputId(workspace.alias, 'label')
|
||||
const slugId = workspaceInputId(workspace.alias, 'slug')
|
||||
const savedNameDraft = vault.label ?? ''
|
||||
const savedShortLabelDraft = sanitizeWorkspaceShortLabel(vault.shortLabel || workspace.shortLabel)
|
||||
const [nameDraft, setNameDraft] = useState(savedNameDraft)
|
||||
const [shortLabelDraft, setShortLabelDraft] = useState(savedShortLabelDraft)
|
||||
|
||||
const commitNameDraft = () => {
|
||||
if (!canEdit || nameDraft === savedNameDraft) return
|
||||
onUpdateWorkspaceIdentity?.(vault.path, { label: nameDraft })
|
||||
}
|
||||
|
||||
const commitShortLabelDraft = () => {
|
||||
const normalizedShortLabel = sanitizeWorkspaceShortLabel(shortLabelDraft)
|
||||
if (!canEdit || normalizedShortLabel === savedShortLabelDraft) return
|
||||
onUpdateWorkspaceIdentity?.(vault.path, { shortLabel: normalizedShortLabel })
|
||||
}
|
||||
|
||||
const blurOnEnter = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.currentTarget.blur()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(80px,0.28fr)_minmax(140px,0.55fr)]">
|
||||
<div className="grid gap-1.5">
|
||||
<WorkspaceFieldLabel
|
||||
htmlFor={nameId}
|
||||
label={t('settings.workspaces.name')}
|
||||
tooltip={t('settings.workspaces.nameTooltip')}
|
||||
/>
|
||||
<Input
|
||||
id={nameId}
|
||||
value={nameDraft}
|
||||
onChange={(event) => setNameDraft(event.target.value)}
|
||||
onBlur={commitNameDraft}
|
||||
onKeyDown={blurOnEnter}
|
||||
aria-label={t('settings.workspaces.nameAria', { label: workspace.label })}
|
||||
disabled={!canEdit}
|
||||
className="h-8 bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<WorkspaceFieldLabel
|
||||
htmlFor={labelId}
|
||||
label={t('settings.workspaces.label')}
|
||||
tooltip={t('settings.workspaces.labelTooltip')}
|
||||
/>
|
||||
<Input
|
||||
id={labelId}
|
||||
value={shortLabelDraft}
|
||||
maxLength={3}
|
||||
onChange={(event) => setShortLabelDraft(sanitizeWorkspaceShortLabel(event.target.value))}
|
||||
onBlur={commitShortLabelDraft}
|
||||
onKeyDown={blurOnEnter}
|
||||
aria-label={t('settings.workspaces.labelAria', { label: workspace.label })}
|
||||
disabled={!canEdit}
|
||||
className="h-8 bg-transparent uppercase"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<WorkspaceFieldLabel
|
||||
htmlFor={slugId}
|
||||
label={t('settings.workspaces.slug')}
|
||||
tooltip={t('settings.workspaces.slugTooltip')}
|
||||
/>
|
||||
<Input
|
||||
id={slugId}
|
||||
value={vault.alias ?? workspace.alias}
|
||||
aria-label={t('settings.workspaces.slugAria', { label: workspace.label })}
|
||||
readOnly
|
||||
aria-readonly="true"
|
||||
className="h-8 cursor-default bg-muted/30 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceRowActions({
|
||||
canEdit,
|
||||
locale,
|
||||
onSetDefaultWorkspace,
|
||||
onUpdateWorkspaceIdentity,
|
||||
onRemoveVault,
|
||||
workspace,
|
||||
vault,
|
||||
}: Pick<WorkspaceSettingsSectionProps, 'locale' | 'onRemoveVault' | 'onSetDefaultWorkspace' | 'onUpdateWorkspaceIdentity'> & {
|
||||
canEdit: boolean
|
||||
workspace: ReturnType<typeof workspaceIdentityFromVault>
|
||||
vault: VaultOption
|
||||
}) {
|
||||
const t = createTranslator(locale)
|
||||
const removeLabel = t('settings.workspaces.removeAria', { label: workspace.label })
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-3">
|
||||
<AccentColorPicker
|
||||
className="gap-1.5"
|
||||
disabled={!canEdit}
|
||||
selectedColor={vault.color ?? null}
|
||||
onSelectColor={(color) => onUpdateWorkspaceIdentity?.(vault.path, { color })}
|
||||
size={18}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
onSetDefaultWorkspace?.(vault.path)
|
||||
trackEvent('workspace_default_changed', { workspace_alias: workspace.alias })
|
||||
}}
|
||||
disabled={!onSetDefaultWorkspace || workspace.defaultForNewNotes}
|
||||
data-testid={`settings-workspace-default-${workspace.alias}`}
|
||||
>
|
||||
{workspace.defaultForNewNotes
|
||||
? t('workspace.manager.default')
|
||||
: t('workspace.manager.makeDefault')}
|
||||
</Button>
|
||||
{onRemoveVault && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => onRemoveVault(vault.path)}
|
||||
disabled={workspace.defaultForNewNotes}
|
||||
aria-label={removeLabel}
|
||||
title={removeLabel}
|
||||
data-testid={`settings-workspace-remove-${workspace.alias}`}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceSettingsRow({
|
||||
defaultWorkspacePath,
|
||||
locale,
|
||||
onSetDefaultWorkspace,
|
||||
onUpdateWorkspaceIdentity,
|
||||
onRemoveVault,
|
||||
vault,
|
||||
}: Pick<WorkspaceSettingsSectionProps, 'defaultWorkspacePath' | 'locale' | 'onRemoveVault' | 'onSetDefaultWorkspace' | 'onUpdateWorkspaceIdentity'> & {
|
||||
vault: VaultOption
|
||||
}) {
|
||||
const workspace = workspaceIdentityFromVault(vault, { defaultWorkspacePath })
|
||||
const canEdit = !!onUpdateWorkspaceIdentity && vault.path !== '' && !vault.managedDefault
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 px-4 py-4" data-testid={`settings-workspace-row-${workspace.alias}`}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-x-4 gap-y-2">
|
||||
<div className="min-w-[220px] flex-1">
|
||||
<div className="truncate text-sm font-medium text-foreground">{workspace.label}</div>
|
||||
<div className="truncate text-[11px] text-muted-foreground">{workspace.path}</div>
|
||||
</div>
|
||||
<WorkspaceRowActions
|
||||
canEdit={canEdit}
|
||||
locale={locale}
|
||||
onSetDefaultWorkspace={onSetDefaultWorkspace}
|
||||
onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity}
|
||||
onRemoveVault={onRemoveVault}
|
||||
workspace={workspace}
|
||||
vault={vault}
|
||||
/>
|
||||
</div>
|
||||
<WorkspaceIdentityInputs
|
||||
canEdit={canEdit}
|
||||
locale={locale}
|
||||
onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity}
|
||||
vault={vault}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WorkspaceSettingsSection({
|
||||
defaultWorkspacePath,
|
||||
enabled,
|
||||
locale,
|
||||
onEnabledChange,
|
||||
onRemoveVault,
|
||||
onReorderVaults,
|
||||
onSetDefaultWorkspace,
|
||||
onUpdateWorkspaceIdentity,
|
||||
vaults,
|
||||
@@ -278,21 +45,16 @@ export function WorkspaceSettingsSection({
|
||||
testId="settings-multi-workspace-enabled"
|
||||
/>
|
||||
{enabled && (
|
||||
<SettingsGroupItem testId="settings-workspace-list">
|
||||
<div className="-mx-4 divide-y divide-border">
|
||||
{vaults.map((vault) => (
|
||||
<WorkspaceSettingsRow
|
||||
key={vault.path}
|
||||
defaultWorkspacePath={defaultWorkspacePath}
|
||||
locale={locale}
|
||||
onSetDefaultWorkspace={onSetDefaultWorkspace}
|
||||
onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity}
|
||||
onRemoveVault={onRemoveVault ? () => setVaultPendingRemoval(vault) : undefined}
|
||||
vault={vault}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SettingsGroupItem>
|
||||
<WorkspaceSettingsRows
|
||||
defaultWorkspacePath={defaultWorkspacePath}
|
||||
locale={locale}
|
||||
onRemoveVault={onRemoveVault}
|
||||
onReorderVaults={onReorderVaults}
|
||||
onSetDefaultWorkspace={onSetDefaultWorkspace}
|
||||
onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity}
|
||||
setVaultPendingRemoval={setVaultPendingRemoval}
|
||||
vaults={vaults}
|
||||
/>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
<ConfirmDeleteDialog
|
||||
|
||||
@@ -71,6 +71,7 @@ interface StatusBarPrimarySectionProps {
|
||||
buildNumber?: string
|
||||
onCheckForUpdates?: () => void
|
||||
onRemoveVault?: (path: string) => void
|
||||
onReorderVaults?: (orderedPaths: string[]) => void
|
||||
onUpdateWorkspaceIdentity?: (path: string, patch: Partial<VaultOption>) => void
|
||||
mcpStatus?: McpStatus
|
||||
onInstallMcp?: () => void
|
||||
@@ -430,7 +431,9 @@ export function StatusBarPrimarySection({
|
||||
onOpenConflictResolver,
|
||||
buildNumber,
|
||||
onCheckForUpdates,
|
||||
onRemoveVault, onUpdateWorkspaceIdentity,
|
||||
onRemoveVault,
|
||||
onReorderVaults,
|
||||
onUpdateWorkspaceIdentity,
|
||||
mcpStatus,
|
||||
onInstallMcp,
|
||||
aiAgentsStatus,
|
||||
@@ -447,13 +450,7 @@ export function StatusBarPrimarySection({
|
||||
stacked = false,
|
||||
compact = false,
|
||||
}: StatusBarPrimarySectionProps) {
|
||||
const {
|
||||
openAddRemote,
|
||||
closeAddRemote,
|
||||
showAddRemote,
|
||||
visibleRemoteStatus,
|
||||
handleRemoteConnected,
|
||||
} = useStatusBarAddRemote({
|
||||
const { openAddRemote, closeAddRemote, showAddRemote, visibleRemoteStatus, handleRemoteConnected } = useStatusBarAddRemote({
|
||||
vaultPath,
|
||||
isGitVault,
|
||||
remoteStatus,
|
||||
@@ -474,8 +471,7 @@ export function StatusBarPrimarySection({
|
||||
onCreateEmptyVault={onCreateEmptyVault}
|
||||
onCloneVault={onCloneVault}
|
||||
onCloneGettingStarted={onCloneGettingStarted}
|
||||
onRemoveVault={onRemoveVault}
|
||||
onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity}
|
||||
{...{ onRemoveVault, onReorderVaults, onUpdateWorkspaceIdentity }}
|
||||
compact={compact}
|
||||
locale={locale}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { Check, Cube, FolderOpen, GitBranch, Plus, Rocket, Warning as AlertTriangle, X } from '@phosphor-icons/react'
|
||||
import { 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,
|
||||
} 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'
|
||||
@@ -23,6 +30,7 @@ interface VaultMenuProps {
|
||||
onCloneVault?: () => void
|
||||
onCloneGettingStarted?: () => void
|
||||
onRemoveVault?: (path: string) => void
|
||||
onReorderVaults?: (orderedPaths: string[]) => void
|
||||
multiWorkspaceEnabled?: boolean
|
||||
onUpdateWorkspaceIdentity?: (path: string, patch: Partial<VaultOption>) => void
|
||||
compact?: boolean
|
||||
@@ -41,6 +49,20 @@ interface VaultMenuItemProps {
|
||||
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
|
||||
@@ -314,6 +336,106 @@ 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
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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(() => sortableVaultPaths(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,
|
||||
@@ -367,12 +489,96 @@ function VaultMenuAction({
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
onUpdateWorkspaceIdentity, compact = false, locale = 'en',
|
||||
onReorderVaults, onUpdateWorkspaceIdentity, compact = false, locale = 'en',
|
||||
} = props
|
||||
const [open, setOpen] = useState(false)
|
||||
const [vaultPendingRemoval, setVaultPendingRemoval] = useState<VaultOption | null>(null)
|
||||
@@ -391,6 +597,22 @@ export function VaultMenu(props: VaultMenuProps) {
|
||||
)),
|
||||
[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 })
|
||||
}
|
||||
|
||||
useDismissibleLayer(open, menuRef, () => setOpen(false))
|
||||
|
||||
@@ -421,76 +643,23 @@ export function VaultMenu(props: VaultMenuProps) {
|
||||
</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: 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' }} />
|
||||
</>
|
||||
)}
|
||||
{vaults.map((vault) => (
|
||||
<VaultMenuItem
|
||||
key={vault.path}
|
||||
vault={vault}
|
||||
isActive={vault.path === defaultPath}
|
||||
canRemove={canRemove && vault.path !== defaultPath}
|
||||
disableMountToggle={vault.path === defaultPath && (includedVaults.length <= 1 || !onSetDefaultWorkspace)}
|
||||
locale={locale}
|
||||
multiWorkspaceEnabled={multiWorkspaceEnabled}
|
||||
onSelect={() => {
|
||||
if (multiWorkspaceEnabled && onSetDefaultWorkspace) onSetDefaultWorkspace(vault.path)
|
||||
else onSwitchVault(vault.path)
|
||||
setOpen(false)
|
||||
}}
|
||||
onMountedChange={(path, mounted) => {
|
||||
if (!mounted && path === defaultPath) {
|
||||
const nextDefaultPath = includedVaults.find((includedVault) => includedVault.path !== path)?.path
|
||||
if (!nextDefaultPath) return
|
||||
onSetDefaultWorkspace?.(nextDefaultPath)
|
||||
}
|
||||
onUpdateWorkspaceIdentity?.(path, { mounted })
|
||||
}}
|
||||
onRequestRemove={onRemoveVault ? () => setVaultPendingRemoval(vault) : undefined}
|
||||
/>
|
||||
))}
|
||||
{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>
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
<ConfirmDeleteDialog
|
||||
open={!!vaultPendingRemoval}
|
||||
|
||||
26
src/hooks/useVaultReorderAction.ts
Normal file
26
src/hooks/useVaultReorderAction.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useCallback, type Dispatch, type SetStateAction } from 'react'
|
||||
import type { VaultOption } from '../components/status-bar/types'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
import { orderVaultsByPath } from '../utils/vaultOrdering'
|
||||
|
||||
function persistableVault(vault: VaultOption): VaultOption {
|
||||
const persistedVault = { ...vault }
|
||||
delete persistedVault.managedDefault
|
||||
return persistedVault
|
||||
}
|
||||
|
||||
export function useVaultReorderAction({
|
||||
allVaults,
|
||||
setExtraVaults,
|
||||
}: {
|
||||
allVaults: VaultOption[]
|
||||
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
|
||||
}) {
|
||||
return useCallback((orderedPaths: string[]) => {
|
||||
const orderedVaults = orderVaultsByPath(allVaults, orderedPaths)
|
||||
if (!orderedVaults) return
|
||||
|
||||
setExtraVaults(orderedVaults.map(persistableVault))
|
||||
trackEvent('vault_order_changed', { vault_count: orderedVaults.length })
|
||||
}, [allVaults, setExtraVaults])
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { formatFolderPickerActionError, pickFolder } from '../utils/vault-dialog
|
||||
import { loadVaultList, saveVaultList } from '../utils/vaultListStore'
|
||||
import type { VaultOption } from '../components/StatusBar'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
import { buildAllVaults } from '../utils/vaultCollections'
|
||||
import { useVaultReorderAction } from './useVaultReorderAction'
|
||||
import { useWorkspaceIdentityActions } from './useWorkspaceIdentityActions'
|
||||
import { sanitizeDefaultWorkspacePath } from './vaultSwitcherSanitization'
|
||||
|
||||
@@ -196,7 +198,7 @@ function isUnavailableGettingStartedVault(vault: VaultOption): boolean {
|
||||
}
|
||||
|
||||
function shouldDropPersistedGettingStartedVault(vault: VaultOption, resolvedDefaultPath: string): boolean {
|
||||
return isCanonicalGettingStartedPath(vault.path, resolvedDefaultPath) || isUnavailableGettingStartedVault(vault)
|
||||
return isCanonicalGettingStartedPath(vault.path, resolvedDefaultPath) && isUnavailableGettingStartedVault(vault)
|
||||
}
|
||||
|
||||
async function checkVaultAvailability(path: string): Promise<boolean> {
|
||||
@@ -341,22 +343,6 @@ function buildVisibleDefaultVaults({
|
||||
return defaultVaults.filter(vault => !hiddenDefaults.includes(vault.path))
|
||||
}
|
||||
|
||||
function buildAllVaults({
|
||||
visibleDefaults,
|
||||
extraVaults,
|
||||
}: {
|
||||
visibleDefaults: VaultOption[]
|
||||
extraVaults: VaultOption[]
|
||||
}): VaultOption[] {
|
||||
const vaultsByPath = new Map<string, VaultOption>()
|
||||
for (const vault of visibleDefaults) vaultsByPath.set(vault.path, vault)
|
||||
for (const vault of extraVaults) {
|
||||
const existingVault = vaultsByPath.get(vault.path)
|
||||
vaultsByPath.set(vault.path, existingVault ? { ...existingVault, ...vault, path: vault.path } : vault)
|
||||
}
|
||||
return [...vaultsByPath.values()]
|
||||
}
|
||||
|
||||
function applyResolvedDefaultPath({
|
||||
defaultAvailable,
|
||||
resolvedDefaultPath,
|
||||
@@ -431,8 +417,8 @@ function useVaultCollections(
|
||||
[defaultVaults, hiddenDefaults],
|
||||
)
|
||||
const allVaults = useMemo(
|
||||
() => buildAllVaults({ visibleDefaults, extraVaults }),
|
||||
[extraVaults, visibleDefaults],
|
||||
() => buildAllVaults({ visibleDefaults, extraVaults, hiddenDefaults }),
|
||||
[extraVaults, hiddenDefaults, visibleDefaults],
|
||||
)
|
||||
const isGettingStartedHidden = useMemo(
|
||||
() => hiddenDefaults.includes(defaultPath),
|
||||
@@ -809,6 +795,7 @@ function removeVaultFromState({
|
||||
}: RemoveVaultStateOptions) {
|
||||
if (isDefault) {
|
||||
setHiddenDefaults(previousHidden => previousHidden.includes(removedPath) ? previousHidden : [...previousHidden, removedPath])
|
||||
setExtraVaults(previousVaults => previousVaults.filter(vault => vault.path !== removedPath))
|
||||
} else {
|
||||
setExtraVaults(previousVaults => previousVaults.filter(vault => vault.path !== removedPath))
|
||||
}
|
||||
@@ -1108,6 +1095,7 @@ function useRestoreGettingStartedAction(options: RestoreGettingStartedOptions) {
|
||||
}
|
||||
|
||||
function useVaultActions({
|
||||
allVaults,
|
||||
defaultAvailable,
|
||||
defaultPath,
|
||||
defaultWorkspacePath,
|
||||
@@ -1132,6 +1120,7 @@ function useVaultActions({
|
||||
|
||||
const switchVault = useSwitchVaultAction(onSwitchRef, setSelectedVaultPath, setVaultPath)
|
||||
const workspaceIdentityActions = useWorkspaceIdentityActions({ setDefaultWorkspacePath, setExtraVaults })
|
||||
const reorderVaults = useVaultReorderAction({ allVaults, setExtraVaults })
|
||||
const registerVaultSelection = useRegisterVaultSelectionAction({
|
||||
defaultAvailable,
|
||||
defaultPath,
|
||||
@@ -1184,6 +1173,7 @@ function useVaultActions({
|
||||
selectedVaultPath,
|
||||
vaultPath,
|
||||
}),
|
||||
reorderVaults,
|
||||
restoreGettingStarted: useRestoreGettingStartedAction({
|
||||
defaultPath,
|
||||
onToastRef,
|
||||
@@ -1252,6 +1242,7 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
|
||||
handleVaultCloned,
|
||||
registerVaultSelection,
|
||||
removeVault,
|
||||
reorderVaults,
|
||||
restoreGettingStarted,
|
||||
setDefaultWorkspace,
|
||||
syncVaultSelection,
|
||||
@@ -1277,6 +1268,7 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
|
||||
loaded,
|
||||
registerVaultSelection,
|
||||
removeVault,
|
||||
reorderVaults,
|
||||
restoreGettingStarted,
|
||||
setDefaultWorkspace,
|
||||
selectedVaultPath,
|
||||
|
||||
@@ -125,6 +125,8 @@
|
||||
"settings.workspaces.labelTooltip": "Die kurzen Initialen, die in Notizen, Suchergebnissen, Breadcrumbs und Vault-Badges angezeigt werden.",
|
||||
"settings.workspaces.slugTooltip": "Das stabile Präfix, das in vault-übergreifenden Links und Beziehungen verwendet wird. Derzeit schreibgeschützt, um zu vermeiden, dass bestehende Verweise beschädigt werden.",
|
||||
"settings.workspaces.color": "Farbe",
|
||||
"settings.workspaces.moveUpAria": "Vault {label} nach oben verschieben",
|
||||
"settings.workspaces.moveDownAria": "Vault {label} nach unten verschieben",
|
||||
"settings.workspaces.removeAria": "Vault {label} entfernen",
|
||||
"settings.appearance.title": "Darstellung",
|
||||
"settings.appearance.description": "Wählen Sie den App-Farbmodus aus, der für Tolaria Chrome, Editor-Oberflächen, Menüs und Dialoge verwendet werden soll.",
|
||||
|
||||
@@ -125,6 +125,8 @@
|
||||
"settings.workspaces.labelTooltip": "The short initials shown on notes, search results, breadcrumbs, and vault badges.",
|
||||
"settings.workspaces.slugTooltip": "The stable prefix used in cross-vault links and relationships. It is read-only for now to avoid breaking existing references.",
|
||||
"settings.workspaces.color": "Color",
|
||||
"settings.workspaces.moveUpAria": "Move vault {label} up",
|
||||
"settings.workspaces.moveDownAria": "Move vault {label} down",
|
||||
"settings.workspaces.removeAria": "Remove vault {label}",
|
||||
"settings.appearance.title": "Appearance",
|
||||
"settings.appearance.description": "Choose the app color mode used for Tolaria chrome, editor surfaces, menus, and dialogs.",
|
||||
|
||||
@@ -125,6 +125,8 @@
|
||||
"settings.workspaces.labelTooltip": "Las iniciales abreviadas que se muestran en las notas, los resultados de búsqueda, las rutas de navegación y las insignias de los almacenes.",
|
||||
"settings.workspaces.slugTooltip": "El prefijo estable utilizado en los enlaces y las relaciones entre almacenes. Por el momento, es de solo lectura para evitar que se dañen las referencias existentes.",
|
||||
"settings.workspaces.color": "Color",
|
||||
"settings.workspaces.moveUpAria": "Mover la bóveda {label} hacia arriba",
|
||||
"settings.workspaces.moveDownAria": "Mover la bóveda {label} hacia abajo",
|
||||
"settings.workspaces.removeAria": "Eliminar el almacén {label}",
|
||||
"settings.appearance.title": "Apariencia",
|
||||
"settings.appearance.description": "Elija el modo de color de la aplicación que se utilizará para Tolaria Chrome, las superficies del editor, los menús y los cuadros de diálogo.",
|
||||
|
||||
@@ -125,6 +125,8 @@
|
||||
"settings.workspaces.labelTooltip": "Las iniciales abreviadas que se muestran en las notas, los resultados de búsqueda, las rutas de navegación y las insignias de los almacenes.",
|
||||
"settings.workspaces.slugTooltip": "El prefijo estable utilizado en los enlaces y las relaciones entre cajas fuertes. Por ahora, es de solo lectura para evitar que se rompan las referencias existentes.",
|
||||
"settings.workspaces.color": "Color",
|
||||
"settings.workspaces.moveUpAria": "Subir el almacén {label}",
|
||||
"settings.workspaces.moveDownAria": "Mover el almacén {label} hacia abajo",
|
||||
"settings.workspaces.removeAria": "Eliminar el almacén {label}",
|
||||
"settings.appearance.title": "Apariencia",
|
||||
"settings.appearance.description": "Seleccione el modo de color de la aplicación que se utilizará para Tolaria Chrome, las superficies del editor, los menús y los cuadros de diálogo.",
|
||||
|
||||
@@ -125,6 +125,8 @@
|
||||
"settings.workspaces.labelTooltip": "Initiales abrégées affichées sur les notes, les résultats de recherche, le fil d'Ariane et les badges de coffre-fort.",
|
||||
"settings.workspaces.slugTooltip": "Préfixe stable utilisé dans les liens et les relations entre coffres-forts. Pour l'instant, il est en lecture seule afin d'éviter de casser les références existantes.",
|
||||
"settings.workspaces.color": "Couleur",
|
||||
"settings.workspaces.moveUpAria": "Déplacer le coffre {label} vers le haut",
|
||||
"settings.workspaces.moveDownAria": "Déplacer le coffre {label} vers le bas",
|
||||
"settings.workspaces.removeAria": "Supprimer le coffre {label}",
|
||||
"settings.appearance.title": "Apparence",
|
||||
"settings.appearance.description": "Choisissez le mode de couleur de l'application utilisé pour Tolaria Chrome, les surfaces de l'éditeur, les menus et les boîtes de dialogue.",
|
||||
|
||||
@@ -125,6 +125,8 @@
|
||||
"settings.workspaces.labelTooltip": "Le iniziali brevi visualizzate su note, risultati di ricerca, breadcrumb e badge dei vault.",
|
||||
"settings.workspaces.slugTooltip": "Prefisso stabile utilizzato nei link e nelle relazioni tra vault. Per il momento è di sola lettura per evitare di compromettere i riferimenti esistenti.",
|
||||
"settings.workspaces.color": "Colore",
|
||||
"settings.workspaces.moveUpAria": "Sposta il vault {label} }",
|
||||
"settings.workspaces.moveDownAria": "Sposta il vault {label} in basso",
|
||||
"settings.workspaces.removeAria": "Rimuovi vault {label}",
|
||||
"settings.appearance.title": "Aspetto",
|
||||
"settings.appearance.description": "Scegli la modalità colore dell'app da utilizzare per Tolaria Chrome, le interfacce dell'editor, i menu e le finestre di dialogo.",
|
||||
|
||||
@@ -125,6 +125,8 @@
|
||||
"settings.workspaces.labelTooltip": "ノート、検索結果、ブレッドクラム、およびボルトバッジに表示される短いイニシャル。",
|
||||
"settings.workspaces.slugTooltip": "クロスボルトリンクと関係性で使用される安定したプレフィックス。既存の参照が破損するのを防ぐため、現時点では読み取り専用です。",
|
||||
"settings.workspaces.color": "色",
|
||||
"settings.workspaces.moveUpAria": "ボールト{label}を上に移動",
|
||||
"settings.workspaces.moveDownAria": "ボールト{label}を下に移動",
|
||||
"settings.workspaces.removeAria": "ボールト {label} を削除",
|
||||
"settings.appearance.title": "外観",
|
||||
"settings.appearance.description": "Tolaria chrome、エディターのサーフェス、メニュー、およびダイアログに使用するアプリのカラーモードを選択します。",
|
||||
|
||||
@@ -125,6 +125,8 @@
|
||||
"settings.workspaces.labelTooltip": "노트, 검색 결과, 브레드크럼, 및 보관함 배지에 표시되는 짧은 이니셜.",
|
||||
"settings.workspaces.slugTooltip": "Vault 간 링크 및 관계에 사용되는 안정적인 접두사. 기존 참조가 손상되는 것을 방지하기 위해 현재는 읽기 전용입니다.",
|
||||
"settings.workspaces.color": "색상",
|
||||
"settings.workspaces.moveUpAria": "보관함 {label} 위로 이동",
|
||||
"settings.workspaces.moveDownAria": "보관함 {label} 아래로 이동",
|
||||
"settings.workspaces.removeAria": "보관함 {label} 제거",
|
||||
"settings.appearance.title": "외관",
|
||||
"settings.appearance.description": "Tolaria chrome, 편집기 표면, 메뉴, 대화 상자에 사용되는 앱 색상 모드를 선택하세요.",
|
||||
|
||||
@@ -125,6 +125,8 @@
|
||||
"settings.workspaces.labelTooltip": "Skrócone inicjały wyświetlane w notatkach, wynikach wyszukiwania, ścieżkach nawigacyjnych i znacznikach magazynów.",
|
||||
"settings.workspaces.slugTooltip": "Stały prefiks używany w linkach i relacjach między magazynami. Obecnie jest on dostępny tylko do odczytu, aby uniknąć uszkodzenia istniejących odwołań.",
|
||||
"settings.workspaces.color": "Kolor",
|
||||
"settings.workspaces.moveUpAria": "Przenieś skarbiec {label} w górę",
|
||||
"settings.workspaces.moveDownAria": "Przenieś skarbiec {label} w dół",
|
||||
"settings.workspaces.removeAria": "Usuń skarbiec {label}",
|
||||
"settings.appearance.title": "Wygląd",
|
||||
"settings.appearance.description": "Wybierz tryb kolorów używany w interfejsie Tolaria, edytorze, menu i oknach dialogowych.",
|
||||
|
||||
@@ -125,6 +125,8 @@
|
||||
"settings.workspaces.labelTooltip": "As iniciais abreviadas exibidas em notas, resultados de busca, breadcrumbs e badges de cofre.",
|
||||
"settings.workspaces.slugTooltip": "O prefixo estável usado em links e relacionamentos entre cofres. Por enquanto, ele é somente leitura para evitar que referências existentes sejam corrompidas.",
|
||||
"settings.workspaces.color": "Cor",
|
||||
"settings.workspaces.moveUpAria": "Mover o cofre {label} para cima",
|
||||
"settings.workspaces.moveDownAria": "Mover o cofre {label} para baixo",
|
||||
"settings.workspaces.removeAria": "Remover cofre {label}",
|
||||
"settings.appearance.title": "Aparência",
|
||||
"settings.appearance.description": "Escolha o modo de cores do aplicativo usado para o Tolaria Chrome, as superfícies do editor, os menus e as caixas de diálogo.",
|
||||
|
||||
@@ -125,6 +125,8 @@
|
||||
"settings.workspaces.labelTooltip": "As iniciais abreviadas apresentadas em notas, resultados de pesquisa, breadcrumbs e emblemas de cofre.",
|
||||
"settings.workspaces.slugTooltip": "O prefixo estável utilizado em ligações e relações entre cofres. Por agora, é apenas de leitura para evitar danificar as referências existentes.",
|
||||
"settings.workspaces.color": "Cor",
|
||||
"settings.workspaces.moveUpAria": "Mover o cofre {label} para cima",
|
||||
"settings.workspaces.moveDownAria": "Mover o cofre {label} para baixo",
|
||||
"settings.workspaces.removeAria": "Remover cofre {label}",
|
||||
"settings.appearance.title": "Aparência",
|
||||
"settings.appearance.description": "Escolha o modo de cor da aplicação utilizado para o Tolaria Chrome, as superfícies do editor, os menus e as caixas de diálogo.",
|
||||
|
||||
@@ -125,6 +125,8 @@
|
||||
"settings.workspaces.labelTooltip": "Краткие инициалы, отображаемые в заметках, результатах поиска, навигационных цепочках и значках хранилищ.",
|
||||
"settings.workspaces.slugTooltip": "Стабильный префикс, используемый в ссылках и связях между хранилищами. Пока что он доступен только для чтения, чтобы не нарушить существующие ссылки.",
|
||||
"settings.workspaces.color": "Цвет",
|
||||
"settings.workspaces.moveUpAria": "Переместить хранилище {label} вверх",
|
||||
"settings.workspaces.moveDownAria": "Переместить хранилище {label} вниз",
|
||||
"settings.workspaces.removeAria": "Удалить хранилище {label}",
|
||||
"settings.appearance.title": "Внешний вид",
|
||||
"settings.appearance.description": "Выберите цветовой режим приложения, который будет использоваться для Tolaria Chrome, интерфейса редактора, меню и диалоговых окон.",
|
||||
|
||||
@@ -125,6 +125,8 @@
|
||||
"settings.workspaces.labelTooltip": "Tên viết tắt ngắn hiển thị trên ghi chú, kết quả tìm kiếm, đường dẫn điều hướng và huy hiệu kho lưu trữ.",
|
||||
"settings.workspaces.slugTooltip": "Tiền tố ổn định được sử dụng trong các liên kết và mối quan hệ giữa các vault. Hiện tại, tiền tố này ở chế độ chỉ đọc để tránh làm hỏng các tham chiếu hiện có.",
|
||||
"settings.workspaces.color": "Màu",
|
||||
"settings.workspaces.moveUpAria": "Di chuyển vault {label} lên",
|
||||
"settings.workspaces.moveDownAria": "Di chuyển kho {label} xuống dưới",
|
||||
"settings.workspaces.removeAria": "Xóa kho {label}",
|
||||
"settings.appearance.title": "Giao diện",
|
||||
"settings.appearance.description": "Chọn chế độ màu của ứng dụng dùng cho phần khung Tolaria, vùng soạn thảo, menu và hộp thoại.",
|
||||
|
||||
@@ -125,6 +125,8 @@
|
||||
"settings.workspaces.labelTooltip": "显示在笔记、搜索结果、面包屑导航和 Vault 标签上的简短首字母缩写。",
|
||||
"settings.workspaces.slugTooltip": "用于跨保管库链接和关系中的稳定前缀。为了避免破坏现有引用,该字段目前为只读。",
|
||||
"settings.workspaces.color": "颜色",
|
||||
"settings.workspaces.moveUpAria": "将保管库 {label} 上移",
|
||||
"settings.workspaces.moveDownAria": "将保管库 {label} 向下移动",
|
||||
"settings.workspaces.removeAria": "删除保管库 {label}",
|
||||
"settings.appearance.title": "外观",
|
||||
"settings.appearance.description": "选择 Tolaria 界面、编辑器、菜单和对话框使用的颜色模式。",
|
||||
|
||||
@@ -125,6 +125,8 @@
|
||||
"settings.workspaces.labelTooltip": "顯示在筆記、搜尋結果、頁面路徑和 Vault 徽章上的縮寫首字母。",
|
||||
"settings.workspaces.slugTooltip": "用於跨 Vault 連結和關係的穩定前置詞。為了避免破壞現有參考,目前為唯讀狀態。",
|
||||
"settings.workspaces.color": "顏色",
|
||||
"settings.workspaces.moveUpAria": "將密室 {label} 向上移動",
|
||||
"settings.workspaces.moveDownAria": "將 Vault {label} 向下移動",
|
||||
"settings.workspaces.removeAria": "移除 Vault {label}",
|
||||
"settings.appearance.title": "外觀",
|
||||
"settings.appearance.description": "選擇 Tolaria 介面、編輯器、選單和對話方塊使用的顏色模式。",
|
||||
|
||||
32
src/utils/vaultCollections.test.ts
Normal file
32
src/utils/vaultCollections.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { VaultOption } from '../components/status-bar/types'
|
||||
import { buildAllVaults } from './vaultCollections'
|
||||
|
||||
const defaultVault: VaultOption = { label: 'Getting Started', path: '/getting-started', managedDefault: true }
|
||||
const workVault: VaultOption = { label: 'Work', path: '/work' }
|
||||
|
||||
describe('vaultCollections', () => {
|
||||
it('keeps the managed default first until users persist a custom order', () => {
|
||||
expect(buildAllVaults({
|
||||
visibleDefaults: [defaultVault],
|
||||
extraVaults: [workVault],
|
||||
hiddenDefaults: [],
|
||||
})).toEqual([defaultVault, workVault])
|
||||
})
|
||||
|
||||
it('uses persisted order when it includes the managed default', () => {
|
||||
expect(buildAllVaults({
|
||||
visibleDefaults: [defaultVault],
|
||||
extraVaults: [workVault, { ...defaultVault, managedDefault: undefined }],
|
||||
hiddenDefaults: [],
|
||||
})).toEqual([workVault, defaultVault])
|
||||
})
|
||||
|
||||
it('keeps hidden managed defaults out of persisted order', () => {
|
||||
expect(buildAllVaults({
|
||||
visibleDefaults: [],
|
||||
extraVaults: [workVault, defaultVault],
|
||||
hiddenDefaults: [defaultVault.path],
|
||||
})).toEqual([workVault])
|
||||
})
|
||||
})
|
||||
54
src/utils/vaultCollections.ts
Normal file
54
src/utils/vaultCollections.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { VaultOption } from '../components/status-bar/types'
|
||||
|
||||
function buildPersistedVaultOrder({
|
||||
extraVaults,
|
||||
hiddenDefaults,
|
||||
visibleDefaults,
|
||||
}: {
|
||||
extraVaults: VaultOption[]
|
||||
hiddenDefaults: string[]
|
||||
visibleDefaults: VaultOption[]
|
||||
}): VaultOption[] {
|
||||
const visibleDefaultsByPath = new Map(visibleDefaults.map((vault) => [vault.path, vault]))
|
||||
const orderedVaults: VaultOption[] = []
|
||||
const seenPaths = new Set<string>()
|
||||
|
||||
for (const vault of extraVaults) {
|
||||
if (hiddenDefaults.includes(vault.path) || seenPaths.has(vault.path)) continue
|
||||
const defaultVault = visibleDefaultsByPath.get(vault.path)
|
||||
orderedVaults.push(defaultVault ? { ...defaultVault, ...vault, managedDefault: defaultVault.managedDefault } : vault)
|
||||
seenPaths.add(vault.path)
|
||||
}
|
||||
|
||||
for (const vault of visibleDefaults) {
|
||||
if (!seenPaths.has(vault.path)) orderedVaults.push(vault)
|
||||
}
|
||||
|
||||
return orderedVaults
|
||||
}
|
||||
|
||||
export function buildAllVaults({
|
||||
hiddenDefaults,
|
||||
visibleDefaults,
|
||||
extraVaults,
|
||||
}: {
|
||||
hiddenDefaults: string[]
|
||||
visibleDefaults: VaultOption[]
|
||||
extraVaults: VaultOption[]
|
||||
}): VaultOption[] {
|
||||
const visibleDefaultPaths = new Set(visibleDefaults.map((vault) => vault.path))
|
||||
const hasPersistedDefaultOrder = extraVaults.some((vault) => visibleDefaultPaths.has(vault.path))
|
||||
|
||||
if (hasPersistedDefaultOrder) {
|
||||
return buildPersistedVaultOrder({ extraVaults, hiddenDefaults, visibleDefaults })
|
||||
}
|
||||
|
||||
const vaultsByPath = new Map<string, VaultOption>()
|
||||
for (const vault of visibleDefaults) vaultsByPath.set(vault.path, vault)
|
||||
for (const vault of extraVaults) {
|
||||
if (hiddenDefaults.includes(vault.path)) continue
|
||||
const existingVault = vaultsByPath.get(vault.path)
|
||||
vaultsByPath.set(vault.path, existingVault ? { ...existingVault, ...vault, path: vault.path } : vault)
|
||||
}
|
||||
return [...vaultsByPath.values()]
|
||||
}
|
||||
35
src/utils/vaultOrdering.test.ts
Normal file
35
src/utils/vaultOrdering.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { VaultOption } from '../components/status-bar/types'
|
||||
import { canMoveVaultPath, moveVaultPath, orderVaultsByPath } from './vaultOrdering'
|
||||
|
||||
const vaults: VaultOption[] = [
|
||||
{ label: 'Laputa', path: '/laputa' },
|
||||
{ label: 'Research', path: '/research' },
|
||||
{ label: 'Archive', path: '/archive' },
|
||||
]
|
||||
|
||||
describe('vaultOrdering', () => {
|
||||
it('orders vaults by a complete path list', () => {
|
||||
expect(orderVaultsByPath(vaults, ['/archive', '/laputa', '/research'])).toEqual([
|
||||
vaults[2],
|
||||
vaults[0],
|
||||
vaults[1],
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects incomplete or unknown path lists', () => {
|
||||
expect(orderVaultsByPath(vaults, ['/archive', '/laputa'])).toBeNull()
|
||||
expect(orderVaultsByPath(vaults, ['/archive', '/laputa', '/missing'])).toBeNull()
|
||||
})
|
||||
|
||||
it('moves vault paths one slot at a time', () => {
|
||||
expect(moveVaultPath(vaults, '/research', 'up')).toEqual(['/research', '/laputa', '/archive'])
|
||||
expect(moveVaultPath(vaults, '/research', 'down')).toEqual(['/laputa', '/archive', '/research'])
|
||||
})
|
||||
|
||||
it('reports whether a vault can move in a direction', () => {
|
||||
expect(canMoveVaultPath(vaults, '/laputa', 'up')).toBe(false)
|
||||
expect(canMoveVaultPath(vaults, '/laputa', 'down')).toBe(true)
|
||||
expect(canMoveVaultPath(vaults, '/archive', 'down')).toBe(false)
|
||||
})
|
||||
})
|
||||
40
src/utils/vaultOrdering.ts
Normal file
40
src/utils/vaultOrdering.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { VaultOption } from '../components/status-bar/types'
|
||||
|
||||
export type VaultMoveDirection = 'up' | 'down'
|
||||
|
||||
function vaultPathList(vaults: VaultOption[]): string[] {
|
||||
return vaults.map((vault) => vault.path)
|
||||
}
|
||||
|
||||
export function orderVaultsByPath(vaults: VaultOption[], orderedPaths: string[]): VaultOption[] | null {
|
||||
if (vaults.length !== orderedPaths.length) return null
|
||||
|
||||
const vaultsByPath = new Map(vaults.map((vault) => [vault.path, vault]))
|
||||
const orderedVaults: VaultOption[] = []
|
||||
|
||||
for (const path of orderedPaths) {
|
||||
const vault = vaultsByPath.get(path)
|
||||
if (!vault) return null
|
||||
orderedVaults.push(vault)
|
||||
}
|
||||
|
||||
return orderedVaults
|
||||
}
|
||||
|
||||
export function moveVaultPath(vaults: VaultOption[], path: string, direction: VaultMoveDirection): string[] | null {
|
||||
const orderedPaths = vaultPathList(vaults)
|
||||
const currentIndex = orderedPaths.indexOf(path)
|
||||
if (currentIndex === -1) return null
|
||||
|
||||
const nextIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1
|
||||
if (nextIndex < 0 || nextIndex >= orderedPaths.length) return null
|
||||
|
||||
const nextPaths = [...orderedPaths]
|
||||
const [movedPath] = nextPaths.splice(currentIndex, 1)
|
||||
nextPaths.splice(nextIndex, 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