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:
+1
-34
@@ -37,39 +37,6 @@
|
||||
@if (dialogs.overwrite(); as req) {
|
||||
<app-overwrite-dialog [request]="req" />
|
||||
}
|
||||
@if (dialogs.editor(); as req) {
|
||||
<app-editor-dialog [request]="req" />
|
||||
}
|
||||
|
||||
<!-- toasts -->
|
||||
<div class="pointer-events-none fixed right-3 bottom-8 z-50 flex w-80 flex-col gap-2">
|
||||
@for (t of toast.toasts(); track t.id) {
|
||||
<div
|
||||
(mouseenter)="toast.pause(t.id)"
|
||||
(mouseleave)="toast.resume(t.id)"
|
||||
class="group pointer-events-auto relative flex items-start gap-2 overflow-hidden rounded-lg border border-border bg-card p-3 text-xs text-card-foreground shadow-lg"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="toastIcon(t.kind)"
|
||||
size="1rem"
|
||||
[class]="t.kind === 'error' ? 'shrink-0 text-destructive' : 'shrink-0 text-success'"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 wrap-break-word">{{ t.text }}</span>
|
||||
<button
|
||||
(click)="toast.dismiss(t.id)"
|
||||
class="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaX" size="0.9rem" />
|
||||
</button>
|
||||
<!-- auto-dismiss countdown; freezes with the timer while hovered -->
|
||||
<div
|
||||
class="toast-countdown absolute inset-x-0 bottom-0 h-0.5 origin-left"
|
||||
[class.bg-destructive]="t.kind === 'error'"
|
||||
[class.bg-success]="t.kind === 'success'"
|
||||
[class.bg-primary]="t.kind === 'info'"
|
||||
[style.animation-duration.ms]="t.ttlMs"
|
||||
></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<app-toasts />
|
||||
</div>
|
||||
|
||||
+2
-7
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PromptRequest | null>(null);
|
||||
readonly confirm = signal<ConfirmRequest | null>(null);
|
||||
readonly chmod = signal<ChmodRequest | null>(null);
|
||||
readonly overwrite = signal<OverwriteRequest | null>(null);
|
||||
readonly editor = signal<EditorRequest | null>(null);
|
||||
readonly manager = signal(false);
|
||||
|
||||
/** Ask what to do about one name collision; resolves with the user's choice. */
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<div class="flex h-screen flex-col bg-background text-foreground">
|
||||
@if (!ctx) {
|
||||
<div class="flex flex-1 items-center justify-center">
|
||||
<div class="max-w-sm rounded-lg border border-border bg-card p-6 text-center shadow-sm">
|
||||
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" />
|
||||
<p class="mt-2 text-sm font-medium">No file to edit</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
This window was opened without an editor context. Launch files from the app.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- header: on macOS this row IS the titlebar (hiddenInset + drag region),
|
||||
elsewhere the native titlebar sits above and this is a compact strip -->
|
||||
<header
|
||||
class="flex shrink-0 items-center gap-2 border-b border-border pr-2"
|
||||
[class.h-10]="isMacChrome"
|
||||
[class.pl-24]="isMacChrome"
|
||||
[class.electrobun-webkit-app-region-drag]="isMacChrome"
|
||||
[class.h-9]="!isMacChrome"
|
||||
[class.pl-3]="!isMacChrome"
|
||||
>
|
||||
<ng-icon name="mynaFileText" size="0.95rem" class="shrink-0 text-muted-foreground" />
|
||||
<h1 class="min-w-0 truncate font-mono text-sm font-semibold">
|
||||
{{ ctx.name }}{{ dirty() ? ' •' : '' }}
|
||||
</h1>
|
||||
<span class="shrink-0 text-[11px] text-muted-foreground">{{ ctx.sessionTitle }}</span>
|
||||
<div class="flex-1"></div>
|
||||
</header>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="flex flex-1 items-center justify-center">
|
||||
<ng-icon name="mynaSpinner" size="1.25rem" class="animate-spin text-primary" />
|
||||
</div>
|
||||
} @else if (loadError(); as error) {
|
||||
<div class="flex flex-1 items-center justify-center">
|
||||
<div class="max-w-md rounded-lg border border-border bg-card p-6 text-center shadow-sm">
|
||||
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" />
|
||||
<p class="mt-2 text-sm font-medium">Can't open {{ ctx.name }}</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">{{ error }}</p>
|
||||
<button
|
||||
(click)="discardAndClose()"
|
||||
class="mt-4 rounded-md border border-border px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||
>
|
||||
Close Window
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<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>
|
||||
|
||||
<footer class="flex shrink-0 items-center gap-2 border-t border-border px-4 py-2">
|
||||
<span class="text-[11px] text-muted-foreground">⌘S save · ⌘W close</span>
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
(click)="requestClose()"
|
||||
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||
>
|
||||
Close
|
||||
</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>
|
||||
</footer>
|
||||
}
|
||||
|
||||
<!-- dirty-close prompt. Component-local on purpose: the shared confirm
|
||||
dialog pulls in PanelsService, which must not boot in this window. -->
|
||||
@if (closePrompt()) {
|
||||
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancelClose()">
|
||||
<div
|
||||
class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
|
||||
(mousedown)="$event.stopPropagation()"
|
||||
>
|
||||
<h2 class="text-sm font-semibold">Save changes to "{{ ctx.name }}"?</h2>
|
||||
<p class="mt-1.5 text-xs text-muted-foreground">
|
||||
Your changes will be lost if you don't save them.
|
||||
</p>
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<button
|
||||
(click)="discardAndClose()"
|
||||
class="rounded-md px-3 py-1.5 text-xs font-medium text-destructive hover:bg-accent"
|
||||
>
|
||||
Don't Save
|
||||
</button>
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
(click)="cancelClose()"
|
||||
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
(click)="saveAndClose()"
|
||||
[disabled]="saving()"
|
||||
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<app-toasts />
|
||||
</div>
|
||||
@@ -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<string | null>(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<ElementRef<HTMLTextAreaElement>>('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<typeof setTimeout> | 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<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ----
|
||||
|
||||
+16
-3
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: `
|
||||
<div class="pointer-events-none fixed right-3 bottom-8 z-50 flex w-80 flex-col gap-2">
|
||||
@for (t of toast.toasts(); track t.id) {
|
||||
<div
|
||||
(mouseenter)="toast.pause(t.id)"
|
||||
(mouseleave)="toast.resume(t.id)"
|
||||
class="group pointer-events-auto relative flex items-start gap-2 overflow-hidden rounded-lg border border-border bg-card p-3 text-xs text-card-foreground shadow-lg"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="toastIcon(t.kind)"
|
||||
size="1rem"
|
||||
[class]="t.kind === 'error' ? 'shrink-0 text-destructive' : 'shrink-0 text-success'"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 wrap-break-word">{{ t.text }}</span>
|
||||
<button
|
||||
(click)="toast.dismiss(t.id)"
|
||||
class="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaX" size="0.9rem" />
|
||||
</button>
|
||||
<!-- auto-dismiss countdown; freezes with the timer while hovered -->
|
||||
<div
|
||||
class="toast-countdown absolute inset-x-0 bottom-0 h-0.5 origin-left"
|
||||
[class.bg-destructive]="t.kind === 'error'"
|
||||
[class.bg-success]="t.kind === 'success'"
|
||||
[class.bg-primary]="t.kind === 'info'"
|
||||
[style.animation-duration.ms]="t.ttlMs"
|
||||
></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user