Files
miniSCP/src/bun/editor-windows.ts
T
JokinandClaude Fable 5 1652b9e298 Editor opens in native windows (one per file)
Real BrowserWindow per edited file with its own RPC instance, deduped by
sessionId+path. The same Angular bundle boots an editor-only root via
preload globals. Native close isn't cancellable in electrobun, so the
editor mirrors its draft to bun; traffic-light closes get a native
Save/Discard prompt after the fact, and failed saves park the draft in
the edit-leftovers dir. In-window closes (Cmd+W, Close) use a styled
prompt; the quit guard now also counts unsaved editors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:36:11 +02:00

181 lines
5.6 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;
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<EditorRecord>;
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<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();
},
},
);
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<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);
});
}
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);
}
}