Add streaming terminal panel, status bar, and toast notifications

Step 3 of the build plan. Long-running ddev commands (start/stop/
restart) now spawn via commandRunner.ts and stream stdout/stderr to
the renderer over IPC (terminal:data/terminal:exit), instead of the
previous fire-and-forget JSON exec. A single useTerminalEvents hook
owns turning those events into terminal panel lines, status bar
progress, and success/error toasts, decoupled from whichever mutation
triggered the command so later features (snapshots, addons) can reuse
the same pipeline. Cancel is wired end-to-end: the status bar's Cancel
button kills the underlying child process via a tracked operation id.

Verified against the real scratch DDEV project via a CDP driver
script (Electron launched with --remoteDebuggingPort, driven by
clicking real DOM buttons): confirmed live streaming output, the
status bar's spinner/cancel affordance, and that cancelling mid-
restart genuinely interrupts the process rather than just hiding the
UI (only the first output line appears, vs. full output on a normal
run).
This commit is contained in:
R3ap3R
2026-08-02 23:40:29 -05:00
parent 602659c45f
commit 199c4d254e
17 changed files with 478 additions and 55 deletions
+98
View File
@@ -0,0 +1,98 @@
import { spawn, type ChildProcess } from 'child_process'
import type { WebContents } from 'electron'
const EXTRA_PATH_DIRS = ['/opt/homebrew/bin', '/usr/local/bin', '/opt/local/bin']
const ENV_WITH_DDEV_PATH = {
...process.env,
PATH: [...EXTRA_PATH_DIRS, process.env.PATH].join(':')
}
const running = new Map<string, ChildProcess>()
const cancelledIds = new Set<string>()
export class CommandFailedError extends Error {
constructor(
message: string,
public readonly exitCode: number | null
) {
super(message)
this.name = 'CommandFailedError'
}
}
export class CommandCancelledError extends Error {
constructor() {
super('Command was cancelled')
this.name = 'CommandCancelledError'
}
}
// Spawns `ddev <args>` and streams stdout/stderr to the renderer as
// terminal:data events keyed by operationId, so a terminal panel can show
// live progress instead of waiting for the whole command to finish.
export function runStreamed(
operationId: string,
args: string[],
sender: WebContents
): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn('ddev', args, { env: ENV_WITH_DDEV_PATH })
running.set(operationId, child)
const tail: string[] = []
const trackTail = (chunk: string): void => {
tail.push(chunk)
if (tail.length > 20) tail.shift()
}
child.stdout.on('data', (data: Buffer) => {
const chunk = data.toString()
trackTail(chunk)
if (!sender.isDestroyed()) {
sender.send('terminal:data', { operationId, stream: 'stdout', chunk })
}
})
child.stderr.on('data', (data: Buffer) => {
const chunk = data.toString()
trackTail(chunk)
if (!sender.isDestroyed()) {
sender.send('terminal:data', { operationId, stream: 'stderr', chunk })
}
})
child.on('error', (err) => {
running.delete(operationId)
cancelledIds.delete(operationId)
if (!sender.isDestroyed()) {
sender.send('terminal:exit', { operationId, exitCode: null, cancelled: false })
}
reject(new CommandFailedError(err.message, null))
})
child.on('close', (code) => {
running.delete(operationId)
const wasCancelled = cancelledIds.delete(operationId)
if (!sender.isDestroyed()) {
sender.send('terminal:exit', { operationId, exitCode: code, cancelled: wasCancelled })
}
if (wasCancelled) {
reject(new CommandCancelledError())
} else if (code === 0) {
resolve()
} else {
reject(
new CommandFailedError(tail.join('').trim() || `ddev exited with code ${code}`, code)
)
}
})
})
}
export function cancelCommand(operationId: string): boolean {
const child = running.get(operationId)
if (!child) return false
cancelledIds.add(operationId)
child.kill()
return true
}
-17
View File
@@ -67,11 +67,6 @@ async function runDdevRead<T>(args: string[]): Promise<T> {
return envelope.raw
}
// Mutation commands (start/stop/restart): success is just a non-fatal `msg`, no `raw`.
async function runDdevCommand(args: string[]): Promise<void> {
await execDdev<never>(args)
}
// ddev sometimes prints non-JSON progress lines before the final JSON envelope
// (opt-in prompts, deprecation notices); the envelope is always the last line.
function tryParseLastJsonLine<T>(output: string): T | null {
@@ -96,15 +91,3 @@ export async function listProjects(): Promise<DdevProjectSummary[]> {
export async function describeProject(name: string): Promise<DdevProjectDetail> {
return runDdevRead<DdevProjectDetail>(['describe', name])
}
export async function startProject(name: string): Promise<void> {
await runDdevCommand(['start', name])
}
export async function stopProject(name: string): Promise<void> {
await runDdevCommand(['stop', name])
}
export async function restartProject(name: string): Promise<void> {
await runDdevCommand(['restart', name])
}
+2
View File
@@ -3,6 +3,7 @@ import { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'
import { registerProjectsIpc } from './ipc/projects'
import { registerTerminalIpc } from './ipc/terminal'
function createWindow(): void {
// Create the browser window.
@@ -52,6 +53,7 @@ app.whenReady().then(() => {
})
registerProjectsIpc()
registerTerminalIpc()
createWindow()
+11 -4
View File
@@ -1,10 +1,17 @@
import { ipcMain } from 'electron'
import { describeProject, listProjects, restartProject, startProject, stopProject } from '../ddev'
import { describeProject, listProjects } from '../ddev'
import { runStreamed } from '../commandRunner'
export function registerProjectsIpc(): void {
ipcMain.handle('projects:list', () => listProjects())
ipcMain.handle('projects:describe', (_event, name: string) => describeProject(name))
ipcMain.handle('projects:start', (_event, name: string) => startProject(name))
ipcMain.handle('projects:stop', (_event, name: string) => stopProject(name))
ipcMain.handle('projects:restart', (_event, name: string) => restartProject(name))
ipcMain.handle('projects:start', (event, operationId: string, name: string) =>
runStreamed(operationId, ['start', name], event.sender)
)
ipcMain.handle('projects:stop', (event, operationId: string, name: string) =>
runStreamed(operationId, ['stop', name], event.sender)
)
ipcMain.handle('projects:restart', (event, operationId: string, name: string) =>
runStreamed(operationId, ['restart', name], event.sender)
)
}
+6
View File
@@ -0,0 +1,6 @@
import { ipcMain } from 'electron'
import { cancelCommand } from '../commandRunner'
export function registerTerminalIpc(): void {
ipcMain.handle('terminal:cancel', (_event, operationId: string) => cancelCommand(operationId))
}