The setImage-swapped progress frames lost the template treatment on macOS and rendered black-on-dark once transfers started. Dial back per user decision: the tray is now just the glyph (template image set once at construction, never swapped) and clicking it activates the window — no menu, no progress effects. Removes everything that only served the fancy version: progress frames + generation, engine.aggregateProgress, the workspacesSync webview->bun channel (schema, send proxy, panels effect, handlers) and the activate-tab action. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
208 lines
5.9 KiB
TypeScript
208 lines
5.9 KiB
TypeScript
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";
|
|
import type { SettingsStore } from "./store/settings";
|
|
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;
|
|
/** Assigned after the rpc object exists (it needs rpc.send). */
|
|
events: {
|
|
sessionClosed: (info: { sessionId: string; reason: string }) => void;
|
|
connectionsChanged: () => 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) };
|
|
}
|
|
}
|
|
|
|
export function createAppRPC({ registry, engine, store, settings, edits, 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 ----
|
|
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 {};
|
|
}),
|
|
|
|
upsertFolder: ({ folder }) =>
|
|
wrap(async () => {
|
|
const id = await store.upsertFolder(folder);
|
|
events.connectionsChanged();
|
|
return { id };
|
|
}),
|
|
|
|
deleteFolder: ({ id }) =>
|
|
wrap(async () => {
|
|
await store.deleteFolder(id);
|
|
events.connectionsChanged();
|
|
return {};
|
|
}),
|
|
|
|
// ---- settings ----
|
|
getSettings: () => wrap(async () => ({ settings: await settings.load() })),
|
|
|
|
saveSettings: ({ settings: next }) =>
|
|
wrap(async () => {
|
|
await settings.save(next);
|
|
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 {};
|
|
}),
|
|
},
|
|
},
|
|
});
|
|
}
|