Electron Git Client
An end-to-end shape for a desktop Git app using IPC tuples, abortable subprocesses, and typed renderer calls.
Yieldless fits Electron well because the important boundaries in an Electron app are all failure-heavy: the renderer asks for work through IPC, the main process touches the filesystem, the main process launches long-running child processes like git clone.
Main-process contract
import type { IpcProcedure } from "yieldless/ipc";
type GitContract = {
cloneRepository: IpcProcedure<
[url: string, directory: string],
{ path: string },
Error
>;
getStatus: IpcProcedure<[directory: string], { output: string }, Error>;
};Main-process implementation
import { createAbortableIpcMain } from "yieldless/ipc";
import { runTaskGroup } from "yieldless/task";
import { runCommandSafe } from "yieldless/node";
const ipc = createAbortableIpcMain<GitContract>(ipcMain);
const windowLifecycle = new AbortController();
ipc.handle("cloneRepository", async (_event, requestSignal, url, directory) => {
return await runTaskGroup(async (_group, signal) => {
const [cloneError] = await runCommandSafe(
"git",
["clone", url, directory],
{ signal },
);
if (cloneError) {
return [cloneError, null];
}
return [null, { path: directory }];
}, {
signal: AbortSignal.any([windowLifecycle.signal, requestSignal]),
});
});If the window is torn down or a parent task group is canceled, the subprocess is aborted through Node's native child-process signal handling.
Preload bridge
import { createAbortableIpcBridge, createAbortableIpcRenderer } from "yieldless/ipc";
const client = createAbortableIpcRenderer<GitContract>(ipcRenderer);
export const gitBridge = createAbortableIpcBridge(client, [
"cloneRepository",
"getStatus",
] as const);Renderer usage
const controller = new AbortController();
const [error, result] = await window.gitBridge.withSignal.cloneRepository(
controller.signal,
"git@github.com:binbandit/yieldless.git",
"/tmp/yieldless",
);
if (error) {
showToast(error.message);
return;
}
openRepository(result.path);Why this boundary feels cleaner
- The renderer never depends on Electron's lossy thrown-error conversion.
- The main process can use the same tuple style it uses everywhere else.
- Long-running Git subprocesses can be canceled as soon as the UI no longer cares about them.
Good additions
Deduplicate status reads if several panels can request the same repository at once.
import { singleFlight } from "yieldless/singleflight";
const getStatus = singleFlight(
(signal, directory: string) =>
runCommandSafe("git", ["status", "--short"], {
cwd: directory,
signal,
}),
);Wait for one app or window event with cleanup.
import { onceEventSafe } from "yieldless/event";
const [closeError] = await onceEventSafe(mainWindow, "closed", {
signal: windowLifecycle.signal,
});Avoid
Do not expose arbitrary IPC channels to the renderer.
contextBridge.exposeInMainWorld("ipc", ipcRenderer);Build a small preload bridge with createIpcBridge() or createAbortableIpcBridge() so the renderer only sees the operations it is allowed to call.
Repository Indexer
A larger example that combines queues, pub/sub, caching, batching, limits, retries, task groups, and Node subprocesses.
Electron PR Review Workbench
A practical architecture for a GitHub pull-request review desktop app using tuples in the right places and ordinary UI state in the renderer.