Skip to content

Commit fb25a08

Browse files
author
chenbo
committed
Improve desktop task run feedback
1 parent 0823450 commit fb25a08

4 files changed

Lines changed: 72 additions & 24 deletions

File tree

apps/desktop/src/main/main.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
1+
import { spawn, type ChildProcess } from "node:child_process";
22
import { existsSync, readdirSync, statSync } from "node:fs";
33
import path from "node:path";
44
import { fileURLToPath } from "node:url";
@@ -22,7 +22,7 @@ interface RunTaskRejected {
2222
type RunTaskResult = RunTaskStarted | RunTaskRejected;
2323

2424
let mainWindow: BrowserWindow | undefined;
25-
let currentTask: ChildProcessWithoutNullStreams | undefined;
25+
let currentTask: ChildProcess | undefined;
2626
let currentRepo: string | undefined;
2727

2828
const __filename = fileURLToPath(import.meta.url);
@@ -66,7 +66,7 @@ function registerIpc(): void {
6666
ipcMain.handle("task:run", (_event, input: RunTaskInput): RunTaskResult => {
6767
if (currentTask) return { error: "A task is already running." };
6868
const repo = input.repo.trim();
69-
const task = input.task.trim();
69+
const task = normalizeTaskForCli(input.task);
7070
if (!repo) return { error: "Select a repository first." };
7171
if (!task) return { error: "Enter a task first." };
7272
if (!existsSync(repo)) return { error: `Repository does not exist: ${repo}` };
@@ -76,23 +76,36 @@ function registerIpc(): void {
7676
currentRepo = repo;
7777
currentTask = spawn(command, args, {
7878
cwd: repo,
79-
shell: false,
79+
env: process.env,
80+
shell: process.platform === "win32",
8081
windowsHide: true
8182
});
8283

83-
currentTask.stdout.on("data", (chunk: Buffer) => {
84+
mainWindow?.webContents.send("task:output", { stream: "system", text: `Starting: ${formatCommand(command, args)}\n` });
85+
86+
currentTask.stdout?.on("data", (chunk: Buffer) => {
8487
mainWindow?.webContents.send("task:output", { stream: "stdout", text: chunk.toString("utf8") });
8588
});
8689

87-
currentTask.stderr.on("data", (chunk: Buffer) => {
90+
currentTask.stderr?.on("data", (chunk: Buffer) => {
8891
mainWindow?.webContents.send("task:output", { stream: "stderr", text: chunk.toString("utf8") });
8992
});
9093

94+
let finished = false;
9195
currentTask.once("error", (error) => {
96+
finished = true;
97+
currentTask = undefined;
9298
mainWindow?.webContents.send("task:output", { stream: "stderr", text: `${error.message}\n` });
99+
mainWindow?.webContents.send("task:exit", {
100+
code: null,
101+
signal: "spawn-error",
102+
error: error.message
103+
});
93104
});
94105

95106
currentTask.once("close", (code, signal) => {
107+
if (finished) return;
108+
finished = true;
96109
const reportPath = currentRepo ? findLatestReport(currentRepo) : undefined;
97110
currentTask = undefined;
98111
mainWindow?.webContents.send("task:exit", {
@@ -140,6 +153,18 @@ function findLatestReport(repo: string): string | undefined {
140153
return candidates[0]?.file;
141154
}
142155

156+
function formatCommand(command: string, args: string[]): string {
157+
return [command, ...args].map(quoteArg).join(" ");
158+
}
159+
160+
function normalizeTaskForCli(task: string): string {
161+
return task.trim().replace(/\s+/gu, " ");
162+
}
163+
164+
function quoteArg(value: string): string {
165+
return /[\s"]/u.test(value) ? `"${value.replaceAll('"', '\\"').replaceAll("\n", "\\n")}"` : value;
166+
}
167+
143168
registerIpc();
144169

145170
app

apps/desktop/src/main/preload.cts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { contextBridge, ipcRenderer, type IpcRendererEvent } from "electron";
22

3-
type TaskOutputHandler = (event: { stream: "stdout" | "stderr"; text: string }) => void;
4-
type TaskExitHandler = (event: { code: number | null; signal: string | null; reportPath?: string }) => void;
3+
type TaskOutputHandler = (event: { stream: "stdout" | "stderr" | "system"; text: string }) => void;
4+
type TaskExitHandler = (event: { code: number | null; signal: string | null; reportPath?: string; error?: string }) => void;
55

66
contextBridge.exposeInMainWorld("openCodePlusPlus", {
77
selectRepo: () => ipcRenderer.invoke("repo:select") as Promise<string | undefined>,
@@ -11,12 +11,13 @@ contextBridge.exposeInMainWorld("openCodePlusPlus", {
1111
getLatestReport: (repo: string) => ipcRenderer.invoke("report:latest", repo) as Promise<string | undefined>,
1212
openLatestReport: (repo: string) => ipcRenderer.invoke("report:open", repo) as Promise<{ opened: boolean; path?: string; error?: string }>,
1313
onTaskOutput: (handler: TaskOutputHandler) => {
14-
const listener = (_event: IpcRendererEvent, payload: { stream: "stdout" | "stderr"; text: string }) => handler(payload);
14+
const listener = (_event: IpcRendererEvent, payload: { stream: "stdout" | "stderr" | "system"; text: string }) => handler(payload);
1515
ipcRenderer.on("task:output", listener);
1616
return () => ipcRenderer.off("task:output", listener);
1717
},
1818
onTaskExit: (handler: TaskExitHandler) => {
19-
const listener = (_event: IpcRendererEvent, payload: { code: number | null; signal: string | null; reportPath?: string }) => handler(payload);
19+
const listener = (_event: IpcRendererEvent, payload: { code: number | null; signal: string | null; reportPath?: string; error?: string }) =>
20+
handler(payload);
2021
ipcRenderer.on("task:exit", listener);
2122
return () => ipcRenderer.off("task:exit", listener);
2223
}

apps/desktop/src/preload/global.d.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ declare global {
88
stopTask: () => Promise<{ stopped: boolean }>;
99
getLatestReport: (repo: string) => Promise<string | undefined>;
1010
openLatestReport: (repo: string) => Promise<{ opened: boolean; path?: string; error?: string }>;
11-
onTaskOutput: (handler: (event: { stream: "stdout" | "stderr"; text: string }) => void) => () => void;
12-
onTaskExit: (handler: (event: { code: number | null; signal: string | null; reportPath?: string }) => void) => () => void;
11+
onTaskOutput: (handler: (event: { stream: "stdout" | "stderr" | "system"; text: string }) => void) => () => void;
12+
onTaskExit: (handler: (event: { code: number | null; signal: string | null; reportPath?: string; error?: string }) => void) => () => void;
1313
};
1414
}
1515
}

apps/desktop/src/renderer/src/App.tsx

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,15 @@ function App() {
2727
const offExit = bridge.onTaskExit((event) => {
2828
setRunning(false);
2929
setReportPath(event.reportPath);
30+
if (event.error) {
31+
appendLog("stderr", `${event.error}\n`);
32+
}
3033
appendLog("system", `\nTask exited with ${event.code === null ? `signal ${event.signal ?? "unknown"}` : `code ${event.code}`}\n`);
31-
setStatus(event.reportPath ? "Task finished. A report is ready to open." : "Task finished. No report was found yet.");
34+
if (event.error) {
35+
setStatus(`Task failed to start: ${event.error}`);
36+
} else {
37+
setStatus(event.reportPath ? "Task finished. A report is ready to open." : "Task finished. No report was found yet.");
38+
}
3239
});
3340
return () => {
3441
offOutput();
@@ -41,8 +48,9 @@ function App() {
4148
}, [logs]);
4249

4350
const commandPreview = useMemo(() => {
44-
if (!repo || !task.trim()) return 'opencode-plusplus.cmd oc run "<task>" --repo "<repo>" --max-loops 2';
45-
return `opencode-plusplus.cmd oc run "${task.trim()}" --repo "${repo}" --max-loops 2`;
51+
const normalizedTask = normalizeTaskForCli(task);
52+
if (!repo || !normalizedTask) return 'opencode-plusplus.cmd oc run "<task>" --repo "<repo>" --max-loops 2';
53+
return `opencode-plusplus.cmd oc run "${normalizedTask}" --repo "${repo}" --max-loops 2`;
4654
}, [repo, task]);
4755

4856
if (!bridge) {
@@ -77,15 +85,25 @@ function App() {
7785
async function runTask(): Promise<void> {
7886
setLogs([]);
7987
setReportPath(undefined);
80-
const result = await bridge.runTask({ repo, task });
81-
if (result.error) {
82-
appendLog("stderr", `${result.error}\n`);
83-
setStatus(result.error);
84-
return;
85-
}
8688
setRunning(true);
87-
setStatus(`Running task with PID ${result.pid ?? "unknown"}.`);
88-
appendLog("system", `$ ${result.command} ${(result.args ?? []).map(quoteArg).join(" ")}\n\n`);
89+
setStatus("Starting task...");
90+
appendLog("system", "Starting task...\n");
91+
try {
92+
const result = await bridge.runTask({ repo, task });
93+
if (result.error) {
94+
setRunning(false);
95+
appendLog("stderr", `${result.error}\n`);
96+
setStatus(result.error);
97+
return;
98+
}
99+
setStatus(`Running task with PID ${result.pid ?? "unknown"}.`);
100+
appendLog("system", `$ ${result.command} ${(result.args ?? []).map(quoteArg).join(" ")}\n\n`);
101+
} catch (error) {
102+
setRunning(false);
103+
const message = error instanceof Error ? error.message : String(error);
104+
appendLog("stderr", `${message}\n`);
105+
setStatus(`Task failed to start: ${message}`);
106+
}
89107
}
90108

91109
async function stopTask(): Promise<void> {
@@ -185,7 +203,11 @@ function App() {
185203
}
186204

187205
function quoteArg(value: string): string {
188-
return /\s/.test(value) ? `"${value.replaceAll('"', '\\"')}"` : value;
206+
return /\s/u.test(value) ? `"${value.replaceAll('"', '\\"').replaceAll("\n", "\\n")}"` : value;
207+
}
208+
209+
function normalizeTaskForCli(task: string): string {
210+
return task.trim().replace(/\s+/gu, " ");
189211
}
190212

191213
createRoot(document.getElementById("root") as HTMLElement).render(<App />);

0 commit comments

Comments
 (0)