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 { 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 { 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 via `open [-a app]`; Windows via * `cmd /c start` (empty "" arg = window title slot); Linux via the app * directly or `xdg-open`. Everything else (temp download, directory watch, * auto-upload) is platform-neutral. Windows path pending first smoke test. */ function launchEditor(filePath: string, editorApp: string): void { let args: string[]; switch (process.platform) { case "darwin": args = editorApp ? ["open", "-a", editorApp, filePath] : ["open", filePath]; break; case "win32": args = editorApp ? ["cmd", "/c", "start", "", editorApp, filePath] : ["cmd", "/c", "start", "", filePath]; break; default: args = editorApp ? [editorApp, filePath] : ["xdg-open", filePath]; } Bun.spawn(args, { stdout: "ignore", stderr: "ignore" }); } interface WatchedEdit { sessionId: string; remotePath: string; tempDir: string; tempFile: string; watcher: FSWatcher; debounce: ReturnType | 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), }); } /** * Park unsaveable editor content (window closed, session dead) in the * leftovers dir so the next-boot prompt offers it back to the user. */ async rescueDraft(name: string, content: string): Promise { const dir = path.join(this.editsDir, crypto.randomUUID().slice(0, 8)); await fs.mkdir(dir, { recursive: true }); await fs.writeFile( path.join(dir, "meta.json"), JSON.stringify({ name, rescuedAt: new Date().toISOString() }), ); await fs.writeFile(path.join(dir, name), content, "utf8"); console.log(`[edit] rescued unsaved draft of ${name} -> ${dir}`); } /** 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 { 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 { 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 }); } } }