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:
Jokin
2026-07-13 22:36:11 +02:00
co-authored by Claude Fable 5
parent 70f3f2a2e6
commit 1652b9e298
18 changed files with 716 additions and 239 deletions
+52 -10
View File
@@ -1,8 +1,10 @@
import Electrobun, { ApplicationMenu, BrowserWindow, Updater, Utils } from "electrobun/bun";
import { BRANDING } from "../shared/branding";
import { effectiveConcurrency } from "../shared/types";
import type { EditorOpenContext } from "../shared/rpc";
import { effectiveConcurrency, type AppSettings } from "../shared/types";
import { PanelContextMenu } from "./context-menu";
import { EditManager } from "./edits";
import { EditorWindows } from "./editor-windows";
import { SessionRegistry } from "./fs/registry";
import { applyApplicationMenu } from "./menu";
import { createAppRPC } from "./rpc-handlers";
@@ -11,6 +13,7 @@ import { migrateLegacyDataDir } from "./store/paths";
import { SettingsStore } from "./store/settings";
import { AppTray } from "./tray";
import { TransferEngine } from "./transfers/engine";
import { buildPreload } from "./windows";
const PROD_URL = "views://app/index.html";
@@ -44,6 +47,8 @@ const contextMenu = new PanelContextMenu();
const events = {
sessionClosed: (_info: { sessionId: string; reason: string }) => {},
connectionsChanged: () => {},
openEditor: (_ctx: EditorOpenContext) => {},
settingsChanged: (_settings: AppSettings) => {},
};
const rpc = createAppRPC({ registry, engine, store, settings, edits, contextMenu, events });
const url = await getMainViewUrl();
@@ -66,10 +71,23 @@ const win = new BrowserWindow({
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
: {}),
// lets the UI pick the fused layout before first paint
preload: `window.__APP_PLATFORM=${JSON.stringify(process.platform)};`,
preload: buildPreload("main"),
rpc,
});
const editors = new EditorWindows({
registry,
settings,
edits,
url,
mainFrame: () => win.getFrame(),
notifyMain: { pathRefresh: (info) => rpc.send.pathRefresh(info) },
});
events.openEditor = (ctx) => editors.open(ctx);
events.settingsChanged = (s) => editors.broadcastSettings(s);
// clean editors close with the main window; dirty ones get to ask first
win.on("close", () => editors.closeAllForAppExit());
new AppTray(win);
contextMenu.onAction = (action) => rpc.send.menuAction({ action });
@@ -85,7 +103,11 @@ engine.onUpdate = (snapshot) => {
const previous = notifiedStates.get(snapshot.id);
if (previous !== snapshot.state) {
notifiedStates.set(snapshot.id, snapshot.state);
if (!windowFocused && (snapshot.state === "completed" || snapshot.state === "failed")) {
if (
!windowFocused &&
!editors.anyFocused() &&
(snapshot.state === "completed" || snapshot.state === "failed")
) {
Utils.showNotification({
title: snapshot.state === "completed" ? "Transfer complete" : "Transfer failed",
body:
@@ -102,18 +124,24 @@ engine.onUpdate = (snapshot) => {
}
};
// don't quit out from under running transfers
// don't quit out from under running transfers or unsaved editors
let quitConfirmed = false;
Electrobun.events.on("before-quit", (event) => {
if (quitConfirmed || !engine.hasActiveJobs()) return;
const transfers = engine.activeJobCount();
const dirtyEditors = editors.dirtyCount();
if (quitConfirmed || (transfers === 0 && dirtyEditors === 0)) return;
(event as { response: { allow: boolean } }).response = { allow: false };
void (async () => {
const count = engine.activeJobCount();
const parts = [
transfers > 0 && `${transfers} transfer${transfers === 1 ? " is" : "s are"} still running`,
dirtyEditors > 0 &&
`${dirtyEditors} file${dirtyEditors === 1 ? " has" : "s have"} unsaved changes`,
].filter(Boolean);
const { response } = await Utils.showMessageBox({
type: "warning",
title: BRANDING.appName,
message: `${count} transfer${count === 1 ? " is" : "s are"} still running.`,
detail: "Quitting now will abort them.",
message: `${parts.join(" and ")}.`,
detail: "Quitting now will abort transfers and discard unsaved changes.",
buttons: ["Quit Anyway", "Cancel"],
defaultId: 1,
cancelId: 1,
@@ -124,14 +152,28 @@ Electrobun.events.on("before-quit", (event) => {
}
})();
});
events.sessionClosed = (info) => rpc.send.sessionClosed(info);
events.sessionClosed = (info) => {
rpc.send.sessionClosed(info);
editors.notifySessionClosed(info);
};
events.connectionsChanged = () => void applyApplicationMenu(store);
void settings.load().then((s) => engine.setConcurrency(effectiveConcurrency(s)));
void applyApplicationMenu(store);
ApplicationMenu.on("application-menu-clicked", (event) => {
const action = (event as { data?: { action?: string } }).data?.action;
if (action) rpc.send.menuAction({ action });
if (!action) return;
// ⌘W while an editor window is focused closes that editor (with its dirty
// prompt), not a main-window tab. Other menu actions deliberately still
// target the main window.
if (action === "close-tab") {
const focusedEditor = editors.focusedKey();
if (focusedEditor) {
editors.requestClose(focusedEditor);
return;
}
}
rpc.send.menuAction({ action });
});
console.log(`${BRANDING.appName} started (${url})`);