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
+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 {};
}),
},
},
});