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
-158
View File
@@ -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: `
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40">
<div
class="flex h-[80vh] w-[85vw] max-w-5xl flex-col rounded-lg border border-border bg-card text-card-foreground shadow-xl"
(keydown)="onKeydown($event)"
>
<div class="flex items-center gap-2 border-b border-border px-4 py-2.5">
<ng-icon name="mynaFileText" size="0.95rem" class="shrink-0 text-muted-foreground" />
<h2 class="min-w-0 truncate font-mono text-sm font-semibold">
{{ request().name }}{{ dirty() ? ' •' : '' }}
</h2>
<span class="shrink-0 text-[11px] text-muted-foreground">
{{ request().sessionTitle }}
</span>
<div class="flex-1"></div>
<button
(click)="close()"
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaX" size="1rem" />
</button>
</div>
<textarea
#box
[value]="text()"
(input)="onInput($any($event.target).value)"
spellcheck="false"
class="min-h-0 flex-1 resize-none bg-transparent p-4 font-mono text-xs leading-relaxed outline-none"
></textarea>
<div class="flex items-center gap-2 border-t border-border px-4 py-2.5">
<span class="text-[11px] text-muted-foreground">⌘S saves · Esc closes</span>
<div class="flex-1"></div>
<button
(click)="close()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
Cancel
</button>
<button
(click)="save()"
[disabled]="saving() || !dirty()"
class="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
@if (saving()) {
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
}
Save
</button>
</div>
</div>
</div>
`,
})
export class EditorDialog {
readonly request = input.required<EditorRequest>();
readonly text = signal('');
readonly dirty = signal(false);
readonly saving = signal(false);
private readonly box = viewChild.required<ElementRef<HTMLTextAreaElement>>('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<void> {
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();
}
}