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