diff --git a/docs/architecture.md b/docs/architecture.md index b9a1f75..fd75a8f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -218,10 +218,13 @@ component; tokens → `ui/src/styles.css`. skip, overwrite dialog with all/none batch modes). - `settings.service.ts` — one signal per setting; **imperative apply pattern** (`applyTheme/DarkTone/Accent` set `data-*` attributes on ``; - `applyUiScale` sets its inline `font-size`); `applyAll` also serves the `settingsChanged` broadcast so editor - and manager windows restyle live (they render no settings UI, so no - persist echo). New setting = five touches here + types.ts (backend merges - automatically). + `applyUiScale` sets its inline `font-size`); `applyAll` also serves the + `settingsChanged` broadcast, so every window follows a save made in any + other. All three window RPCs serve `saveSettings` (editor and manager windows + save too — the scale shortcuts); bun's `settingsSaved()` in index.ts runs the + side effects once and rebroadcasts to every window **except the sender**, so + there is no echo to fight a held Cmd+. `applyAll` never persists. New setting + = five touches here + types.ts (backend merges automatically). - `focus.service.ts` — dependency-free `tick`/`bump()`; dialogs bump it, the main window's panels listen. Exists so dialogs don't inject PanelsService (whose constructor spawns a workspace — fatal in secondary windows). diff --git a/docs/gotchas.md b/docs/gotchas.md index b648a5d..7b3917c 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -210,6 +210,12 @@ touching the adjacent code. - Settings: the backend spread-merges (`{...DEFAULT_SETTINGS, ...data}`) so new keys auto-default, but the UI SettingsService reads field-by-field — a new setting silently stays at default until wired in all five spots. +- **Every window's RPC must serve `saveSettings`.** The scale shortcuts + shipped in editor and manager windows while only the main RPC served it: + the request went unanswered, the change applied locally, and main's next + broadcast snapped it back. All three factories now share + `saveSettingsHandler`, and `settingsSaved()` in index.ts rebroadcasts to + every window but the sender — an echo would drag a held Cmd+ backwards. - The editor draft mirror sends full content per tick (2 MB cap upstream); don't shrink the 400 ms debounce. - `panel.touched` gates localDir auto-navigation; `connectToken` guards diff --git a/src/bun/editor-windows.ts b/src/bun/editor-windows.ts index f6cb574..f6ef3e1 100644 --- a/src/bun/editor-windows.ts +++ b/src/bun/editor-windows.ts @@ -33,6 +33,8 @@ export interface EditorWindowsDeps { mainFrame: () => { x: number; y: number }; /** Late-bound senders targeting the MAIN window's webview. */ notifyMain: { pathRefresh: (info: { sessionId: string; dir: string }) => void }; + /** An editor saved settings; `key` identifies it so the rebroadcast skips it. */ + onSettingsSaved: (settings: AppSettings, key: string) => void; } /** @@ -77,6 +79,7 @@ export class EditorWindows { record.suppressPrompt = true; record.win?.close(); }, + onSettingsSaved: (next) => this.deps.onSettingsSaved(next, key), }, ); @@ -165,8 +168,11 @@ export class EditorWindows { }); } - broadcastSettings(settings: AppSettings): void { - this.windows.forEach((rec) => rec.rpc.send.settingsChanged({ settings })); + /** `exceptKey`: the editor that saved — it already shows the new values. */ + broadcastSettings(settings: AppSettings, exceptKey?: string): void { + this.windows.forEach((rec, key) => { + if (key !== exceptKey) rec.rpc.send.settingsChanged({ settings }); + }); } private baseTitle(ctx: EditorOpenContext): string { diff --git a/src/bun/index.ts b/src/bun/index.ts index 0181671..728ba21 100644 --- a/src/bun/index.ts +++ b/src/bun/index.ts @@ -92,6 +92,7 @@ const editors = new EditorWindows({ currentSettings: () => currentSettings, mainFrame: () => win.getFrame(), notifyMain: { pathRefresh: (info) => rpc.send.pathRefresh(info) }, + onSettingsSaved: (s, key) => settingsSaved(s, { editor: key }), }); const manager = new ManagerWindow({ store, @@ -105,19 +106,30 @@ const manager = new ManagerWindow({ menuAction: (info) => rpc.send.menuAction(info), }, rebuildMenu: () => void applyApplicationMenu(store), + onSettingsSaved: (s) => settingsSaved(s, "manager"), }); events.openEditor = (ctx) => editors.open(ctx); -events.settingsChanged = (s) => { +/** + * Every settings save lands here, whichever window made it (editor and manager + * windows save too — the scale shortcuts): side effects run once, then every + * OTHER window follows. The sender is skipped — it already shows the new + * values, and an echo arriving behind a newer local change (a held Cmd+) would + * drag it backwards. + */ +function settingsSaved(s: AppSettings, sender: "main" | "manager" | { editor: string }): void { + engine.setConcurrency(effectiveConcurrency(s)); const localeChanged = s.locale !== currentSettings.locale; currentSettings = s; // future windows' preloads bake the fresh theme if (localeChanged) { setBackendLocale(s.locale); void applyApplicationMenu(store); // relabel the native menu in the new language } - editors.broadcastSettings(s); - manager.broadcastSettings(s); -}; + if (sender !== "main") rpc.send.settingsChanged({ settings: s }); + if (sender !== "manager") manager.broadcastSettings(s); + editors.broadcastSettings(s, typeof sender === "object" ? sender.editor : undefined); +} +events.settingsChanged = (s) => settingsSaved(s, "main"); events.openConnectionManager = () => manager.open(); function showAbout(): void { diff --git a/src/bun/manager-window.ts b/src/bun/manager-window.ts index ccec9c8..db0b76d 100644 --- a/src/bun/manager-window.ts +++ b/src/bun/manager-window.ts @@ -24,6 +24,8 @@ export interface ManagerWindowDeps { }; /** Rebuilds the native app menu after store changes. */ rebuildMenu: () => void; + /** The manager saved settings — rebroadcast them to every other window. */ + onSettingsSaved: (settings: AppSettings) => void; } /** @@ -70,6 +72,7 @@ export class ManagerWindow { this.close(); }, onClose: () => this.close(), + onSettingsSaved: (next) => this.deps.onSettingsSaved(next), }, ); diff --git a/src/bun/rpc-handlers.ts b/src/bun/rpc-handlers.ts index 3880579..fbf491c 100644 --- a/src/bun/rpc-handlers.ts +++ b/src/bun/rpc-handlers.ts @@ -1,6 +1,6 @@ import { BrowserView, Updater, Utils } from "electrobun/bun"; import type { AppRPC, EditorOpenContext } from "../shared/rpc"; -import { effectiveConcurrency, type AppSettings, type Result } from "../shared/types"; +import type { AppSettings, Result } from "../shared/types"; import { readFileText, writeFileText, type EditManager } from "./edits"; import { listLocalDrives } from "./fs/local"; import type { SessionRegistry } from "./fs/registry"; @@ -99,6 +99,22 @@ function connectionStoreHandlers(store: ConnectionStore, onChanged: () => void) }; } +/** + * saveSettings for every window kind: persist, then hand the new object to the + * composition root (index.ts), which applies the side effects once and + * rebroadcasts to every OTHER window. Known ceiling: whole-object writes — two + * windows saving in the same instant, the later one wins. Upgrade path: send a + * patch and merge it bun-side. + */ +const saveSettingsHandler = + (store: SettingsStore, onSaved: (next: AppSettings) => void) => + ({ settings: next }: { settings: AppSettings }) => + wrap(async () => { + await store.save(next); + onSaved(next); + return {}; + }); + const pickKeyFileHandler = () => wrap(async () => { const { homedir } = await import("node:os"); @@ -224,13 +240,7 @@ export function createAppRPC({ // ---- settings ---- getSettings: () => wrap(async () => ({ settings: await settings.load() })), - saveSettings: ({ settings: next }) => - wrap(async () => { - await settings.save(next); - engine.setConcurrency(effectiveConcurrency(next)); - events.settingsChanged(next); - return {}; - }), + saveSettings: saveSettingsHandler(settings, (next) => events.settingsChanged(next)), // ---- file editing ---- readFile: ({ sessionId, path }) => @@ -347,6 +357,8 @@ export interface EditorRpcHooks { onSaved: () => void; /** Webview resolved its dirty prompt: close without the native fallback. */ onCloseConfirmed: () => void; + /** This window saved settings (e.g. the scale shortcuts) — rebroadcast them. */ + onSettingsSaved: (next: AppSettings) => void; } /** @@ -368,6 +380,7 @@ export function createEditorRPC( })), getSettings: () => wrap(async () => ({ settings: await deps.settings.load() })), + saveSettings: saveSettingsHandler(deps.settings, hooks.onSettingsSaved), editorSave: ({ content }) => wrap(async () => { @@ -397,6 +410,8 @@ export interface ManagerRpcHooks { onConnect: (id: string) => void; onOpenStack: (id: string) => void; onClose: () => void; + /** This window saved settings (e.g. the scale shortcuts) — rebroadcast them. */ + onSettingsSaved: (next: AppSettings) => void; } /** RPC for the single connection-manager window (per-window instance). */ @@ -411,6 +426,7 @@ export function createManagerRPC( ...connectionStoreHandlers(deps.store, hooks.onChanged), pickKeyFile: pickKeyFileHandler, getSettings: () => wrap(async () => ({ settings: await deps.settings.load() })), + saveSettings: saveSettingsHandler(deps.settings, hooks.onSettingsSaved), managerConnect: ({ id }) => wrap(async () => {