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
+41 -1
View File
@@ -1,7 +1,7 @@
import { Injectable } from '@angular/core';
import { Electroview } from 'electrobun/view';
import type { AppRPC } from '@shared/rpc';
import type { TransferSnapshot } from '@shared/types';
import type { AppSettings, TransferSnapshot } from '@shared/types';
// Matches MAX_REQUEST_TIME_MS on the bun side — SFTP ops can be slow.
const MAX_REQUEST_TIME_MS = 10 * 60_000;
@@ -19,6 +19,9 @@ export class RpcService {
private transferListeners = new Set<(s: TransferSnapshot) => void>();
private closedListeners = new Set<(e: SessionClosedEvent) => void>();
private menuListeners = new Set<(action: string) => void>();
private settingsListeners = new Set<(settings: AppSettings) => void>();
private pathRefreshListeners = new Set<(e: { sessionId: string; dir: string }) => void>();
private editorCloseListeners = new Set<() => void>();
private rpc: ReturnType<typeof Electroview.defineRPC<AppRPC>> | undefined;
private ready: Promise<void> = Promise.resolve();
@@ -40,6 +43,15 @@ export class RpcService {
menuAction: ({ action }) => {
for (const listener of this.menuListeners) listener(action);
},
settingsChanged: ({ settings }) => {
for (const listener of this.settingsListeners) listener(settings);
},
pathRefresh: (event) => {
for (const listener of this.pathRefreshListeners) listener(event);
},
editorCloseRequest: () => {
for (const listener of this.editorCloseListeners) listener();
},
},
},
});
@@ -68,6 +80,18 @@ export class RpcService {
this.menuListeners.add(listener);
}
onSettingsChanged(listener: (settings: AppSettings) => void): void {
this.settingsListeners.add(listener);
}
onPathRefresh(listener: (event: { sessionId: string; dir: string }) => void): void {
this.pathRefreshListeners.add(listener);
}
onEditorCloseRequest(listener: () => void): void {
this.editorCloseListeners.add(listener);
}
/** Typed request proxy: `await rpc.request.listDir({...})`. Waits for the bridge. */
get request() {
const rpc = this.rpc;
@@ -87,4 +111,20 @@ export class RpcService {
});
}
/** Fire-and-forget message proxy: `rpc.send.editorState({...})`. Waits for the bridge. */
get send() {
const rpc = this.rpc;
if (!rpc) throw new Error('RPC is only available inside the desktop app');
const ready = this.ready;
return new Proxy({} as (typeof rpc)['send'], {
get:
(_target, method: string) =>
(payload?: unknown) => {
void ready.then(() => {
const methods = rpc.send as unknown as Record<string, (p?: unknown) => void>;
methods[method](payload);
});
},
});
}
}