Edit file: built-in editor + external editor with watch & auto-upload

- 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>
This commit is contained in:
Jokin
2026-07-11 20:28:54 +02:00
co-authored by Claude Fable 5
parent 38ac454380
commit e4f7ca86f7
17 changed files with 606 additions and 5 deletions
+3
View File
@@ -37,6 +37,9 @@
@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">
+21
View File
@@ -5,6 +5,7 @@ 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';
@@ -28,6 +29,7 @@ import { TransferBar } from './transfer-bar/transfer-bar';
ConfirmDialog,
ChmodDialog,
OverwriteDialog,
EditorDialog,
TransferBar,
ConnectionManager,
],
@@ -46,9 +48,25 @@ export class App {
document.title = BRANDING.appName;
if (this.rpc.available) {
this.rpc.onMenuAction((action) => this.handleMenuAction(action));
void this.promptForEditLeftovers();
}
}
/** Temp files from external edits in a previous run (crash / quit mid-edit). */
private async promptForEditLeftovers(): Promise<void> {
const res = await this.rpc.request.getEditLeftovers({});
if (!res.ok || !res.names.length) return;
const shown = res.names.slice(0, 5).join(', ');
const more = res.names.length > 5 ? `, +${res.names.length - 5} more` : '';
this.dialogs.confirm.set({
title: 'Leftover edited files',
body: `A previous session left ${res.names.length} temporary edited file(s) behind (${shown}${more}). Clear them?`,
confirmLabel: 'Clear',
destructive: true,
onConfirm: () => void this.rpc.request.clearEditLeftovers({}),
});
}
/** Native application-menu actions forwarded from the bun process. */
private handleMenuAction(action: string): void {
if (action.startsWith('connect:')) {
@@ -72,6 +90,9 @@ export class App {
case 'new-file':
this.actions.createFile(this.panels.active());
break;
case 'edit-file':
void this.actions.edit(this.panels.active());
break;
case 'refresh-active':
void this.panels.active().refresh();
break;
+11
View File
@@ -1,5 +1,6 @@
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';
@@ -33,12 +34,22 @@ 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. */
+158
View File
@@ -0,0 +1,158 @@
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();
}
}
+38
View File
@@ -4,6 +4,7 @@ import { DialogsService } from './dialogs.service';
import { joinPath } from './format';
import { type PanelState } from './panels.service';
import { RpcService } from './rpc.service';
import { SettingsService } from './settings.service';
import { ToastService } from './toast.service';
/**
@@ -15,6 +16,7 @@ export class FileActionsService {
private readonly rpc = inject(RpcService);
private readonly toast = inject(ToastService);
private readonly dialogs = inject(DialogsService);
private readonly settings = inject(SettingsService);
/** Selection if non-empty, else the cursor entry. Never the ".." parent row. */
targets(panel: PanelState): FileEntry[] {
@@ -56,6 +58,42 @@ export class FileActionsService {
});
}
/** Open the cursor file in the built-in editor or the configured external app. */
async edit(panel: PanelState): Promise<void> {
const entry = panel.sorted()[panel.cursor()];
if (!entry || entry.type !== 'file' || entry.name === '..') return;
if (this.settings.editor() === 'custom') {
const res = await this.rpc.request.editExternal({
sessionId: panel.sessionId(),
path: entry.path,
});
if (!res.ok) {
this.toast.error(res.error);
} else if (res.watching) {
this.toast.info(`Editing ${entry.name} — saves upload automatically`);
}
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({
sessionId: panel.sessionId(),
path: entry.path,
name: entry.name,
sessionTitle: panel.title(),
content: res.content,
panel,
});
}
rename(panel: PanelState): void {
const entry = panel.sorted()[panel.cursor()];
if (!entry || entry.name === '..') return;
+7
View File
@@ -37,6 +37,13 @@
>
<ng-icon [name]="copyIcon()" size="0.95rem" />
</button>
<button
(click)="run('edit')"
title="Edit file (F4)"
class="grid size-6 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaFileText" size="0.9rem" />
</button>
<button
(click)="run('rename')"
title="Rename (F2)"
+5 -2
View File
@@ -65,9 +65,9 @@ export class FilePanel {
void this.transfers.copyToOtherPanel(this.side());
}
run(action: 'rename' | 'mkdir' | 'createFile' | 'chmod' | 'delete'): void {
run(action: 'edit' | 'rename' | 'mkdir' | 'createFile' | 'chmod' | 'delete'): void {
this.activate();
this.actions[action](this.panel());
void this.actions[action](this.panel());
}
pickLocal(): void {
@@ -186,6 +186,9 @@ export class FilePanel {
case 'F2':
this.actions.rename(panel);
break;
case 'F4':
void this.actions.edit(panel);
break;
case 'F5':
void this.transfers.copyToOtherPanel(this.side());
break;
+2
View File
@@ -15,6 +15,7 @@ import {
mynaEdit,
mynaFile,
mynaFilePlus,
mynaFileText,
mynaFolder,
mynaFolderPlus,
mynaHardDrive,
@@ -43,6 +44,7 @@ export const APP_ICONS = {
mynaEdit,
mynaFile,
mynaFilePlus,
mynaFileText,
mynaFolder,
mynaFolderPlus,
mynaHardDrive,
+16
View File
@@ -10,6 +10,8 @@ export class SettingsService {
readonly transferMode = signal<AppSettings['transferMode']>(DEFAULT_SETTINGS.transferMode);
readonly transferConcurrency = signal(DEFAULT_SETTINGS.transferConcurrency);
readonly autoClearCompleted = signal(DEFAULT_SETTINGS.autoClearCompleted);
readonly editor = signal<AppSettings['editor']>(DEFAULT_SETTINGS.editor);
readonly editorApp = signal(DEFAULT_SETTINGS.editorApp);
private readonly rpc = inject(RpcService);
@@ -23,6 +25,8 @@ export class SettingsService {
this.transferMode.set(res.settings.transferMode);
this.transferConcurrency.set(res.settings.transferConcurrency);
this.autoClearCompleted.set(res.settings.autoClearCompleted);
this.editor.set(res.settings.editor);
this.editorApp.set(res.settings.editorApp);
this.applyTheme();
this.applyTextSize();
});
@@ -60,6 +64,16 @@ export class SettingsService {
void this.persist();
}
setEditor(editor: AppSettings['editor']): void {
this.editor.set(editor);
void this.persist();
}
setEditorApp(path: string): void {
this.editorApp.set(path);
void this.persist();
}
private applyTheme(): void {
const theme = this.theme();
if (theme === 'system') {
@@ -86,6 +100,8 @@ export class SettingsService {
transferMode: this.transferMode(),
transferConcurrency: this.transferConcurrency(),
autoClearCompleted: this.autoClearCompleted(),
editor: this.editor(),
editorApp: this.editorApp(),
};
}
+64
View File
@@ -2,6 +2,7 @@ import { Component, ElementRef, effect, inject, signal, viewChild } from '@angul
import { NgIcon } from '@ng-icons/core';
import { PanelsService } from '../panels.service';
import { IS_MAC_CHROME } from '../platform';
import { RpcService } from '../rpc.service';
import { SettingsService } from '../settings.service';
const THEMES = ['system', 'light', 'dark'] as const;
@@ -188,6 +189,55 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
</div>
}
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">Editor</span>
<div class="flex rounded-md border border-border p-0.5">
<button
(click)="settings.setEditor('builtin')"
class="rounded px-2 py-0.5 text-[11px]"
[class.bg-accent]="settings.editor() === 'builtin'"
[class.text-foreground]="settings.editor() === 'builtin'"
[class.text-muted-foreground]="settings.editor() !== 'builtin'"
>
Built-in
</button>
<button
(click)="settings.setEditor('custom')"
class="rounded px-2 py-0.5 text-[11px]"
[class.bg-accent]="settings.editor() === 'custom'"
[class.text-foreground]="settings.editor() === 'custom'"
[class.text-muted-foreground]="settings.editor() !== 'custom'"
>
Custom
</button>
</div>
</div>
@if (settings.editor() === 'custom') {
<div class="mt-2.5 flex items-center justify-between gap-2 pl-3">
<span class="min-w-0 truncate text-xs text-muted-foreground" [title]="settings.editorApp()">
{{ editorAppName() }}
</span>
<div class="flex shrink-0 items-center gap-1">
<button
(click)="pickEditorApp()"
class="rounded-md border border-border px-2 py-0.5 text-[11px] hover:bg-accent"
>
Choose…
</button>
@if (settings.editorApp()) {
<button
(click)="settings.setEditorApp('')"
title="Reset to system default"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaX" size="0.7rem" />
</button>
}
</div>
</div>
}
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">Finished transfers</span>
<div class="flex rounded-md border border-border p-0.5">
@@ -219,6 +269,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
export class TabBar {
readonly panels = inject(PanelsService);
readonly settings = inject(SettingsService);
private readonly rpc = inject(RpcService);
readonly settingsOpen = signal(false);
readonly isMacChrome = IS_MAC_CHROME;
readonly themes = THEMES;
@@ -248,6 +299,19 @@ export class TabBar {
this.panels.closeWorkspace(id);
}
/** "Visual Studio Code.app" -> "Visual Studio Code"; empty -> system default. */
editorAppName(): string {
const path = this.settings.editorApp();
if (!path) return 'System default';
const base = path.split('/').filter(Boolean).pop() ?? path;
return base.replace(/\.app$/i, '');
}
async pickEditorApp(): Promise<void> {
const res = await this.rpc.request.pickEditorApp({});
if (res.ok && res.path) this.settings.setEditorApp(res.path);
}
/** Insertion line at the drop position (inset shadow: no layout shift). */
dropIndicatorShadow(index: number): string | null {
const target = this.dropTarget();