Files
miniSCP/src/bun/rpc-handlers.ts
T
JokinandClaude Fable 5 7dd59a9752 Connection manager in its own native window, modal to main
Single window centered over the main app. Electrobun has no native
modality, so it's approximated: bun bounces the main window's focus back
to the manager, the main webview shows a click-swallowing overlay, and
main-targeted menu/keyboard actions are dropped while open. Editor
windows stay fully usable. Connect/open-stack execute in the main
window via the existing connect: action channel; store edits push a
connectionsChanged refresh. Trigger moved from the panel dropdown to a
tab-bar button.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 23:11:58 +02:00

390 lines
11 KiB
TypeScript

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;
openConnectionManager: () => void;
};
}
async function wrap<T extends object>(fn: () => Promise<T> | T): Promise<Result<T>> {
try {
return { ok: true, ...(await fn()) };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
}
}
/** Connection-store requests, shared by the main and manager window rpcs. */
function connectionStoreHandlers(store: ConnectionStore, onChanged: () => void) {
return {
getConnections: () => wrap(async () => ({ store: await store.load() })),
upsertConnection: ({ connection }: { connection: Parameters<ConnectionStore["upsertConnection"]>[0] }) =>
wrap(async () => {
const id = await store.upsertConnection(connection);
onChanged();
return { id };
}),
deleteConnection: ({ id }: { id: string }) =>
wrap(async () => {
await store.deleteConnection(id);
onChanged();
return {};
}),
upsertStack: ({ stack }: { stack: Parameters<ConnectionStore["upsertStack"]>[0] }) =>
wrap(async () => {
const id = await store.upsertStack(stack);
onChanged();
return { id };
}),
deleteStack: ({ id }: { id: string }) =>
wrap(async () => {
await store.deleteStack(id);
onChanged();
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);
onChanged();
return result;
}),
getSshConfigHosts: () => wrap(async () => ({ hosts: loadSshConfigHosts() })),
};
}
const pickKeyFileHandler = () =>
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 };
});
export function createAppRPC({
registry,
engine,
store,
settings,
edits,
contextMenu,
events,
}: AppDeps) {
return BrowserView.defineRPC<AppRPC>({
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 ----
...connectionStoreHandlers(store, () => events.connectionsChanged()),
// ---- 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: pickKeyFileHandler,
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 {};
}),
// ---- connection-manager window ----
openConnectionManager: () =>
wrap(async () => {
events.openConnectionManager();
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<AppRPC>({
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),
},
},
});
}
export interface ManagerRpcHooks {
/** Store changed: rebuild the app menu + tell the main window to reload. */
onChanged: () => void;
/** Connect / open-stack execute in the MAIN window's workspace. */
onConnect: (id: string) => void;
onOpenStack: (id: string) => void;
onClose: () => void;
}
/** RPC for the single connection-manager window (per-window instance). */
export function createManagerRPC(
deps: { store: ConnectionStore; settings: SettingsStore },
hooks: ManagerRpcHooks,
) {
return BrowserView.defineRPC<AppRPC>({
maxRequestTime: MAX_REQUEST_TIME_MS,
handlers: {
requests: {
...connectionStoreHandlers(deps.store, hooks.onChanged),
pickKeyFile: pickKeyFileHandler,
getSettings: () => wrap(async () => ({ settings: await deps.settings.load() })),
managerConnect: ({ id }) =>
wrap(async () => {
hooks.onConnect(id);
return {};
}),
managerOpenStack: ({ id }) =>
wrap(async () => {
hooks.onOpenStack(id);
return {};
}),
managerClose: () =>
wrap(async () => {
hooks.onClose();
return {};
}),
},
},
});
}