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
+27 -5
View File
@@ -1,6 +1,11 @@
import { contextBridge, ipcRenderer } from 'electron'
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
import type { DdevProjectDetail, DdevProjectSummary } from '../shared/types'
import type {
DdevProjectDetail,
DdevProjectSummary,
TerminalDataEvent,
TerminalExitEvent
} from '../shared/types'
// Custom APIs for renderer
const api = {
@@ -8,9 +13,26 @@ const api = {
list: (): Promise<DdevProjectSummary[]> => ipcRenderer.invoke('projects:list'),
describe: (name: string): Promise<DdevProjectDetail> =>
ipcRenderer.invoke('projects:describe', name),
start: (name: string): Promise<void> => ipcRenderer.invoke('projects:start', name),
stop: (name: string): Promise<void> => ipcRenderer.invoke('projects:stop', name),
restart: (name: string): Promise<void> => ipcRenderer.invoke('projects:restart', name)
start: (operationId: string, name: string): Promise<void> =>
ipcRenderer.invoke('projects:start', operationId, name),
stop: (operationId: string, name: string): Promise<void> =>
ipcRenderer.invoke('projects:stop', operationId, name),
restart: (operationId: string, name: string): Promise<void> =>
ipcRenderer.invoke('projects:restart', operationId, name)
},
terminal: {
cancel: (operationId: string): Promise<boolean> =>
ipcRenderer.invoke('terminal:cancel', operationId),
onData: (callback: (event: TerminalDataEvent) => void): (() => void) => {
const listener = (_event: IpcRendererEvent, data: TerminalDataEvent): void => callback(data)
ipcRenderer.on('terminal:data', listener)
return () => ipcRenderer.removeListener('terminal:data', listener)
},
onExit: (callback: (event: TerminalExitEvent) => void): (() => void) => {
const listener = (_event: IpcRendererEvent, data: TerminalExitEvent): void => callback(data)
ipcRenderer.on('terminal:exit', listener)
return () => ipcRenderer.removeListener('terminal:exit', listener)
}
}
}