feat: support vault reordering

This commit is contained in:
lucaronin
2026-05-14 17:17:54 +02:00
parent 7174061b2a
commit e844cfa0bc
31 changed files with 864 additions and 389 deletions

View File

@@ -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

View File

@@ -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} />

View File

@@ -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, {

View File

@@ -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>

View File

@@ -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}

View 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>
)
}

View 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>
)
}

View File

@@ -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

View File

@@ -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}
/>

View File

@@ -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}

View 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])
}

View File

@@ -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,

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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、エディターのサーフェス、メニュー、およびダイアログに使用するアプリのカラーモードを選択します。",

View File

@@ -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, 편집기 표면, 메뉴, 대화 상자에 사용되는 앱 색상 모드를 선택하세요.",

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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, интерфейса редактора, меню и диалоговых окон.",

View File

@@ -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.",

View File

@@ -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 界面、编辑器、菜单和对话框使用的颜色模式。",

View File

@@ -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 介面、編輯器、選單和對話方塊使用的顏色模式。",

View 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])
})
})

View 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()]
}

View 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)
})
})

View 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
}