- Built-in editor (default): large modal with monospace textarea, Cmd+S save, dirty guard; reads/writes through the provider streams so it works on local and remote files identically (2MB cap with a friendly error) - External editor: remote files download to appDataDir/edits/<id>, open via macOS `open -a`, the temp DIR is watched (survives atomic rename-saves) and every save enqueues a normal upload job (progress/errors in the transfer list); watchers + temp dirs are cleaned when the session disconnects - Editor setting: Built-in | Custom with a NATIVE app picker (electrobun openFileDialog, /Applications, .app filter) and reset-to-default - Leftover temp files from a crash prompt for cleanup at boot - F4, toolbar button (mynaFileText), and File > Edit File (Cmd+E) menu entry - README: feature notes + Portability notes (open/start/xdg-open seam documented in code and README per user request) Verified headless against the box: sftp read/write roundtrip incl. UTF-8, size cap, watch->save->auto-upload->remote-updated, session cleanup, orphan listing and clearing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
159 lines
4.9 KiB
TypeScript
159 lines
4.9 KiB
TypeScript
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();
|
|
}
|
|
}
|