Save interface-scale changes from every window

Ctrl/Cmd +/-/0 in editor and connection-manager windows called
SettingsService.persist() -> saveSettings, but only the main window's RPC
served that request: it went unanswered, the new scale applied locally, and
the next broadcast from main snapped it back.

All three window RPCs now serve saveSettings through one shared
saveSettingsHandler (persist, then hand off). index.ts's settingsSaved() is
the single place settings side effects run — transfer concurrency, locale +
app menu, the preload snapshot — whichever window saved, and it rebroadcasts
to every window EXCEPT the sender: an echo arriving behind a newer local
change (a held Cmd+) would drag it backwards. Editors are skipped by their
registry key, main and the manager by name.

Known ceiling, noted at the handler: whole-object writes, so two windows
saving in the same instant means the later one wins.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-23 16:24:27 +02:00
co-authored by Claude
parent f7f02083fe
commit c3d97ce524
6 changed files with 64 additions and 18 deletions
+7 -4
View File
@@ -218,10 +218,13 @@ component; tokens → `ui/src/styles.css`.
skip, overwrite dialog with all/none batch modes). skip, overwrite dialog with all/none batch modes).
- `settings.service.ts` — one signal per setting; **imperative apply pattern** - `settings.service.ts` — one signal per setting; **imperative apply pattern**
(`applyTheme/DarkTone/Accent` set `data-*` attributes on `<html>`; (`applyTheme/DarkTone/Accent` set `data-*` attributes on `<html>`;
`applyUiScale` sets its inline `font-size`); `applyAll` also serves the `settingsChanged` broadcast so editor `applyUiScale` sets its inline `font-size`); `applyAll` also serves the
and manager windows restyle live (they render no settings UI, so no `settingsChanged` broadcast, so every window follows a save made in any
persist echo). New setting = five touches here + types.ts (backend merges other. All three window RPCs serve `saveSettings` (editor and manager windows
automatically). 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 - `focus.service.ts` — dependency-free `tick`/`bump()`; dialogs bump it, the
main window's panels listen. Exists so dialogs don't inject PanelsService main window's panels listen. Exists so dialogs don't inject PanelsService
(whose constructor spawns a workspace — fatal in secondary windows). (whose constructor spawns a workspace — fatal in secondary windows).
+6
View File
@@ -210,6 +210,12 @@ touching the adjacent code.
- Settings: the backend spread-merges (`{...DEFAULT_SETTINGS, ...data}`) so - Settings: the backend spread-merges (`{...DEFAULT_SETTINGS, ...data}`) so
new keys auto-default, but the UI SettingsService reads field-by-field — 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. 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); - The editor draft mirror sends full content per tick (2 MB cap upstream);
don't shrink the 400 ms debounce. don't shrink the 400 ms debounce.
- `panel.touched` gates localDir auto-navigation; `connectToken` guards - `panel.touched` gates localDir auto-navigation; `connectToken` guards
+8 -2
View File
@@ -33,6 +33,8 @@ export interface EditorWindowsDeps {
mainFrame: () => { x: number; y: number }; mainFrame: () => { x: number; y: number };
/** Late-bound senders targeting the MAIN window's webview. */ /** Late-bound senders targeting the MAIN window's webview. */
notifyMain: { pathRefresh: (info: { sessionId: string; dir: string }) => void }; 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.suppressPrompt = true;
record.win?.close(); record.win?.close();
}, },
onSettingsSaved: (next) => this.deps.onSettingsSaved(next, key),
}, },
); );
@@ -165,8 +168,11 @@ export class EditorWindows {
}); });
} }
broadcastSettings(settings: AppSettings): void { /** `exceptKey`: the editor that saved — it already shows the new values. */
this.windows.forEach((rec) => rec.rpc.send.settingsChanged({ settings })); broadcastSettings(settings: AppSettings, exceptKey?: string): void {
this.windows.forEach((rec, key) => {
if (key !== exceptKey) rec.rpc.send.settingsChanged({ settings });
});
} }
private baseTitle(ctx: EditorOpenContext): string { private baseTitle(ctx: EditorOpenContext): string {
+16 -4
View File
@@ -92,6 +92,7 @@ const editors = new EditorWindows({
currentSettings: () => currentSettings, currentSettings: () => currentSettings,
mainFrame: () => win.getFrame(), mainFrame: () => win.getFrame(),
notifyMain: { pathRefresh: (info) => rpc.send.pathRefresh(info) }, notifyMain: { pathRefresh: (info) => rpc.send.pathRefresh(info) },
onSettingsSaved: (s, key) => settingsSaved(s, { editor: key }),
}); });
const manager = new ManagerWindow({ const manager = new ManagerWindow({
store, store,
@@ -105,19 +106,30 @@ const manager = new ManagerWindow({
menuAction: (info) => rpc.send.menuAction(info), menuAction: (info) => rpc.send.menuAction(info),
}, },
rebuildMenu: () => void applyApplicationMenu(store), rebuildMenu: () => void applyApplicationMenu(store),
onSettingsSaved: (s) => settingsSaved(s, "manager"),
}); });
events.openEditor = (ctx) => editors.open(ctx); 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; const localeChanged = s.locale !== currentSettings.locale;
currentSettings = s; // future windows' preloads bake the fresh theme currentSettings = s; // future windows' preloads bake the fresh theme
if (localeChanged) { if (localeChanged) {
setBackendLocale(s.locale); setBackendLocale(s.locale);
void applyApplicationMenu(store); // relabel the native menu in the new language void applyApplicationMenu(store); // relabel the native menu in the new language
} }
editors.broadcastSettings(s); if (sender !== "main") rpc.send.settingsChanged({ settings: s });
manager.broadcastSettings(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(); events.openConnectionManager = () => manager.open();
function showAbout(): void { function showAbout(): void {
+3
View File
@@ -24,6 +24,8 @@ export interface ManagerWindowDeps {
}; };
/** Rebuilds the native app menu after store changes. */ /** Rebuilds the native app menu after store changes. */
rebuildMenu: () => void; 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(); this.close();
}, },
onClose: () => this.close(), onClose: () => this.close(),
onSettingsSaved: (next) => this.deps.onSettingsSaved(next),
}, },
); );
+24 -8
View File
@@ -1,6 +1,6 @@
import { BrowserView, Updater, Utils } from "electrobun/bun"; import { BrowserView, Updater, Utils } from "electrobun/bun";
import type { AppRPC, EditorOpenContext } from "../shared/rpc"; 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 { readFileText, writeFileText, type EditManager } from "./edits";
import { listLocalDrives } from "./fs/local"; import { listLocalDrives } from "./fs/local";
import type { SessionRegistry } from "./fs/registry"; 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 = () => const pickKeyFileHandler = () =>
wrap(async () => { wrap(async () => {
const { homedir } = await import("node:os"); const { homedir } = await import("node:os");
@@ -224,13 +240,7 @@ export function createAppRPC({
// ---- settings ---- // ---- settings ----
getSettings: () => wrap(async () => ({ settings: await settings.load() })), getSettings: () => wrap(async () => ({ settings: await settings.load() })),
saveSettings: ({ settings: next }) => saveSettings: saveSettingsHandler(settings, (next) => events.settingsChanged(next)),
wrap(async () => {
await settings.save(next);
engine.setConcurrency(effectiveConcurrency(next));
events.settingsChanged(next);
return {};
}),
// ---- file editing ---- // ---- file editing ----
readFile: ({ sessionId, path }) => readFile: ({ sessionId, path }) =>
@@ -347,6 +357,8 @@ export interface EditorRpcHooks {
onSaved: () => void; onSaved: () => void;
/** Webview resolved its dirty prompt: close without the native fallback. */ /** Webview resolved its dirty prompt: close without the native fallback. */
onCloseConfirmed: () => void; 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() })), getSettings: () => wrap(async () => ({ settings: await deps.settings.load() })),
saveSettings: saveSettingsHandler(deps.settings, hooks.onSettingsSaved),
editorSave: ({ content }) => editorSave: ({ content }) =>
wrap(async () => { wrap(async () => {
@@ -397,6 +410,8 @@ export interface ManagerRpcHooks {
onConnect: (id: string) => void; onConnect: (id: string) => void;
onOpenStack: (id: string) => void; onOpenStack: (id: string) => void;
onClose: () => 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). */ /** RPC for the single connection-manager window (per-window instance). */
@@ -411,6 +426,7 @@ export function createManagerRPC(
...connectionStoreHandlers(deps.store, hooks.onChanged), ...connectionStoreHandlers(deps.store, hooks.onChanged),
pickKeyFile: pickKeyFileHandler, pickKeyFile: pickKeyFileHandler,
getSettings: () => wrap(async () => ({ settings: await deps.settings.load() })), getSettings: () => wrap(async () => ({ settings: await deps.settings.load() })),
saveSettings: saveSettingsHandler(deps.settings, hooks.onSettingsSaved),
managerConnect: ({ id }) => managerConnect: ({ id }) =>
wrap(async () => { wrap(async () => {