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:
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user