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>
190 lines
6.1 KiB
TypeScript
190 lines
6.1 KiB
TypeScript
import { Utils, type BrowserWindow } from "electrobun/bun";
|
|
import { BRANDING } from "../shared/branding";
|
|
import type { EditorOpenContext } from "../shared/rpc";
|
|
import type { AppSettings } from "../shared/types";
|
|
import { writeFileText, type EditManager } from "./edits";
|
|
import type { SessionRegistry } from "./fs/registry";
|
|
import { createEditorRPC } from "./rpc-handlers";
|
|
import { openSecondaryWindow, WindowRegistry } from "./windows";
|
|
import type { SettingsStore } from "./store/settings";
|
|
|
|
const EDITOR_WIDTH = 900;
|
|
const EDITOR_HEIGHT = 680;
|
|
|
|
interface EditorRecord {
|
|
win: BrowserWindow;
|
|
ctx: EditorOpenContext;
|
|
dirty: boolean;
|
|
/** Last mirrored content — what the native-close Save fallback writes. */
|
|
draft: string | null;
|
|
/** Set by editorClose: the webview already resolved its dirty prompt. */
|
|
suppressPrompt: boolean;
|
|
rpc: ReturnType<typeof createEditorRPC>;
|
|
}
|
|
|
|
export interface EditorWindowsDeps {
|
|
registry: SessionRegistry;
|
|
settings: SettingsStore;
|
|
edits: EditManager;
|
|
/** The once-resolved dev/prod view url. */
|
|
url: string;
|
|
/** Live settings snapshot — baked into new windows' preloads (theme flash). */
|
|
currentSettings: () => AppSettings;
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* One native window per edited file (key: sessionId+path). The webview owns
|
|
* the styled dirty prompt for closes it can see (⌘W, Close button); the
|
|
* native traffic-light close is NOT cancellable in electrobun, so the draft
|
|
* mirrored via editorState lets us offer a native Save/Discard box after the
|
|
* window is already gone. A failed fallback save parks the draft in the
|
|
* edits leftovers dir (offered back at next boot).
|
|
*/
|
|
export class EditorWindows {
|
|
private windows: WindowRegistry<EditorRecord>;
|
|
|
|
constructor(private deps: EditorWindowsDeps) {
|
|
this.windows = new WindowRegistry(deps.mainFrame);
|
|
}
|
|
|
|
open(ctx: EditorOpenContext): void {
|
|
const key = `${ctx.sessionId}\0${ctx.path}`;
|
|
if (this.windows.activateExisting(key)) return;
|
|
|
|
const record: Partial<EditorRecord> = { ctx, dirty: false, draft: null, suppressPrompt: false };
|
|
const rpc = createEditorRPC(
|
|
{ registry: this.deps.registry, settings: this.deps.settings },
|
|
{
|
|
ctx,
|
|
onDraft: (state) => {
|
|
record.dirty = state.dirty;
|
|
record.draft = state.content;
|
|
this.setDirtyTitle(record as EditorRecord);
|
|
},
|
|
onSaved: () => {
|
|
record.dirty = false;
|
|
record.draft = null;
|
|
this.setDirtyTitle(record as EditorRecord);
|
|
this.deps.notifyMain.pathRefresh({
|
|
sessionId: ctx.sessionId,
|
|
dir: this.dirOf(ctx),
|
|
});
|
|
},
|
|
onCloseConfirmed: () => {
|
|
record.suppressPrompt = true;
|
|
record.win?.close();
|
|
},
|
|
onSettingsSaved: (next) => this.deps.onSettingsSaved(next, key),
|
|
},
|
|
);
|
|
|
|
const win = openSecondaryWindow({
|
|
title: this.baseTitle(ctx),
|
|
url: this.deps.url,
|
|
mode: "editor",
|
|
settings: this.deps.currentSettings(),
|
|
ctx,
|
|
frame: this.windows.nextFrame(EDITOR_WIDTH, EDITOR_HEIGHT),
|
|
rpc,
|
|
});
|
|
record.win = win;
|
|
record.rpc = rpc;
|
|
this.windows.set(key, record as EditorRecord);
|
|
this.windows.track(key, win, (k) => void this.handleNativeClose(k));
|
|
console.log(`[editor] opened window for ${ctx.sessionId}:${ctx.path}`);
|
|
}
|
|
|
|
/** The window is already gone; decide the draft's fate from what we hold. */
|
|
private async handleNativeClose(key: string): Promise<void> {
|
|
const record = this.windows.get(key);
|
|
this.windows.delete(key);
|
|
if (!record || !record.dirty || record.suppressPrompt || record.draft === null) return;
|
|
|
|
const { ctx, draft } = record;
|
|
const { response } = await Utils.showMessageBox({
|
|
type: "warning",
|
|
title: BRANDING.appName,
|
|
message: `Save changes to "${ctx.name}"?`,
|
|
detail: "The editor window was closed with unsaved changes.",
|
|
buttons: ["Save", "Discard"],
|
|
defaultId: 0,
|
|
cancelId: 1,
|
|
});
|
|
if (response !== 0) return;
|
|
try {
|
|
await writeFileText(this.deps.registry.get(ctx.sessionId), ctx.path, draft);
|
|
this.deps.notifyMain.pathRefresh({ sessionId: ctx.sessionId, dir: this.dirOf(ctx) });
|
|
} catch (error) {
|
|
await this.deps.edits.rescueDraft(ctx.name, draft);
|
|
await Utils.showMessageBox({
|
|
type: "error",
|
|
title: BRANDING.appName,
|
|
message: `Couldn't save "${ctx.name}".`,
|
|
detail: `${error instanceof Error ? error.message : error}\n\nA local copy was kept and will be offered at the next launch.`,
|
|
buttons: ["OK"],
|
|
defaultId: 0,
|
|
cancelId: 0,
|
|
});
|
|
}
|
|
}
|
|
|
|
dirtyCount(): number {
|
|
let count = 0;
|
|
this.windows.forEach((rec) => {
|
|
if (rec.dirty) count++;
|
|
});
|
|
return count;
|
|
}
|
|
|
|
anyFocused(): boolean {
|
|
return this.windows.anyFocused();
|
|
}
|
|
|
|
focusedKey(): string | null {
|
|
return this.windows.focusedKey();
|
|
}
|
|
|
|
/** Ask a window to run its styled close flow (⌘W routing, main close). */
|
|
requestClose(key: string): void {
|
|
this.windows.get(key)?.rpc.send.editorCloseRequest({});
|
|
}
|
|
|
|
/** Main window closed: clean editors just close, dirty ones get to ask. */
|
|
closeAllForAppExit(): void {
|
|
this.windows.forEach((rec, key) => {
|
|
if (rec.dirty) this.requestClose(key);
|
|
else rec.win.close();
|
|
});
|
|
}
|
|
|
|
notifySessionClosed(info: { sessionId: string; reason: string }): void {
|
|
this.windows.forEach((rec) => {
|
|
if (rec.ctx.sessionId === info.sessionId) rec.rpc.send.sessionClosed(info);
|
|
});
|
|
}
|
|
|
|
/** `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 {
|
|
return `${ctx.name} — ${ctx.sessionTitle}`;
|
|
}
|
|
|
|
private setDirtyTitle(record: EditorRecord): void {
|
|
record.win.setTitle(`${record.dirty ? "• " : ""}${this.baseTitle(record.ctx)}`);
|
|
}
|
|
|
|
private dirOf(ctx: EditorOpenContext): string {
|
|
return this.deps.registry.get(ctx.sessionId).dirname(ctx.path);
|
|
}
|
|
}
|