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
|
||||
}
|
||||
|
||||
// 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])
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
+27
-17
@@ -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 (
|
||||
<div className="flex h-screen w-screen 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="border-b border-neutral-200 px-4 py-3 dark:border-neutral-800">
|
||||
<h1 className="text-sm font-semibold">Aurora Dockside</h1>
|
||||
</div>
|
||||
<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 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">
|
||||
<div className="border-b border-neutral-200 px-4 py-3 dark:border-neutral-800">
|
||||
<h1 className="text-sm font-semibold">Aurora Dockside</h1>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<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>
|
||||
)}
|
||||
</main>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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<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(
|
||||
action: (name: string) => Promise<void>
|
||||
verb: string,
|
||||
action: (operationId: string, name: string) => Promise<void>
|
||||
): UseMutationResult<void, Error, string> {
|
||||
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<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> {
|
||||
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> {
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user