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>
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import { BrowserWindow } from "electrobun/bun";
|
||||
|
||||
const isMac = process.platform === "darwin";
|
||||
|
||||
/**
|
||||
* The preload globals every window gets. Single composition point: the UI
|
||||
* reads them in ui/src/app/platform.ts before first paint (window chrome and
|
||||
* root-component selection both depend on them).
|
||||
*/
|
||||
export function buildPreload(mode: "main" | "editor", ctx?: unknown): string {
|
||||
let script =
|
||||
`window.__APP_PLATFORM=${JSON.stringify(process.platform)};` +
|
||||
`window.__APP_MODE=${JSON.stringify(mode)};`;
|
||||
if (ctx !== undefined) script += `window.__APP_CTX=${JSON.stringify(ctx)};`;
|
||||
return script;
|
||||
}
|
||||
|
||||
export interface SecondaryWindowOptions {
|
||||
title: string;
|
||||
/** The once-resolved dev/prod url (see getMainViewUrl in index.ts). */
|
||||
url: string;
|
||||
mode: "editor"; // widens when more window kinds exist (connection manager…)
|
||||
ctx?: unknown;
|
||||
frame: { x: number; y: number; width: number; height: number };
|
||||
/** Per-window rpc instance. NEVER share one across windows — each
|
||||
* BrowserView steals the rpc's transport when it binds. */
|
||||
rpc: NonNullable<ConstructorParameters<typeof BrowserWindow>[0]>["rpc"];
|
||||
}
|
||||
|
||||
/**
|
||||
* App-chromed secondary window.
|
||||
*
|
||||
* PORTABILITY: the only spot that branches on platform for secondary-window
|
||||
* chrome. macOS fuses the titlebar (traffic lights overlay the UI's h-10
|
||||
* header row, same treatment as the main window); Windows/Linux keep the
|
||||
* native titlebar and the UI renders a compact non-drag header instead.
|
||||
*/
|
||||
export function openSecondaryWindow(opts: SecondaryWindowOptions): BrowserWindow {
|
||||
return new BrowserWindow({
|
||||
title: opts.title,
|
||||
url: opts.url,
|
||||
frame: opts.frame,
|
||||
...(isMac
|
||||
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
|
||||
: {}),
|
||||
preload: buildPreload(opts.mode, opts.ctx),
|
||||
rpc: opts.rpc,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyed registry of secondary windows: dedupe/activate, cascade placement off
|
||||
* an anchor window, focus tracking, close cleanup. A future "window follows
|
||||
* window" grouping step slots in here — the registry already owns the window
|
||||
* handles and the anchor it would need (anchor.on('move') + setPosition).
|
||||
*/
|
||||
export class WindowRegistry<Rec extends { win: BrowserWindow }> {
|
||||
private records = new Map<string, Rec>();
|
||||
private focused = new Set<string>();
|
||||
private opened = 0;
|
||||
|
||||
constructor(private anchorFrame: () => { x: number; y: number }) {}
|
||||
|
||||
get(key: string): Rec | undefined {
|
||||
return this.records.get(key);
|
||||
}
|
||||
|
||||
/** True (and focuses the window) when the key already has a live window. */
|
||||
activateExisting(key: string): boolean {
|
||||
const rec = this.records.get(key);
|
||||
if (!rec) return false;
|
||||
rec.win.activate();
|
||||
return true;
|
||||
}
|
||||
|
||||
set(key: string, rec: Rec): void {
|
||||
this.records.set(key, rec);
|
||||
}
|
||||
|
||||
/** Cascade off the anchor so stacked windows never hide each other fully. */
|
||||
nextFrame(width: number, height: number): { x: number; y: number; width: number; height: number } {
|
||||
const anchor = this.anchorFrame();
|
||||
const step = (this.opened++ % 8) * 28;
|
||||
return { x: anchor.x + 90 + step, y: anchor.y + 70 + step, width, height };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire close/focus/blur for a window. electrobun's emitter has no off(),
|
||||
* so these closures capture only (this, key) — never the record — letting
|
||||
* closed-window content be garbage collected after delete().
|
||||
*/
|
||||
track(key: string, win: BrowserWindow, onClose: (key: string) => void): void {
|
||||
win.on("focus", () => this.focused.add(key));
|
||||
win.on("blur", () => this.focused.delete(key));
|
||||
win.on("close", () => {
|
||||
this.focused.delete(key);
|
||||
onClose(key);
|
||||
});
|
||||
}
|
||||
|
||||
delete(key: string): void {
|
||||
this.records.delete(key);
|
||||
}
|
||||
|
||||
anyFocused(): boolean {
|
||||
return this.focused.size > 0;
|
||||
}
|
||||
|
||||
focusedKey(): string | null {
|
||||
for (const key of this.focused) return key;
|
||||
return null;
|
||||
}
|
||||
|
||||
forEach(fn: (rec: Rec, key: string) => void): void {
|
||||
for (const [key, rec] of this.records) fn(rec, key);
|
||||
}
|
||||
|
||||
size(): number {
|
||||
return this.records.size;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user