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:
@@ -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
|
||||||
|
}
|
||||||
@@ -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])
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(() => {})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+27
-17
@@ -1,29 +1,39 @@
|
|||||||
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">
|
||||||
<aside className="flex w-72 flex-shrink-0 flex-col border-r border-neutral-200 dark:border-neutral-800">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
<div className="border-b border-neutral-200 px-4 py-3 dark:border-neutral-800">
|
<aside className="flex w-72 flex-shrink-0 flex-col border-r border-neutral-200 dark:border-neutral-800">
|
||||||
<h1 className="text-sm font-semibold">Aurora Dockside</h1>
|
<div className="border-b border-neutral-200 px-4 py-3 dark:border-neutral-800">
|
||||||
</div>
|
<h1 className="text-sm font-semibold">Aurora Dockside</h1>
|
||||||
<div className="flex-1 overflow-y-auto">
|
|
||||||
<ProjectList />
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
<main className="flex-1 overflow-y-auto">
|
|
||||||
{selectedProjectName ? (
|
|
||||||
<ProjectDetail name={selectedProjectName} />
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full items-center justify-center text-sm text-neutral-500">
|
|
||||||
Select a project to see its details.
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="flex-1 overflow-y-auto">
|
||||||
</main>
|
<ProjectList />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<main className="flex-1 overflow-y-auto">
|
||||||
|
{selectedProjectName ? (
|
||||||
|
<ProjectDetail name={selectedProjectName} />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-neutral-500">
|
||||||
|
Select a project to see its details.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<TerminalPanel />
|
||||||
|
<StatusBar />
|
||||||
|
<Toaster />
|
||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}))
|
||||||
@@ -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 })
|
||||||
|
}))
|
||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user