From 1652b9e2986e74fd46d82946da8e40c511baed0d Mon Sep 17 00:00:00 2001 From: Jokin Date: Mon, 13 Jul 2026 22:36:11 +0200 Subject: [PATCH] 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 --- src/bun/editor-windows.ts | Bin 0 -> 5734 bytes src/bun/edits.ts | 15 ++ src/bun/index.ts | 62 ++++++- src/bun/rpc-handlers.ts | 65 ++++++- src/bun/windows.ts | 121 ++++++++++++ src/shared/rpc.ts | 29 ++- ui/src/app/app.html | 35 +--- ui/src/app/app.ts | 9 +- ui/src/app/dialogs.service.ts | 11 -- ui/src/app/dialogs/editor-dialog.ts | 158 ---------------- ui/src/app/editor-window/editor-window.html | 120 ++++++++++++ ui/src/app/editor-window/editor-window.ts | 192 ++++++++++++++++++++ ui/src/app/file-actions.service.ts | 14 +- ui/src/app/panels.service.ts | 7 + ui/src/app/platform.ts | 19 +- ui/src/app/rpc.service.ts | 42 ++++- ui/src/app/toasts/toasts.ts | 50 +++++ ui/src/main.ts | 6 +- 18 files changed, 716 insertions(+), 239 deletions(-) create mode 100644 src/bun/editor-windows.ts create mode 100644 src/bun/windows.ts delete mode 100644 ui/src/app/dialogs/editor-dialog.ts create mode 100644 ui/src/app/editor-window/editor-window.html create mode 100644 ui/src/app/editor-window/editor-window.ts create mode 100644 ui/src/app/toasts/toasts.ts diff --git a/src/bun/editor-windows.ts b/src/bun/editor-windows.ts new file mode 100644 index 0000000000000000000000000000000000000000..64f0c0604f0a27011f4e5b288ed588d64ef64e6c GIT binary patch literal 5734 zcmb7I!EPJL5zUqQidtfUC2e}>TowsstszVDTG+K@ND6$|T|~o~9&w`4G}GM_MbHHH z1NN9h&iOQdNnTa;kV9Iw69JYrJ>6B+Rj*!EO?fx9F3^(x7Rt&GC@iLiUO7AW#=S4= z+|Fr5g|m0mHI>PNv*TGk#J}$TUfG;|b#Zuhe0uiddTWvl{UmiJAC6sG=Vg7f*)AHE zbds0By7Q^2k8B;xLwMw{n`WP!JDg50O$ZR_A34Fo{pQS)=B^Cpby=A!sEF5N)o;@} zy)kYhVra11IvUNqG~SoCzA!hX4{p)gU-+Smt8ImuGifk-yNh>6tpm8tvkAOW8Q0RY zBnuy0n#|UD>K5wr0qOsbsGUntS_0OFs#7lERnyYy`FZH0%V6mqH2FX$$ER247k__$ zdVKYU4(PAXpYLzKd2@35Gf`P%*fj<8JFPcx%Kw)C2PD6Qbo@JHw zhAz{4Lq%Fu<21X~82f~t3?bg7MHmrc1%%KCs%KRdl>jz0UJ%FGx+9h|63KHjzAw$3 z(h8x=7vzk$)jjM09!SikDH32BRD+*Qr`Y1($y)m+2=It5OqjX)3OIbhk+B8sAmWGp zz17~Hd62x@^{R&5ADgLX*ERqf(GCuAr6#2Ot`&N`5hSbkBg~@*uHZ*oXXe@Zin+NT z;&M5O2WGC)JK~SVhd6qdmi23w-oanJJ{|l3wm@*}+1=QJ|LaYl=1odtWZnGBul-2hWVz)IQ zvGdxqFovWLiwZ~E5TU|45{!DlhqAPWI0*ju$G_hXNI*y9SqQf7@3RaRrO2iLWBwE8$Q8wpZpA~e%r1VJ?4<4++5xS?Lmas=FCvz>_DIROqAUaZ4`)#vEtAU8%UtCY*sblM7wXNqF*UCq-1#HPB zDbaj9Kzic6KCuJw(`;Y?FjzOlY7@7Jk+9&H%hXjFD$~tECxzlpCw&U$&NYr7mZYI77zV)Em5?urYh4i7t}W1n)9 zmATmz2{Zr&)40qS((TO+O>C8GKhO|$>KAoJk6b}2wsd>oR_@74Z(cCRqD@(&C;AWw zY*BiGjzmcF6W`~ku)(a~G4*~EMSS=`@z2OJSctLQf&1o#7Q=%Blo0UgBNW|Gu<+7~ z0U#Y%Qx`u z^NnkfOc>S}03CJK)*PsXbgL8z7`9lkKM8A>4ZMY`Q|kh?^yeX2@WQ!E-* zj`Rv`UdSfYGdu=q2NI3nsAPBh1h>v+M(QAwbi(1;vhiyG>F{X6%*U!UD4j0>q;t1M z$dR2@dHr1=j_fu*62(WS0SKBJQGh-Wr4}tLhM^PINH0lVBvCAl^m+Ab{cC+lmCe!$ zQ{Z$#45(W(4TQNFkg6&gV}#)r7N}|_Lv9}GL>TF=8FpGgu zB9k@(x2;1D%o0Z&MijUK#$g?qh{mq}im$ZNsKHQLJXl8GSwe^r?hhg0Eeh zu49#nG_4n}Z8q~JXZ>+r7D&gEU)%`<%Sxs2_9 zuj%d_o6z(XTg_rK3AVmUZZc*Mgb0LHw&$CS*Qsl+Wq+Zf2a%g>~>e* uE(KPb`^3EnU4M1mc)qzdBxr-*nDDhLi}0^iOr|$>iWDXJcp?E`?fn-$_G~-= literal 0 HcmV?d00001 diff --git a/src/bun/edits.ts b/src/bun/edits.ts index 39dfd3c..d0592f8 100644 --- a/src/bun/edits.ts +++ b/src/bun/edits.ts @@ -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 { + 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] = [ diff --git a/src/bun/index.ts b/src/bun/index.ts index 2ec7889..c9128ba 100644 --- a/src/bun/index.ts +++ b/src/bun/index.ts @@ -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})`); diff --git a/src/bun/rpc-handlers.ts b/src/bun/rpc-handlers.ts index 106b099..3118d09 100644 --- a/src/bun/rpc-handlers.ts +++ b/src/bun/rpc-handlers.ts @@ -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({ + 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), }, }, }); diff --git a/src/bun/windows.ts b/src/bun/windows.ts new file mode 100644 index 0000000..310b3b7 --- /dev/null +++ b/src/bun/windows.ts @@ -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[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 { + private records = new Map(); + private focused = new Set(); + 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; + } +} diff --git a/src/shared/rpc.ts b/src/shared/rpc.ts index f7dc788..90074cb 100644 --- a/src/shared/rpc.ts +++ b/src/shared/rpc.ts @@ -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; response: Result }; + }; + messages: { + /** Editor → bun draft mirror; safety net for the non-cancellable native close. */ + editorState: { dirty: boolean; content: string }; }; - messages: Record; }; webview: { requests: Record; @@ -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; }; }; }; diff --git a/ui/src/app/app.html b/ui/src/app/app.html index 676fb41..3afeec3 100644 --- a/ui/src/app/app.html +++ b/ui/src/app/app.html @@ -37,39 +37,6 @@ @if (dialogs.overwrite(); as req) { } - @if (dialogs.editor(); as req) { - - } - -
- @for (t of toast.toasts(); track t.id) { -
- - {{ t.text }} - - -
-
- } -
+ diff --git a/ui/src/app/app.ts b/ui/src/app/app.ts index 61bbf6a..cc3603e 100644 --- a/ui/src/app/app.ts +++ b/ui/src/app/app.ts @@ -5,7 +5,6 @@ import { ConnectionManager } from './connection-manager/connection-manager'; import { ConnectionsService } from './connections.service'; import { ChmodDialog } from './dialogs/chmod-dialog'; import { ConfirmDialog } from './dialogs/confirm-dialog'; -import { EditorDialog } from './dialogs/editor-dialog'; import { OverwriteDialog } from './dialogs/overwrite-dialog'; import { PromptDialog } from './dialogs/prompt-dialog'; import { DialogsService } from './dialogs.service'; @@ -16,6 +15,7 @@ import { RpcService } from './rpc.service'; import { StatusBar } from './status-bar/status-bar'; import { TabBar } from './tab-bar/tab-bar'; import { ToastService } from './toast.service'; +import { Toasts } from './toasts/toasts'; import { TransferBar } from './transfer-bar/transfer-bar'; import { TransfersService } from './transfers.service'; @@ -30,7 +30,7 @@ import { TransfersService } from './transfers.service'; ConfirmDialog, ChmodDialog, OverwriteDialog, - EditorDialog, + Toasts, TransferBar, ConnectionManager, ], @@ -174,9 +174,4 @@ export class App { } } - toastIcon(kind: 'error' | 'info' | 'success'): string { - if (kind === 'error') return 'mynaXCircle'; - if (kind === 'success') return 'mynaCheckCircle'; - return 'mynaCheck'; - } } diff --git a/ui/src/app/dialogs.service.ts b/ui/src/app/dialogs.service.ts index cd783b0..b0b91d6 100644 --- a/ui/src/app/dialogs.service.ts +++ b/ui/src/app/dialogs.service.ts @@ -1,6 +1,5 @@ import { Injectable, signal } from '@angular/core'; import type { FileEntry } from '@shared/types'; -import type { PanelState } from './panels.service'; export type OverwriteChoice = 'overwrite' | 'skip' | 'overwriteAll' | 'skipAll' | 'cancel'; @@ -34,22 +33,12 @@ export interface ChmodRequest { onConfirm: (mode: number) => void; } -export interface EditorRequest { - sessionId: string; - path: string; - name: string; - sessionTitle: string; - content: string; - panel: PanelState; // refreshed after save -} - @Injectable({ providedIn: 'root' }) export class DialogsService { readonly prompt = signal(null); readonly confirm = signal(null); readonly chmod = signal(null); readonly overwrite = signal(null); - readonly editor = signal(null); readonly manager = signal(false); /** Ask what to do about one name collision; resolves with the user's choice. */ diff --git a/ui/src/app/dialogs/editor-dialog.ts b/ui/src/app/dialogs/editor-dialog.ts deleted file mode 100644 index fe23002..0000000 --- a/ui/src/app/dialogs/editor-dialog.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { - Component, - ElementRef, - afterNextRender, - inject, - input, - signal, - viewChild, -} from '@angular/core'; -import { NgIcon } from '@ng-icons/core'; -import { DialogsService, type EditorRequest } from '../dialogs.service'; -import { PanelsService } from '../panels.service'; -import { RpcService } from '../rpc.service'; -import { ToastService } from '../toast.service'; - -@Component({ - selector: 'app-editor-dialog', - imports: [NgIcon], - template: ` -
-
-
- -

- {{ request().name }}{{ dirty() ? ' •' : '' }} -

- - {{ request().sessionTitle }} - -
- -
- - - -
- ⌘S saves · Esc closes -
- - -
-
-
- `, -}) -export class EditorDialog { - readonly request = input.required(); - - readonly text = signal(''); - readonly dirty = signal(false); - readonly saving = signal(false); - - private readonly box = viewChild.required>('box'); - private readonly dialogs = inject(DialogsService); - private readonly panels = inject(PanelsService); - private readonly rpc = inject(RpcService); - private readonly toast = inject(ToastService); - - constructor() { - afterNextRender(() => { - this.text.set(this.request().content); - this.box().nativeElement.focus(); - }); - } - - onInput(value: string): void { - this.text.set(value); - this.dirty.set(value !== this.request().content); - } - - onKeydown(event: KeyboardEvent): void { - if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') { - event.preventDefault(); - void this.save(); - } else if (event.key === 'Escape') { - event.preventDefault(); - this.close(); - } else if (event.key === 'Tab' && event.target === this.box().nativeElement) { - // keep Tab as an input character inside the editor - event.preventDefault(); - const el = this.box().nativeElement; - const { selectionStart, selectionEnd, value } = el; - el.value = `${value.slice(0, selectionStart)}\t${value.slice(selectionEnd)}`; - el.selectionStart = el.selectionEnd = selectionStart + 1; - this.onInput(el.value); - } - } - - async save(): Promise { - const req = this.request(); - if (!this.dirty() || this.saving()) return; - this.saving.set(true); - try { - const res = await this.rpc.request.writeFile({ - sessionId: req.sessionId, - path: req.path, - content: this.text(), - }); - if (!res.ok) { - this.toast.error(res.error); - return; - } - this.toast.success(`Saved ${req.name}`); - req.content = this.text(); - this.dirty.set(false); - void req.panel.refresh(); - } finally { - this.saving.set(false); - } - } - - close(): void { - if (this.dirty()) { - this.dialogs.confirm.set({ - title: 'Discard changes?', - body: `"${this.request().name}" has unsaved changes.`, - confirmLabel: 'Discard', - destructive: true, - onConfirm: () => this.forceClose(), - }); - return; - } - this.forceClose(); - } - - private forceClose(): void { - this.dialogs.editor.set(null); - this.panels.refocus(); - } -} diff --git a/ui/src/app/editor-window/editor-window.html b/ui/src/app/editor-window/editor-window.html new file mode 100644 index 0000000..c400ef6 --- /dev/null +++ b/ui/src/app/editor-window/editor-window.html @@ -0,0 +1,120 @@ +
+ @if (!ctx) { +
+
+ +

No file to edit

+

+ This window was opened without an editor context. Launch files from the app. +

+
+
+ } @else { + +
+ +

+ {{ ctx.name }}{{ dirty() ? ' •' : '' }} +

+ {{ ctx.sessionTitle }} +
+
+ + @if (loading()) { +
+ +
+ } @else if (loadError(); as error) { +
+
+ +

Can't open {{ ctx.name }}

+

{{ error }}

+ +
+
+ } @else { + + +
+ ⌘S save · ⌘W close +
+ + +
+ } + + + @if (closePrompt()) { +
+
+

Save changes to "{{ ctx.name }}"?

+

+ Your changes will be lost if you don't save them. +

+
+ +
+ + +
+
+
+ } + } + + +
diff --git a/ui/src/app/editor-window/editor-window.ts b/ui/src/app/editor-window/editor-window.ts new file mode 100644 index 0000000..2e18ec3 --- /dev/null +++ b/ui/src/app/editor-window/editor-window.ts @@ -0,0 +1,192 @@ +import { + Component, + DOCUMENT, + ElementRef, + afterNextRender, + computed, + effect, + inject, + signal, + viewChild, +} from '@angular/core'; +import { NgIcon } from '@ng-icons/core'; +import { EDITOR_CONTEXT, IS_MAC_CHROME } from '../platform'; +import { RpcService } from '../rpc.service'; +import { SettingsService } from '../settings.service'; +import { ToastService } from '../toast.service'; +import { Toasts } from '../toasts/toasts'; + +/** Debounce for mirroring the draft to bun (the traffic-light-close safety net). */ +const DRAFT_DEBOUNCE_MS = 400; + +/** + * Root component of an editor window (window.__APP_MODE === 'editor'). + * + * Deliberately injects only Rpc/Toast/Settings: main-window root services + * (PanelsService, ConnectionsService, TransfersService — and the dialogs that + * inject them) have constructor side effects like creating workspaces and + * loading connections that must not run here. + */ +@Component({ + selector: 'app-root', + imports: [NgIcon, Toasts], + templateUrl: './editor-window.html', + host: { '(window:keydown)': 'onKeydown($event)', '(window:blur)': 'flushDraft()' }, +}) +export class EditorWindow { + readonly ctx = EDITOR_CONTEXT; + readonly isMacChrome = IS_MAC_CHROME; + + readonly loading = signal(true); + readonly loadError = signal(null); + readonly text = signal(''); + readonly savedContent = signal(''); + readonly dirty = computed(() => this.text() !== this.savedContent()); + readonly saving = signal(false); + readonly closePrompt = signal(false); + readonly sessionDead = signal(false); + + private readonly box = viewChild>('box'); + private readonly rpc = inject(RpcService); + private readonly toast = inject(ToastService); + private readonly document = inject(DOCUMENT); + // injected for its side effects: fetches settings and applies theme/accent/ + // text-size data attributes to this window's document root + private readonly settings = inject(SettingsService); + + private draftTimer: ReturnType | null = null; + private lastSentDirty = false; + + constructor() { + const ctx = this.ctx; + if (!ctx) return; // opened outside the app (plain browser) — error card renders + + const baseTitle = `${ctx.name} — ${ctx.sessionTitle}`; + effect(() => { + this.document.title = `${this.dirty() ? '• ' : ''}${baseTitle}`; + }); + + this.rpc.onEditorCloseRequest(() => this.requestClose()); + this.rpc.onSessionClosed((event) => { + if (event.sessionId === ctx.sessionId) this.sessionDead.set(true); + }); + + afterNextRender(() => void this.load(ctx.sessionId, ctx.path)); + } + + private async load(sessionId: string, path: string): Promise { + const res = await this.rpc.request.readFile({ sessionId, path }); + if (!res.ok) { + this.loadError.set(res.error); + this.loading.set(false); + return; + } + this.savedContent.set(res.content); + this.text.set(res.content); + this.loading.set(false); + queueMicrotask(() => this.box()?.nativeElement.focus()); + } + + onInput(value: string): void { + this.text.set(value); + this.scheduleDraft(); + } + + // ---- draft mirror: bun holds the latest content for the native-close prompt ---- + + private scheduleDraft(): void { + if (this.dirty() !== this.lastSentDirty) { + // dirty flag flipped — tell bun immediately so the native title updates + this.flushDraft(); + return; + } + if (this.draftTimer) clearTimeout(this.draftTimer); + this.draftTimer = setTimeout(() => this.flushDraft(), DRAFT_DEBOUNCE_MS); + } + + flushDraft(): void { + if (!this.ctx || this.loading()) return; + if (this.draftTimer) { + clearTimeout(this.draftTimer); + this.draftTimer = null; + } + this.lastSentDirty = this.dirty(); + this.rpc.send.editorState({ dirty: this.dirty(), content: this.text() }); + } + + // ---- save / close ---- + + async save(): Promise { + if (!this.dirty() || this.saving()) return true; + const content = this.text(); + this.saving.set(true); + try { + const res = await this.rpc.request.editorSave({ content }); + if (!res.ok) { + this.toast.error(res.error); + return false; + } + this.savedContent.set(content); + this.lastSentDirty = false; // bun record already cleaned by the handler + this.toast.success(`Saved ${this.ctx!.name}`); + return true; + } finally { + this.saving.set(false); + } + } + + /** ⌘W / Close button / bun's editorCloseRequest: prompt if dirty, else close. */ + requestClose(): void { + if (this.dirty()) { + this.closePrompt.set(true); + } else { + void this.rpc.request.editorClose({}); + } + } + + async saveAndClose(): Promise { + if (await this.save()) void this.rpc.request.editorClose({}); + } + + discardAndClose(): void { + void this.rpc.request.editorClose({}); + } + + cancelClose(): void { + this.closePrompt.set(false); + queueMicrotask(() => this.box()?.nativeElement.focus()); + } + + // ---- keyboard ---- + + onKeydown(event: KeyboardEvent): void { + const mod = event.metaKey || event.ctrlKey; + if (mod && event.key.toLowerCase() === 's') { + event.preventDefault(); + void this.save(); + } else if (mod && event.key.toLowerCase() === 'w') { + // reaches us on Windows/Linux; on macOS the app menu intercepts ⌘W and + // bun routes it back as editorCloseRequest + event.preventDefault(); + this.requestClose(); + } else if (event.key === 'Escape' && this.closePrompt()) { + event.preventDefault(); + this.cancelClose(); + } else if (event.key === 'Tab' && event.target === this.box()?.nativeElement) { + // keep Tab as an input character inside the editor + event.preventDefault(); + this.insertTab(); + } + } + + private insertTab(): void { + const el = this.box()!.nativeElement; + // execCommand keeps the native undo stack intact where supported + if (!this.document.execCommand('insertText', false, '\t')) { + const { selectionStart, selectionEnd, value } = el; + el.value = `${value.slice(0, selectionStart)}\t${value.slice(selectionEnd)}`; + el.selectionStart = el.selectionEnd = selectionStart + 1; + } + this.onInput(el.value); + } +} diff --git a/ui/src/app/file-actions.service.ts b/ui/src/app/file-actions.service.ts index b6c182a..5f7c316 100644 --- a/ui/src/app/file-actions.service.ts +++ b/ui/src/app/file-actions.service.ts @@ -105,22 +105,14 @@ export class FileActionsService { return; } - const res = await this.rpc.request.readFile({ - sessionId: panel.sessionId(), - path: entry.path, - }); - if (!res.ok) { - this.toast.error(res.error); - return; - } - this.dialogs.editor.set({ + // built-in editor: its own native window (reads the file itself) + const res = await this.rpc.request.openEditor({ sessionId: panel.sessionId(), path: entry.path, name: entry.name, sessionTitle: panel.title(), - content: res.content, - panel, }); + if (!res.ok) this.toast.error(res.error); } rename(panel: PanelState): void { diff --git a/ui/src/app/panels.service.ts b/ui/src/app/panels.service.ts index 66dc15d..db93c2c 100644 --- a/ui/src/app/panels.service.ts +++ b/ui/src/app/panels.service.ts @@ -223,6 +223,13 @@ export class PanelsService { } } }); + + // an editor window saved a file — refresh panels showing that directory + this.rpc.onPathRefresh(({ sessionId, dir }) => { + for (const panel of this.allPanels()) { + if (panel.sessionId() === sessionId && panel.cwd() === dir) void panel.refresh(); + } + }); } // ---- panels of the active workspace ---- diff --git a/ui/src/app/platform.ts b/ui/src/app/platform.ts index a0c2fc8..6f501cf 100644 --- a/ui/src/app/platform.ts +++ b/ui/src/app/platform.ts @@ -1,12 +1,25 @@ -// Injected by the bun process via BrowserWindow preload before any page script. -// Brand-neutral name so rebrands never touch this file. +// Injected by the bun process via BrowserWindow preload (src/bun/windows.ts +// buildPreload) before any page script. Brand-neutral names so rebrands never +// touch this file. +import type { EditorOpenContext } from '@shared/rpc'; + declare global { interface Window { __APP_PLATFORM?: string; + /** Which root component this window boots: the full app or one editor. */ + __APP_MODE?: 'main' | 'editor'; + /** Window-kind specific context (EditorOpenContext for editor windows). */ + __APP_CTX?: unknown; } } export const PLATFORM = typeof window !== 'undefined' ? (window.__APP_PLATFORM ?? '') : ''; -/** True when the window uses macOS hiddenInset chrome (tab bar fused with titlebar). */ +/** True when the window uses macOS hiddenInset chrome (header fused with titlebar). */ export const IS_MAC_CHROME = PLATFORM === 'darwin'; + +export const APP_MODE: 'main' | 'editor' = + typeof window !== 'undefined' ? (window.__APP_MODE ?? 'main') : 'main'; + +export const EDITOR_CONTEXT = + typeof window !== 'undefined' ? (window.__APP_CTX as EditorOpenContext | undefined) : undefined; diff --git a/ui/src/app/rpc.service.ts b/ui/src/app/rpc.service.ts index 4fdd40d..ae39167 100644 --- a/ui/src/app/rpc.service.ts +++ b/ui/src/app/rpc.service.ts @@ -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> | undefined; private ready: Promise = 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 void>; + methods[method](payload); + }); + }, + }); + } } diff --git a/ui/src/app/toasts/toasts.ts b/ui/src/app/toasts/toasts.ts new file mode 100644 index 0000000..2459eca --- /dev/null +++ b/ui/src/app/toasts/toasts.ts @@ -0,0 +1,50 @@ +import { Component, inject } from '@angular/core'; +import { NgIcon } from '@ng-icons/core'; +import { ToastService } from '../toast.service'; + +/** Toast stack overlay; shared by the main window and editor windows. */ +@Component({ + selector: 'app-toasts', + imports: [NgIcon], + template: ` +
+ @for (t of toast.toasts(); track t.id) { +
+ + {{ t.text }} + + +
+
+ } +
+ `, +}) +export class Toasts { + readonly toast = inject(ToastService); + + toastIcon(kind: 'error' | 'info' | 'success'): string { + if (kind === 'error') return 'mynaXCircle'; + if (kind === 'success') return 'mynaCheckCircle'; + return 'mynaCheck'; + } +} diff --git a/ui/src/main.ts b/ui/src/main.ts index 190f341..ea3e8f4 100644 --- a/ui/src/main.ts +++ b/ui/src/main.ts @@ -1,5 +1,9 @@ import { bootstrapApplication } from '@angular/platform-browser'; import { appConfig } from './app/app.config'; import { App } from './app/app'; +import { EditorWindow } from './app/editor-window/editor-window'; +import { APP_MODE } from './app/platform'; -bootstrapApplication(App, appConfig).catch((err) => console.error(err)); +// One bundle, two window kinds: the preload global picks the root component. +const root = APP_MODE === 'editor' ? EditorWindow : App; +bootstrapApplication(root, appConfig).catch((err) => console.error(err));