Add logs tab with real-time server output and consolidate syntax highlighting

- Implement dedicated Logs tab showing stdout/stderr from OpenCode server
- Add log level parsing (INFO, ERROR, WARN, DEBUG) with color coding
- Stream logs from main process to renderer via IPC events
- Persist scroll position and auto-scroll state per instance
- Synchronize instance IDs between renderer and main process
- Consolidate syntax highlighting to single shared highlighter instance
- Optimize markdown rendering with global highlighter initialization
- Fix code block copy button to always appear on right side
- Enable debug logging with --print-logs --log-level DEBUG flags
This commit is contained in:
Shantur Rathore
2025-10-23 11:14:35 +01:00
parent b836086978
commit 3c5c4755b8
13 changed files with 779 additions and 157 deletions

View File

@@ -18,9 +18,9 @@ function generateId(): string {
}
export function setupInstanceIPC(mainWindow: BrowserWindow) {
ipcMain.handle("instance:create", async (event, folder: string) => {
const id = generateId()
processManager.setMainWindow(mainWindow)
ipcMain.handle("instance:create", async (event, id: string, folder: string) => {
const instance: Instance = {
id,
folder,
@@ -32,7 +32,7 @@ export function setupInstanceIPC(mainWindow: BrowserWindow) {
instances.set(id, instance)
try {
const { pid, port } = await processManager.spawn(folder)
const { pid, port } = await processManager.spawn(folder, id)
instance.port = port
instance.pid = pid
@@ -48,7 +48,7 @@ export function setupInstanceIPC(mainWindow: BrowserWindow) {
})
}
return { port, pid }
return { id, port, pid }
} catch (error) {
instance.status = "error"
instance.error = error instanceof Error ? error.message : String(error)

View File

@@ -1,5 +1,5 @@
import { spawn, ChildProcess } from "child_process"
import { app } from "electron"
import { app, BrowserWindow } from "electron"
import { existsSync, statSync } from "fs"
import { execSync } from "child_process"
@@ -15,17 +15,48 @@ interface ProcessMeta {
startTime: number
childProcess: ChildProcess
logs: string[]
instanceId: string
}
class ProcessManager {
private processes = new Map<number, ProcessMeta>()
private mainWindow: BrowserWindow | null = null
async spawn(folder: string): Promise<ProcessInfo> {
setMainWindow(window: BrowserWindow) {
this.mainWindow = window
}
private parseLogLevel(message: string): "info" | "error" | "warn" | "debug" {
const upperMessage = message.toUpperCase()
if (upperMessage.includes("[ERROR]") || upperMessage.includes("ERROR:")) return "error"
if (upperMessage.includes("[WARN]") || upperMessage.includes("WARN:")) return "warn"
if (upperMessage.includes("[DEBUG]") || upperMessage.includes("DEBUG:")) return "debug"
if (upperMessage.includes("[INFO]") || upperMessage.includes("INFO:")) return "info"
return "info"
}
private sendLog(instanceId: string, level: "info" | "error" | "warn" | "debug", message: string) {
if (this.mainWindow && message.trim()) {
const parsedLevel = this.parseLogLevel(message)
this.mainWindow.webContents.send("instance:log", {
id: instanceId,
entry: {
timestamp: Date.now(),
level: parsedLevel,
message: message.trim(),
},
})
}
}
async spawn(folder: string, instanceId: string): Promise<ProcessInfo> {
this.validateFolder(folder)
this.validateOpenCodeBinary()
this.sendLog(instanceId, "info", `Starting OpenCode server for ${folder}...`)
return new Promise((resolve, reject) => {
const child = spawn("opencode", ["serve", "--port", "0"], {
const child = spawn("opencode", ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"], {
cwd: folder,
stdio: ["ignore", "pipe", "pipe"],
env: process.env,
@@ -34,6 +65,7 @@ class ProcessManager {
const timeout = setTimeout(() => {
child.kill("SIGKILL")
this.sendLog(instanceId, "error", "Server startup timeout (10s exceeded)")
reject(new Error("Server startup timeout (10s exceeded)"))
}, 10000)
@@ -49,6 +81,10 @@ class ProcessManager {
stdoutBuffer = lines.pop() || ""
for (const line of lines) {
if (!line.trim()) continue
this.sendLog(instanceId, "info", line)
const portMatch = line.match(/opencode server listening on http:\/\/[^:]+:(\d+)/)
if (portMatch && !portFound) {
portFound = true
@@ -62,13 +98,13 @@ class ProcessManager {
startTime: Date.now(),
childProcess: child,
logs: [line],
instanceId,
}
this.processes.set(child.pid!, meta)
resolve({ pid: child.pid!, port })
}
const logEntry = { timestamp: Date.now(), level: "info", message: line }
const meta = this.processes.get(child.pid!)
if (meta) {
meta.logs.push(line)
@@ -84,7 +120,10 @@ class ProcessManager {
stderrBuffer = lines.pop() || ""
for (const line of lines) {
const logEntry = { timestamp: Date.now(), level: "error", message: line }
if (!line.trim()) continue
this.sendLog(instanceId, "error", line)
const meta = this.processes.get(child.pid!)
if (meta) {
meta.logs.push(line)

View File

@@ -2,20 +2,23 @@ import { contextBridge, ipcRenderer } from "electron"
export interface ElectronAPI {
selectFolder: () => Promise<string | null>
createInstance: (folder: string) => Promise<{ port: number; pid: number }>
createInstance: (id: string, folder: string) => Promise<{ id: string; port: number; pid: number }>
stopInstance: (pid: number) => Promise<void>
onInstanceStarted: (callback: (data: { id: string; port: number; pid: number }) => void) => void
onInstanceError: (callback: (data: { id: string; error: string }) => void) => void
onInstanceStopped: (callback: (data: { id: string }) => void) => void
onInstanceLog: (
callback: (data: { id: string; entry: { timestamp: number; level: string; message: string } }) => void,
callback: (data: {
id: string
entry: { timestamp: number; level: "info" | "error" | "warn" | "debug"; message: string }
}) => void,
) => void
onNewInstance: (callback: () => void) => void
}
const electronAPI: ElectronAPI = {
selectFolder: () => ipcRenderer.invoke("dialog:selectFolder"),
createInstance: (folder: string) => ipcRenderer.invoke("instance:create", folder),
createInstance: (id: string, folder: string) => ipcRenderer.invoke("instance:create", id, folder),
stopInstance: (pid: number) => ipcRenderer.invoke("instance:stop", pid),
onInstanceStarted: (callback) => {
ipcRenderer.on("instance:started", (_, data) => callback(data))