2026-05-14 12:00:48 +02:00
|
|
|
import { Info, Trash as Trash2 } from '@phosphor-icons/react'
|
2026-05-11 16:37:06 +02:00
|
|
|
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 { ConfirmDeleteDialog } from './ConfirmDeleteDialog'
|
|
|
|
|
import { SettingsGroup, SettingsGroupItem, 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'
|
|
|
|
|
|
|
|
|
|
interface WorkspaceSettingsSectionProps {
|
|
|
|
|
defaultWorkspacePath?: string | null
|
|
|
|
|
enabled: boolean
|
|
|
|
|
locale: AppLocale
|
|
|
|
|
onEnabledChange: (enabled: boolean) => void
|
|
|
|
|
onRemoveVault?: (path: 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,
|
|
|
|
|
onSetDefaultWorkspace,
|
|
|
|
|
onUpdateWorkspaceIdentity,
|
|
|
|
|
vaults,
|
|
|
|
|
}: WorkspaceSettingsSectionProps) {
|
|
|
|
|
const t = createTranslator(locale)
|
|
|
|
|
const [vaultPendingRemoval, setVaultPendingRemoval] = useState<VaultOption | null>(null)
|
|
|
|
|
const pendingRemovalIdentity = vaultPendingRemoval ? workspaceIdentityFromVault(vaultPendingRemoval, { defaultWorkspacePath }) : null
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<TooltipProvider>
|
|
|
|
|
<SettingsGroup>
|
|
|
|
|
<SettingsSwitchRow
|
|
|
|
|
label={t('settings.workspaces.enable')}
|
|
|
|
|
description={t('settings.workspaces.enableDescription')}
|
|
|
|
|
checked={enabled}
|
|
|
|
|
onChange={onEnabledChange}
|
|
|
|
|
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>
|
|
|
|
|
)}
|
|
|
|
|
</SettingsGroup>
|
|
|
|
|
<ConfirmDeleteDialog
|
|
|
|
|
open={!!vaultPendingRemoval}
|
|
|
|
|
title={t('status.vault.removeConfirmTitle')}
|
|
|
|
|
message={t('status.vault.removeConfirmMessage', { label: pendingRemovalIdentity?.label ?? '' })}
|
|
|
|
|
confirmLabel={t('status.vault.removeConfirmAction')}
|
|
|
|
|
onCancel={() => setVaultPendingRemoval(null)}
|
|
|
|
|
onConfirm={() => {
|
|
|
|
|
if (vaultPendingRemoval) {
|
|
|
|
|
onRemoveVault?.(vaultPendingRemoval.path)
|
|
|
|
|
}
|
|
|
|
|
setVaultPendingRemoval(null)
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</TooltipProvider>
|
|
|
|
|
)
|
|
|
|
|
}
|