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 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 // ddev sometimes prints non-JSON progress lines before the final JSON envelope
// (opt-in prompts, deprecation notices); the envelope is always the last line. // (opt-in prompts, deprecation notices); the envelope is always the last line.
function tryParseLastJsonLine<T>(output: string): T | null { 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> { export async function describeProject(name: string): Promise<DdevProjectDetail> {
return runDdevRead<DdevProjectDetail>(['describe', name]) 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 { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset' import icon from '../../resources/icon.png?asset'
import { registerProjectsIpc } from './ipc/projects' import { registerProjectsIpc } from './ipc/projects'
import { registerTerminalIpc } from './ipc/terminal'
function createWindow(): void { function createWindow(): void {
// Create the browser window. // Create the browser window.
@@ -52,6 +53,7 @@ app.whenReady().then(() => {
}) })
registerProjectsIpc() registerProjectsIpc()
registerTerminalIpc()
createWindow() createWindow()
+11 -4
View File
@@ -1,10 +1,17 @@
import { ipcMain } from 'electron' 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 { export function registerProjectsIpc(): void {
ipcMain.handle('projects:list', () => listProjects()) ipcMain.handle('projects:list', () => listProjects())
ipcMain.handle('projects:describe', (_event, name: string) => describeProject(name)) ipcMain.handle('projects:describe', (_event, name: string) => describeProject(name))
ipcMain.handle('projects:start', (_event, name: string) => startProject(name)) ipcMain.handle('projects:start', (event, operationId: string, name: string) =>
ipcMain.handle('projects:stop', (_event, name: string) => stopProject(name)) runStreamed(operationId, ['start', name], event.sender)
ipcMain.handle('projects:restart', (_event, name: string) => restartProject(name)) )
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))
}
+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 { 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 // Custom APIs for renderer
const api = { const api = {
@@ -8,9 +13,26 @@ const api = {
list: (): Promise<DdevProjectSummary[]> => ipcRenderer.invoke('projects:list'), list: (): Promise<DdevProjectSummary[]> => ipcRenderer.invoke('projects:list'),
describe: (name: string): Promise<DdevProjectDetail> => describe: (name: string): Promise<DdevProjectDetail> =>
ipcRenderer.invoke('projects:describe', name), ipcRenderer.invoke('projects:describe', name),
start: (name: string): Promise<void> => ipcRenderer.invoke('projects:start', name), start: (operationId: string, name: string): Promise<void> =>
stop: (name: string): Promise<void> => ipcRenderer.invoke('projects:stop', name), ipcRenderer.invoke('projects:start', operationId, name),
restart: (name: string): Promise<void> => ipcRenderer.invoke('projects:restart', 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)
}
} }
} }
+5
View File
@@ -10,6 +10,11 @@ vi.stubGlobal('api', {
start: vi.fn(), start: vi.fn(),
stop: vi.fn(), stop: vi.fn(),
restart: vi.fn() restart: vi.fn()
},
terminal: {
cancel: vi.fn(),
onData: vi.fn().mockReturnValue(() => {}),
onExit: vi.fn().mockReturnValue(() => {})
} }
}) })
+11 -1
View File
@@ -1,12 +1,18 @@
import { ProjectDetail } from './components/projects/ProjectDetail' import { ProjectDetail } from './components/projects/ProjectDetail'
import { ProjectList } from './components/projects/ProjectList' import { ProjectList } from './components/projects/ProjectList'
import { TerminalPanel } from './components/terminal/TerminalPanel'
import { StatusBar } from './components/layout/StatusBar'
import { Toaster } from './components/ui/Toaster'
import { useAppStore } from './stores/appStore' import { useAppStore } from './stores/appStore'
import { useTerminalEvents } from './hooks/useTerminalEvents'
function App(): React.JSX.Element { function App(): React.JSX.Element {
const selectedProjectName = useAppStore((s) => s.selectedProjectName) const selectedProjectName = useAppStore((s) => s.selectedProjectName)
useTerminalEvents()
return ( return (
<div className="flex h-screen w-screen bg-white text-neutral-900 dark:bg-neutral-950 dark:text-neutral-100"> <div className="flex h-screen w-screen flex-col bg-white text-neutral-900 dark:bg-neutral-950 dark:text-neutral-100">
<div className="flex flex-1 overflow-hidden">
<aside className="flex w-72 flex-shrink-0 flex-col border-r border-neutral-200 dark:border-neutral-800"> <aside className="flex w-72 flex-shrink-0 flex-col border-r border-neutral-200 dark:border-neutral-800">
<div className="border-b border-neutral-200 px-4 py-3 dark:border-neutral-800"> <div className="border-b border-neutral-200 px-4 py-3 dark:border-neutral-800">
<h1 className="text-sm font-semibold">Aurora Dockside</h1> <h1 className="text-sm font-semibold">Aurora Dockside</h1>
@@ -25,6 +31,10 @@ function App(): React.JSX.Element {
)} )}
</main> </main>
</div> </div>
<TerminalPanel />
<StatusBar />
<Toaster />
</div>
) )
} }
@@ -0,0 +1,40 @@
import { Loader2, X } from 'lucide-react'
import { useStatusStore } from '../../stores/statusStore'
import { useTerminalStore } from '../../stores/terminalStore'
export function StatusBar(): React.JSX.Element {
const operationId = useStatusStore((s) => s.operationId)
const label = useStatusStore((s) => s.label)
const setActiveOperation = useTerminalStore((s) => s.setActiveOperation)
const setPanelOpen = useTerminalStore((s) => s.setPanelOpen)
return (
<footer className="flex h-8 flex-shrink-0 items-center justify-between border-t border-neutral-200 bg-neutral-50 px-3 text-xs text-neutral-500 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-400">
{operationId && label ? (
<>
<button
type="button"
onClick={() => {
setActiveOperation(operationId)
setPanelOpen(true)
}}
className="flex items-center gap-1.5 hover:text-neutral-900 dark:hover:text-neutral-100"
>
<Loader2 size={12} className="animate-spin" />
{label}
</button>
<button
type="button"
onClick={() => window.api.terminal.cancel(operationId)}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950"
>
<X size={12} />
Cancel
</button>
</>
) : (
<span>Ready</span>
)}
</footer>
)
}
@@ -0,0 +1,40 @@
import { useEffect, useRef } from 'react'
import { X } from 'lucide-react'
import { useTerminalStore } from '../../stores/terminalStore'
export function TerminalPanel(): React.JSX.Element | null {
const isPanelOpen = useTerminalStore((s) => s.isPanelOpen)
const activeOperationId = useTerminalStore((s) => s.activeOperationId)
const operation = useTerminalStore((s) =>
s.activeOperationId ? s.operations[s.activeOperationId] : null
)
const setPanelOpen = useTerminalStore((s) => s.setPanelOpen)
const scrollRef = useRef<HTMLDivElement>(null)
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
}, [operation?.lines.length])
if (!isPanelOpen || !activeOperationId || !operation) return null
return (
<div className="flex h-64 flex-shrink-0 flex-col border-t border-neutral-200 bg-neutral-950 dark:border-neutral-800">
<div className="flex items-center justify-between border-b border-neutral-800 px-3 py-1.5">
<span className="text-xs font-medium text-neutral-300">{operation.label}</span>
<button
type="button"
onClick={() => setPanelOpen(false)}
className="rounded p-1 text-neutral-400 hover:bg-neutral-800 hover:text-neutral-200"
>
<X size={14} />
</button>
</div>
<div
ref={scrollRef}
className="flex-1 overflow-y-auto px-3 py-2 font-mono text-xs text-neutral-200"
>
<pre className="whitespace-pre-wrap">{operation.lines.join('')}</pre>
</div>
</div>
)
}
@@ -0,0 +1,45 @@
import { useEffect } from 'react'
import { CheckCircle2, XCircle } from 'lucide-react'
import { clsx } from 'clsx'
import { useToastStore, type Toast } from '../../stores/toastStore'
const AUTO_DISMISS_MS = 4000
function ToastItem({ toast }: { toast: Toast }): React.JSX.Element {
const removeToast = useToastStore((s) => s.removeToast)
useEffect(() => {
const timer = setTimeout(() => removeToast(toast.id), AUTO_DISMISS_MS)
return () => clearTimeout(timer)
}, [toast.id, removeToast])
const isSuccess = toast.variant === 'success'
return (
<div
className={clsx(
'flex items-center gap-2 rounded-lg border px-3 py-2 text-sm shadow-lg backdrop-blur',
isSuccess
? 'border-emerald-200 bg-emerald-50/95 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/95 dark:text-emerald-300'
: 'border-red-200 bg-red-50/95 text-red-800 dark:border-red-900 dark:bg-red-950/95 dark:text-red-300'
)}
>
{isSuccess ? <CheckCircle2 size={16} /> : <XCircle size={16} />}
{toast.message}
</div>
)
}
export function Toaster(): React.JSX.Element {
const toasts = useToastStore((s) => s.toasts)
return (
<div className="pointer-events-none fixed right-4 top-4 z-50 flex flex-col gap-2">
{toasts.map((toast) => (
<div key={toast.id} className="pointer-events-auto">
<ToastItem toast={toast} />
</div>
))}
</div>
)
}
+24 -6
View File
@@ -6,6 +6,8 @@ import {
type UseQueryResult type UseQueryResult
} from '@tanstack/react-query' } from '@tanstack/react-query'
import type { DdevProjectDetail, DdevProjectSummary } from '@shared/types' import type { DdevProjectDetail, DdevProjectSummary } from '@shared/types'
import { useTerminalStore } from '../stores/terminalStore'
import { useStatusStore } from '../stores/statusStore'
const PROJECTS_KEY = ['projects'] as const const PROJECTS_KEY = ['projects'] as const
const projectDetailKey = (name: string): readonly [string, string] => ['project', name] as const const projectDetailKey = (name: string): readonly [string, string] => ['project', name] as const
@@ -27,13 +29,23 @@ export function useProjectDetail(name: string | null): UseQueryResult<DdevProjec
}) })
} }
// Runs a streamed ddev command (start/stop/restart) for a project. Generates
// the operationId here so the terminal panel and status bar can start
// tracking it before the IPC call even resolves.
function useProjectAction( function useProjectAction(
action: (name: string) => Promise<void> verb: string,
action: (operationId: string, name: string) => Promise<void>
): UseMutationResult<void, Error, string> { ): UseMutationResult<void, Error, string> {
const queryClient = useQueryClient() const queryClient = useQueryClient()
return useMutation({ return useMutation({
mutationFn: action, mutationFn: async (name: string) => {
onSuccess: (_data, name) => { const operationId = crypto.randomUUID()
const label = `${verb} ${name}`
useTerminalStore.getState().startOperation(operationId, label)
useStatusStore.getState().begin(operationId, label)
await action(operationId, name)
},
onSettled: (_data, _error, name) => {
queryClient.invalidateQueries({ queryKey: PROJECTS_KEY }) queryClient.invalidateQueries({ queryKey: PROJECTS_KEY })
queryClient.invalidateQueries({ queryKey: projectDetailKey(name) }) queryClient.invalidateQueries({ queryKey: projectDetailKey(name) })
} }
@@ -41,13 +53,19 @@ function useProjectAction(
} }
export function useStartProject(): UseMutationResult<void, Error, string> { export function useStartProject(): UseMutationResult<void, Error, string> {
return useProjectAction((name) => window.api.projects.start(name)) return useProjectAction('Start', (operationId, name) =>
window.api.projects.start(operationId, name)
)
} }
export function useStopProject(): UseMutationResult<void, Error, string> { export function useStopProject(): UseMutationResult<void, Error, string> {
return useProjectAction((name) => window.api.projects.stop(name)) return useProjectAction('Stop', (operationId, name) =>
window.api.projects.stop(operationId, name)
)
} }
export function useRestartProject(): UseMutationResult<void, Error, string> { export function useRestartProject(): UseMutationResult<void, Error, string> {
return useProjectAction((name) => window.api.projects.restart(name)) return useProjectAction('Restart', (operationId, name) =>
window.api.projects.restart(operationId, name)
)
} }
@@ -0,0 +1,41 @@
import { useEffect } from 'react'
import { useTerminalStore } from '../stores/terminalStore'
import { useStatusStore } from '../stores/statusStore'
import { useToastStore } from '../stores/toastStore'
// Wires the main process's terminal:data / terminal:exit IPC events into the
// terminal/status/toast stores. Mount once near the app root — every
// long-running ddev command (start/stop/restart, and later snapshots/addons)
// flows through this same event stream regardless of which mutation kicked
// it off, so this is the single place that owns "what happens when a
// command finishes."
export function useTerminalEvents(): void {
useEffect(() => {
const unsubData = window.api.terminal.onData(({ operationId, chunk }) => {
useTerminalStore.getState().appendChunk(operationId, chunk)
})
const unsubExit = window.api.terminal.onExit(({ operationId, exitCode, cancelled }) => {
const op = useTerminalStore.getState().operations[operationId]
const status = cancelled ? 'cancelled' : exitCode === 0 ? 'success' : 'error'
useTerminalStore.getState().finishOperation(operationId, status, exitCode)
if (useStatusStore.getState().operationId === operationId) {
useStatusStore.getState().end()
}
if (op) {
if (status === 'success') {
useToastStore.getState().addToast('success', `${op.label} succeeded`)
} else if (status === 'error') {
useToastStore.getState().addToast('error', `${op.label} failed`)
}
}
})
return () => {
unsubData()
unsubExit()
}
}, [])
}
+15
View File
@@ -0,0 +1,15 @@
import { create } from 'zustand'
interface StatusState {
operationId: string | null
label: string | null
begin: (operationId: string, label: string) => void
end: () => void
}
export const useStatusStore = create<StatusState>((set) => ({
operationId: null,
label: null,
begin: (operationId, label): void => set({ operationId, label }),
end: (): void => set({ operationId: null, label: null })
}))
+65
View File
@@ -0,0 +1,65 @@
import { create } from 'zustand'
export type OperationStatus = 'running' | 'success' | 'error' | 'cancelled'
export interface TerminalOperation {
id: string
label: string
lines: string[]
status: OperationStatus
exitCode: number | null
}
interface TerminalState {
operations: Record<string, TerminalOperation>
activeOperationId: string | null
isPanelOpen: boolean
startOperation: (id: string, label: string) => void
appendChunk: (id: string, chunk: string) => void
finishOperation: (
id: string,
status: Exclude<OperationStatus, 'running'>,
exitCode: number | null
) => void
setActiveOperation: (id: string | null) => void
setPanelOpen: (open: boolean) => void
}
export const useTerminalStore = create<TerminalState>((set) => ({
operations: {},
activeOperationId: null,
isPanelOpen: false,
startOperation: (id, label): void =>
set((state) => ({
operations: {
...state.operations,
[id]: { id, label, lines: [], status: 'running', exitCode: null }
},
activeOperationId: id,
isPanelOpen: true
})),
appendChunk: (id, chunk): void =>
set((state) => {
const op = state.operations[id]
if (!op) return state
return {
operations: {
...state.operations,
[id]: { ...op, lines: [...op.lines, chunk] }
}
}
}),
finishOperation: (id, status, exitCode): void =>
set((state) => {
const op = state.operations[id]
if (!op) return state
return {
operations: {
...state.operations,
[id]: { ...op, status, exitCode }
}
}
}),
setActiveOperation: (id): void => set({ activeOperationId: id }),
setPanelOpen: (open): void => set({ isPanelOpen: open })
}))
+24
View File
@@ -0,0 +1,24 @@
import { create } from 'zustand'
export type ToastVariant = 'success' | 'error'
export interface Toast {
id: string
variant: ToastVariant
message: string
}
interface ToastState {
toasts: Toast[]
addToast: (variant: ToastVariant, message: string) => void
removeToast: (id: string) => void
}
export const useToastStore = create<ToastState>((set) => ({
toasts: [],
addToast: (variant, message): void =>
set((state) => ({
toasts: [...state.toasts, { id: crypto.randomUUID(), variant, message }]
})),
removeToast: (id): void => set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) }))
}))
+8 -6
View File
@@ -67,12 +67,14 @@ export interface DdevProjectDetail extends DdevProjectSummary {
xdebug_enabled: boolean xdebug_enabled: boolean
} }
export interface DdevCommandResult { export interface TerminalDataEvent {
ok: boolean operationId: string
message: string stream: 'stdout' | 'stderr'
chunk: string
} }
export interface DdevError { export interface TerminalExitEvent {
ok: false operationId: string
message: string exitCode: number | null
cancelled: boolean
} }