diff --git a/lara.lock b/lara.lock index 3cdd2a42..8ed1a470 100644 --- a/lara.lock +++ b/lara.lock @@ -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 diff --git a/src/App.tsx b/src/App.tsx index c2437473..55228d7e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1758,7 +1758,7 @@ function App() { - 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} /> + 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} /> setToastMessage(null)} /> @@ -1808,7 +1808,7 @@ function App() { onCommit={conflictResolver.commitResolution} onClose={conflictFlow.handleCloseConflictResolver} /> - + diff --git a/src/components/SettingsPanel.test.tsx b/src/components/SettingsPanel.test.tsx index 80b85bb7..79aa47a6 100644 --- a/src/components/SettingsPanel.test.tsx +++ b/src/components/SettingsPanel.test.tsx @@ -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( { 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, { diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index a5cf1e2b..451fae17 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -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) => void + onRemoveVault?: (path: string) => void; onReorderVaults?: (orderedPaths: string[]) => void; onSetDefaultWorkspace?: (path: string) => void; onUpdateWorkspaceIdentity?: (path: string, patch: Partial) => 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) => void + onRemoveVault?: (path: string) => void; onReorderVaults?: (orderedPaths: string[]) => void; onSetDefaultWorkspace?: (path: string) => void; onUpdateWorkspaceIdentity?: (path: string, patch: Partial) => 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) => void + onRemoveVault?: (path: string) => void; onReorderVaults?: (orderedPaths: string[]) => void; onSetDefaultWorkspace?: (path: string) => void; onUpdateWorkspaceIdentity?: (path: string, patch: Partial) => 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} /> diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index ebb0b37b..d4e261cf 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -95,6 +95,7 @@ interface StatusBarProps { buildNumber?: string onCheckForUpdates?: () => void onRemoveVault?: (path: string) => void + onReorderVaults?: (orderedPaths: string[]) => void onUpdateWorkspaceIdentity?: (path: string, patch: Partial) => 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} diff --git a/src/components/WorkspaceMoveButtons.tsx b/src/components/WorkspaceMoveButtons.tsx new file mode 100644 index 00000000..7eb93963 --- /dev/null +++ b/src/components/WorkspaceMoveButtons.tsx @@ -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 +} + +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 ( + + 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" + > + + + 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" + > + + + + ) +} diff --git a/src/components/WorkspaceSettingsRows.tsx b/src/components/WorkspaceSettingsRows.tsx new file mode 100644 index 00000000..6191a3fa --- /dev/null +++ b/src/components/WorkspaceSettingsRows.tsx @@ -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) => 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 ( + + + {label} + + + + + + + + {tooltip} + + + ) +} + +function WorkspaceIdentityInputs({ + canEdit, + locale, + onUpdateWorkspaceIdentity, + vault, +}: Pick & { + 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) => { + if (event.key === 'Enter') event.currentTarget.blur() + } + + return ( + + + + setNameDraft(event.target.value)} + onBlur={commitNameDraft} + onKeyDown={blurOnEnter} + aria-label={t('settings.workspaces.nameAria', { label: workspace.label })} + disabled={!canEdit} + className="h-8 bg-transparent" + /> + + + + 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" + /> + + + + + + + ) +} + +function WorkspaceRowActions({ + canEdit, + canMoveDown, + canMoveUp, + locale, + onMoveVault, + onSetDefaultWorkspace, + onUpdateWorkspaceIdentity, + onRemoveVault, + workspace, + vault, +}: Pick & { + canEdit: boolean + canMoveDown: boolean + canMoveUp: boolean + onMoveVault?: (path: string, direction: VaultMoveDirection) => void + workspace: ReturnType + vault: VaultOption +}) { + const t = createTranslator(locale) + const removeLabel = t('settings.workspaces.removeAria', { label: workspace.label }) + + return ( + + + onUpdateWorkspaceIdentity?.(vault.path, { color })} + size={18} + /> + { + 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')} + + {onRemoveVault && ( + 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" + > + + + )} + + ) +} + +function WorkspaceSettingsRow({ + defaultWorkspacePath, + locale, + onMoveVault, + onSetDefaultWorkspace, + onUpdateWorkspaceIdentity, + onRemoveVault, + vaults, + vault, +}: Pick & { + onMoveVault?: (path: string, direction: VaultMoveDirection) => void + vault: VaultOption +}) { + const workspace = workspaceIdentityFromVault(vault, { defaultWorkspacePath }) + const canEdit = !!onUpdateWorkspaceIdentity && vault.path !== '' && !vault.managedDefault + + return ( + + + + {workspace.label} + {workspace.path} + + + + + + ) +} + +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 ( + + + {vaults.map((vault) => ( + setVaultPendingRemoval(vault) : undefined} + vaults={vaults} + vault={vault} + /> + ))} + + + ) +} diff --git a/src/components/WorkspaceSettingsSection.tsx b/src/components/WorkspaceSettingsSection.tsx index 81c99c4e..8bacefd5 100644 --- a/src/components/WorkspaceSettingsSection.tsx +++ b/src/components/WorkspaceSettingsSection.tsx @@ -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) => 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 ( - - - {label} - - - - - - - - {tooltip} - - - ) -} - -function WorkspaceIdentityInputs({ - canEdit, - locale, - onUpdateWorkspaceIdentity, - vault, -}: Pick & { - 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) => { - if (event.key === 'Enter') { - event.currentTarget.blur() - } - } - - return ( - - - - setNameDraft(event.target.value)} - onBlur={commitNameDraft} - onKeyDown={blurOnEnter} - aria-label={t('settings.workspaces.nameAria', { label: workspace.label })} - disabled={!canEdit} - className="h-8 bg-transparent" - /> - - - - 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" - /> - - - - - - - ) -} - -function WorkspaceRowActions({ - canEdit, - locale, - onSetDefaultWorkspace, - onUpdateWorkspaceIdentity, - onRemoveVault, - workspace, - vault, -}: Pick & { - canEdit: boolean - workspace: ReturnType - vault: VaultOption -}) { - const t = createTranslator(locale) - const removeLabel = t('settings.workspaces.removeAria', { label: workspace.label }) - - return ( - - onUpdateWorkspaceIdentity?.(vault.path, { color })} - size={18} - /> - { - 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')} - - {onRemoveVault && ( - 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" - > - - - )} - - ) -} - -function WorkspaceSettingsRow({ - defaultWorkspacePath, - locale, - onSetDefaultWorkspace, - onUpdateWorkspaceIdentity, - onRemoveVault, - vault, -}: Pick & { - vault: VaultOption -}) { - const workspace = workspaceIdentityFromVault(vault, { defaultWorkspacePath }) - const canEdit = !!onUpdateWorkspaceIdentity && vault.path !== '' && !vault.managedDefault - - return ( - - - - {workspace.label} - {workspace.path} - - - - - - ) -} - 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 && ( - - - {vaults.map((vault) => ( - setVaultPendingRemoval(vault) : undefined} - vault={vault} - /> - ))} - - + )} void onRemoveVault?: (path: string) => void + onReorderVaults?: (orderedPaths: string[]) => void onUpdateWorkspaceIdentity?: (path: string, patch: Partial) => 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} /> diff --git a/src/components/status-bar/VaultMenu.tsx b/src/components/status-bar/VaultMenu.tsx index 671ecfa3..8741fff6 100644 --- a/src/components/status-bar/VaultMenu.tsx +++ b/src/components/status-bar/VaultMenu.tsx @@ -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) => 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 ( + + {children} + + ) +} + +function renderVaultMenuItem({ + canRemove, + defaultPath, + disableMountToggleForPath, + locale, + multiWorkspaceEnabled, + onMountedChange, + onRemoveVault, + onSelectVault, + setVaultPendingRemoval, + vault, +}: Omit & { vault: VaultOption }) { + return ( + 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) => ( + + {renderVaultMenuItem({ ...props, vault })} + + )) + } + + return ( + + + {vaults.map((vault) => ( + + {renderVaultMenuItem({ ...props, vault })} + + ))} + + + ) +} + 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 ( + + {multiWorkspaceEnabled && ( + <> + { + onOpenVaultSettings() + setOpen(false) + } : undefined} + /> + + > + )} + + {actions.length > 0 && } + {actions.map((action) => ( + { + action.onClick() + setOpen(false) + }} + /> + ))} + + ) +} + 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(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) { {open && ( - - {multiWorkspaceEnabled && ( - <> - { - onOpenVaultSettings() - setOpen(false) - } : undefined} - /> - - > - )} - {vaults.map((vault) => ( - { - 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 && } - {actions.map((action) => ( - { - action.onClick() - setOpen(false) - }} - /> - ))} - + )} > +}) { + 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]) +} diff --git a/src/hooks/useVaultSwitcher.ts b/src/hooks/useVaultSwitcher.ts index 8593226f..d8559fe1 100644 --- a/src/hooks/useVaultSwitcher.ts +++ b/src/hooks/useVaultSwitcher.ts @@ -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 { @@ -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() - 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, diff --git a/src/lib/locales/de-DE.json b/src/lib/locales/de-DE.json index 98c20f10..5cbf82c6 100644 --- a/src/lib/locales/de-DE.json +++ b/src/lib/locales/de-DE.json @@ -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.", diff --git a/src/lib/locales/en.json b/src/lib/locales/en.json index 44fe3763..8b60866e 100644 --- a/src/lib/locales/en.json +++ b/src/lib/locales/en.json @@ -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.", diff --git a/src/lib/locales/es-419.json b/src/lib/locales/es-419.json index f72025cd..93b21b9c 100644 --- a/src/lib/locales/es-419.json +++ b/src/lib/locales/es-419.json @@ -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.", diff --git a/src/lib/locales/es-ES.json b/src/lib/locales/es-ES.json index 64bd8a1a..08bb5864 100644 --- a/src/lib/locales/es-ES.json +++ b/src/lib/locales/es-ES.json @@ -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.", diff --git a/src/lib/locales/fr-FR.json b/src/lib/locales/fr-FR.json index eeee684c..03da9d52 100644 --- a/src/lib/locales/fr-FR.json +++ b/src/lib/locales/fr-FR.json @@ -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.", diff --git a/src/lib/locales/it-IT.json b/src/lib/locales/it-IT.json index ac494681..0d7d8e12 100644 --- a/src/lib/locales/it-IT.json +++ b/src/lib/locales/it-IT.json @@ -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.", diff --git a/src/lib/locales/ja-JP.json b/src/lib/locales/ja-JP.json index 2af0bc9f..59c63c8a 100644 --- a/src/lib/locales/ja-JP.json +++ b/src/lib/locales/ja-JP.json @@ -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、エディターのサーフェス、メニュー、およびダイアログに使用するアプリのカラーモードを選択します。", diff --git a/src/lib/locales/ko-KR.json b/src/lib/locales/ko-KR.json index def1da55..f8f66b1d 100644 --- a/src/lib/locales/ko-KR.json +++ b/src/lib/locales/ko-KR.json @@ -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, 편집기 표면, 메뉴, 대화 상자에 사용되는 앱 색상 모드를 선택하세요.", diff --git a/src/lib/locales/pl-PL.json b/src/lib/locales/pl-PL.json index ba829b08..c2f7aea2 100644 --- a/src/lib/locales/pl-PL.json +++ b/src/lib/locales/pl-PL.json @@ -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.", diff --git a/src/lib/locales/pt-BR.json b/src/lib/locales/pt-BR.json index 4cd81ce2..da71ea4c 100644 --- a/src/lib/locales/pt-BR.json +++ b/src/lib/locales/pt-BR.json @@ -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.", diff --git a/src/lib/locales/pt-PT.json b/src/lib/locales/pt-PT.json index 1e6a09d6..4eb7bbb3 100644 --- a/src/lib/locales/pt-PT.json +++ b/src/lib/locales/pt-PT.json @@ -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.", diff --git a/src/lib/locales/ru-RU.json b/src/lib/locales/ru-RU.json index cfe0ccf7..30a2d4e3 100644 --- a/src/lib/locales/ru-RU.json +++ b/src/lib/locales/ru-RU.json @@ -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, интерфейса редактора, меню и диалоговых окон.", diff --git a/src/lib/locales/vi.json b/src/lib/locales/vi.json index 356b00e2..9c6489aa 100644 --- a/src/lib/locales/vi.json +++ b/src/lib/locales/vi.json @@ -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.", diff --git a/src/lib/locales/zh-CN.json b/src/lib/locales/zh-CN.json index c21fcfec..b8ae9e1e 100644 --- a/src/lib/locales/zh-CN.json +++ b/src/lib/locales/zh-CN.json @@ -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 界面、编辑器、菜单和对话框使用的颜色模式。", diff --git a/src/lib/locales/zh-TW.json b/src/lib/locales/zh-TW.json index b3ed7778..0a51f1a5 100644 --- a/src/lib/locales/zh-TW.json +++ b/src/lib/locales/zh-TW.json @@ -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 介面、編輯器、選單和對話方塊使用的顏色模式。", diff --git a/src/utils/vaultCollections.test.ts b/src/utils/vaultCollections.test.ts new file mode 100644 index 00000000..226585bb --- /dev/null +++ b/src/utils/vaultCollections.test.ts @@ -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]) + }) +}) diff --git a/src/utils/vaultCollections.ts b/src/utils/vaultCollections.ts new file mode 100644 index 00000000..f676e357 --- /dev/null +++ b/src/utils/vaultCollections.ts @@ -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() + + 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() + 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()] +} diff --git a/src/utils/vaultOrdering.test.ts b/src/utils/vaultOrdering.test.ts new file mode 100644 index 00000000..39260a3b --- /dev/null +++ b/src/utils/vaultOrdering.test.ts @@ -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) + }) +}) diff --git a/src/utils/vaultOrdering.ts b/src/utils/vaultOrdering.ts new file mode 100644 index 00000000..c00be274 --- /dev/null +++ b/src/utils/vaultOrdering.ts @@ -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 +}