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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user