import { AI_AGENT_DEFINITIONS, createMissingAiAgentsStatus, getAiAgentDefinition, resolveDefaultAiAgent, type AiAgentId, type AiAgentsStatus, } from '../lib/aiAgents' import { useState, useRef, useCallback, useEffect, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactNode, type RefObject, } from 'react' import { Moon, Sun, X } from '@phosphor-icons/react' import { Copy } from 'lucide-react' import type { Settings } from '../types' import { APP_LOCALES, SYSTEM_UI_LANGUAGE, createTranslator, localeDisplayName, resolveEffectiveLocale, serializeUiLanguagePreference, type AppLocale, type UiLanguagePreference, } from '../lib/i18n' import { applyThemeModeToDocument, DEFAULT_THEME_MODE, readStoredThemeMode, type ThemeMode, writeStoredThemeMode, } from '../lib/themeMode' import { normalizeReleaseChannel, serializeReleaseChannel, type ReleaseChannel } from '../lib/releaseChannel' import { shouldHideGitignoredFiles } from '../lib/gitignoredVisibility' import { trackEvent } from '../lib/telemetry' import { trackAllNotesVisibilityChanged } from '../lib/productAnalytics' import { resolveAllNotesFileVisibility, settingsWithAllNotesFileVisibility, type AllNotesFileVisibility, } from '../utils/allNotesFileVisibility' import { Button } from './ui/button' import { Checkbox, type CheckedState } from './ui/checkbox' import { Input } from './ui/input' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from './ui/select' import { Switch } from './ui/switch' interface SettingsPanelProps { open: boolean settings: Settings aiAgentsStatus?: AiAgentsStatus locale?: AppLocale systemLocale?: AppLocale onSave: (settings: Settings) => void onCopyMcpConfig?: () => void isGitVault?: boolean explicitOrganizationEnabled?: boolean onSaveExplicitOrganization?: (enabled: boolean) => void onClose: () => void } interface SettingsDraft { pullInterval: number autoGitEnabled: boolean autoGitIdleThresholdSeconds: number autoGitInactiveThresholdSeconds: number autoAdvanceInboxAfterOrganize: boolean defaultAiAgent: AiAgentId releaseChannel: ReleaseChannel themeMode: ThemeMode uiLanguage: UiLanguagePreference initialH1AutoRename: boolean hideGitignoredFiles: boolean allNotesFileVisibility: AllNotesFileVisibility crashReporting: boolean analytics: boolean explicitOrganization: boolean } interface SettingsBodyProps { t: Translate pullInterval: number setPullInterval: (value: number) => void isGitVault: boolean autoGitEnabled: boolean setAutoGitEnabled: (value: boolean) => void autoGitIdleThresholdSeconds: number setAutoGitIdleThresholdSeconds: (value: number) => void autoGitInactiveThresholdSeconds: number setAutoGitInactiveThresholdSeconds: (value: number) => void autoAdvanceInboxAfterOrganize: boolean setAutoAdvanceInboxAfterOrganize: (value: boolean) => void aiAgentsStatus: AiAgentsStatus defaultAiAgent: AiAgentId setDefaultAiAgent: (value: AiAgentId) => void onCopyMcpConfig?: () => void releaseChannel: ReleaseChannel setReleaseChannel: (value: ReleaseChannel) => void themeMode: ThemeMode setThemeMode: (value: ThemeMode) => void uiLanguage: UiLanguagePreference setUiLanguage: (value: UiLanguagePreference) => void locale: AppLocale systemLocale: AppLocale initialH1AutoRename: boolean setInitialH1AutoRename: (value: boolean) => void hideGitignoredFiles: boolean setHideGitignoredFiles: (value: boolean) => void allNotesFileVisibility: AllNotesFileVisibility setAllNotesFileVisibility: (value: AllNotesFileVisibility) => void explicitOrganization: boolean setExplicitOrganization: (value: boolean) => void crashReporting: boolean setCrashReporting: (value: boolean) => void analytics: boolean setAnalytics: (value: boolean) => void } const PULL_INTERVAL_OPTIONS = [1, 2, 5, 10, 15, 30] as const const DEFAULT_AUTOGIT_IDLE_THRESHOLD_SECONDS = 90 const DEFAULT_AUTOGIT_INACTIVE_THRESHOLD_SECONDS = 30 type Translate = ReturnType function isSaveShortcut(event: ReactKeyboardEvent): boolean { return event.key === 'Enter' && (event.metaKey || event.ctrlKey) } function createSettingsDraft( settings: Settings, explicitOrganizationEnabled: boolean, ): SettingsDraft { return { pullInterval: settings.auto_pull_interval_minutes ?? 5, autoGitEnabled: settings.autogit_enabled ?? false, autoGitIdleThresholdSeconds: sanitizePositiveInteger( settings.autogit_idle_threshold_seconds, DEFAULT_AUTOGIT_IDLE_THRESHOLD_SECONDS, ), autoGitInactiveThresholdSeconds: sanitizePositiveInteger( settings.autogit_inactive_threshold_seconds, DEFAULT_AUTOGIT_INACTIVE_THRESHOLD_SECONDS, ), autoAdvanceInboxAfterOrganize: settings.auto_advance_inbox_after_organize ?? false, defaultAiAgent: resolveDefaultAiAgent(settings.default_ai_agent), releaseChannel: normalizeReleaseChannel(settings.release_channel), themeMode: resolveSettingsDraftThemeMode(settings.theme_mode), uiLanguage: settings.ui_language ?? SYSTEM_UI_LANGUAGE, initialH1AutoRename: settings.initial_h1_auto_rename_enabled ?? true, hideGitignoredFiles: shouldHideGitignoredFiles(settings), allNotesFileVisibility: resolveAllNotesFileVisibility(settings), crashReporting: settings.crash_reporting_enabled ?? false, analytics: settings.analytics_enabled ?? false, explicitOrganization: explicitOrganizationEnabled, } } function resolveSettingsDraftThemeMode(themeMode: Settings['theme_mode']): ThemeMode { if (themeMode) return themeMode if (typeof window === 'undefined') return DEFAULT_THEME_MODE return readStoredThemeMode(window.localStorage) ?? DEFAULT_THEME_MODE } function resolveTelemetryConsent(settings: Settings, draft: SettingsDraft): boolean | null { if (draft.crashReporting || draft.analytics) return true return settings.telemetry_consent === null ? null : false } function resolveAnonymousId(settings: Settings, draft: SettingsDraft): string | null { if (draft.crashReporting || draft.analytics) { return settings.anonymous_id ?? crypto.randomUUID() } return settings.anonymous_id } function buildSettingsFromDraft(settings: Settings, draft: SettingsDraft): Settings { const nextSettings = { auto_pull_interval_minutes: draft.pullInterval, autogit_enabled: draft.autoGitEnabled, autogit_idle_threshold_seconds: draft.autoGitIdleThresholdSeconds, autogit_inactive_threshold_seconds: draft.autoGitInactiveThresholdSeconds, auto_advance_inbox_after_organize: draft.autoAdvanceInboxAfterOrganize, telemetry_consent: resolveTelemetryConsent(settings, draft), crash_reporting_enabled: draft.crashReporting, analytics_enabled: draft.analytics, anonymous_id: resolveAnonymousId(settings, draft), release_channel: serializeReleaseChannel(draft.releaseChannel), theme_mode: draft.themeMode, ui_language: serializeUiLanguagePreference(draft.uiLanguage), initial_h1_auto_rename_enabled: draft.initialH1AutoRename, default_ai_agent: draft.defaultAiAgent, hide_gitignored_files: draft.hideGitignoredFiles, } return settingsWithAllNotesFileVisibility(nextSettings, draft.allNotesFileVisibility) } function trackTelemetryConsentChange(previousAnalytics: boolean, nextAnalytics: boolean): void { if (!previousAnalytics && nextAnalytics) trackEvent('telemetry_opted_in') if (previousAnalytics && !nextAnalytics) trackEvent('telemetry_opted_out') } function isChecked(checked: CheckedState): boolean { return checked === true } function sanitizePositiveInteger(value: number | null | undefined, fallback: number): number { if (value === null || value === undefined || !Number.isFinite(value) || value < 1) return fallback return Math.round(value) } function applyThemeModeSelection(value: ThemeMode): void { if (typeof document !== 'undefined') applyThemeModeToDocument(document, value) if (typeof window !== 'undefined') writeStoredThemeMode(window.localStorage, value) } function useSettingsPanelAutofocus(panelRef: RefObject): void { useEffect(() => { const timer = setTimeout(() => { const focusTarget = panelRef.current?.querySelector('[data-settings-autofocus="true"]') focusTarget?.focus() }, 50) return () => clearTimeout(timer) }, [panelRef]) } export function SettingsPanel({ open, settings, aiAgentsStatus = createMissingAiAgentsStatus(), locale = 'en', systemLocale = locale, onSave, onCopyMcpConfig, isGitVault = true, explicitOrganizationEnabled = true, onSaveExplicitOrganization, onClose, }: SettingsPanelProps) { if (!open) return null return ( ) } type SettingsPanelInnerProps = Omit & { aiAgentsStatus: AiAgentsStatus locale: AppLocale systemLocale: AppLocale isGitVault: boolean explicitOrganizationEnabled: boolean } function SettingsPanelInner({ settings, aiAgentsStatus, systemLocale, onSave, onCopyMcpConfig, isGitVault, explicitOrganizationEnabled, onSaveExplicitOrganization, onClose, }: SettingsPanelInnerProps) { const [draft, setDraft] = useState(() => createSettingsDraft(settings, explicitOrganizationEnabled)) const panelRef = useRef(null) const draftLocale = resolveEffectiveLocale(draft.uiLanguage, [systemLocale]) const t = createTranslator(draftLocale) useEffect(() => { setDraft(createSettingsDraft(settings, explicitOrganizationEnabled)) }, [explicitOrganizationEnabled, settings]) useSettingsPanelAutofocus(panelRef) const updateDraft = useCallback( (key: Key, value: SettingsDraft[Key]) => { setDraft((current) => ({ ...current, [key]: value })) }, [], ) const handleGitignoredVisibilityChange = useCallback((value: boolean) => { updateDraft('hideGitignoredFiles', value) onSave({ ...settings, hide_gitignored_files: value }) }, [onSave, settings, updateDraft]) const handleAllNotesFileVisibilityChange = useCallback((value: AllNotesFileVisibility) => { trackAllNotesVisibilityChanged(draft.allNotesFileVisibility, value) updateDraft('allNotesFileVisibility', value) onSave(settingsWithAllNotesFileVisibility(settings, value)) }, [draft.allNotesFileVisibility, onSave, settings, updateDraft]) const handleThemeModeChange = useCallback((value: ThemeMode) => { updateDraft('themeMode', value) applyThemeModeSelection(value) onSave({ ...settings, theme_mode: value }) }, [onSave, settings, updateDraft]) const handleSave = useCallback(() => { trackTelemetryConsentChange(settings.analytics_enabled === true, draft.analytics) onSave(buildSettingsFromDraft(settings, draft)) onSaveExplicitOrganization?.(draft.explicitOrganization) onClose() }, [draft, onClose, onSave, onSaveExplicitOrganization, settings]) const handleBackdropClick = useCallback((event: ReactMouseEvent) => { if (event.target === event.currentTarget) onClose() }, [onClose]) const handleKeyDown = useCallback( (event: ReactKeyboardEvent) => { if (event.key === 'Escape') { event.stopPropagation() onClose() return } if (isSaveShortcut(event)) { event.preventDefault() handleSave() } }, [handleSave, onClose], ) return (
updateDraft('pullInterval', value)} isGitVault={isGitVault} autoGitEnabled={draft.autoGitEnabled} setAutoGitEnabled={(value) => updateDraft('autoGitEnabled', value)} autoGitIdleThresholdSeconds={draft.autoGitIdleThresholdSeconds} setAutoGitIdleThresholdSeconds={(value) => updateDraft('autoGitIdleThresholdSeconds', value)} autoGitInactiveThresholdSeconds={draft.autoGitInactiveThresholdSeconds} setAutoGitInactiveThresholdSeconds={(value) => updateDraft('autoGitInactiveThresholdSeconds', value)} autoAdvanceInboxAfterOrganize={draft.autoAdvanceInboxAfterOrganize} setAutoAdvanceInboxAfterOrganize={(value) => updateDraft('autoAdvanceInboxAfterOrganize', value)} aiAgentsStatus={aiAgentsStatus} defaultAiAgent={draft.defaultAiAgent} setDefaultAiAgent={(value) => updateDraft('defaultAiAgent', value)} onCopyMcpConfig={onCopyMcpConfig} releaseChannel={draft.releaseChannel} setReleaseChannel={(value) => updateDraft('releaseChannel', value)} themeMode={draft.themeMode} setThemeMode={handleThemeModeChange} uiLanguage={draft.uiLanguage} setUiLanguage={(value) => updateDraft('uiLanguage', value)} initialH1AutoRename={draft.initialH1AutoRename} setInitialH1AutoRename={(value) => updateDraft('initialH1AutoRename', value)} hideGitignoredFiles={draft.hideGitignoredFiles} setHideGitignoredFiles={handleGitignoredVisibilityChange} allNotesFileVisibility={draft.allNotesFileVisibility} setAllNotesFileVisibility={handleAllNotesFileVisibilityChange} explicitOrganization={draft.explicitOrganization} setExplicitOrganization={(value) => updateDraft('explicitOrganization', value)} crashReporting={draft.crashReporting} setCrashReporting={(value) => updateDraft('crashReporting', value)} analytics={draft.analytics} setAnalytics={(value) => updateDraft('analytics', value)} />
) } function SettingsHeader({ onClose, t }: { onClose: () => void; t: Translate }) { return (
{t('settings.title')}
) } function SettingsBody(props: SettingsBodyProps) { return (
) } function SettingsSyncAndAppearanceSections({ t, locale, systemLocale, pullInterval, setPullInterval, isGitVault, autoGitEnabled, setAutoGitEnabled, autoGitIdleThresholdSeconds, setAutoGitIdleThresholdSeconds, autoGitInactiveThresholdSeconds, setAutoGitInactiveThresholdSeconds, releaseChannel, setReleaseChannel, themeMode, setThemeMode, uiLanguage, setUiLanguage, }: SettingsBodyProps) { return ( <> ) } function SettingsContentSections({ t, initialH1AutoRename, setInitialH1AutoRename, hideGitignoredFiles, setHideGitignoredFiles, allNotesFileVisibility, setAllNotesFileVisibility, }: SettingsBodyProps) { return ( <> ) } function SettingsAgentWorkflowSections({ t, autoAdvanceInboxAfterOrganize, setAutoAdvanceInboxAfterOrganize, aiAgentsStatus, defaultAiAgent, setDefaultAiAgent, onCopyMcpConfig, explicitOrganization, setExplicitOrganization, crashReporting, setCrashReporting, analytics, setAnalytics, }: SettingsBodyProps) { return ( <> ) } function SyncAndUpdatesSection({ t, pullInterval, setPullInterval, releaseChannel, setReleaseChannel, }: Pick) { return ( <> setPullInterval(Number(value))} options={PULL_INTERVAL_OPTIONS.map((value) => ({ value: `${value}`, label: `${value}`, }))} testId="settings-pull-interval" autoFocus={true} /> setReleaseChannel(value as ReleaseChannel)} options={[ { value: 'stable', label: t('settings.releaseStable') }, { value: 'alpha', label: t('settings.releaseAlpha') }, ]} testId="settings-release-channel" /> ) } function AppearanceSettingsSection({ t, themeMode, setThemeMode, }: Pick) { return ( <> ) } function ThemeModeControl({ value, onChange, t, }: { value: ThemeMode onChange: (value: ThemeMode) => void t: Translate }) { return (
) } function ThemeModeButton({ children, label, selected, value, onSelect, }: { children: ReactNode label: string selected: boolean value: ThemeMode onSelect: (value: ThemeMode) => void }) { return ( ) } function autoGitSectionDescription(isGitVault: boolean, t: Translate): string { return isGitVault ? t('settings.autogit.description.enabled') : t('settings.autogit.description.disabled') } function AutoGitSettingsSection({ t, isGitVault, autoGitEnabled, setAutoGitEnabled, autoGitIdleThresholdSeconds, setAutoGitIdleThresholdSeconds, autoGitInactiveThresholdSeconds, setAutoGitInactiveThresholdSeconds, }: Pick< SettingsBodyProps, | 't' | 'isGitVault' | 'autoGitEnabled' | 'setAutoGitEnabled' | 'autoGitIdleThresholdSeconds' | 'setAutoGitIdleThresholdSeconds' | 'autoGitInactiveThresholdSeconds' | 'setAutoGitInactiveThresholdSeconds' >) { return ( <> ) } function buildLanguageOptions(t: Translate, locale: AppLocale, systemLocale: AppLocale) { return [ { value: SYSTEM_UI_LANGUAGE, label: t('settings.language.system', { language: localeDisplayName(systemLocale, locale), }), }, ...APP_LOCALES.map((appLocale) => ({ value: appLocale, label: localeDisplayName(appLocale, locale), })), ] } function LanguageSettingsSection({ t, locale, systemLocale, uiLanguage, setUiLanguage, }: Pick) { return ( <> setUiLanguage(value as UiLanguagePreference)} options={buildLanguageOptions(t, locale, systemLocale)} testId="settings-ui-language" />
{t('settings.language.summary')}
) } function TitleSettingsSection({ t, initialH1AutoRename, setInitialH1AutoRename, }: Pick) { return ( <> ) } function VaultContentSettingsSection({ t, hideGitignoredFiles, setHideGitignoredFiles, allNotesFileVisibility, setAllNotesFileVisibility, }: Pick< SettingsBodyProps, 't' | 'hideGitignoredFiles' | 'setHideGitignoredFiles' | 'allNotesFileVisibility' | 'setAllNotesFileVisibility' >) { const updateAllNotesFileVisibility = (patch: Partial) => { setAllNotesFileVisibility({ ...allNotesFileVisibility, ...patch }) } return ( <>
updateAllNotesFileVisibility({ pdfs: checked })} testId="settings-all-notes-show-pdfs" /> updateAllNotesFileVisibility({ images: checked })} testId="settings-all-notes-show-images" /> updateAllNotesFileVisibility({ unsupported: checked })} testId="settings-all-notes-show-unsupported" />
) } function buildDefaultAiAgentOptions(aiAgentsStatus: AiAgentsStatus, t: Translate): Array<{ value: string; label: string }> { return AI_AGENT_DEFINITIONS.map((definition) => { const status = aiAgentsStatus[definition.id] const suffix = status.status === 'installed' ? ` (${t('settings.aiAgents.installed')}${status.version ? ` ${status.version}` : ''})` : ` (${t('settings.aiAgents.missing')})` return { value: definition.id, label: `${definition.label}${suffix}`, } }) } function AiAgentSettingsSection({ t, aiAgentsStatus, defaultAiAgent, setDefaultAiAgent, onCopyMcpConfig, }: Pick) { return ( <> setDefaultAiAgent(value as AiAgentId)} options={buildDefaultAiAgentOptions(aiAgentsStatus, t)} testId="settings-default-ai-agent" />
{renderDefaultAiAgentSummary(defaultAiAgent, aiAgentsStatus, t)}
{onCopyMcpConfig ? ( ) : null} ) } function PrivacySettingsSection({ t, crashReporting, setCrashReporting, analytics, setAnalytics, }: Pick) { return ( <> ) } function SettingsSection({ children, showDivider = true, }: { children: ReactNode showDivider?: boolean }) { return (
{showDivider ? : null} {children}
) } function SectionHeading({ title, description, }: { title: string description: string }) { return (
{title}
{description}
) } function Divider() { return
} function renderDefaultAiAgentSummary(defaultAiAgent: AiAgentId, aiAgentsStatus: AiAgentsStatus, t: Translate): string { const definition = getAiAgentDefinition(defaultAiAgent) const status = aiAgentsStatus[defaultAiAgent] if (status.status === 'installed') { return t('settings.aiAgents.ready', { agent: definition.label, version: status.version ? ` ${status.version}` : '', }) } return t('settings.aiAgents.notInstalled', { agent: definition.label }) } function LabeledSelect({ label, value, onValueChange, options, testId, autoFocus = false, }: { label: string value: string onValueChange: (value: string) => void options: Array<{ value: string; label: string }> testId: string autoFocus?: boolean }) { return (
) } function LabeledNumberInput({ label, value, onValueChange, testId, disabled = false, }: { label: string value: number onValueChange: (value: number) => void testId: string disabled?: boolean }) { return (
onValueChange(sanitizePositiveInteger(Number(event.target.value), value))} data-testid={testId} className="w-full bg-transparent" />
) } function OrganizationWorkflowSection({ t, checked, onChange, autoAdvanceInboxAfterOrganize, onChangeAutoAdvanceInboxAfterOrganize, }: { t: Translate checked: boolean onChange: (value: boolean) => void autoAdvanceInboxAfterOrganize: boolean onChangeAutoAdvanceInboxAfterOrganize: (value: boolean) => void }) { return ( <> ) } function SettingsSwitchRow({ label, description, checked, onChange, disabled = false, testId, }: { label: string description: string checked: boolean onChange: (value: boolean) => void disabled?: boolean testId?: string }) { return ( ) } function SettingsCheckboxRow({ label, description, checked, onChange, testId, }: { label: string description: string checked: boolean onChange: (value: boolean) => void testId: string }) { return ( ) } function TelemetryToggle({ label, description, checked, onChange, testId, }: { label: string description: string checked: boolean onChange: (value: boolean) => void testId: string }) { return ( ) } function SettingsFooter({ onClose, onSave, t }: { onClose: () => void; onSave: () => void; t: Translate }) { return (
{t('settings.footerShortcut')}
) }