From 199c4d254e0e3d8ae6f04dc0f4651c7e25a7984d Mon Sep 17 00:00:00 2001 From: R3ap3R Date: Sun, 2 Aug 2026 23:40:29 -0500 Subject: [PATCH] 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). --- src/main/commandRunner.ts | 98 +++++++++++++++++++ src/main/ddev.ts | 17 ---- src/main/index.ts | 2 + src/main/ipc/projects.ts | 15 ++- src/main/ipc/terminal.ts | 6 ++ src/preload/index.ts | 32 +++++- src/renderer/src/App.test.tsx | 5 + src/renderer/src/App.tsx | 44 +++++---- .../src/components/layout/StatusBar.tsx | 40 ++++++++ .../src/components/terminal/TerminalPanel.tsx | 40 ++++++++ src/renderer/src/components/ui/Toaster.tsx | 45 +++++++++ src/renderer/src/hooks/useDdev.ts | 30 ++++-- src/renderer/src/hooks/useTerminalEvents.ts | 41 ++++++++ src/renderer/src/stores/statusStore.ts | 15 +++ src/renderer/src/stores/terminalStore.ts | 65 ++++++++++++ src/renderer/src/stores/toastStore.ts | 24 +++++ src/shared/types.ts | 14 +-- 17 files changed, 478 insertions(+), 55 deletions(-) create mode 100644 src/main/commandRunner.ts create mode 100644 src/main/ipc/terminal.ts create mode 100644 src/renderer/src/components/layout/StatusBar.tsx create mode 100644 src/renderer/src/components/terminal/TerminalPanel.tsx create mode 100644 src/renderer/src/components/ui/Toaster.tsx create mode 100644 src/renderer/src/hooks/useTerminalEvents.ts create mode 100644 src/renderer/src/stores/statusStore.ts create mode 100644 src/renderer/src/stores/terminalStore.ts create mode 100644 src/renderer/src/stores/toastStore.ts diff --git a/src/main/commandRunner.ts b/src/main/commandRunner.ts new file mode 100644 index 0000000..1868855 --- /dev/null +++ b/src/main/commandRunner.ts @@ -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() +const cancelledIds = new Set() + +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 ` 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 { + 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 +} diff --git a/src/main/ddev.ts b/src/main/ddev.ts index fabce72..b4aec62 100644 --- a/src/main/ddev.ts +++ b/src/main/ddev.ts @@ -67,11 +67,6 @@ async function runDdevRead(args: string[]): Promise { return envelope.raw } -// Mutation commands (start/stop/restart): success is just a non-fatal `msg`, no `raw`. -async function runDdevCommand(args: string[]): Promise { - await execDdev(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(output: string): T | null { @@ -96,15 +91,3 @@ export async function listProjects(): Promise { export async function describeProject(name: string): Promise { return runDdevRead(['describe', name]) } - -export async function startProject(name: string): Promise { - await runDdevCommand(['start', name]) -} - -export async function stopProject(name: string): Promise { - await runDdevCommand(['stop', name]) -} - -export async function restartProject(name: string): Promise { - await runDdevCommand(['restart', name]) -} diff --git a/src/main/index.ts b/src/main/index.ts index 061c8c9..a8e7514 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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() diff --git a/src/main/ipc/projects.ts b/src/main/ipc/projects.ts index 7b4bc1a..4993c93 100644 --- a/src/main/ipc/projects.ts +++ b/src/main/ipc/projects.ts @@ -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) + ) } diff --git a/src/main/ipc/terminal.ts b/src/main/ipc/terminal.ts new file mode 100644 index 0000000..ee31703 --- /dev/null +++ b/src/main/ipc/terminal.ts @@ -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)) +} diff --git a/src/preload/index.ts b/src/preload/index.ts index c1ad00f..4fcdf13 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => ipcRenderer.invoke('projects:list'), describe: (name: string): Promise => ipcRenderer.invoke('projects:describe', name), - start: (name: string): Promise => ipcRenderer.invoke('projects:start', name), - stop: (name: string): Promise => ipcRenderer.invoke('projects:stop', name), - restart: (name: string): Promise => ipcRenderer.invoke('projects:restart', name) + start: (operationId: string, name: string): Promise => + ipcRenderer.invoke('projects:start', operationId, name), + stop: (operationId: string, name: string): Promise => + ipcRenderer.invoke('projects:stop', operationId, name), + restart: (operationId: string, name: string): Promise => + ipcRenderer.invoke('projects:restart', operationId, name) + }, + terminal: { + cancel: (operationId: string): Promise => + 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) + } } } diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index fda93c3..fddd8ea 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -10,6 +10,11 @@ vi.stubGlobal('api', { start: vi.fn(), stop: vi.fn(), restart: vi.fn() + }, + terminal: { + cancel: vi.fn(), + onData: vi.fn().mockReturnValue(() => {}), + onExit: vi.fn().mockReturnValue(() => {}) } }) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index fd113aa..6222f3a 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,29 +1,39 @@ import { ProjectDetail } from './components/projects/ProjectDetail' 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 { useTerminalEvents } from './hooks/useTerminalEvents' function App(): React.JSX.Element { const selectedProjectName = useAppStore((s) => s.selectedProjectName) + useTerminalEvents() return ( -
- -
- {selectedProjectName ? ( - - ) : ( -
- Select a project to see its details. +
+
+
+
+ +
+ +
+ {selectedProjectName ? ( + + ) : ( +
+ Select a project to see its details. +
+ )} +
+
+ + + ) } diff --git a/src/renderer/src/components/layout/StatusBar.tsx b/src/renderer/src/components/layout/StatusBar.tsx new file mode 100644 index 0000000..9ea1a27 --- /dev/null +++ b/src/renderer/src/components/layout/StatusBar.tsx @@ -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 ( +
+ {operationId && label ? ( + <> + + + + ) : ( + Ready + )} +
+ ) +} diff --git a/src/renderer/src/components/terminal/TerminalPanel.tsx b/src/renderer/src/components/terminal/TerminalPanel.tsx new file mode 100644 index 0000000..454ff54 --- /dev/null +++ b/src/renderer/src/components/terminal/TerminalPanel.tsx @@ -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(null) + + useEffect(() => { + scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }) + }, [operation?.lines.length]) + + if (!isPanelOpen || !activeOperationId || !operation) return null + + return ( +
+
+ {operation.label} + +
+
+
{operation.lines.join('')}
+
+
+ ) +} diff --git a/src/renderer/src/components/ui/Toaster.tsx b/src/renderer/src/components/ui/Toaster.tsx new file mode 100644 index 0000000..4bc59d1 --- /dev/null +++ b/src/renderer/src/components/ui/Toaster.tsx @@ -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 ( +
+ {isSuccess ? : } + {toast.message} +
+ ) +} + +export function Toaster(): React.JSX.Element { + const toasts = useToastStore((s) => s.toasts) + + return ( +
+ {toasts.map((toast) => ( +
+ +
+ ))} +
+ ) +} diff --git a/src/renderer/src/hooks/useDdev.ts b/src/renderer/src/hooks/useDdev.ts index a3f720a..eee0194 100644 --- a/src/renderer/src/hooks/useDdev.ts +++ b/src/renderer/src/hooks/useDdev.ts @@ -6,6 +6,8 @@ import { type UseQueryResult } from '@tanstack/react-query' import type { DdevProjectDetail, DdevProjectSummary } from '@shared/types' +import { useTerminalStore } from '../stores/terminalStore' +import { useStatusStore } from '../stores/statusStore' const PROJECTS_KEY = ['projects'] as const const projectDetailKey = (name: string): readonly [string, string] => ['project', name] as const @@ -27,13 +29,23 @@ export function useProjectDetail(name: string | null): UseQueryResult Promise + verb: string, + action: (operationId: string, name: string) => Promise ): UseMutationResult { const queryClient = useQueryClient() return useMutation({ - mutationFn: action, - onSuccess: (_data, name) => { + mutationFn: async (name: string) => { + 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: projectDetailKey(name) }) } @@ -41,13 +53,19 @@ function useProjectAction( } export function useStartProject(): UseMutationResult { - return useProjectAction((name) => window.api.projects.start(name)) + return useProjectAction('Start', (operationId, name) => + window.api.projects.start(operationId, name) + ) } export function useStopProject(): UseMutationResult { - return useProjectAction((name) => window.api.projects.stop(name)) + return useProjectAction('Stop', (operationId, name) => + window.api.projects.stop(operationId, name) + ) } export function useRestartProject(): UseMutationResult { - return useProjectAction((name) => window.api.projects.restart(name)) + return useProjectAction('Restart', (operationId, name) => + window.api.projects.restart(operationId, name) + ) } diff --git a/src/renderer/src/hooks/useTerminalEvents.ts b/src/renderer/src/hooks/useTerminalEvents.ts new file mode 100644 index 0000000..54aa83e --- /dev/null +++ b/src/renderer/src/hooks/useTerminalEvents.ts @@ -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() + } + }, []) +} diff --git a/src/renderer/src/stores/statusStore.ts b/src/renderer/src/stores/statusStore.ts new file mode 100644 index 0000000..e186977 --- /dev/null +++ b/src/renderer/src/stores/statusStore.ts @@ -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((set) => ({ + operationId: null, + label: null, + begin: (operationId, label): void => set({ operationId, label }), + end: (): void => set({ operationId: null, label: null }) +})) diff --git a/src/renderer/src/stores/terminalStore.ts b/src/renderer/src/stores/terminalStore.ts new file mode 100644 index 0000000..e13664d --- /dev/null +++ b/src/renderer/src/stores/terminalStore.ts @@ -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 + activeOperationId: string | null + isPanelOpen: boolean + startOperation: (id: string, label: string) => void + appendChunk: (id: string, chunk: string) => void + finishOperation: ( + id: string, + status: Exclude, + exitCode: number | null + ) => void + setActiveOperation: (id: string | null) => void + setPanelOpen: (open: boolean) => void +} + +export const useTerminalStore = create((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 }) +})) diff --git a/src/renderer/src/stores/toastStore.ts b/src/renderer/src/stores/toastStore.ts new file mode 100644 index 0000000..2d71a3e --- /dev/null +++ b/src/renderer/src/stores/toastStore.ts @@ -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((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) })) +})) diff --git a/src/shared/types.ts b/src/shared/types.ts index 460b4fe..32aaa72 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -67,12 +67,14 @@ export interface DdevProjectDetail extends DdevProjectSummary { xdebug_enabled: boolean } -export interface DdevCommandResult { - ok: boolean - message: string +export interface TerminalDataEvent { + operationId: string + stream: 'stdout' | 'stderr' + chunk: string } -export interface DdevError { - ok: false - message: string +export interface TerminalExitEvent { + operationId: string + exitCode: number | null + cancelled: boolean }