Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 18 additions & 2 deletions apps/lite/electron/src/ipc.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,31 @@
import type { ProjectForFrontend, RefInfo } from "@gitbutler/but-sdk";

export type LongRunningTaskStatus = "running" | "cancelling" | "done" | "cancelled" | "error";

export interface LongRunningTaskSnapshot {
taskId: number;
durationMs: number;
step: number;
status: LongRunningTaskStatus;
message?: string;
}

export interface LiteElectronApi {
ping(input: string): Promise<string>;
getVersion(): Promise<string>;
listProjects(): Promise<ProjectForFrontend[]>;
headInfo(projectId: string): Promise<RefInfo>;
listLongRunningTasks(): Promise<LongRunningTaskSnapshot[]>;
startLongRunningTask(durationMs: number): Promise<number>;
cancelLongRunningTask(taskId: number): Promise<boolean>;
onLongRunningTaskEvent(listener: (event: LongRunningTaskSnapshot) => void): () => void;
}

export const liteIpcChannels = {
ping: "lite:ping",
getVersion: "lite:get-version",
listProjects: "projects:list",
headInfo: "workspace:head-info",
listLongRunningTasks: "long-running:list",
startLongRunningTask: "long-running:start",
cancelLongRunningTask: "long-running:cancel",
longRunningTaskEvent: "long-running:event",
} as const;
37 changes: 37 additions & 0 deletions apps/lite/electron/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { liteIpcChannels } from "#electron/ipc";
import {
cancelLongRunningTask,
listLongRunningTasks,
startLongRunningTask,
subscribeLongRunningTaskEvents,
} from "#electron/model/longRunning";
import { listProjects } from "#electron/model/projects";
import { headInfo } from "#electron/model/workspace";
import { app, BrowserWindow, ipcMain } from "electron";
Expand All @@ -16,6 +22,37 @@ function registerIpcHandlers(): void {
ipcMain.handle(liteIpcChannels.getVersion, async (): Promise<string> => {
return await Promise.resolve(app.getVersion());
});

// Returns all known task snapshots for initial renderer hydration.
ipcMain.handle(
liteIpcChannels.listLongRunningTasks,
async (): Promise<ReturnType<typeof listLongRunningTasks>> => {
return await Promise.resolve(listLongRunningTasks());
},
);

// Starts a new non-blocking task in Rust and returns its task id.
ipcMain.handle(
liteIpcChannels.startLongRunningTask,
async (_event, durationMs: number): Promise<number> => {
return await Promise.resolve(startLongRunningTask(durationMs));
},
);

// Requests cancellation for an existing task id.
ipcMain.handle(
liteIpcChannels.cancelLongRunningTask,
async (_event, taskId: number): Promise<boolean> => {
return await Promise.resolve(cancelLongRunningTask(taskId));
},
);

// Pushes incremental task snapshot updates from main to all renderer windows.
subscribeLongRunningTaskEvents((event) => {
for (const browserWindow of BrowserWindow.getAllWindows()) {
browserWindow.webContents.send(liteIpcChannels.longRunningTaskEvent, event);
}
});
}

async function createMainWindow(): Promise<void> {
Expand Down
155 changes: 155 additions & 0 deletions apps/lite/electron/src/model/longRunning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import {
longRunningCancelTsfn,
LongRunningEventKind,
longRunningStartTsfn,
} from "@gitbutler/but-sdk";
import type { LongRunningTaskSnapshot } from "#electron/ipc";

const MAX_DURATION_MS = 600000;
type LongRunningTaskListener = (event: LongRunningTaskSnapshot) => void;

const activeTaskIds = new Set<number>();
const tasks = new Map<number, LongRunningTaskSnapshot>();
const listeners = new Set<LongRunningTaskListener>();
Comment on lines +11 to +13
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We keep track of the tasks, active or not, and subscribers.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also means that the processes are persisted across UI reloads.
Only explicitly cancelling the operations will terminate them.


/**
* Starts a non-blocking task in Rust and tracks task snapshots for renderer consumption.
*/
export function startLongRunningTask(durationMs: number): number {
if (!Number.isInteger(durationMs) || durationMs < 1 || durationMs > MAX_DURATION_MS) {
throw new Error("durationMs must be an integer between 1 and 600000.");
}

let taskId = 0;

taskId = longRunningStartTsfn(durationMs, (err, event) => {
const currentSnapshot = tasks.get(taskId);
if (!currentSnapshot) {
return;
}

if (err) {
activeTaskIds.delete(taskId);
const nextSnapshot: LongRunningTaskSnapshot = {
...currentSnapshot,
status: "error",
message: err.message ?? "unknown error",
};
setTaskSnapshot(nextSnapshot);
return;
}

if (!activeTaskIds.has(taskId)) {
return;
}

const snapshotWithStep: LongRunningTaskSnapshot = {
...currentSnapshot,
step: typeof event.step === "number" ? event.step : currentSnapshot.step,
};

if (event.kind === LongRunningEventKind.Progress) {
setTaskSnapshot({
...snapshotWithStep,
status: "running",
message: undefined,
});
return;
}

if (event.kind === LongRunningEventKind.Done) {
activeTaskIds.delete(taskId);
setTaskSnapshot({
...snapshotWithStep,
status: "done",
message: undefined,
});
return;
}

if (event.kind === LongRunningEventKind.Cancelled) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I read this correctly, here you'd have to set should_interrupt to true.
After all, the frontend has to have a mechanism to set that boolean so the thread which polls it knows it should stop.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yikes, GitHub you got me. I thought I am looking at lib.rs, but I am not.
Anyway, I don't seem to be able to find the spot where this variable is set.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The variable event is one of the arguments passed into this callback. So this comes originally from the rust side --> napified --> ends up here.

If I read this correctly, here you'd have to set should_interrupt to true.

That's correct. In this implementation, the way we stop it from the JS side is through the cancelLongRunningTask function bellow.

activeTaskIds.delete(taskId);
setTaskSnapshot({
...snapshotWithStep,
status: "cancelled",
message: undefined,
});
return;
}

activeTaskIds.delete(taskId);
setTaskSnapshot({
...snapshotWithStep,
status: "error",
message: event.message ?? "unknown error",
});
});

activeTaskIds.add(taskId);
const initialSnapshot: LongRunningTaskSnapshot = {
taskId,
durationMs,
step: 0,
status: "running",
};
setTaskSnapshot(initialSnapshot);

return taskId;
}

