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
+18
View File
@@ -25,6 +25,12 @@ remote server over SSH/SFTP, so you can move files **local↔remote**,
(MB/s or Mbps), persisted across restarts.
- On macOS the tab bar is fused with the window titlebar; other platforms keep
a standard titlebar.
- **Edit files** (F4 / toolbar / File menu): a built-in editor works on local
and remote files directly; or pick an external app (Options → Editor →
Custom → Choose…). External edits of remote files download to a temp folder,
are watched, and every save auto-uploads (visible in the transfer list) until
the session disconnects. Leftover temp files from a crash are offered for
cleanup at the next start.
- Browse, sort (name/size/modified, folders first), keyboard navigation.
- Create folder / file, rename, delete (recursive), change permissions (chmod).
- Upload, download, and remote→remote copy — files **and** whole folders —
@@ -136,6 +142,18 @@ Saved connections live in a plain JSON file in the OS app-data directory
(On Macs, function keys may need `fn` — every action also has a toolbar button.)
## Portability notes
The codebase is platform-neutral except for two seams, both macOS-only today:
- **External editor launch** (`launchEditor` in [src/bun/edits.ts](src/bun/edits.ts)):
uses macOS `open [-a app] file`. A Windows port should use
`cmd /c start "" [app] file`; Linux `xdg-open file` (or invoke the configured
editor binary directly). The `.app` picker in the `pickEditorApp` handler
needs the equivalent executable picker per platform.
- **Application menu** ([src/bun/menu.ts](src/bun/menu.ts)): electrobun menus
are macOS-only; the in-app UI covers every menu action.
## Known issues
- **Rare native crash under load.** A long-running session (multiple remote
+187
View File
@@ -0,0 +1,187 @@
import { watch, type FSWatcher } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { pipeline } from "node:stream/promises";
import type { FileSystemProvider } from "./fs/provider";
import type { SessionRegistry } from "./fs/registry";
import type { SettingsStore } from "./store/settings";
import { appDataDir } from "./store/paths";
import type { TransferEngine } from "./transfers/engine";
// Built-in editor guard: bigger than this is not a config file.
const MAX_EDIT_BYTES = 2 * 1024 * 1024;
const SAVE_DEBOUNCE_MS = 400;
/** Read a whole file as UTF-8 through any provider (local or sftp). */
export async function readFileText(
provider: FileSystemProvider,
filePath: string,
): Promise<string> {
const stat = await provider.stat(filePath);
if (stat.size > MAX_EDIT_BYTES) {
throw new Error(
`File is ${Math.round(stat.size / 1024 / 1024)}MB — too large for the built-in editor. Use a custom editor instead.`,
);
}
const chunks: Buffer[] = [];
const stream = provider.createReadStream(filePath);
for await (const chunk of stream) {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
}
return Buffer.concat(chunks).toString("utf8");
}
/** Write UTF-8 content through any provider. */
export function writeFileText(
provider: FileSystemProvider,
filePath: string,
content: string,
): Promise<void> {
return new Promise((resolve, reject) => {
const stream = provider.createWriteStream(filePath);
stream.on("error", reject);
stream.end(Buffer.from(content, "utf8"), () => resolve());
});
}
/**
* Launch a file in an external editor.
*
* PORTABILITY: this is the only platform-specific piece of the edit feature.
* macOS delivers the file to any GUI app uniformly via `open [-a app]`.
* A Windows port should use `cmd /c start "" [app] file` and a Linux port
* `xdg-open file` (or `app file` when an editor is configured) — swap the
* spawn below and the .app picker in pickEditorApp; everything else
* (temp download, directory watch, auto-upload) is platform-neutral.
*/
function launchEditor(filePath: string, editorApp: string): void {
const args = editorApp ? ["open", "-a", editorApp, filePath] : ["open", filePath];
Bun.spawn(args, { stdout: "ignore", stderr: "ignore" });
}
interface WatchedEdit {
sessionId: string;
remotePath: string;
tempDir: string;
tempFile: string;
watcher: FSWatcher;
debounce: ReturnType<typeof setTimeout> | null;
}
/**
* External-editor flow for remote files: download to a temp dir, open the
* editor, watch the directory (survives atomic rename-saves), and enqueue an
* upload job on every save. Watchers die with their session; temp dirs left
* behind by a crash are offered for cleanup at next boot.
*/
export class EditManager {
private readonly editsDir = path.join(appDataDir(), "edits");
private watched: WatchedEdit[] = [];
constructor(
private registry: SessionRegistry,
private engine: TransferEngine,
private settings: SettingsStore,
) {}
async editExternal(sessionId: string, filePath: string): Promise<{ watching: boolean }> {
const { editorApp } = await this.settings.load();
if (sessionId === "local") {
launchEditor(filePath, editorApp);
return { watching: false };
}
const provider = this.registry.get(sessionId);
const tempDir = path.join(this.editsDir, crypto.randomUUID().slice(0, 8));
const name = provider.basename(filePath);
const tempFile = path.join(tempDir, name);
await fs.mkdir(tempDir, { recursive: true });
await fs.writeFile(
path.join(tempDir, "meta.json"),
JSON.stringify({ remotePath: filePath, sessionId, downloadedAt: new Date().toISOString() }),
);
const { createWriteStream } = await import("node:fs");
await pipeline(provider.createReadStream(filePath), createWriteStream(tempFile));
launchEditor(tempFile, editorApp);
const edit: WatchedEdit = {
sessionId,
remotePath: filePath,
tempDir,
tempFile,
watcher: watch(tempDir, (_event, changed) => {
if (changed !== name) return;
if (edit.debounce) clearTimeout(edit.debounce);
edit.debounce = setTimeout(() => this.uploadEdit(edit), SAVE_DEBOUNCE_MS);
}),
debounce: null,
};
this.watched.push(edit);
console.log(`[edit] watching ${tempFile} -> ${sessionId}:${filePath}`);
return { watching: true };
}
private uploadEdit(edit: WatchedEdit): void {
const provider = this.registry.get(edit.sessionId);
this.engine.enqueue({
srcSessionId: "local",
items: [{ path: edit.tempFile, type: "file" }],
dstSessionId: edit.sessionId,
dstDir: provider.dirname(edit.remotePath),
});
}
/** Session gone (disconnect, tab close, death): stop its watchers, drop temps. */
stopForSession(sessionId: string): void {
const [mine, rest] = [
this.watched.filter((w) => w.sessionId === sessionId),
this.watched.filter((w) => w.sessionId !== sessionId),
];
this.watched = rest;
for (const edit of mine) {
edit.watcher.close();
if (edit.debounce) clearTimeout(edit.debounce);
void fs.rm(edit.tempDir, { recursive: true, force: true });
}
}
/** Temp files from previous runs (crash / quit mid-edit). */
async listLeftovers(): Promise<string[]> {
const names: string[] = [];
let entries: string[];
try {
entries = await fs.readdir(this.editsDir);
} catch {
return names;
}
const active = new Set(this.watched.map((w) => path.basename(w.tempDir)));
for (const entry of entries) {
if (active.has(entry)) continue;
try {
const files = await fs.readdir(path.join(this.editsDir, entry));
const file = files.find((f) => f !== "meta.json");
names.push(file ?? entry);
} catch {
names.push(entry);
}
}
return names;
}
async clearLeftovers(): Promise<void> {
const active = new Set(this.watched.map((w) => path.basename(w.tempDir)));
let entries: string[] = [];
try {
entries = await fs.readdir(this.editsDir);
} catch {
return;
}
for (const entry of entries) {
if (active.has(entry)) continue;
await fs.rm(path.join(this.editsDir, entry), { recursive: true, force: true });
}
}
}
+3 -1
View File
@@ -1,6 +1,7 @@
import { ApplicationMenu, BrowserWindow, Updater } from "electrobun/bun";
import { BRANDING } from "../shared/branding";
import { effectiveConcurrency } from "../shared/types";
import { EditManager } from "./edits";
import { SessionRegistry } from "./fs/registry";
import { applyApplicationMenu } from "./menu";
import { createAppRPC } from "./rpc-handlers";
@@ -32,11 +33,12 @@ const registry = new SessionRegistry();
const engine = new TransferEngine(registry);
const store = new ConnectionStore();
const settings = new SettingsStore();
const edits = new EditManager(registry, engine, settings);
const events = {
sessionClosed: (_info: { sessionId: string; reason: string }) => {},
connectionsChanged: () => {},
};
const rpc = createAppRPC({ registry, engine, store, settings, events });
const rpc = createAppRPC({ registry, engine, store, settings, edits, events });
const url = await getMainViewUrl();
const isMac = process.platform === "darwin";
+2
View File
@@ -48,6 +48,8 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
{ label: "New Folder", action: "new-folder" },
{ label: "New File", action: "new-file" },
{ type: "separator" },
{ label: "Edit File", action: "edit-file", accelerator: "e" },
{ type: "separator" },
{ label: "Connect", submenu: connectSubmenu },
],
},
+43 -2
View File
@@ -1,6 +1,7 @@
import { BrowserView } from "electrobun/bun";
import { BrowserView, Utils } from "electrobun/bun";
import type { AppRPC } from "../shared/rpc";
import { effectiveConcurrency, type Result } from "../shared/types";
import { readFileText, writeFileText, type EditManager } from "./edits";
import type { SessionRegistry } from "./fs/registry";
import { SftpProvider } from "./fs/sftp";
import type { ConnectionStore } from "./store/connections";
@@ -17,6 +18,7 @@ export interface AppDeps {
engine: TransferEngine;
store: ConnectionStore;
settings: SettingsStore;
edits: EditManager;
/** Assigned after the rpc object exists (it needs rpc.send). */
events: {
sessionClosed: (info: { sessionId: string; reason: string }) => void;
@@ -32,7 +34,7 @@ async function wrap<T extends object>(fn: () => Promise<T> | T): Promise<Result<
}
}
export function createAppRPC({ registry, engine, store, settings, events }: AppDeps) {
export function createAppRPC({ registry, engine, store, settings, edits, events }: AppDeps) {
return BrowserView.defineRPC<AppRPC>({
maxRequestTime: MAX_REQUEST_TIME_MS,
handlers: {
@@ -45,6 +47,7 @@ export function createAppRPC({ registry, engine, store, settings, events }: AppD
if (!sessionId) return;
registry.drop(sessionId);
engine.failSession(sessionId, reason);
edits.stopForSession(sessionId);
events.sessionClosed({ sessionId, reason });
});
sessionId = registry.add(provider);
@@ -55,6 +58,7 @@ export function createAppRPC({ registry, engine, store, settings, events }: AppD
disconnect: ({ sessionId }) =>
wrap(async () => {
edits.stopForSession(sessionId);
await registry.remove(sessionId);
console.log(`[rpc] disconnected ${sessionId}`);
return {};
@@ -160,6 +164,43 @@ export function createAppRPC({ registry, engine, store, settings, events }: AppD
engine.setConcurrency(effectiveConcurrency(next));
return {};
}),
// ---- file editing ----
readFile: ({ sessionId, path }) =>
wrap(async () => ({
content: await readFileText(registry.get(sessionId), path),
})),
writeFile: ({ sessionId, path, content }) =>
wrap(async () => {
await writeFileText(registry.get(sessionId), path, content);
return {};
}),
editExternal: ({ sessionId, path }) =>
wrap(() => edits.editExternal(sessionId, path)),
pickEditorApp: () =>
wrap(async () => {
const picked = await Utils.openFileDialog({
startingFolder: "/Applications",
allowedFileTypes: "app",
canChooseFiles: true,
canChooseDirectory: false,
allowsMultipleSelection: false,
});
const path = picked.find((p) => p.trim().length > 0) ?? null;
return { path };
}),
getEditLeftovers: () =>
wrap(async () => ({ names: await edits.listLeftovers() })),
clearEditLeftovers: () =>
wrap(async () => {
await edits.clearLeftovers();
return {};
}),
},
},
});
+22
View File
@@ -74,6 +74,28 @@ export type AppRPC = {
response: Result<{ settings: AppSettings }>;
};
saveSettings: { params: { settings: AppSettings }; response: Result };
// file editing
readFile: {
params: { sessionId: string; path: string };
response: Result<{ content: string }>;
};
writeFile: {
params: { sessionId: string; path: string; content: string };
response: Result;
};
editExternal: {
params: { sessionId: string; path: string };
response: Result<{ watching: boolean }>;
};
pickEditorApp: {
params: Record<string, never>;
response: Result<{ path: string | null }>;
};
getEditLeftovers: {
params: Record<string, never>;
response: Result<{ names: string[] }>;
};
clearEditLeftovers: { params: Record<string, never>; response: Result };
};
messages: Record<never, never>;
};
+6
View File
@@ -52,6 +52,10 @@ export interface AppSettings {
transferConcurrency: number;
/** Remove completed transfers from the list automatically. */
autoClearCompleted: boolean;
/** Edit files with the built-in editor or an external application. */
editor: "builtin" | "custom";
/** Full path to the external editor .app; empty = OS default application. */
editorApp: string;
}
export const DEFAULT_SETTINGS: AppSettings = {
@@ -61,6 +65,8 @@ export const DEFAULT_SETTINGS: AppSettings = {
transferMode: "single",
transferConcurrency: 3,
autoClearCompleted: true,
editor: "builtin",
editorApp: "",
};
export function effectiveConcurrency(settings: AppSettings): number {
+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();