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