diff --git a/src/main/commandRunner.ts b/src/main/commandRunner.ts index 2cdade7..613c13a 100644 --- a/src/main/commandRunner.ts +++ b/src/main/commandRunner.ts @@ -97,3 +97,39 @@ export function cancelCommand(operationId: string): boolean { child.kill() return true } + +// Log tailing (`ddev logs -f`) runs indefinitely rather than completing, so +// it doesn't fit runStreamed's resolve/reject-on-exit model or the terminal +// panel's operation semantics (no "success"/"failure" toast makes sense for +// an open-ended stream). It shares the same running/cancel bookkeeping — +// stopping a log stream reuses cancelCommand — but pushes chunks over a +// dedicated logs:data channel instead of terminal:data. +export function startLogStream(operationId: string, args: string[], sender: WebContents): void { + const child = spawn('ddev', args, { env: ENV_WITH_DDEV_PATH }) + running.set(operationId, child) + + const forward = (stream: 'stdout' | 'stderr') => (data: Buffer) => { + if (!sender.isDestroyed()) { + sender.send('logs:data', { operationId, stream, chunk: data.toString() }) + } + } + child.stdout.on('data', forward('stdout')) + child.stderr.on('data', forward('stderr')) + + const finish = (): void => { + running.delete(operationId) + cancelledIds.delete(operationId) + if (!sender.isDestroyed()) { + sender.send('logs:exit', { operationId }) + } + } + child.on('close', finish) + child.on('error', finish) +} + +export function killAllRunningCommands(): void { + for (const child of running.values()) { + child.kill() + } + running.clear() +} diff --git a/src/main/index.ts b/src/main/index.ts index 8a31fb7..48c57a2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -6,6 +6,8 @@ import { registerProjectsIpc } from './ipc/projects' import { registerTerminalIpc } from './ipc/terminal' import { registerDatabaseIpc } from './ipc/database' import { registerAddonsIpc } from './ipc/addons' +import { registerLogsIpc } from './ipc/logs' +import { killAllRunningCommands } from './commandRunner' function createWindow(): void { // Create the browser window. @@ -58,6 +60,7 @@ app.whenReady().then(() => { registerTerminalIpc() registerDatabaseIpc() registerAddonsIpc() + registerLogsIpc() createWindow() @@ -77,5 +80,11 @@ app.on('window-all-closed', () => { } }) +// Kill any still-running ddev processes (e.g. an open `ddev logs -f` stream) +// so they don't linger as orphans after the app exits. +app.on('before-quit', () => { + killAllRunningCommands() +}) + // In this file you can include the rest of your app's specific main process // code. You can also put them in separate files and require them here. diff --git a/src/main/ipc/logs.ts b/src/main/ipc/logs.ts new file mode 100644 index 0000000..d1c6c1f --- /dev/null +++ b/src/main/ipc/logs.ts @@ -0,0 +1,8 @@ +import { ipcMain } from 'electron' +import { startLogStream } from '../commandRunner' + +export function registerLogsIpc(): void { + ipcMain.handle('logs:start', (event, operationId: string, name: string, service: string) => + startLogStream(operationId, ['logs', name, '-f', '-s', service, '--tail', '200'], event.sender) + ) +} diff --git a/src/preload/index.ts b/src/preload/index.ts index a1c8020..e4dc01f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -6,6 +6,8 @@ import type { DdevProjectDetail, DdevProjectSummary, DdevSnapshot, + LogDataEvent, + LogExitEvent, TerminalDataEvent, TerminalExitEvent } from '../shared/types' @@ -63,6 +65,20 @@ const api = { ipcRenderer.invoke('addons:install', operationId, name, addonRepo), remove: (operationId: string, name: string, addonName: string): Promise => ipcRenderer.invoke('addons:remove', operationId, name, addonName) + }, + logs: { + start: (operationId: string, name: string, service: string): Promise => + ipcRenderer.invoke('logs:start', operationId, name, service), + onData: (callback: (event: LogDataEvent) => void): (() => void) => { + const listener = (_event: IpcRendererEvent, data: LogDataEvent): void => callback(data) + ipcRenderer.on('logs:data', listener) + return () => ipcRenderer.removeListener('logs:data', listener) + }, + onExit: (callback: (event: LogExitEvent) => void): (() => void) => { + const listener = (_event: IpcRendererEvent, data: LogExitEvent): void => callback(data) + ipcRenderer.on('logs:exit', listener) + return () => ipcRenderer.removeListener('logs:exit', listener) + } } } diff --git a/src/renderer/src/components/logs/LogViewer.tsx b/src/renderer/src/components/logs/LogViewer.tsx new file mode 100644 index 0000000..1aad5a7 --- /dev/null +++ b/src/renderer/src/components/logs/LogViewer.tsx @@ -0,0 +1,122 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { X } from 'lucide-react' +import { clsx } from 'clsx' +import { useLogStream } from '../../hooks/useLogStream' + +function LogPane({ + name, + service, + filter +}: { + name: string + service: string + filter: string +}): React.JSX.Element { + const { lines, isStreaming } = useLogStream(name, service) + const scrollRef = useRef(null) + + const filteredLines = useMemo(() => { + if (!filter.trim()) return lines + const q = filter.toLowerCase() + return lines.filter((line) => line.text.toLowerCase().includes(q)) + }, [lines, filter]) + + useEffect(() => { + scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }) + }, [filteredLines.length]) + + return ( + <> + + + {isStreaming ? 'streaming' : 'stopped'} + +
+ {filteredLines.length === 0 ? ( +

+ {lines.length === 0 ? 'Waiting for log output…' : 'No lines match the filter.'} +

+ ) : ( +
+ {filteredLines.map((line, i) => ( +
+ {line.text} +
+ ))} +
+ )} +
+ + ) +} + +export function LogViewer({ + name, + services, + onClose +}: { + name: string + services: string[] + onClose: () => void +}): React.JSX.Element { + const [service, setService] = useState(services[0] ?? 'web') + const [filter, setFilter] = useState('') + + return ( +
+
+
+
+

Logs — {name}

+
+ +
+ +
+ + setFilter(e.target.value)} + placeholder="Filter logs…" + className="flex-1 rounded-md border border-neutral-300 px-3 py-1.5 text-sm dark:border-neutral-700 dark:bg-neutral-950" + /> +
+ + +
+
+ ) +} diff --git a/src/renderer/src/components/projects/ProjectDetail.tsx b/src/renderer/src/components/projects/ProjectDetail.tsx index 6a74326..c4e293a 100644 --- a/src/renderer/src/components/projects/ProjectDetail.tsx +++ b/src/renderer/src/components/projects/ProjectDetail.tsx @@ -1,4 +1,5 @@ -import { Play, RotateCw, Square } from 'lucide-react' +import { useState } from 'react' +import { FileText, Play, RotateCw, Square } from 'lucide-react' import { useProjectDetail, useRestartProject, @@ -8,12 +9,14 @@ import { import { StatusBadge } from './StatusBadge' import { DatabaseSection } from './DatabaseSection' import { AddonsSection } from './AddonsSection' +import { LogViewer } from '../logs/LogViewer' export function ProjectDetail({ name }: { name: string }): React.JSX.Element { const { data: project, isLoading, isError, error } = useProjectDetail(name) const startProject = useStartProject() const stopProject = useStopProject() const restartProject = useRestartProject() + const [isLogsOpen, setIsLogsOpen] = useState(false) const isBusy = startProject.isPending || stopProject.isPending || restartProject.isPending @@ -66,6 +69,14 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element { > Restart + @@ -143,6 +154,14 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element { + + {isLogsOpen && ( + setIsLogsOpen(false)} + /> + )} ) } diff --git a/src/renderer/src/hooks/useLogStream.ts b/src/renderer/src/hooks/useLogStream.ts new file mode 100644 index 0000000..76169c0 --- /dev/null +++ b/src/renderer/src/hooks/useLogStream.ts @@ -0,0 +1,55 @@ +import { useEffect, useRef, useState } from 'react' + +interface LogLine { + stream: 'stdout' | 'stderr' + text: string +} + +interface UseLogStreamResult { + lines: LogLine[] + isStreaming: boolean +} + +// Owns the lifecycle of a single `ddev logs -f` subprocess for one +// (name, service) pair. Callers must remount this (e.g. `key={service}`) +// when the service changes — state resets via fresh useState initializers +// on mount rather than manual resets inside the effect, since React's +// hooks lint flags synchronous setState-to-reset calls in an effect body. +// Always stops the subprocess on unmount so a closed log viewer doesn't +// leave an orphaned `ddev logs -f` process running. +export function useLogStream(name: string, service: string): UseLogStreamResult { + const [lines, setLines] = useState([]) + const [isStreaming, setIsStreaming] = useState(true) + // Data arrives as arbitrary byte chunks, not newline-delimited — a chunk + // can span partial lines or bundle many lines together. Buffer per stream + // so filtering/display operates on real lines instead of raw chunks. + const buffers = useRef({ stdout: '', stderr: '' }) + + useEffect(() => { + const operationId = crypto.randomUUID() + buffers.current = { stdout: '', stderr: '' } + + const unsubData = window.api.logs.onData((event) => { + if (event.operationId !== operationId) return + const combined = buffers.current[event.stream] + event.chunk + const parts = combined.split('\n') + buffers.current[event.stream] = parts.pop() ?? '' + if (parts.length === 0) return + setLines((prev) => [...prev, ...parts.map((text) => ({ stream: event.stream, text }))]) + }) + const unsubExit = window.api.logs.onExit((event) => { + if (event.operationId !== operationId) return + setIsStreaming(false) + }) + + window.api.logs.start(operationId, name, service) + + return () => { + unsubData() + unsubExit() + window.api.terminal.cancel(operationId) + } + }, [name, service]) + + return { lines, isStreaming } +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 0c6000a..d43b75c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -112,3 +112,13 @@ export interface TerminalExitEvent { exitCode: number | null cancelled: boolean } + +export interface LogDataEvent { + operationId: string + stream: 'stdout' | 'stderr' + chunk: string +} + +export interface LogExitEvent { + operationId: string +}