/**
* Requests cancellation for a task and marks it as cancelling until the Rust callback emits a terminal state.
*/
export function cancelLongRunningTask(taskId: number): boolean {
if (!activeTaskIds.has(taskId)) {
return false;
}

const snapshot = tasks.get(taskId);
if (snapshot) {
const nextSnapshot: LongRunningTaskSnapshot = {
...snapshot,
status: "cancelling",
message: undefined,
};
setTaskSnapshot(nextSnapshot);
}

const cancelled = longRunningCancelTsfn(taskId);
if (!cancelled && snapshot) {
const nextSnapshot: LongRunningTaskSnapshot = {
...snapshot,
status: "error",
message: "Task could not be cancelled (already finished).",
};
setTaskSnapshot(nextSnapshot);
}

return cancelled;
}

/**
* Returns all known task snapshots, including terminal ones, newest first.
*/
export function listLongRunningTasks(): LongRunningTaskSnapshot[] {
return [...tasks.values()].sort((left, right) => right.taskId - left.taskId);
}

export function subscribeLongRunningTaskEvents(listener: LongRunningTaskListener): () => void {
listeners.add(listener);

return () => {
listeners.delete(listener);
};
}

function setTaskSnapshot(snapshot: LongRunningTaskSnapshot): void {
tasks.set(snapshot.taskId, snapshot);
emitLongRunningTaskEvent(snapshot);
}

function emitLongRunningTaskEvent(event: LongRunningTaskSnapshot): void {
for (const listener of listeners) {
listener(event);
}
}
33 changes: 25 additions & 8 deletions apps/lite/electron/src/preload.cts
Original file line number Diff line number Diff line change
@@ -1,20 +1,37 @@
import { contextBridge, ipcRenderer } from "electron";
import type { LiteElectronApi } from "#electron/ipc";
import {
type LiteElectronApi,
type LongRunningTaskSnapshot,
} from "#electron/ipc";
import { contextBridge, ipcRenderer, type IpcRendererEvent } from "electron";
import type { ProjectForFrontend, RefInfo } from "@gitbutler/but-sdk";

const api: LiteElectronApi = {
async ping(input: string): Promise<string> {
return await ipcRenderer.invoke("lite:ping", input);
},
async getVersion(): Promise<string> {
return await ipcRenderer.invoke("lite:get-version");
},
async listProjects(): Promise<ProjectForFrontend[]> {
return await ipcRenderer.invoke("projects:list");
},
async headInfo(projectId: string): Promise<RefInfo> {
return await ipcRenderer.invoke("workspace:head-info", projectId);
},
async listLongRunningTasks(): Promise<LongRunningTaskSnapshot[]> {
return await ipcRenderer.invoke("long-running:list");
},
async startLongRunningTask(durationMs: number): Promise<number> {
return await ipcRenderer.invoke("long-running:start", durationMs);
},
async cancelLongRunningTask(taskId: number): Promise<boolean> {
return await ipcRenderer.invoke("long-running:cancel", taskId);
},
onLongRunningTaskEvent(listener: (event: LongRunningTaskSnapshot) => void): () => void {
function eventListener(_event: IpcRendererEvent, taskEvent: LongRunningTaskSnapshot): void {
listener(taskEvent);
}

ipcRenderer.on("long-running:event", eventListener);

return () => {
ipcRenderer.removeListener("long-running:event", eventListener);
};
},
};

contextBridge.exposeInMainWorld("lite", api);
5 changes: 3 additions & 2 deletions apps/lite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,11 @@
}
},
"dependencies": {
"@gitbutler/but-sdk": "workspace:*",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-router": "^1.163.2",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"@gitbutler/but-sdk": "workspace:*"
"react-dom": "^19.2.4"
},
"devDependencies": {
"@types/node": "^22.19.11",
Expand Down
Loading
Loading