"use client"; import { invoke } from "@tauri-apps/api/core"; import { writeText as writeClipboardText } from "@tauri-apps/plugin-clipboard-manager"; import { openUrl } from "@tauri-apps/plugin-opener"; import Color from "color"; import { motion, useReducedMotion } from "motion/react"; import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { BsCamera, BsMic } from "react-icons/bs"; import { DataRootSetting } from "@/components/data-root-setting"; import { DnsBlocklistDialog } from "@/components/dns-blocklist-dialog"; import { LoadingButton } from "@/components/loading-button"; import { useTheme } from "@/components/theme-provider"; import { AnimatedSwitch } from "@/components/ui/animated-switch"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { ColorPicker, ColorPickerAlpha, ColorPickerEyeDropper, ColorPickerFormat, ColorPickerHue, ColorPickerOutput, ColorPickerSelection, } from "@/components/ui/color-picker"; import { ConfirmationMark } from "@/components/ui/confirmation-mark"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { useCloudAuth } from "@/hooks/use-cloud-auth"; import { useCommercialTrial } from "@/hooks/use-commercial-trial"; import { useInputModality } from "@/hooks/use-input-modality"; import { useLanguage } from "@/hooks/use-language"; import type { PermissionType } from "@/hooks/use-permissions"; import { usePermissions } from "@/hooks/use-permissions"; import { translateBackendError } from "@/lib/backend-errors"; import { effectivePlanOf } from "@/lib/entitlements"; import { MOTION_SPRING_POSITION } from "@/lib/motion"; import { applyThemeColors, clearThemeColors, getThemeByColors, getThemeById, normalizeThemeColors, THEME_VARIABLES, THEMES, withThemeTransition, } from "@/lib/themes"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; import { cn } from "@/lib/utils"; import type { SetDefaultBrowserOutcome } from "@/types"; import { RippleButton } from "./ui/ripple"; interface AppSettings { set_as_default_browser: boolean; theme: string; custom_theme?: Record; api_enabled: boolean; api_port: number; api_token?: string; disable_auto_updates?: boolean; keep_decrypted_profiles_in_ram?: boolean; fingerprint_gate_disabled?: boolean; vpn_extension_warning_disabled?: boolean; trash_retention_days?: number; } /** Retention choices offered for the trash, in days. The backend clamps to 1..=365. */ const TRASH_RETENTION_OPTIONS = [7, 14, 30, 90, 365]; const DEFAULT_TRASH_RETENTION_DAYS = 30; /** The offered choices, plus the stored value when it is not one of them. */ function trashRetentionOptions(current: number): number[] { if (TRASH_RETENTION_OPTIONS.includes(current)) return TRASH_RETENTION_OPTIONS; return [...TRASH_RETENTION_OPTIONS, current].sort((a, b) => a - b); } interface CustomThemeState { selectedThemeId: string | null; colors: Record; } interface PermissionInfo { permission_type: PermissionType; isGranted: boolean; description: string; } // Version update progress toasts are handled globally via useVersionUpdater interface SettingsDialogProps { isOpen: boolean; onClose: () => void; onIntegrationsOpen?: () => void; subPage?: boolean; /** A section to scroll to and focus as soon as the page opens. */ initialSection?: string | null; } export function SettingsDialog({ isOpen, onClose, onIntegrationsOpen, subPage, initialSection = null, }: SettingsDialogProps) { const [settings, setSettings] = useState({ set_as_default_browser: false, theme: "system", custom_theme: undefined, api_enabled: false, api_port: 10108, api_token: undefined, }); const [originalSettings, setOriginalSettings] = useState({ set_as_default_browser: false, theme: "system", custom_theme: undefined, api_enabled: false, api_port: 10108, api_token: undefined, }); const [customThemeState, setCustomThemeState] = useState({ selectedThemeId: null, colors: {}, }); const [isDefaultBrowser, setIsDefaultBrowser] = useState(false); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [saveError, setSaveError] = useState(null); const [saved, setSaved] = useState(false); const [search, setSearch] = useState(""); const [jumpTo, setJumpTo] = useState(initialSection); const sectionsRef = useRef(null); const scrollerRef = useRef(null); // The section the reader is in, so the nav can point at it. A click pins // its target until the reader scrolls away, because the last sections can // never reach the top of the scroller on their own. const [activeSection, setActiveSection] = useState(null); const pinnedSectionRef = useRef<{ id: string; scrollTop: number } | null>( null, ); const reduceMotion = useReducedMotion(); const inputModality = useInputModality(); const animateNav = !reduceMotion && inputModality === "pointer"; const [isSettingDefault, setIsSettingDefault] = useState(false); const [isClearingCache, setIsClearingCache] = useState(false); const [isClearingTraffic, setIsClearingTraffic] = useState(false); const [permissions, setPermissions] = useState([]); const [isLoadingPermissions, setIsLoadingPermissions] = useState(false); const [requestingPermission, setRequestingPermission] = useState(null); const [dnsBlocklistDialogOpen, setDnsBlocklistDialogOpen] = useState(false); const [hasE2ePassword, setHasE2ePassword] = useState(false); const [e2ePassword, setE2ePassword] = useState(""); const [e2ePasswordConfirm, setE2ePasswordConfirm] = useState(""); const [e2eError, setE2eError] = useState(""); const [isSavingE2e, setIsSavingE2e] = useState(false); const [isRemovingE2e, setIsRemovingE2e] = useState(false); const [isVerifyE2eOpen, setIsVerifyE2eOpen] = useState(false); const [verifyE2ePassword, setVerifyE2ePassword] = useState(""); const [isVerifyingE2e, setIsVerifyingE2e] = useState(false); // Theme-restore guards: settings load asynchronously, and the dialog can be // unmounted directly by rail navigation (bypassing handleClose). Refs mirror // the loaded state so the unmount cleanup can restore the persisted theme. const [hasLoadedSettings, setHasLoadedSettings] = useState(false); const hasLoadedSettingsRef = useRef(false); const originalSettingsRef = useRef(null); const [systemInfo, setSystemInfo] = useState<{ app_version: string; os: string; arch: string; portable: boolean; } | null>(null); const { t } = useTranslation(); const { setTheme } = useTheme(); const { requestPermission, isMicrophoneAccessGranted, isCameraAccessGranted, currentOS, } = usePermissions(isOpen); const isMacOS = currentOS === "macos"; const isLinux = currentOS === "linux"; const { trialStatus } = useCommercialTrial(); const { user: cloudUser } = useCloudAuth(); // Encryption is available to everyone except team members who aren't owners const canUseEncryption = cloudUser == null || effectivePlanOf(cloudUser) !== "team" || cloudUser.teamRole === "owner"; const { currentLanguage, changeLanguage, supportedLanguages, isLoading: isLanguageLoading, } = useLanguage(); const [selectedLanguage, setSelectedLanguage] = useState(null); const [originalLanguage, setOriginalLanguage] = useState(null); const getPermissionIcon = useCallback((type: PermissionType) => { switch (type) { case "microphone": return ; case "camera": return ; } }, []); const getPermissionDisplayName = useCallback( (type: PermissionType) => { switch (type) { case "microphone": return t("settings.permissions.microphone"); case "camera": return t("settings.permissions.camera"); } }, [t], ); const getStatusBadge = useCallback( (isGranted: boolean) => { if (isGranted) { return ( {t("common.status.granted")} ); } return {t("common.status.notGranted")}; }, [t], ); const getPermissionDescription = useCallback( (type: PermissionType) => { switch (type) { case "microphone": return t("settings.permissions.microphoneDescription"); case "camera": return t("settings.permissions.cameraDescription"); } }, [t], ); // `animate: false` on the restore paths. Opening Settings re-applies the // theme already on screen, so a whole-document cross-fade to an identical // palette animates nothing. Worse, the mount effect below did it twice in a // row, and the second transition aborts the first mid-snapshot. const applyCustomTheme = useCallback( (vars: Record, options?: { animate?: boolean }) => { const apply = () => { applyThemeColors(vars); }; if (options?.animate === false) { apply(); return; } withThemeTransition(apply); }, [], ); const clearCustomTheme = useCallback((options?: { animate?: boolean }) => { if (options?.animate === false) { clearThemeColors(); return; } withThemeTransition(clearThemeColors); }, []); const loadSettings = useCallback(async () => { setIsLoading(true); try { const appSettings = await invoke("get_app_settings"); const tokyoNightTheme = getThemeById("tokyo-night"); if (!tokyoNightTheme) { throw new Error("Tokyo Night theme not found"); } const merged: AppSettings = { ...appSettings, custom_theme: appSettings.custom_theme && Object.keys(appSettings.custom_theme).length > 0 ? normalizeThemeColors(appSettings.custom_theme) : tokyoNightTheme.colors, }; // One-shot migration off the old localStorage flag. Without it, a user // who explicitly turned the warning off would start getting hard blocks // after updating — the single most likely support complaint here. let migrated = merged; try { if ( localStorage.getItem("consistency-warn-disabled") === "1" && !merged.fingerprint_gate_disabled ) { migrated = { ...merged, fingerprint_gate_disabled: true }; await invoke("save_app_settings", { settings: migrated, }); } localStorage.removeItem("consistency-warn-disabled"); } catch (err) { console.warn("Failed to migrate consistency warning preference:", err); } setSettings(migrated); setOriginalSettings(migrated); originalSettingsRef.current = migrated; hasLoadedSettingsRef.current = true; setHasLoadedSettings(true); // Initialize custom theme state if (merged.theme === "custom" && merged.custom_theme) { const matchingTheme = getThemeByColors(merged.custom_theme); setCustomThemeState({ selectedThemeId: matchingTheme?.id ?? null, colors: merged.custom_theme, }); // Keep the user's custom colors applied while Settings is open — // without this the page shows the default palette until save/close. applyCustomTheme(merged.custom_theme); } else if (merged.theme === "custom") { // Initialize with Tokyo Night if no custom theme exists setCustomThemeState({ selectedThemeId: "tokyo-night", colors: tokyoNightTheme.colors, }); } // Check E2E password status try { const hasPassword = await invoke("check_has_e2e_password"); setHasE2ePassword(hasPassword); } catch { setHasE2ePassword(false); } // Load system info try { const info = await invoke<{ app_version: string; os: string; arch: string; portable: boolean; }>("get_system_info"); setSystemInfo(info); } catch { setSystemInfo(null); } } catch (error) { console.error("Failed to load settings:", error); } finally { setIsLoading(false); } }, [applyCustomTheme]); const loadPermissions = useCallback(() => { setIsLoadingPermissions(true); try { if (!isMacOS) { // On non-macOS platforms, don't show permissions setPermissions([]); return; } const permissionList: PermissionInfo[] = [ { permission_type: "microphone", isGranted: isMicrophoneAccessGranted, description: getPermissionDescription("microphone"), }, { permission_type: "camera", isGranted: isCameraAccessGranted, description: getPermissionDescription("camera"), }, ]; setPermissions(permissionList); } catch (error) { console.error("Failed to load permissions:", error); } finally { setIsLoadingPermissions(false); } }, [ getPermissionDescription, isCameraAccessGranted, isMacOS, isMicrophoneAccessGranted, ]); // The Linux implementation shells out to `which` plus two `xdg-mime query` // calls, and `xdg-mime` is a shell script that forks further. Without this // guard a slow desktop lets the poll below stack one unfinished call on top // of another every few seconds, and each one occupies a worker of the same // runtime every other Tauri command shares. const defaultBrowserCheckInFlight = useRef(false); const checkDefaultBrowserStatus = useCallback(async () => { if (defaultBrowserCheckInFlight.current) { return; } defaultBrowserCheckInFlight.current = true; try { const isDefault = await invoke("is_default_browser"); setIsDefaultBrowser(isDefault); } catch (error) { console.error("Failed to check default browser status:", error); } finally { defaultBrowserCheckInFlight.current = false; } }, []); const handleSetDefaultBrowser = useCallback(async () => { setIsSettingDefault(true); try { // Windows keeps the final choice for its own settings page, so a call // that succeeded does not always mean Donut is the default yet. Say which // of the two happened. Saying nothing at all is what left the user // watching the badge stay "Inactive" with no explanation. const outcome = await invoke( "set_as_default_browser", ); await checkDefaultBrowserStatus(); if (outcome.status === "awaitingSystemSettings") { showSuccessToast(t("settings.defaultBrowser.finishInSystemSettings"), { description: t( "settings.defaultBrowser.finishInSystemSettingsDescription", ), duration: 8000, }); } else { showSuccessToast(t("settings.defaultBrowser.setSuccess")); } } catch (error) { showErrorToast(t("settings.defaultBrowser.setFailed"), { description: error instanceof Error ? error.message : typeof error === "string" ? error : t("common.errors.unknown"), duration: 8000, }); } finally { setIsSettingDefault(false); } }, [checkDefaultBrowserStatus, t]); const handleClearTraffic = useCallback(async () => { setIsClearingTraffic(true); try { await invoke("clear_all_traffic_stats"); showSuccessToast(t("settings.privacy.clearTrafficSuccess")); } catch (error) { showErrorToast( error instanceof Error ? error.message : t("common.errors.unknown"), ); } finally { setIsClearingTraffic(false); } }, [t]); const handleClearCache = useCallback(async () => { setIsClearingCache(true); try { await invoke("clear_all_version_cache_and_refetch"); // Also clear traffic stats cache await invoke("clear_all_traffic_stats"); // Don't show immediate success toast - let the version update progress events handle it } catch (error) { console.error("Failed to clear cache:", error); showErrorToast(t("settings.advanced.clearCacheFailed"), { description: error instanceof Error ? error.message : t("common.errors.unknown"), duration: 4000, }); } finally { setIsClearingCache(false); } }, [t]); const handleRequestPermission = useCallback( async (permissionType: PermissionType) => { setRequestingPermission(permissionType); try { const granted = await requestPermission(permissionType); if (granted) { showSuccessToast( permissionType === "microphone" ? t("permissionDialog.grantedToastMicrophone") : t("permissionDialog.grantedToastCamera"), ); return; } await openUrl( `x-apple.systempreferences:com.apple.preference.security?${ permissionType === "microphone" ? "Privacy_Microphone" : "Privacy_Camera" }`, ); showErrorToast( permissionType === "microphone" ? t("permissionDialog.stillNotGrantedMicrophone") : t("permissionDialog.stillNotGrantedCamera"), ); } catch (error) { console.error("Failed to request permission:", error); showErrorToast(t("permissionDialog.requestFailed")); } finally { setRequestingPermission(null); } }, [requestPermission, t], ); const handleSave = useCallback(async () => { setIsSaving(true); setSaveError(null); try { // Update settings with current custom theme state let settingsToSave: AppSettings = { ...settings, custom_theme: settings.theme === "custom" ? customThemeState.colors : settings.custom_theme, }; console.log("[settings-dialog] Saving settings:", { theme: settingsToSave.theme, hasCustomTheme: !!settingsToSave.custom_theme, customThemeKeys: settingsToSave.custom_theme ? Object.keys(settingsToSave.custom_theme).length : 0, }); const savedSettings = await invoke("save_app_settings", { settings: settingsToSave, }); console.log("[settings-dialog] Saved settings response:", { theme: savedSettings.theme, hasCustomTheme: !!savedSettings.custom_theme, customThemeKeys: savedSettings.custom_theme ? Object.keys(savedSettings.custom_theme).length : 0, }); // Update settings with any generated tokens setSettings(savedSettings); settingsToSave = savedSettings; setOriginalSettings(savedSettings); originalSettingsRef.current = savedSettings; // Pass the actual theme value through. Calling setTheme("dark") here // when the user is on "custom" pushes the provider state to "dark", // which triggers its clear-custom-vars effect and wipes the CSS // variables we set just below — that's the bug where saving a custom // theme made it disappear until the app was restarted. setTheme(settings.theme); // Apply or clear custom variables only on Save if (settings.theme === "custom") { if (Object.keys(customThemeState.colors).length > 0) { try { clearThemeColors(); applyThemeColors(customThemeState.colors); } catch { /* empty */ } } } else { try { clearThemeColors(); } catch { /* empty */ } } // Save language if changed if (selectedLanguage !== originalLanguage) { await changeLanguage( selectedLanguage === "system" ? null : (selectedLanguage as | "en" | "es" | "pt" | "fr" | "zh" | "ja" | "ko" | "ru" | "vi"), ); setOriginalLanguage(selectedLanguage); } setOriginalSettings(settingsToSave); // Keep the ref in sync so the unmount-restore effect re-applies the // just-SAVED theme, not the pre-save one (which reverted the theme // until app restart). originalSettingsRef.current = settingsToSave; setSaved(true); if (!subPage) onClose(); } catch (error) { console.error("Failed to save settings:", error); const message = translateBackendError(t, error); setSaveError(message); showErrorToast(t("appFeedback.saveFailed"), { description: message }); } finally { setIsSaving(false); } }, [ onClose, setTheme, settings, customThemeState, selectedLanguage, originalLanguage, changeLanguage, subPage, t, ]); const updateSetting = useCallback( ( key: keyof AppSettings, value: boolean | number | string | Record | undefined, ) => { setSaveError(null); setSettings((prev) => ({ ...prev, [key]: value as unknown as never })); }, [], ); const discardChanges = useCallback(() => { // Restore original theme when closing without saving // Only a revert the user can see is worth animating. const changed = originalSettings.theme !== settings.theme; if (originalSettings.theme === "custom" && originalSettings.custom_theme) { applyCustomTheme(originalSettings.custom_theme, { animate: changed }); } else { clearCustomTheme({ animate: false }); setTheme(originalSettings.theme, { animate: changed }); } setSettings(originalSettings); setSelectedLanguage(originalLanguage); setCustomThemeState({ selectedThemeId: originalSettings.custom_theme ? (getThemeByColors(originalSettings.custom_theme)?.id ?? null) : null, colors: originalSettings.custom_theme ?? {}, }); setSaveError(null); setSaved(false); }, [ applyCustomTheme, clearCustomTheme, originalSettings, originalLanguage, setTheme, settings.theme, ]); const handleClose = useCallback(() => { discardChanges(); onClose(); }, [discardChanges, onClose]); // Only clear custom theme when switching away from custom, don't apply live // changes. Gated on the async settings load: before it resolves the state // still holds the "system" default, and clearing then wipes the user's // custom theme vars on every Settings visit (the theme-reverts-to-dark bug). // // This effect is both the restore-on-open and the live switch when the user // picks a theme, so it animates only a real change: the first run after the // settings load is re-applying the palette already on screen. Clearing the // inline custom vars is never the animated half — switching to a stylesheet // palette makes them invisible either way, and running two transitions // back to back just aborts the first one mid-snapshot. const appliedThemeRef = useRef(null); useEffect(() => { if (hasLoadedSettings && settings.theme !== "custom") { const previous = appliedThemeRef.current; appliedThemeRef.current = settings.theme; clearCustomTheme({ animate: false }); setTheme(settings.theme, { animate: previous !== null && previous !== settings.theme, }); } }, [hasLoadedSettings, settings.theme, clearCustomTheme, setTheme]); // Unmount-safe theme restore: rail navigation unmounts this dialog without // calling handleClose, which used to leave the theme vars wiped for the // rest of the session. useEffect(() => { return () => { const s = originalSettingsRef.current; if (!hasLoadedSettingsRef.current || !s) return; if (s.theme === "custom" && s.custom_theme) { applyThemeColors(s.custom_theme); } else { // A non-custom persisted theme (light/dark/system) uses the // stylesheet palette — strip any leftover inline custom vars so a // just-saved switch away from custom isn't reverted on unmount. clearThemeColors(); setTheme(s.theme, { animate: false }); } }; }, [setTheme]); useEffect(() => { if (isOpen) { loadSettings().catch((err: unknown) => { console.error(err); }); checkDefaultBrowserStatus().catch((err: unknown) => { console.error(err); }); if (isMacOS) { loadPermissions(); } // Re-check periodically so the badge follows a change the user made in // their desktop settings. Ten seconds rather than two: on Linux each // check is three subprocesses, and nobody flips their default browser // often enough to notice the difference. const intervalId = setInterval(() => { checkDefaultBrowserStatus().catch((err: unknown) => { console.error(err); }); }, 10000); // Cleanup interval on component unmount or dialog close return () => { clearInterval(intervalId); }; } }, [ isOpen, isMacOS, loadPermissions, checkDefaultBrowserStatus, loadSettings, ]); // Initialize language selection when dialog opens or language loads useEffect(() => { if (isOpen && !isLanguageLoading) { setSelectedLanguage(currentLanguage); setOriginalLanguage(currentLanguage); } }, [isOpen, currentLanguage, isLanguageLoading]); // Update permissions when the permission states change useEffect(() => { if (isMacOS) { const permissionList: PermissionInfo[] = [ { permission_type: "microphone", isGranted: isMicrophoneAccessGranted, description: getPermissionDescription("microphone"), }, { permission_type: "camera", isGranted: isCameraAccessGranted, description: getPermissionDescription("camera"), }, ]; setPermissions(permissionList); } else { setPermissions([]); } }, [ isMacOS, isMicrophoneAccessGranted, isCameraAccessGranted, getPermissionDescription, ]); const draftColors = settings.theme === "custom" ? customThemeState.colors : (settings.custom_theme ?? {}); const savedColors = originalSettings.custom_theme ?? {}; const themeChanged = settings.theme !== originalSettings.theme || Object.keys(draftColors).length !== Object.keys(savedColors).length || Object.entries(draftColors).some( ([key, value]) => savedColors[key] !== value, ); const pendingChanges = [ ...(themeChanged ? [t("settings.appearance.title")] : []), ...(selectedLanguage !== originalLanguage ? [t("settings.language.title")] : []), ...( [ ["api_enabled", "settings.integrations.title"], ["disable_auto_updates", "settings.disableAutoUpdates"], [ "keep_decrypted_profiles_in_ram", "settings.keepDecryptedProfilesInRam", ], ["fingerprint_gate_disabled", "settings.privacy.consistencyWarning"], [ "vpn_extension_warning_disabled", "settings.privacy.vpnExtensionWarning", ], ] as const ) .filter( ([key]) => Boolean(settings[key]) !== Boolean(originalSettings[key]), ) .map(([, key]) => t(key)), ...((settings.trash_retention_days ?? DEFAULT_TRASH_RETENTION_DAYS) !== (originalSettings.trash_retention_days ?? DEFAULT_TRASH_RETENTION_DAYS) ? [t("settings.trashRetention")] : []), ]; const hasChanges = pendingChanges.length > 0; const sections = [ ["appearance", "settings.appearance.title", ["settings.appearance"]], ["language", "settings.language.title", ["settings.language"]], ...(!systemInfo?.portable ? [ [ "default", "settings.defaultBrowser.title", ["settings.defaultBrowser"], ], ] : []), ...(isMacOS ? [ [ "permissions", "settings.permissions.title", ["settings.permissions"], ], ] : []), ["integrations", "settings.integrations.title", ["settings.integrations"]], ["dns", "dnsBlocklist.title", ["dnsBlocklist"]], ["encryption", "settings.encryption.title", ["settings.encryption"]], ["commercial", "settings.commercial.title", ["settings.commercial"]], ["dataRoot", "settings.dataRoot.title", ["settings.dataRoot"]], [ "advanced", "settings.advanced.title", [ "settings.advanced", "settings.disableAutoUpdates", "settings.disableAutoUpdatesDescription", "settings.keepDecryptedProfilesInRam", "settings.keepDecryptedProfilesInRamDescription", "settings.trashRetention", "settings.trashRetentionDescription", "settings.privacy", ], ], ] as Array<[string, string, string[]]>; const needle = search.trim().toLocaleLowerCase(); const matchingSections = sections.filter(([, , keys]) => { const text = keys .map((key) => JSON.stringify(t(key, { returnObjects: true }))) .join(" ") .toLocaleLowerCase(); return needle.split(/\s+/).every((word) => text.includes(word)); }); const sectionVisible = (id: string) => matchingSections.some(([key]) => key === id); const visibleSectionIds = matchingSections.map(([id]) => id).join(","); const syncActiveSection = useCallback(() => { const scroller = scrollerRef.current; const root = sectionsRef.current; if (!scroller || !root) return; const nodes = [ ...root.querySelectorAll("[data-settings-section]"), ].filter((node) => !node.hidden); if (nodes.length === 0) { setActiveSection(null); return; } const pinned = pinnedSectionRef.current; if (pinned && Math.abs(scroller.scrollTop - pinned.scrollTop) < 4) { setActiveSection(pinned.id); return; } pinnedSectionRef.current = null; const atBottom = scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2; if (atBottom) { setActiveSection(nodes[nodes.length - 1].dataset.settingsSection ?? null); return; } const threshold = scroller.getBoundingClientRect().top + 12; let current = nodes[0]; for (const node of nodes) { if (node.getBoundingClientRect().top <= threshold) current = node; else break; } setActiveSection(current.dataset.settingsSection ?? null); }, []); // biome-ignore lint/correctness/useExhaustiveDependencies: the section list changes with the search filter and the settings load, and the sync must run again then useEffect(() => { syncActiveSection(); }, [syncActiveSection, visibleSectionIds, isLoading]); useEffect(() => { if (!jumpTo) return; const section = sectionsRef.current?.querySelector( `[data-settings-section="${jumpTo}"]`, ); section?.scrollIntoView({ block: "start" }); section?.focus({ preventScroll: true }); if (scrollerRef.current) { pinnedSectionRef.current = { id: jumpTo, scrollTop: scrollerRef.current.scrollTop, }; } setActiveSection(jumpTo); setJumpTo(null); }, [jumpTo]); return ( <> {!subPage && ( {t("settings.title")} )}
setSearch(event.target.value)} onKeyDown={(event) => { // Escape empties the filter before it can close the page. if (event.key === "Escape" && search) { event.preventDefault(); event.stopPropagation(); setSearch(""); } }} placeholder={t("appFeedback.searchSettings")} aria-label={t("appFeedback.searchSettings")} className="h-8" />
{/* The scroller spans the full width (so the wheel works over the side gutters); the width cap lives on the inner column. Fusing them was the dead-wheel-zone bug. */}
{/* Appearance Section */} {/* Language Section */} {/* Default Browser Section - hidden in portable mode */} {!systemInfo?.portable && ( )} {/* Permissions Section - Only show on macOS */} {isMacOS && ( )} {/* Integrations Section */} {/* DNS Blocklist Section */} {/* Sync Encryption Section */} {/* Commercial License Section */} {/* Data directory Section */} {/* Advanced Section */} {matchingSections.length === 0 && (

{t("common.noResults")}

)} {/* System Info */} {systemInfo && !needle && (

{`Donut Browser ${systemInfo.app_version}\n${systemInfo.os} ${systemInfo.arch}${systemInfo.portable ? " (portable)" : ""}`}

)}
{saveError ? (

{saveError}

) : (

{!hasChanges && saved && } {isLoading ? t("common.buttons.loading") : isSaving ? t("common.buttons.saving") : hasChanges ? t("appFeedback.pendingChanges", { count: pendingChanges.length, }) : saved ? t("common.buttons.saved") : t("appFeedback.noChanges")}

)} {hasChanges && (
{t("appFeedback.reviewChanges")}
    {pendingChanges.map((label) => (
  • {label}
  • ))}
)}
{subPage ? (
{ handleSave().catch((err: unknown) => { console.error(err); }); }} disabled={isLoading || !hasChanges} > {t("common.buttons.saveSettings")}
) : ( {t("common.buttons.cancel")} { handleSave().catch((err: unknown) => { console.error(err); }); }} disabled={isLoading || !hasChanges} > {t("common.buttons.saveSettings")} )}
setDnsBlocklistDialogOpen(false)} /> { if (!isVerifyingE2e) { setIsVerifyE2eOpen(open); if (!open) setVerifyE2ePassword(""); } }} > {t("settings.encryption.validateDialog.title")} {t("settings.encryption.validateDialog.description")}
setVerifyE2ePassword(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && verifyE2ePassword.length > 0) { e.preventDefault(); void (async () => { setIsVerifyingE2e(true); try { const ok = await invoke("verify_e2e_password", { password: verifyE2ePassword, }); if (ok) { showSuccessToast( t("settings.encryption.validateDialog.matchToast"), ); setIsVerifyE2eOpen(false); setVerifyE2ePassword(""); } else { showErrorToast( t("settings.encryption.validateDialog.mismatchToast"), ); } } catch (error) { showErrorToast(String(error)); } finally { setIsVerifyingE2e(false); } })(); } }} />
{ setIsVerifyingE2e(true); try { const ok = await invoke("verify_e2e_password", { password: verifyE2ePassword, }); if (ok) { showSuccessToast( t("settings.encryption.validateDialog.matchToast"), ); setIsVerifyE2eOpen(false); setVerifyE2ePassword(""); } else { showErrorToast( t("settings.encryption.validateDialog.mismatchToast"), ); } } catch (error) { showErrorToast(String(error)); } finally { setIsVerifyingE2e(false); } }} > {t("settings.encryption.validateDialog.submit")}
); }