import { BrowserView, Utils } from "electrobun/bun"; import type { AppRPC, EditorOpenContext } from "../shared/rpc"; import { effectiveConcurrency, type AppSettings, type Result } from "../shared/types"; import type { PanelContextMenu } from "./context-menu"; import { readFileText, writeFileText, type EditManager } from "./edits"; import type { SessionRegistry } from "./fs/registry"; import { SftpProvider } from "./fs/sftp"; import type { ConnectionStore } from "./store/connections"; import type { SettingsStore } from "./store/settings"; import { loadSshConfigHosts } from "./store/ssh-config"; import { importWinScpIni } from "./store/winscp-import"; import type { TransferEngine } from "./transfers/engine"; import { removeRecursive } from "./transfers/walk"; // SFTP ops can be slow (big listings, recursive deletes) — the 1s electrobun // default would cut them off. const MAX_REQUEST_TIME_MS = 10 * 60_000; export interface AppDeps { registry: SessionRegistry; engine: TransferEngine; store: ConnectionStore; settings: SettingsStore; edits: EditManager; contextMenu: PanelContextMenu; /** Assigned after the rpc object exists (it needs rpc.send). */ events: { sessionClosed: (info: { sessionId: string; reason: string }) => void; connectionsChanged: () => void; openEditor: (ctx: EditorOpenContext) => void; settingsChanged: (settings: AppSettings) => void; }; } async function wrap(fn: () => Promise | T): Promise> { try { return { ok: true, ...(await fn()) }; } catch (error) { return { ok: false, error: error instanceof Error ? error.message : String(error) }; } } export function createAppRPC({ registry, engine, store, settings, edits, contextMenu, events, }: AppDeps) { return BrowserView.defineRPC({ maxRequestTime: MAX_REQUEST_TIME_MS, handlers: { requests: { // ---- sessions ---- connect: ({ config }) => wrap(async () => { let sessionId = ""; const provider = await SftpProvider.connect(config, (reason) => { if (!sessionId) return; registry.drop(sessionId); engine.failSession(sessionId, reason); edits.stopForSession(sessionId); events.sessionClosed({ sessionId, reason }); }); sessionId = registry.add(provider); const homeDir = await provider.homeDir(); console.log(`[rpc] connected ${sessionId} -> ${config.username}@${config.host}`); return { sessionId, homeDir, fsKey: provider.fsKey }; }), disconnect: ({ sessionId }) => wrap(async () => { edits.stopForSession(sessionId); await registry.remove(sessionId); console.log(`[rpc] disconnected ${sessionId}`); return {}; }), // ---- filesystem ---- listDir: ({ sessionId, path }) => wrap(async () => { const fs = registry.get(sessionId); const dir = fs.normalize(path ?? (await fs.homeDir())); return { path: dir, entries: await fs.list(dir) }; }), mkdir: ({ sessionId, path }) => wrap(async () => { await registry.get(sessionId).mkdir(path); return {}; }), createFile: ({ sessionId, path }) => wrap(async () => { await registry.get(sessionId).createEmptyFile(path); return {}; }), rename: ({ sessionId, oldPath, newPath }) => wrap(async () => { await registry.get(sessionId).rename(oldPath, newPath); return {}; }), deleteEntries: ({ sessionId, entries }) => wrap(async () => { const fs = registry.get(sessionId); for (const { path, type } of entries) { await removeRecursive(fs, path, type); } return {}; }), chmod: ({ sessionId, paths, mode }) => wrap(async () => { const fs = registry.get(sessionId); for (const path of paths) { await fs.chmod(path, mode); } return {}; }), // ---- transfers ---- enqueueTransfers: (params) => wrap(async () => ({ jobIds: engine.enqueue(params) })), cancelTransfer: ({ jobId }) => wrap(async () => { engine.cancel(jobId); return {}; }), dismissTransfer: ({ jobId }) => wrap(async () => { engine.dismiss(jobId); return {}; }), // ---- connection store ---- getConnections: () => wrap(async () => ({ store: await store.load() })), upsertConnection: ({ connection }) => wrap(async () => { const id = await store.upsertConnection(connection); events.connectionsChanged(); return { id }; }), deleteConnection: ({ id }) => wrap(async () => { await store.deleteConnection(id); events.connectionsChanged(); return {}; }), upsertStack: ({ stack }) => wrap(async () => { const id = await store.upsertStack(stack); events.connectionsChanged(); return { id }; }), deleteStack: ({ id }) => wrap(async () => { await store.deleteStack(id); events.connectionsChanged(); return {}; }), importWinScp: () => wrap(async () => { const { homedir } = await import("node:os"); const picked = await Utils.openFileDialog({ startingFolder: homedir(), allowedFileTypes: "*", canChooseFiles: true, canChooseDirectory: false, allowsMultipleSelection: false, }); const file = picked.find((p) => p.trim().length > 0); // dialog cancelled — all-zero result, UI shows nothing if (!file) return { connections: 0, stacks: 0, skipped: 0 }; const text = await (await import("node:fs/promises")).readFile(file, "utf8"); const result = await importWinScpIni(text, store); events.connectionsChanged(); return result; }), getSshConfigHosts: () => wrap(async () => ({ hosts: loadSshConfigHosts() })), // ---- settings ---- getSettings: () => wrap(async () => ({ settings: await settings.load() })), saveSettings: ({ settings: next }) => wrap(async () => { await settings.save(next); engine.setConcurrency(effectiveConcurrency(next)); events.settingsChanged(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 }; }), pickKeyFile: () => wrap(async () => { const { homedir } = await import("node:os"); const picked = await Utils.openFileDialog({ startingFolder: `${homedir()}/.ssh`, allowedFileTypes: "*", 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 {}; }), // ---- context menu + local shell integration ---- showRowMenu: (params) => wrap(async () => { contextMenu.show(params); return {}; }), moveToTrash: ({ path }) => wrap(async () => { Utils.moveToTrash(path); return {}; }), // ---- editor windows ---- openEditor: (ctx) => wrap(async () => { events.openEditor(ctx); return {}; }), }, }, }); } export interface EditorRpcHooks { ctx: EditorOpenContext; /** Draft mirror tick: store dirty flag + content on the window record. */ onDraft: (state: { dirty: boolean; content: string }) => void; /** editorSave succeeded: clean the record, retitle, refresh main panels. */ onSaved: () => void; /** Webview resolved its dirty prompt: close without the native fallback. */ onCloseConfirmed: () => void; } /** * RPC for one editor window. Each window MUST get its own instance (binding a * BrowserView steals the rpc's transport), and the hooks close over that * window's record — no ids on the wire. */ export function createEditorRPC( deps: { registry: SessionRegistry; settings: SettingsStore }, hooks: EditorRpcHooks, ) { return BrowserView.defineRPC({ maxRequestTime: MAX_REQUEST_TIME_MS, handlers: { requests: { readFile: ({ sessionId, path }) => wrap(async () => ({ content: await readFileText(deps.registry.get(sessionId), path), })), getSettings: () => wrap(async () => ({ settings: await deps.settings.load() })), editorSave: ({ content }) => wrap(async () => { const { sessionId, path } = hooks.ctx; await writeFileText(deps.registry.get(sessionId), path, content); hooks.onSaved(); return {}; }), editorClose: () => wrap(async () => { hooks.onCloseConfirmed(); return {}; }), }, messages: { editorState: (state) => hooks.onDraft(state), }, }, }); }