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; } export interface EditorWindowsDeps { registry: SessionRegistry; settings: SettingsStore; edits: EditManager; /** The once-resolved dev/prod view url. */ url: string; mainFrame: () => { x: number; y: number }; /** Late-bound senders targeting the MAIN window's webview. */ notifyMain: { pathRefresh: (info: { sessionId: string; dir: 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; constructor(private deps: EditorWindowsDeps) { this.windows = new WindowRegistry(deps.mainFrame); } open(ctx: EditorOpenContext): void { const key = `${ctx.sessionId}${ctx.path}`; if (this.windows.activateExisting(key)) return; const record: Partial = { 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(); }, }, ); const win = openSecondaryWindow({ title: this.baseTitle(ctx), url: this.deps.url, mode: "editor", 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 { 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); }); } broadcastSettings(settings: AppSettings): void { this.windows.forEach((rec) => 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); } }