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:
Binary file not shown.
@@ -134,6 +134,21 @@ export class EditManager {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Park unsaveable editor content (window closed, session dead) in the
|
||||
* leftovers dir so the next-boot prompt offers it back to the user.
|
||||
*/
|
||||
async rescueDraft(name: string, content: string): Promise<void> {
|
||||
const dir = path.join(this.editsDir, crypto.randomUUID().slice(0, 8));
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(dir, "meta.json"),
|
||||
JSON.stringify({ name, rescuedAt: new Date().toISOString() }),
|
||||
);
|
||||
await fs.writeFile(path.join(dir, name), content, "utf8");
|
||||
console.log(`[edit] rescued unsaved draft of ${name} -> ${dir}`);
|
||||
}
|
||||
|
||||
/** Session gone (disconnect, tab close, death): stop its watchers, drop temps. */
|
||||
stopForSession(sessionId: string): void {
|
||||
const [mine, rest] = [
|
||||
|
||||
+52
-10
@@ -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})`);
|
||||
|
||||
+63
-2
@@ -1,6 +1,6 @@
|
||||
import { BrowserView, Utils } from "electrobun/bun";
|
||||
import type { AppRPC } from "../shared/rpc";
|
||||
import { effectiveConcurrency, type Result } from "../shared/types";
|
||||
import type { AppRPC, EditorOpenContext } from "../shared/rpc";
|
||||
import { effectiveConcurrency, type AppSettings, type Result } from "../shared/types";
|
||||
import type { PanelContextMenu } from "./context-menu";
|
||||
import { readFileText, writeFileText, type EditManager } from "./edits";
|
||||
import type { SessionRegistry } from "./fs/registry";
|
||||
@@ -27,6 +27,8 @@ export interface AppDeps {
|
||||
events: {
|
||||
sessionClosed: (info: { sessionId: string; reason: string }) => void;
|
||||
connectionsChanged: () => void;
|
||||
openEditor: (ctx: EditorOpenContext) => void;
|
||||
settingsChanged: (settings: AppSettings) => void;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -195,6 +197,7 @@ export function createAppRPC({
|
||||
wrap(async () => {
|
||||
await settings.save(next);
|
||||
engine.setConcurrency(effectiveConcurrency(next));
|
||||
events.settingsChanged(next);
|
||||
return {};
|
||||
}),
|
||||
|
||||
@@ -261,6 +264,64 @@ export function createAppRPC({
|
||||
Utils.moveToTrash(path);
|
||||
return {};
|
||||
}),
|
||||
|
||||
// ---- editor windows ----
|
||||
openEditor: (ctx) =>
|
||||
wrap(async () => {
|
||||
events.openEditor(ctx);
|
||||
return {};
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface EditorRpcHooks {
|
||||
ctx: EditorOpenContext;
|
||||
/** Draft mirror tick: store dirty flag + content on the window record. */
|
||||
onDraft: (state: { dirty: boolean; content: string }) => void;
|
||||
/** editorSave succeeded: clean the record, retitle, refresh main panels. */
|
||||
onSaved: () => void;
|
||||
/** Webview resolved its dirty prompt: close without the native fallback. */
|
||||
onCloseConfirmed: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* RPC for one editor window. Each window MUST get its own instance (binding a
|
||||
* BrowserView steals the rpc's transport), and the hooks close over that
|
||||
* window's record — no ids on the wire.
|
||||
*/
|
||||
export function createEditorRPC(
|
||||
deps: { registry: SessionRegistry; settings: SettingsStore },
|
||||
hooks: EditorRpcHooks,
|
||||
) {
|
||||
return BrowserView.defineRPC<AppRPC>({
|
||||
maxRequestTime: MAX_REQUEST_TIME_MS,
|
||||
handlers: {
|
||||
requests: {
|
||||
readFile: ({ sessionId, path }) =>
|
||||
wrap(async () => ({
|
||||
content: await readFileText(deps.registry.get(sessionId), path),
|
||||
})),
|
||||
|
||||
getSettings: () => wrap(async () => ({ settings: await deps.settings.load() })),
|
||||
|
||||
editorSave: ({ content }) =>
|
||||
wrap(async () => {
|
||||
const { sessionId, path } = hooks.ctx;
|
||||
await writeFileText(deps.registry.get(sessionId), path, content);
|
||||
hooks.onSaved();
|
||||
return {};
|
||||
}),
|
||||
|
||||
editorClose: () =>
|
||||
wrap(async () => {
|
||||
hooks.onCloseConfirmed();
|
||||
return {};
|
||||
}),
|
||||
},
|
||||
messages: {
|
||||
editorState: (state) => hooks.onDraft(state),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+28
-1
@@ -11,12 +11,24 @@ import type {
|
||||
TransferSnapshot,
|
||||
} from "./types";
|
||||
|
||||
/** Everything an editor window needs to know about the file it hosts. */
|
||||
export interface EditorOpenContext {
|
||||
sessionId: string;
|
||||
path: string;
|
||||
name: string;
|
||||
sessionTitle: string;
|
||||
}
|
||||
|
||||
// Structurally equivalent to electrobun's ElectrobunRPCSchema, written in
|
||||
// resolved form so this file stays importable from the Angular bundle without
|
||||
// pulling in bun-only type modules.
|
||||
//
|
||||
// bun.requests = calls the webview makes, handled in src/bun/rpc-handlers.ts
|
||||
// webview.messages = push events from bun, handled in ui rpc.service.ts
|
||||
//
|
||||
// ONE schema serves both window kinds (main + editor): electrobun allows
|
||||
// partial handler sets, and messages nobody listens to are silently dropped,
|
||||
// so each side only implements/hears what applies to it.
|
||||
export type AppRPC = {
|
||||
bun: {
|
||||
requests: {
|
||||
@@ -122,8 +134,17 @@ export type AppRPC = {
|
||||
response: Result;
|
||||
};
|
||||
moveToTrash: { params: { path: string }; response: Result };
|
||||
// editor windows
|
||||
/** Main window only: open (or focus) a native editor window. */
|
||||
openEditor: { params: EditorOpenContext; response: Result };
|
||||
/** Editor windows only — handled by that window's own rpc closure. */
|
||||
editorSave: { params: { content: string }; response: Result };
|
||||
editorClose: { params: Record<string, never>; response: Result };
|
||||
};
|
||||
messages: {
|
||||
/** Editor → bun draft mirror; safety net for the non-cancellable native close. */
|
||||
editorState: { dirty: boolean; content: string };
|
||||
};
|
||||
messages: Record<never, never>;
|
||||
};
|
||||
webview: {
|
||||
requests: Record<never, never>;
|
||||
@@ -131,6 +152,12 @@ export type AppRPC = {
|
||||
transferUpdate: TransferSnapshot;
|
||||
sessionClosed: { sessionId: string; reason: string };
|
||||
menuAction: { action: string };
|
||||
/** bun → editor windows when the main window saves settings. */
|
||||
settingsChanged: { settings: AppSettings };
|
||||
/** bun → main after an editor save, so matching panels refresh. */
|
||||
pathRefresh: { sessionId: string; dir: string };
|
||||
/** bun → one editor: run the in-window close flow (dirty prompt). */
|
||||
editorCloseRequest: Record<string, never>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user