diff --git a/apps/desktop-tauri/src-tauri/src/commands/settings.rs b/apps/desktop-tauri/src-tauri/src/commands/settings.rs index 141c06e9317..42cb5b2d405 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/settings.rs @@ -559,11 +559,19 @@ fn parse_language(s: &str) -> Option { Language::resolve(s) } +/// Serializes the load -> patch -> save of `update_settings`. Commands run +/// concurrently, so two overlapping patches would otherwise both load the same +/// file and the later save would drop the earlier change. +static UPDATE_SETTINGS_LOCK: Mutex<()> = Mutex::new(()); + #[tauri::command] pub async fn update_settings( app: tauri::AppHandle, patch: SettingsUpdate, ) -> Result { + let write_guard = UPDATE_SETTINGS_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let mut settings = Settings::load(); let notify_float_bar = patch.notifies_float_bar(); let refresh_provider_data = patch.refreshes_provider_data(); @@ -585,6 +593,7 @@ pub async fn update_settings( } settings.save().map_err(|e| e.to_string())?; + drop(write_guard); if clear_local_usage_cache { crate::commands::clear_provider_local_usage_cache(); } diff --git a/apps/desktop-tauri/src/hooks/useSettings.test.tsx b/apps/desktop-tauri/src/hooks/useSettings.test.tsx index 61d2240176c..aa79b350c12 100644 --- a/apps/desktop-tauri/src/hooks/useSettings.test.tsx +++ b/apps/desktop-tauri/src/hooks/useSettings.test.tsx @@ -69,3 +69,247 @@ describe("useSettings live sync", () => { await waitFor(() => expect(unlisten).toHaveBeenCalledTimes(1)); }); }); + +describe("useSettings update", () => { + beforeEach(() => { + vi.clearAllMocks(); + tauriMocks.getSettingsSnapshot.mockResolvedValue(snapshot(100)); + }); + + const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; + }; + + it("applies the patch before the shell answers and keeps saving off for a fast save", async () => { + const pending = deferred(); + tauriMocks.updateSettings.mockReturnValueOnce(pending.promise); + const initial = snapshot(100); + const { result } = renderHook(() => useSettings(initial)); + // Let the bootstrap snapshot fetch settle so it cannot overwrite the update. + await act(async () => {}); + + let done!: Promise; + act(() => { + done = result.current.update({ windowScalePercent: 150 }); + }); + + expect(result.current.settings.windowScalePercent).toBe(150); + expect(result.current.saving).toBe(false); + + await act(async () => { + pending.resolve(snapshot(150)); + await done; + }); + expect(result.current.settings.windowScalePercent).toBe(150); + expect(result.current.saving).toBe(false); + }); + + it("reports saving only once a save is slow", async () => { + vi.useFakeTimers(); + try { + const pending = deferred(); + tauriMocks.updateSettings.mockReturnValueOnce(pending.promise); + const initial = snapshot(100); + const { result } = renderHook(() => useSettings(initial)); + + let done!: Promise; + act(() => { + done = result.current.update({ windowScalePercent: 150 }); + }); + expect(result.current.saving).toBe(false); + + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(result.current.saving).toBe(true); + + await act(async () => { + pending.resolve(snapshot(150)); + await done; + }); + expect(result.current.saving).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it("merges a per-provider accent color patch instead of replacing the map", async () => { + tauriMocks.updateSettings.mockReturnValueOnce(new Promise(() => {})); + const initial = { + providerAccentColors: { claude: "#111111", codex: "#222222" }, + } as unknown as SettingsSnapshot; + tauriMocks.getSettingsSnapshot.mockResolvedValue(initial); + const { result } = renderHook(() => useSettings(initial)); + await act(async () => {}); + + act(() => { + void result.current.update({ providerAccentColors: { claude: null, grok: "#333333" } }); + }); + + expect(result.current.settings.providerAccentColors).toEqual({ + codex: "#222222", + grok: "#333333", + }); + }); + + it("ignores a stale response that arrives after a newer save", async () => { + const first = deferred(); + const second = deferred(); + tauriMocks.updateSettings + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + const initial = snapshot(100); + const { result } = renderHook(() => useSettings(initial)); + await act(async () => {}); + + let a!: Promise; + let b!: Promise; + act(() => { + a = result.current.update({ windowScalePercent: 125 }); + }); + act(() => { + b = result.current.update({ windowScalePercent: 150 }); + }); + + await act(async () => { + second.resolve(snapshot(150)); + await b; + }); + await act(async () => { + first.resolve(snapshot(125)); + await a; + }); + + expect(result.current.settings.windowScalePercent).toBe(150); + }); + + it("ignores a stale failure that arrives after a newer save succeeded", async () => { + const first = deferred(); + const second = deferred(); + tauriMocks.updateSettings + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + const initial = snapshot(100); + const { result } = renderHook(() => useSettings(initial)); + await act(async () => {}); + + let a!: Promise; + let b!: Promise; + act(() => { + a = result.current.update({ windowScalePercent: 125 }); + }); + act(() => { + b = result.current.update({ windowScalePercent: 150 }); + }); + await act(async () => { + second.resolve(snapshot(150)); + await b; + }); + tauriMocks.getSettingsSnapshot.mockClear(); + await act(async () => { + first.reject(new Error("disk busy")); + await a; + }); + + expect(result.current.error).toBeNull(); + expect(tauriMocks.getSettingsSnapshot).not.toHaveBeenCalled(); + expect(result.current.settings.windowScalePercent).toBe(150); + }); + + it("keeps the resolved shortcut map until the response supplies it", async () => { + tauriMocks.updateSettings.mockReturnValueOnce(new Promise(() => {})); + const initial = { + switcherShortcuts: { next: "Ctrl+Tab", previous: "Ctrl+Shift+Tab" }, + } as unknown as SettingsSnapshot; + tauriMocks.getSettingsSnapshot.mockResolvedValue(initial); + const { result } = renderHook(() => useSettings(initial)); + await act(async () => {}); + + act(() => { + void result.current.update({ switcherShortcuts: { next: "Alt+N" } }); + }); + + expect(result.current.settings.switcherShortcuts).toEqual({ + next: "Ctrl+Tab", + previous: "Ctrl+Shift+Tab", + }); + }); + + it("merges a per-provider metric patch instead of replacing the map", async () => { + tauriMocks.updateSettings.mockReturnValueOnce(new Promise(() => {})); + const initial = { + providerMetrics: { claude: "session", codex: "weekly" }, + } as unknown as SettingsSnapshot; + tauriMocks.getSettingsSnapshot.mockResolvedValue(initial); + const { result } = renderHook(() => useSettings(initial)); + await act(async () => {}); + + act(() => { + void result.current.update({ providerMetrics: { claude: "weekly" } }); + }); + + expect(result.current.settings.providerMetrics).toEqual({ claude: "weekly", codex: "weekly" }); + }); + + it("does not let a broadcast refresh undo a save that is still pending", async () => { + const pending = deferred(); + tauriMocks.updateSettings.mockReturnValueOnce(pending.promise); + const initial = snapshot(100); + const { result } = renderHook(() => useSettings(initial)); + await waitFor(() => + expect(eventMocks.listeners["settings-changed"]).toBeTypeOf("function"), + ); + + let done!: Promise; + act(() => { + done = result.current.update({ windowScalePercent: 150 }); + }); + // An earlier save's broadcast re-fetches the file before this save lands. + tauriMocks.getSettingsSnapshot.mockResolvedValue(snapshot(100)); + await act(async () => { + eventMocks.listeners["settings-changed"](); + }); + expect(result.current.settings.windowScalePercent).toBe(150); + + await act(async () => { + pending.resolve(snapshot(150)); + await done; + }); + expect(result.current.settings.windowScalePercent).toBe(150); + }); + + it("does not restart the saving delay when a second save overlaps", async () => { + vi.useFakeTimers(); + try { + const first = deferred(); + const second = deferred(); + tauriMocks.updateSettings + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + const initial = snapshot(100); + const { result } = renderHook(() => useSettings(initial)); + + act(() => { + void result.current.update({ windowScalePercent: 125 }); + }); + act(() => { + vi.advanceTimersByTime(200); + }); + act(() => { + void result.current.update({ windowScalePercent: 150 }); + }); + act(() => { + vi.advanceTimersByTime(150); + }); + expect(result.current.saving).toBe(true); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/apps/desktop-tauri/src/hooks/useSettings.ts b/apps/desktop-tauri/src/hooks/useSettings.ts index 84244d4fa51..ac662e0545e 100644 --- a/apps/desktop-tauri/src/hooks/useSettings.ts +++ b/apps/desktop-tauri/src/hooks/useSettings.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { listen } from "@tauri-apps/api/event"; import type { SettingsSnapshot, SettingsUpdate } from "../types/bridge"; import { getSettingsSnapshot, updateSettings } from "../lib/tauri"; @@ -10,6 +10,38 @@ interface UseSettingsReturn { update: (patch: SettingsUpdate) => Promise; } +const SAVING_INDICATOR_DELAY_MS = 300; + +/** + * Patch fields whose snapshot value is not the patch value: the shortcut patch + * holds overrides only while the snapshot holds the resolved map. They stay as + * they are until the save response supplies the resolved value. + */ +const NOT_OPTIMISTIC: ReadonlySet = new Set(["switcherShortcuts"]); + +/** Copies the patch fields that also exist in the snapshot (write-only fields are skipped). */ +function applyPatch(current: SettingsSnapshot, patch: SettingsUpdate): SettingsSnapshot { + const next: Record = { ...current }; + for (const [key, value] of Object.entries(patch)) { + if (value !== undefined && key in current && !NOT_OPTIMISTIC.has(key)) next[key] = value; + } + // Accent colors are sent as a per-provider merge patch (`null` clears one + // provider), not as the full map. + if (patch.providerAccentColors) { + const colors: Record = { ...current.providerAccentColors }; + for (const [id, color] of Object.entries(patch.providerAccentColors)) { + if (color === null) delete colors[id]; + else colors[id] = color; + } + next.providerAccentColors = colors; + } + // Metric preferences are merged per provider as well. + if (patch.providerMetrics) { + next.providerMetrics = { ...current.providerMetrics, ...patch.providerMetrics }; + } + return next as unknown as SettingsSnapshot; +} + /** * Manages the current settings state and exposes a mutation helper that * persists changes through the Tauri bridge and refreshes the local copy. @@ -18,6 +50,9 @@ export function useSettings(initial: SettingsSnapshot): UseSettingsReturn { const [settings, setSettings] = useState(initial); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); + // Saves started from this window that have not answered yet. + const pendingSaves = useRef(0); + const [anyPending, setAnyPending] = useState(false); useEffect(() => { let cancelled = false; @@ -53,7 +88,11 @@ export function useSettings(initial: SettingsSnapshot): UseSettingsReturn { Promise.resolve( listen("settings-changed", () => { getSettingsSnapshot() - .then((fresh) => setSettings(fresh)) + .then((fresh) => { + // While a save from this window is pending, its response is the + // newer state; an earlier save's broadcast must not undo it. + if (pendingSaves.current === 0) setSettings(fresh); + }) .catch(() => { // Keep the current copy if the refresh fails. }); @@ -73,11 +112,37 @@ export function useSettings(initial: SettingsSnapshot): UseSettingsReturn { }; }, []); + // `saving` disables every control on the tab, which dims them to 50% + // opacity. A local save finishes in a few milliseconds, so raising the flag + // immediately made the whole tab blink on every checkbox click. Only report + // `saving` once a save has been pending for a noticeable time. + // Keyed on whether any save is pending, not on how many, so an overlapping + // save does not restart the delay. + useEffect(() => { + if (!anyPending) { + setSaving(false); + return; + } + const timer = window.setTimeout(() => setSaving(true), SAVING_INDICATOR_DELAY_MS); + return () => window.clearTimeout(timer); + }, [anyPending]); + + // Responses of overlapping saves may arrive out of order; only the latest + // one is applied so an older snapshot cannot undo a newer change. + const latestRequest = useRef(0); + const update = useCallback(async (patch: SettingsUpdate) => { - setSaving(true); + const request = ++latestRequest.current; + pendingSaves.current += 1; + setAnyPending(true); setError(null); + // Show the change right away instead of waiting for the round trip: the + // controls are controlled, so without this a checkbox flips only after + // the shell answers. + setSettings((current) => applyPatch(current, patch)); try { const next = await updateSettings(patch); + if (request !== latestRequest.current) return; setSettings(next); if (typeof window !== "undefined") { window.dispatchEvent( @@ -87,6 +152,8 @@ export function useSettings(initial: SettingsSnapshot): UseSettingsReturn { ); } } catch (err: unknown) { + // A newer save is in flight; its outcome decides what is shown. + if (request !== latestRequest.current) return; const msg = err instanceof Error ? err.message : String(err); setError(msg); // Re-fetch to stay in sync with disk state on failure @@ -97,7 +164,8 @@ export function useSettings(initial: SettingsSnapshot): UseSettingsReturn { // ignore secondary failure } } finally { - setSaving(false); + pendingSaves.current -= 1; + if (pendingSaves.current === 0) setAnyPending(false); } }, []); diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index 88e6514b427..0fc1412da5c 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -5332,6 +5332,20 @@ html:has(.menu-surface--tray) { max-width: 560px; margin: 6px auto 0; } +.settings-status-slot { + position: relative; + z-index: 5; +} +.settings-status--floating { + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + margin: 6px 0 0; + white-space: nowrap; + background: var(--app-bg); + box-shadow: inset 0 0 0 100vmax rgba(10, 132, 255, 0.08), 0 2px 8px rgba(0, 0, 0, 0.25); +} /* ── Grouped form (SwiftUI Form .grouped) ─────────────────────────── */ .settings-section { diff --git a/apps/desktop-tauri/src/surfaces/Settings.tsx b/apps/desktop-tauri/src/surfaces/Settings.tsx index f3fb995d443..388a5caa6f1 100644 --- a/apps/desktop-tauri/src/surfaces/Settings.tsx +++ b/apps/desktop-tauri/src/surfaces/Settings.tsx @@ -227,14 +227,17 @@ export default function Settings({ state, initialTab: propTab }: { state: Bootst ))} - {/* status bar */} - {(saving || error) && ( -
- {saving ? t("SettingsStatusSaving") : error} -
- )} + {/* status bar – the transient "Saving…" note overlays the tab body so it + never shifts the layout; an error stays in flow so it hides nothing */} +
+ {(saving || error) && ( +
+ {saving ? t("SettingsStatusSaving") : error} +
+ )} +
{/* tab panels */}