mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
feat: add dev server log panel with real-time streaming
Add the ability to view dev server logs in a dedicated panel with: - Real-time log streaming via WebSocket events - ANSI color support using xterm.js - Scrollback buffer (50KB) for log history on reconnect - Output throttling to prevent UI flooding - "View Logs" option in worktree dropdown menu Server changes: - Add scrollback buffer and event emission to DevServerService - Add GET /api/worktree/dev-server-logs endpoint - Add dev-server:started, dev-server:output, dev-server:stopped events UI changes: - Add reusable XtermLogViewer component - Add DevServerLogsPanel dialog component - Add useDevServerLogs hook for WebSocket subscription Closes #462 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -67,6 +67,7 @@ import { createPipelineRoutes } from './routes/pipeline/index.js';
|
||||
import { pipelineService } from './services/pipeline-service.js';
|
||||
import { createIdeationRoutes } from './routes/ideation/index.js';
|
||||
import { IdeationService } from './services/ideation-service.js';
|
||||
import { getDevServerService } from './services/dev-server-service.js';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
@@ -176,6 +177,10 @@ const codexUsageService = new CodexUsageService(codexAppServerService);
|
||||
const mcpTestService = new MCPTestService(settingsService);
|
||||
const ideationService = new IdeationService(events, settingsService, featureLoader);
|
||||
|
||||
// Initialize DevServerService with event emitter for real-time log streaming
|
||||
const devServerService = getDevServerService();
|
||||
devServerService.setEventEmitter(events);
|
||||
|
||||
// Initialize services
|
||||
(async () => {
|
||||
await agentService.initialize();
|
||||
|
||||
@@ -8,12 +8,28 @@ import type { Request, Response, NextFunction } from 'express';
|
||||
import { validatePath, PathNotAllowedError } from '@automaker/platform';
|
||||
|
||||
/**
|
||||
* Creates a middleware that validates specified path parameters in req.body
|
||||
* Helper to get parameter value from request (checks body first, then query)
|
||||
*/
|
||||
function getParamValue(req: Request, paramName: string): unknown {
|
||||
// Check body first (for POST/PUT/PATCH requests)
|
||||
if (req.body && req.body[paramName] !== undefined) {
|
||||
return req.body[paramName];
|
||||
}
|
||||
// Fall back to query params (for GET requests)
|
||||
if (req.query && req.query[paramName] !== undefined) {
|
||||
return req.query[paramName];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a middleware that validates specified path parameters in req.body or req.query
|
||||
* @param paramNames - Names of parameters to validate (e.g., 'projectPath', 'worktreePath')
|
||||
* @example
|
||||
* router.post('/create', validatePathParams('projectPath'), handler);
|
||||
* router.post('/delete', validatePathParams('projectPath', 'worktreePath'), handler);
|
||||
* router.post('/send', validatePathParams('workingDirectory?', 'imagePaths[]'), handler);
|
||||
* router.get('/logs', validatePathParams('worktreePath'), handler); // Works with query params too
|
||||
*
|
||||
* Special syntax:
|
||||
* - 'paramName?' - Optional parameter (only validated if present)
|
||||
@@ -26,8 +42,8 @@ export function validatePathParams(...paramNames: string[]) {
|
||||
// Handle optional parameters (paramName?)
|
||||
if (paramName.endsWith('?')) {
|
||||
const actualName = paramName.slice(0, -1);
|
||||
const value = req.body[actualName];
|
||||
if (value) {
|
||||
const value = getParamValue(req, actualName);
|
||||
if (value && typeof value === 'string') {
|
||||
validatePath(value);
|
||||
}
|
||||
continue;
|
||||
@@ -36,18 +52,20 @@ export function validatePathParams(...paramNames: string[]) {
|
||||
// Handle array parameters (paramName[])
|
||||
if (paramName.endsWith('[]')) {
|
||||
const actualName = paramName.slice(0, -2);
|
||||
const values = req.body[actualName];
|
||||
const values = getParamValue(req, actualName);
|
||||
if (Array.isArray(values) && values.length > 0) {
|
||||
for (const value of values) {
|
||||
validatePath(value);
|
||||
if (typeof value === 'string') {
|
||||
validatePath(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle regular parameters
|
||||
const value = req.body[paramName];
|
||||
if (value) {
|
||||
const value = getParamValue(req, paramName);
|
||||
if (value && typeof value === 'string') {
|
||||
validatePath(value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import { createMigrateHandler } from './routes/migrate.js';
|
||||
import { createStartDevHandler } from './routes/start-dev.js';
|
||||
import { createStopDevHandler } from './routes/stop-dev.js';
|
||||
import { createListDevServersHandler } from './routes/list-dev-servers.js';
|
||||
import { createGetDevServerLogsHandler } from './routes/dev-server-logs.js';
|
||||
import {
|
||||
createGetInitScriptHandler,
|
||||
createPutInitScriptHandler,
|
||||
@@ -97,6 +98,11 @@ export function createWorktreeRoutes(events: EventEmitter): Router {
|
||||
);
|
||||
router.post('/stop-dev', createStopDevHandler());
|
||||
router.post('/list-dev-servers', createListDevServersHandler());
|
||||
router.get(
|
||||
'/dev-server-logs',
|
||||
validatePathParams('worktreePath'),
|
||||
createGetDevServerLogsHandler()
|
||||
);
|
||||
|
||||
// Init script routes
|
||||
router.get('/init-script', createGetInitScriptHandler());
|
||||
|
||||
52
apps/server/src/routes/worktree/routes/dev-server-logs.ts
Normal file
52
apps/server/src/routes/worktree/routes/dev-server-logs.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* GET /dev-server-logs endpoint - Get buffered logs for a worktree's dev server
|
||||
*
|
||||
* Returns the scrollback buffer containing historical log output for a running
|
||||
* dev server. Used by clients to populate the log panel on initial connection
|
||||
* before subscribing to real-time updates via WebSocket.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getDevServerService } from '../../../services/dev-server-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createGetDevServerLogsHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { worktreePath } = req.query as {
|
||||
worktreePath?: string;
|
||||
};
|
||||
|
||||
if (!worktreePath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'worktreePath query parameter is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const devServerService = getDevServerService();
|
||||
const result = devServerService.getServerLogs(worktreePath);
|
||||
|
||||
if (result.success && result.result) {
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
worktreePath: result.result.worktreePath,
|
||||
port: result.result.port,
|
||||
logs: result.result.logs,
|
||||
startedAt: result.result.startedAt,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: result.error || 'Failed to get dev server logs',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error, 'Get dev server logs failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -12,24 +12,123 @@ import * as secureFs from '../lib/secure-fs.js';
|
||||
import path from 'path';
|
||||
import net from 'net';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { EventEmitter } from '../lib/events.js';
|
||||
|
||||
const logger = createLogger('DevServerService');
|
||||
|
||||
// Maximum scrollback buffer size (characters) - matches TerminalService pattern
|
||||
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per dev server
|
||||
|
||||
// Throttle output to prevent overwhelming WebSocket under heavy load
|
||||
const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive feedback
|
||||
const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency
|
||||
|
||||
export interface DevServerInfo {
|
||||
worktreePath: string;
|
||||
port: number;
|
||||
url: string;
|
||||
process: ChildProcess | null;
|
||||
startedAt: Date;
|
||||
// Scrollback buffer for log history (replay on reconnect)
|
||||
scrollbackBuffer: string;
|
||||
// Pending output to be flushed to subscribers
|
||||
outputBuffer: string;
|
||||
// Throttle timer for batching output
|
||||
flushTimeout: NodeJS.Timeout | null;
|
||||
// Flag to indicate server is stopping (prevents output after stop)
|
||||
stopping: boolean;
|
||||
}
|
||||
|
||||
// Port allocation starts at 3001 to avoid conflicts with common dev ports
|
||||
const BASE_PORT = 3001;
|
||||
const MAX_PORT = 3099; // Safety limit
|
||||
|
||||
// Common livereload ports that may need cleanup when stopping dev servers
|
||||
const LIVERELOAD_PORTS = [35729, 35730, 35731] as const;
|
||||
|
||||
class DevServerService {
|
||||
private runningServers: Map<string, DevServerInfo> = new Map();
|
||||
private allocatedPorts: Set<number> = new Set();
|
||||
private emitter: EventEmitter | null = null;
|
||||
|
||||
/**
|
||||
* Set the event emitter for streaming log events
|
||||
* Called during service initialization with the global event emitter
|
||||
*/
|
||||
setEventEmitter(emitter: EventEmitter): void {
|
||||
this.emitter = emitter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append data to scrollback buffer with size limit enforcement
|
||||
* Evicts oldest data when buffer exceeds MAX_SCROLLBACK_SIZE
|
||||
*/
|
||||
private appendToScrollback(server: DevServerInfo, data: string): void {
|
||||
server.scrollbackBuffer += data;
|
||||
if (server.scrollbackBuffer.length > MAX_SCROLLBACK_SIZE) {
|
||||
server.scrollbackBuffer = server.scrollbackBuffer.slice(-MAX_SCROLLBACK_SIZE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush buffered output to WebSocket subscribers
|
||||
* Sends batched output to prevent overwhelming clients under heavy load
|
||||
*/
|
||||
private flushOutput(server: DevServerInfo): void {
|
||||
// Skip flush if server is stopping or buffer is empty
|
||||
if (server.stopping || server.outputBuffer.length === 0) {
|
||||
server.flushTimeout = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let dataToSend = server.outputBuffer;
|
||||
if (dataToSend.length > OUTPUT_BATCH_SIZE) {
|
||||
// Send in batches if buffer is large
|
||||
dataToSend = server.outputBuffer.slice(0, OUTPUT_BATCH_SIZE);
|
||||
server.outputBuffer = server.outputBuffer.slice(OUTPUT_BATCH_SIZE);
|
||||
// Schedule another flush for remaining data
|
||||
server.flushTimeout = setTimeout(() => this.flushOutput(server), OUTPUT_THROTTLE_MS);
|
||||
} else {
|
||||
server.outputBuffer = '';
|
||||
server.flushTimeout = null;
|
||||
}
|
||||
|
||||
// Emit output event for WebSocket streaming
|
||||
if (this.emitter) {
|
||||
this.emitter.emit('dev-server:output', {
|
||||
worktreePath: server.worktreePath,
|
||||
content: dataToSend,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming stdout/stderr data from dev server process
|
||||
* Buffers data for scrollback replay and schedules throttled emission
|
||||
*/
|
||||
private handleProcessOutput(server: DevServerInfo, data: Buffer): void {
|
||||
// Skip output if server is stopping
|
||||
if (server.stopping) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = data.toString();
|
||||
|
||||
// Append to scrollback buffer for replay on reconnect
|
||||
this.appendToScrollback(server, content);
|
||||
|
||||
// Buffer output for throttled live delivery
|
||||
server.outputBuffer += content;
|
||||
|
||||
// Schedule flush if not already scheduled
|
||||
if (!server.flushTimeout) {
|
||||
server.flushTimeout = setTimeout(() => this.flushOutput(server), OUTPUT_THROTTLE_MS);
|
||||
}
|
||||
|
||||
// Also log for debugging (existing behavior)
|
||||
logger.debug(`[Port${server.port}] ${content.trim()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a port is available (not in use by system or by us)
|
||||
@@ -244,10 +343,9 @@ class DevServerService {
|
||||
// Reserve the port (port was already force-killed in findAvailablePort)
|
||||
this.allocatedPorts.add(port);
|
||||
|
||||
// Also kill common related ports (livereload uses 35729 by default)
|
||||
// Also kill common related ports (livereload, etc.)
|
||||
// Some dev servers use fixed ports for HMR/livereload regardless of main port
|
||||
const commonRelatedPorts = [35729, 35730, 35731];
|
||||
for (const relatedPort of commonRelatedPorts) {
|
||||
for (const relatedPort of LIVERELOAD_PORTS) {
|
||||
this.killProcessOnPort(relatedPort);
|
||||
}
|
||||
|
||||
@@ -259,9 +357,14 @@ class DevServerService {
|
||||
logger.debug(`Command: ${devCommand.cmd} ${devCommand.args.join(' ')} with PORT=${port}`);
|
||||
|
||||
// Spawn the dev process with PORT environment variable
|
||||
// FORCE_COLOR enables colored output even when not running in a TTY
|
||||
const env = {
|
||||
...process.env,
|
||||
PORT: String(port),
|
||||
FORCE_COLOR: '1',
|
||||
// Some tools use these additional env vars for color detection
|
||||
COLORTERM: 'truecolor',
|
||||
TERM: 'xterm-256color',
|
||||
};
|
||||
|
||||
const devProcess = spawn(devCommand.cmd, devCommand.args, {
|
||||
@@ -274,32 +377,66 @@ class DevServerService {
|
||||
// Track if process failed early using object to work around TypeScript narrowing
|
||||
const status = { error: null as string | null, exited: false };
|
||||
|
||||
// Log output for debugging
|
||||
// Create server info early so we can reference it in handlers
|
||||
// We'll add it to runningServers after verifying the process started successfully
|
||||
const serverInfo: DevServerInfo = {
|
||||
worktreePath,
|
||||
port,
|
||||
url: `http://localhost:${port}`,
|
||||
process: devProcess,
|
||||
startedAt: new Date(),
|
||||
scrollbackBuffer: '',
|
||||
outputBuffer: '',
|
||||
flushTimeout: null,
|
||||
stopping: false,
|
||||
};
|
||||
|
||||
// Capture stdout with buffer management and event emission
|
||||
if (devProcess.stdout) {
|
||||
devProcess.stdout.on('data', (data: Buffer) => {
|
||||
logger.debug(`[Port${port}] ${data.toString().trim()}`);
|
||||
this.handleProcessOutput(serverInfo, data);
|
||||
});
|
||||
}
|
||||
|
||||
// Capture stderr with buffer management and event emission
|
||||
if (devProcess.stderr) {
|
||||
devProcess.stderr.on('data', (data: Buffer) => {
|
||||
const msg = data.toString().trim();
|
||||
logger.debug(`[Port${port}] ${msg}`);
|
||||
this.handleProcessOutput(serverInfo, data);
|
||||
});
|
||||
}
|
||||
|
||||
// Helper to clean up resources and emit stop event
|
||||
const cleanupAndEmitStop = (exitCode: number | null, errorMessage?: string) => {
|
||||
if (serverInfo.flushTimeout) {
|
||||
clearTimeout(serverInfo.flushTimeout);
|
||||
serverInfo.flushTimeout = null;
|
||||
}
|
||||
|
||||
// Emit stopped event (only if not already stopping - prevents duplicate events)
|
||||
if (this.emitter && !serverInfo.stopping) {
|
||||
this.emitter.emit('dev-server:stopped', {
|
||||
worktreePath,
|
||||
port,
|
||||
exitCode,
|
||||
error: errorMessage,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
this.allocatedPorts.delete(port);
|
||||
this.runningServers.delete(worktreePath);
|
||||
};
|
||||
|
||||
devProcess.on('error', (error) => {
|
||||
logger.error(`Process error:`, error);
|
||||
status.error = error.message;
|
||||
this.allocatedPorts.delete(port);
|
||||
this.runningServers.delete(worktreePath);
|
||||
cleanupAndEmitStop(null, error.message);
|
||||
});
|
||||
|
||||
devProcess.on('exit', (code) => {
|
||||
logger.info(`Process for ${worktreePath} exited with code ${code}`);
|
||||
status.exited = true;
|
||||
this.allocatedPorts.delete(port);
|
||||
this.runningServers.delete(worktreePath);
|
||||
cleanupAndEmitStop(code);
|
||||
});
|
||||
|
||||
// Wait a moment to see if the process fails immediately
|
||||
@@ -319,16 +456,19 @@ class DevServerService {
|
||||
};
|
||||
}
|
||||
|
||||
const serverInfo: DevServerInfo = {
|
||||
worktreePath,
|
||||
port,
|
||||
url: `http://localhost:${port}`,
|
||||
process: devProcess,
|
||||
startedAt: new Date(),
|
||||
};
|
||||
|
||||
// Server started successfully - add to running servers map
|
||||
this.runningServers.set(worktreePath, serverInfo);
|
||||
|
||||
// Emit started event for WebSocket subscribers
|
||||
if (this.emitter) {
|
||||
this.emitter.emit('dev-server:started', {
|
||||
worktreePath,
|
||||
port,
|
||||
url: serverInfo.url,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
@@ -365,6 +505,28 @@ class DevServerService {
|
||||
|
||||
logger.info(`Stopping dev server for ${worktreePath}`);
|
||||
|
||||
// Mark as stopping to prevent further output events
|
||||
server.stopping = true;
|
||||
|
||||
// Clean up flush timeout to prevent memory leaks
|
||||
if (server.flushTimeout) {
|
||||
clearTimeout(server.flushTimeout);
|
||||
server.flushTimeout = null;
|
||||
}
|
||||
|
||||
// Clear any pending output buffer
|
||||
server.outputBuffer = '';
|
||||
|
||||
// Emit stopped event immediately so UI updates right away
|
||||
if (this.emitter) {
|
||||
this.emitter.emit('dev-server:stopped', {
|
||||
worktreePath,
|
||||
port: server.port,
|
||||
exitCode: null, // Will be populated by exit handler if process exits normally
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Kill the process
|
||||
if (server.process && !server.process.killed) {
|
||||
server.process.kill('SIGTERM');
|
||||
@@ -422,6 +584,41 @@ class DevServerService {
|
||||
return this.runningServers.get(worktreePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get buffered logs for a worktree's dev server
|
||||
* Returns the scrollback buffer containing historical log output
|
||||
* Used by the API to serve logs to clients on initial connection
|
||||
*/
|
||||
getServerLogs(worktreePath: string): {
|
||||
success: boolean;
|
||||
result?: {
|
||||
worktreePath: string;
|
||||
port: number;
|
||||
logs: string;
|
||||
startedAt: string;
|
||||
};
|
||||
error?: string;
|
||||
} {
|
||||
const server = this.runningServers.get(worktreePath);
|
||||
|
||||
if (!server) {
|
||||
return {
|
||||
success: false,
|
||||
error: `No dev server running for worktree: ${worktreePath}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
worktreePath: server.worktreePath,
|
||||
port: server.port,
|
||||
logs: server.scrollbackBuffer,
|
||||
startedAt: server.startedAt.toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all allocated ports
|
||||
*/
|
||||
|
||||
300
apps/ui/src/components/ui/xterm-log-viewer.tsx
Normal file
300
apps/ui/src/components/ui/xterm-log-viewer.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import { useEffect, useRef, useCallback, useState, forwardRef, useImperativeHandle } from 'react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { getTerminalTheme, DEFAULT_TERMINAL_FONT } from '@/config/terminal-themes';
|
||||
|
||||
// Types for dynamically imported xterm modules
|
||||
type XTerminal = InstanceType<typeof import('@xterm/xterm').Terminal>;
|
||||
type XFitAddon = InstanceType<typeof import('@xterm/addon-fit').FitAddon>;
|
||||
|
||||
export interface XtermLogViewerRef {
|
||||
/** Append content to the log viewer */
|
||||
append: (content: string) => void;
|
||||
/** Clear all content */
|
||||
clear: () => void;
|
||||
/** Scroll to the bottom */
|
||||
scrollToBottom: () => void;
|
||||
/** Write content (replaces all content) */
|
||||
write: (content: string) => void;
|
||||
}
|
||||
|
||||
export interface XtermLogViewerProps {
|
||||
/** Initial content to display */
|
||||
initialContent?: string;
|
||||
/** Font size in pixels (default: 13) */
|
||||
fontSize?: number;
|
||||
/** Whether to auto-scroll to bottom when new content is added (default: true) */
|
||||
autoScroll?: boolean;
|
||||
/** Custom class name for the container */
|
||||
className?: string;
|
||||
/** Minimum height for the container */
|
||||
minHeight?: number;
|
||||
/** Callback when user scrolls away from bottom */
|
||||
onScrollAwayFromBottom?: () => void;
|
||||
/** Callback when user scrolls to bottom */
|
||||
onScrollToBottom?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A read-only terminal log viewer using xterm.js for perfect ANSI color rendering.
|
||||
* Use this component when you need to display terminal output with ANSI escape codes.
|
||||
*/
|
||||
export const XtermLogViewer = forwardRef<XtermLogViewerRef, XtermLogViewerProps>(
|
||||
(
|
||||
{
|
||||
initialContent,
|
||||
fontSize = 13,
|
||||
autoScroll = true,
|
||||
className,
|
||||
minHeight = 300,
|
||||
onScrollAwayFromBottom,
|
||||
onScrollToBottom,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const xtermRef = useRef<XTerminal | null>(null);
|
||||
const fitAddonRef = useRef<XFitAddon | null>(null);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const autoScrollRef = useRef(autoScroll);
|
||||
const pendingContentRef = useRef<string[]>([]);
|
||||
|
||||
// Get theme from store
|
||||
const getEffectiveTheme = useAppStore((state) => state.getEffectiveTheme);
|
||||
const effectiveTheme = getEffectiveTheme();
|
||||
|
||||
// Track system dark mode for "system" theme
|
||||
const [systemIsDark, setSystemIsDark] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handleChange = (e: MediaQueryListEvent) => setSystemIsDark(e.matches);
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}, []);
|
||||
|
||||
const resolvedTheme =
|
||||
effectiveTheme === 'system' ? (systemIsDark ? 'dark' : 'light') : effectiveTheme;
|
||||
|
||||
// Update autoScroll ref when prop changes
|
||||
useEffect(() => {
|
||||
autoScrollRef.current = autoScroll;
|
||||
}, [autoScroll]);
|
||||
|
||||
// Initialize xterm
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
let mounted = true;
|
||||
|
||||
const initTerminal = async () => {
|
||||
const [{ Terminal }, { FitAddon }] = await Promise.all([
|
||||
import('@xterm/xterm'),
|
||||
import('@xterm/addon-fit'),
|
||||
]);
|
||||
await import('@xterm/xterm/css/xterm.css');
|
||||
|
||||
if (!mounted || !containerRef.current) return;
|
||||
|
||||
const terminalTheme = getTerminalTheme(resolvedTheme);
|
||||
|
||||
const terminal = new Terminal({
|
||||
cursorBlink: false,
|
||||
cursorStyle: 'underline',
|
||||
cursorInactiveStyle: 'none',
|
||||
fontSize,
|
||||
fontFamily: DEFAULT_TERMINAL_FONT,
|
||||
lineHeight: 1.2,
|
||||
theme: terminalTheme,
|
||||
disableStdin: true, // Read-only mode
|
||||
scrollback: 10000,
|
||||
convertEol: true,
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
|
||||
terminal.open(containerRef.current);
|
||||
|
||||
// Try to load WebGL addon for better performance
|
||||
try {
|
||||
const { WebglAddon } = await import('@xterm/addon-webgl');
|
||||
const webglAddon = new WebglAddon();
|
||||
webglAddon.onContextLoss(() => webglAddon.dispose());
|
||||
terminal.loadAddon(webglAddon);
|
||||
} catch {
|
||||
// WebGL not available, continue with canvas renderer
|
||||
}
|
||||
|
||||
// Wait for layout to stabilize then fit
|
||||
requestAnimationFrame(() => {
|
||||
if (mounted && containerRef.current) {
|
||||
try {
|
||||
fitAddon.fit();
|
||||
} catch {
|
||||
// Ignore fit errors during initialization
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
xtermRef.current = terminal;
|
||||
fitAddonRef.current = fitAddon;
|
||||
setIsReady(true);
|
||||
|
||||
// Write initial content if provided
|
||||
if (initialContent) {
|
||||
terminal.write(initialContent);
|
||||
}
|
||||
|
||||
// Write any pending content that was queued before terminal was ready
|
||||
if (pendingContentRef.current.length > 0) {
|
||||
pendingContentRef.current.forEach((content) => terminal.write(content));
|
||||
pendingContentRef.current = [];
|
||||
}
|
||||
};
|
||||
|
||||
initTerminal();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
if (xtermRef.current) {
|
||||
xtermRef.current.dispose();
|
||||
xtermRef.current = null;
|
||||
}
|
||||
fitAddonRef.current = null;
|
||||
setIsReady(false);
|
||||
};
|
||||
}, []); // Only run once on mount
|
||||
|
||||
// Update theme when it changes
|
||||
useEffect(() => {
|
||||
if (xtermRef.current && isReady) {
|
||||
const terminalTheme = getTerminalTheme(resolvedTheme);
|
||||
xtermRef.current.options.theme = terminalTheme;
|
||||
}
|
||||
}, [resolvedTheme, isReady]);
|
||||
|
||||
// Update font size when it changes
|
||||
useEffect(() => {
|
||||
if (xtermRef.current && isReady) {
|
||||
xtermRef.current.options.fontSize = fontSize;
|
||||
fitAddonRef.current?.fit();
|
||||
}
|
||||
}, [fontSize, isReady]);
|
||||
|
||||
// Handle resize
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !isReady) return;
|
||||
|
||||
const handleResize = () => {
|
||||
if (fitAddonRef.current && containerRef.current) {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
if (rect.width > 0 && rect.height > 0) {
|
||||
try {
|
||||
fitAddonRef.current.fit();
|
||||
} catch {
|
||||
// Ignore fit errors
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(handleResize);
|
||||
resizeObserver.observe(containerRef.current);
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, [isReady]);
|
||||
|
||||
// Monitor scroll position
|
||||
useEffect(() => {
|
||||
if (!isReady || !containerRef.current) return;
|
||||
|
||||
const viewport = containerRef.current.querySelector('.xterm-viewport') as HTMLElement | null;
|
||||
if (!viewport) return;
|
||||
|
||||
const checkScroll = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = viewport;
|
||||
const isAtBottom = scrollHeight - scrollTop - clientHeight <= 10;
|
||||
|
||||
if (isAtBottom) {
|
||||
autoScrollRef.current = true;
|
||||
onScrollToBottom?.();
|
||||
} else {
|
||||
autoScrollRef.current = false;
|
||||
onScrollAwayFromBottom?.();
|
||||
}
|
||||
};
|
||||
|
||||
viewport.addEventListener('scroll', checkScroll, { passive: true });
|
||||
return () => viewport.removeEventListener('scroll', checkScroll);
|
||||
}, [isReady, onScrollAwayFromBottom, onScrollToBottom]);
|
||||
|
||||
// Expose methods via ref
|
||||
const append = useCallback((content: string) => {
|
||||
if (xtermRef.current) {
|
||||
xtermRef.current.write(content);
|
||||
if (autoScrollRef.current) {
|
||||
xtermRef.current.scrollToBottom();
|
||||
}
|
||||
} else {
|
||||
// Queue content if terminal isn't ready yet
|
||||
pendingContentRef.current.push(content);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
if (xtermRef.current) {
|
||||
xtermRef.current.clear();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (xtermRef.current) {
|
||||
xtermRef.current.scrollToBottom();
|
||||
autoScrollRef.current = true;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const write = useCallback((content: string) => {
|
||||
if (xtermRef.current) {
|
||||
xtermRef.current.reset();
|
||||
xtermRef.current.write(content);
|
||||
if (autoScrollRef.current) {
|
||||
xtermRef.current.scrollToBottom();
|
||||
}
|
||||
} else {
|
||||
pendingContentRef.current = [content];
|
||||
}
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
append,
|
||||
clear,
|
||||
scrollToBottom,
|
||||
write,
|
||||
}));
|
||||
|
||||
const terminalTheme = getTerminalTheme(resolvedTheme);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={className}
|
||||
style={{
|
||||
minHeight,
|
||||
backgroundColor: terminalTheme.background,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
XtermLogViewer.displayName = 'XtermLogViewer';
|
||||
@@ -0,0 +1,289 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Loader2,
|
||||
Terminal,
|
||||
ArrowDown,
|
||||
ExternalLink,
|
||||
Square,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
GitBranch,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { XtermLogViewer, type XtermLogViewerRef } from '@/components/ui/xterm-log-viewer';
|
||||
import { useDevServerLogs } from '../hooks/use-dev-server-logs';
|
||||
import type { WorktreeInfo } from '../types';
|
||||
|
||||
interface DevServerLogsPanelProps {
|
||||
/** Whether the panel is open */
|
||||
open: boolean;
|
||||
/** Callback when the panel is closed */
|
||||
onClose: () => void;
|
||||
/** The worktree to show logs for */
|
||||
worktree: WorktreeInfo | null;
|
||||
/** Callback to stop the dev server */
|
||||
onStopDevServer?: (worktree: WorktreeInfo) => void;
|
||||
/** Callback to open the dev server URL in browser */
|
||||
onOpenDevServerUrl?: (worktree: WorktreeInfo) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Panel component for displaying dev server logs with ANSI color rendering
|
||||
* and auto-scroll functionality.
|
||||
*
|
||||
* Features:
|
||||
* - Real-time log streaming via WebSocket
|
||||
* - Full ANSI color code rendering via xterm.js
|
||||
* - Auto-scroll to bottom (can be paused by scrolling up)
|
||||
* - Server status indicators
|
||||
* - Quick actions (stop server, open in browser)
|
||||
*/
|
||||
export function DevServerLogsPanel({
|
||||
open,
|
||||
onClose,
|
||||
worktree,
|
||||
onStopDevServer,
|
||||
onOpenDevServerUrl,
|
||||
}: DevServerLogsPanelProps) {
|
||||
const xtermRef = useRef<XtermLogViewerRef>(null);
|
||||
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
|
||||
const lastLogsLengthRef = useRef(0);
|
||||
const lastWorktreePathRef = useRef<string | null>(null);
|
||||
|
||||
const {
|
||||
logs,
|
||||
isRunning,
|
||||
isLoading,
|
||||
error,
|
||||
port,
|
||||
url,
|
||||
startedAt,
|
||||
exitCode,
|
||||
serverError,
|
||||
fetchLogs,
|
||||
} = useDevServerLogs({
|
||||
worktreePath: open ? (worktree?.path ?? null) : null,
|
||||
autoSubscribe: open,
|
||||
});
|
||||
|
||||
// Write logs to xterm when they change
|
||||
useEffect(() => {
|
||||
if (!xtermRef.current || !logs) return;
|
||||
|
||||
// If worktree changed, reset the terminal and write all content
|
||||
if (lastWorktreePathRef.current !== worktree?.path) {
|
||||
lastWorktreePathRef.current = worktree?.path ?? null;
|
||||
lastLogsLengthRef.current = 0;
|
||||
xtermRef.current.write(logs);
|
||||
lastLogsLengthRef.current = logs.length;
|
||||
return;
|
||||
}
|
||||
|
||||
// If logs got shorter (e.g., cleared), rewrite all
|
||||
if (logs.length < lastLogsLengthRef.current) {
|
||||
xtermRef.current.write(logs);
|
||||
lastLogsLengthRef.current = logs.length;
|
||||
return;
|
||||
}
|
||||
|
||||
// Append only the new content
|
||||
if (logs.length > lastLogsLengthRef.current) {
|
||||
const newContent = logs.slice(lastLogsLengthRef.current);
|
||||
xtermRef.current.append(newContent);
|
||||
lastLogsLengthRef.current = logs.length;
|
||||
}
|
||||
}, [logs, worktree?.path]);
|
||||
|
||||
// Reset when panel opens with a new worktree
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setAutoScrollEnabled(true);
|
||||
if (worktree?.path !== lastWorktreePathRef.current) {
|
||||
lastLogsLengthRef.current = 0;
|
||||
}
|
||||
}
|
||||
}, [open, worktree?.path]);
|
||||
|
||||
// Scroll to bottom handler
|
||||
const scrollToBottom = useCallback(() => {
|
||||
xtermRef.current?.scrollToBottom();
|
||||
setAutoScrollEnabled(true);
|
||||
}, []);
|
||||
|
||||
// Format the started time
|
||||
const formatStartedAt = useCallback((timestamp: string | null) => {
|
||||
if (!timestamp) return null;
|
||||
try {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!worktree) return null;
|
||||
|
||||
const formattedStartTime = formatStartedAt(startedAt);
|
||||
const lineCount = logs ? logs.split('\n').length : 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent
|
||||
className="w-[70vw] max-w-[900px] max-h-[85vh] flex flex-col gap-0 p-0 overflow-hidden"
|
||||
data-testid="dev-server-logs-panel"
|
||||
>
|
||||
{/* Compact Header */}
|
||||
<DialogHeader className="shrink-0 px-4 py-3 border-b border-border/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="flex items-center gap-2 text-base">
|
||||
<Terminal className="w-4 h-4 text-primary" />
|
||||
<span>Dev Server</span>
|
||||
{isRunning ? (
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-green-500/10 text-green-500 text-xs font-medium">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
|
||||
Running
|
||||
</span>
|
||||
) : exitCode !== null ? (
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-red-500/10 text-red-500 text-xs font-medium">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
Stopped ({exitCode})
|
||||
</span>
|
||||
) : null}
|
||||
</DialogTitle>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isRunning && url && onOpenDevServerUrl && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs"
|
||||
onClick={() => onOpenDevServerUrl(worktree)}
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5 mr-1.5" />
|
||||
Open
|
||||
</Button>
|
||||
)}
|
||||
{isRunning && onStopDevServer && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => onStopDevServer(worktree)}
|
||||
>
|
||||
<Square className="w-3 h-3 mr-1.5 fill-current" />
|
||||
Stop
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => fetchLogs()}
|
||||
title="Refresh logs"
|
||||
>
|
||||
<RefreshCw className={cn('w-3.5 h-3.5', isLoading && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info bar - more compact */}
|
||||
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<GitBranch className="w-3 h-3" />
|
||||
<span className="font-medium text-foreground/80">{worktree.branch}</span>
|
||||
</span>
|
||||
{port && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="text-muted-foreground/60">Port</span>
|
||||
<span className="font-mono text-primary">{port}</span>
|
||||
</span>
|
||||
)}
|
||||
{formattedStartTime && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formattedStartTime}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Error displays - inline */}
|
||||
{(error || serverError) && (
|
||||
<div className="shrink-0 px-4 py-2 bg-destructive/5 border-b border-destructive/20">
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-xs text-destructive">
|
||||
<AlertCircle className="w-3.5 h-3.5 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
{serverError && (
|
||||
<div className="flex items-center gap-2 text-xs text-destructive">
|
||||
<AlertCircle className="w-3.5 h-3.5 shrink-0" />
|
||||
<span>Server error: {serverError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Log content area - fills remaining space */}
|
||||
<div
|
||||
className="flex-1 min-h-0 overflow-hidden bg-zinc-950"
|
||||
data-testid="dev-server-logs-content"
|
||||
>
|
||||
{isLoading && !logs ? (
|
||||
<div className="flex items-center justify-center h-full min-h-[300px] text-muted-foreground">
|
||||
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
||||
<span className="text-sm">Loading logs...</span>
|
||||
</div>
|
||||
) : !logs && !isRunning ? (
|
||||
<div className="flex flex-col items-center justify-center h-full min-h-[300px] text-muted-foreground p-8">
|
||||
<Terminal className="w-10 h-10 mb-3 opacity-20" />
|
||||
<p className="text-sm">No dev server running</p>
|
||||
<p className="text-xs mt-1 opacity-60">Start a dev server to see logs here</p>
|
||||
</div>
|
||||
) : !logs ? (
|
||||
<div className="flex flex-col items-center justify-center h-full min-h-[300px] text-muted-foreground p-8">
|
||||
<div className="w-8 h-8 mb-3 rounded-full border-2 border-muted-foreground/20 border-t-muted-foreground/60 animate-spin" />
|
||||
<p className="text-sm">Waiting for output...</p>
|
||||
<p className="text-xs mt-1 opacity-60">
|
||||
Logs will appear as the server generates output
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<XtermLogViewer
|
||||
ref={xtermRef}
|
||||
className="h-full"
|
||||
minHeight={280}
|
||||
fontSize={13}
|
||||
autoScroll={autoScrollEnabled}
|
||||
onScrollAwayFromBottom={() => setAutoScrollEnabled(false)}
|
||||
onScrollToBottom={() => setAutoScrollEnabled(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer status bar */}
|
||||
<div className="shrink-0 flex items-center justify-between px-4 py-2 bg-muted/30 border-t border-border/50 text-xs text-muted-foreground">
|
||||
<span className="font-mono">{lineCount > 0 ? `${lineCount} lines` : 'No output'}</span>
|
||||
{!autoScrollEnabled && logs && (
|
||||
<button
|
||||
onClick={scrollToBottom}
|
||||
className="inline-flex items-center gap-1.5 px-2 py-1 rounded hover:bg-muted transition-colors text-primary"
|
||||
>
|
||||
<ArrowDown className="w-3 h-3" />
|
||||
Scroll to bottom
|
||||
</button>
|
||||
)}
|
||||
{autoScrollEnabled && logs && (
|
||||
<span className="inline-flex items-center gap-1.5 opacity-60">
|
||||
<ArrowDown className="w-3 h-3" />
|
||||
Auto-scroll
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export { BranchSwitchDropdown } from './branch-switch-dropdown';
|
||||
export { DevServerLogsPanel } from './dev-server-logs-panel';
|
||||
export { WorktreeActionsDropdown } from './worktree-actions-dropdown';
|
||||
export { WorktreeTab } from './worktree-tab';
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
Copy,
|
||||
ScrollText,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -56,6 +57,7 @@ interface WorktreeActionsDropdownProps {
|
||||
onStartDevServer: (worktree: WorktreeInfo) => void;
|
||||
onStopDevServer: (worktree: WorktreeInfo) => void;
|
||||
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
|
||||
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
|
||||
onRunInitScript: (worktree: WorktreeInfo) => void;
|
||||
hasInitScript: boolean;
|
||||
}
|
||||
@@ -83,6 +85,7 @@ export function WorktreeActionsDropdown({
|
||||
onStartDevServer,
|
||||
onStopDevServer,
|
||||
onOpenDevServerUrl,
|
||||
onViewDevServerLogs,
|
||||
onRunInitScript,
|
||||
hasInitScript,
|
||||
}: WorktreeActionsDropdownProps) {
|
||||
@@ -147,6 +150,10 @@ export function WorktreeActionsDropdown({
|
||||
<Globe className="w-3.5 h-3.5 mr-2" />
|
||||
Open in Browser
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onViewDevServerLogs(worktree)} className="text-xs">
|
||||
<ScrollText className="w-3.5 h-3.5 mr-2" />
|
||||
View Logs
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onStopDevServer(worktree)}
|
||||
className="text-xs text-destructive focus:text-destructive"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// @ts-nocheck
|
||||
import type { JSX } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -45,6 +45,7 @@ interface WorktreeTabProps {
|
||||
onStartDevServer: (worktree: WorktreeInfo) => void;
|
||||
onStopDevServer: (worktree: WorktreeInfo) => void;
|
||||
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
|
||||
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
|
||||
onRunInitScript: (worktree: WorktreeInfo) => void;
|
||||
hasInitScript: boolean;
|
||||
}
|
||||
@@ -87,6 +88,7 @@ export function WorktreeTab({
|
||||
onStartDevServer,
|
||||
onStopDevServer,
|
||||
onOpenDevServerUrl,
|
||||
onViewDevServerLogs,
|
||||
onRunInitScript,
|
||||
hasInitScript,
|
||||
}: WorktreeTabProps) {
|
||||
@@ -337,6 +339,7 @@ export function WorktreeTab({
|
||||
onStartDevServer={onStartDevServer}
|
||||
onStopDevServer={onStopDevServer}
|
||||
onOpenDevServerUrl={onOpenDevServerUrl}
|
||||
onViewDevServerLogs={onViewDevServerLogs}
|
||||
onRunInitScript={onRunInitScript}
|
||||
hasInitScript={hasInitScript}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { useWorktrees } from './use-worktrees';
|
||||
export { useDevServers } from './use-dev-servers';
|
||||
export { useDevServerLogs } from './use-dev-server-logs';
|
||||
export { useBranches } from './use-branches';
|
||||
export { useWorktreeActions } from './use-worktree-actions';
|
||||
export { useRunningFeatures } from './use-running-features';
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { pathsEqual } from '@/lib/utils';
|
||||
|
||||
const logger = createLogger('DevServerLogs');
|
||||
|
||||
export interface DevServerLogState {
|
||||
/** The log content (buffered + live) */
|
||||
logs: string;
|
||||
/** Whether the server is currently running */
|
||||
isRunning: boolean;
|
||||
/** Whether initial logs are being fetched */
|
||||
isLoading: boolean;
|
||||
/** Error message if fetching logs failed */
|
||||
error: string | null;
|
||||
/** Server port (if running) */
|
||||
port: number | null;
|
||||
/** Server URL (if running) */
|
||||
url: string | null;
|
||||
/** Timestamp when the server started */
|
||||
startedAt: string | null;
|
||||
/** Exit code (if server stopped) */
|
||||
exitCode: number | null;
|
||||
/** Error message from server (if stopped with error) */
|
||||
serverError: string | null;
|
||||
}
|
||||
|
||||
interface UseDevServerLogsOptions {
|
||||
/** Path to the worktree to monitor logs for */
|
||||
worktreePath: string | null;
|
||||
/** Whether to automatically subscribe to log events (default: true) */
|
||||
autoSubscribe?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to subscribe to dev server log events and manage log state.
|
||||
*
|
||||
* This hook:
|
||||
* 1. Fetches initial buffered logs from the server
|
||||
* 2. Subscribes to WebSocket events for real-time log streaming
|
||||
* 3. Handles server started/stopped events
|
||||
* 4. Provides log state for rendering in a panel
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { logs, isRunning, isLoading } = useDevServerLogs({
|
||||
* worktreePath: '/path/to/worktree'
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevServerLogsOptions) {
|
||||
const [state, setState] = useState<DevServerLogState>({
|
||||
logs: '',
|
||||
isRunning: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
port: null,
|
||||
url: null,
|
||||
startedAt: null,
|
||||
exitCode: null,
|
||||
serverError: null,
|
||||
});
|
||||
|
||||
// Keep track of whether we've fetched initial logs
|
||||
const hasFetchedInitialLogs = useRef(false);
|
||||
|
||||
/**
|
||||
* Fetch buffered logs from the server
|
||||
*/
|
||||
const fetchLogs = useCallback(async () => {
|
||||
if (!worktreePath) return;
|
||||
|
||||
setState((prev) => ({ ...prev, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.getDevServerLogs) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: 'Dev server logs API not available',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.worktree.getDevServerLogs(worktreePath);
|
||||
|
||||
if (result.success && result.result) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
logs: result.result!.logs,
|
||||
isRunning: true,
|
||||
isLoading: false,
|
||||
port: result.result!.port,
|
||||
url: `http://localhost:${result.result!.port}`,
|
||||
startedAt: result.result!.startedAt,
|
||||
error: null,
|
||||
}));
|
||||
hasFetchedInitialLogs.current = true;
|
||||
} else {
|
||||
// Server might not be running - this is not necessarily an error
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
isRunning: false,
|
||||
error: result.error || null,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch dev server logs:', error);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch logs',
|
||||
}));
|
||||
}
|
||||
}, [worktreePath]);
|
||||
|
||||
/**
|
||||
* Clear logs and reset state
|
||||
*/
|
||||
const clearLogs = useCallback(() => {
|
||||
setState({
|
||||
logs: '',
|
||||
isRunning: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
port: null,
|
||||
url: null,
|
||||
startedAt: null,
|
||||
exitCode: null,
|
||||
serverError: null,
|
||||
});
|
||||
hasFetchedInitialLogs.current = false;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Append content to logs
|
||||
*/
|
||||
const appendLogs = useCallback((content: string) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
logs: prev.logs + content,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Fetch initial logs when worktreePath changes
|
||||
useEffect(() => {
|
||||
if (worktreePath && autoSubscribe) {
|
||||
hasFetchedInitialLogs.current = false;
|
||||
fetchLogs();
|
||||
} else {
|
||||
clearLogs();
|
||||
}
|
||||
}, [worktreePath, autoSubscribe, fetchLogs, clearLogs]);
|
||||
|
||||
// Subscribe to WebSocket events
|
||||
useEffect(() => {
|
||||
if (!worktreePath || !autoSubscribe) return;
|
||||
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.onDevServerLogEvent) {
|
||||
logger.warn('Dev server log event API not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribe = api.worktree.onDevServerLogEvent((event) => {
|
||||
// Filter events to only handle those for our worktree
|
||||
if (!pathsEqual(event.payload.worktreePath, worktreePath)) return;
|
||||
|
||||
switch (event.type) {
|
||||
case 'dev-server:started': {
|
||||
const { payload } = event;
|
||||
logger.info('Dev server started:', payload);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isRunning: true,
|
||||
port: payload.port,
|
||||
url: payload.url,
|
||||
startedAt: payload.timestamp,
|
||||
exitCode: null,
|
||||
serverError: null,
|
||||
// Clear logs on restart
|
||||
logs: '',
|
||||
}));
|
||||
hasFetchedInitialLogs.current = false;
|
||||
break;
|
||||
}
|
||||
case 'dev-server:output': {
|
||||
const { payload } = event;
|
||||
// Append the new output to existing logs
|
||||
if (payload.content) {
|
||||
appendLogs(payload.content);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'dev-server:stopped': {
|
||||
const { payload } = event;
|
||||
logger.info('Dev server stopped:', payload);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isRunning: false,
|
||||
exitCode: payload.exitCode,
|
||||
serverError: payload.error ?? null,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [worktreePath, autoSubscribe, appendLogs]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
fetchLogs,
|
||||
clearLogs,
|
||||
appendLogs,
|
||||
};
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
useWorktreeActions,
|
||||
useRunningFeatures,
|
||||
} from './hooks';
|
||||
import { WorktreeTab } from './components';
|
||||
import { WorktreeTab, DevServerLogsPanel } from './components';
|
||||
|
||||
export function WorktreePanel({
|
||||
projectPath,
|
||||
@@ -84,6 +84,10 @@ export function WorktreePanel({
|
||||
// Track whether init script exists for the project
|
||||
const [hasInitScript, setHasInitScript] = useState(false);
|
||||
|
||||
// Log panel state management
|
||||
const [logPanelOpen, setLogPanelOpen] = useState(false);
|
||||
const [logPanelWorktree, setLogPanelWorktree] = useState<WorktreeInfo | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectPath) {
|
||||
setHasInitScript(false);
|
||||
@@ -164,6 +168,18 @@ export function WorktreePanel({
|
||||
[projectPath]
|
||||
);
|
||||
|
||||
// Handle opening the log panel for a specific worktree
|
||||
const handleViewDevServerLogs = useCallback((worktree: WorktreeInfo) => {
|
||||
setLogPanelWorktree(worktree);
|
||||
setLogPanelOpen(true);
|
||||
}, []);
|
||||
|
||||
// Handle closing the log panel
|
||||
const handleCloseLogPanel = useCallback(() => {
|
||||
setLogPanelOpen(false);
|
||||
// Keep logPanelWorktree set for smooth close animation
|
||||
}, []);
|
||||
|
||||
const mainWorktree = worktrees.find((w) => w.isMain);
|
||||
const nonMainWorktrees = worktrees.filter((w) => !w.isMain);
|
||||
|
||||
@@ -213,6 +229,7 @@ export function WorktreePanel({
|
||||
onStartDevServer={handleStartDevServer}
|
||||
onStopDevServer={handleStopDevServer}
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
onViewDevServerLogs={handleViewDevServerLogs}
|
||||
onRunInitScript={handleRunInitScript}
|
||||
hasInitScript={hasInitScript}
|
||||
/>
|
||||
@@ -269,6 +286,7 @@ export function WorktreePanel({
|
||||
onStartDevServer={handleStartDevServer}
|
||||
onStopDevServer={handleStopDevServer}
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
onViewDevServerLogs={handleViewDevServerLogs}
|
||||
onRunInitScript={handleRunInitScript}
|
||||
hasInitScript={hasInitScript}
|
||||
/>
|
||||
@@ -303,6 +321,15 @@ export function WorktreePanel({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Dev Server Logs Panel */}
|
||||
<DevServerLogsPanel
|
||||
open={logPanelOpen}
|
||||
onClose={handleCloseLogPanel}
|
||||
worktree={logPanelWorktree}
|
||||
onStopDevServer={handleStopDevServer}
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1760,6 +1760,22 @@ function createMockWorktreeAPI(): WorktreeAPI {
|
||||
};
|
||||
},
|
||||
|
||||
getDevServerLogs: async (worktreePath: string) => {
|
||||
console.log('[Mock] Getting dev server logs:', { worktreePath });
|
||||
return {
|
||||
success: false,
|
||||
error: 'No dev server running for this worktree',
|
||||
};
|
||||
},
|
||||
|
||||
onDevServerLogEvent: (callback) => {
|
||||
console.log('[Mock] Subscribing to dev server log events');
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
console.log('[Mock] Unsubscribing from dev server log events');
|
||||
};
|
||||
},
|
||||
|
||||
getPRInfo: async (worktreePath: string, branchName: string) => {
|
||||
console.log('[Mock] Getting PR info:', { worktreePath, branchName });
|
||||
return {
|
||||
|
||||
@@ -510,7 +510,53 @@ type EventType =
|
||||
| 'ideation:analysis'
|
||||
| 'worktree:init-started'
|
||||
| 'worktree:init-output'
|
||||
| 'worktree:init-completed';
|
||||
| 'worktree:init-completed'
|
||||
| 'dev-server:started'
|
||||
| 'dev-server:output'
|
||||
| 'dev-server:stopped';
|
||||
|
||||
/**
|
||||
* Dev server log event payloads for WebSocket streaming
|
||||
*/
|
||||
export interface DevServerStartedEvent {
|
||||
worktreePath: string;
|
||||
port: number;
|
||||
url: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface DevServerOutputEvent {
|
||||
worktreePath: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface DevServerStoppedEvent {
|
||||
worktreePath: string;
|
||||
port: number;
|
||||
exitCode: number | null;
|
||||
error?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export type DevServerLogEvent =
|
||||
| { type: 'dev-server:started'; payload: DevServerStartedEvent }
|
||||
| { type: 'dev-server:output'; payload: DevServerOutputEvent }
|
||||
| { type: 'dev-server:stopped'; payload: DevServerStoppedEvent };
|
||||
|
||||
/**
|
||||
* Response type for fetching dev server logs
|
||||
*/
|
||||
export interface DevServerLogsResponse {
|
||||
success: boolean;
|
||||
result?: {
|
||||
worktreePath: string;
|
||||
port: number;
|
||||
logs: string;
|
||||
startedAt: string;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
type EventCallback = (payload: unknown) => void;
|
||||
|
||||
@@ -1710,6 +1756,24 @@ export class HttpApiClient implements ElectronAPI {
|
||||
this.post('/api/worktree/start-dev', { projectPath, worktreePath }),
|
||||
stopDevServer: (worktreePath: string) => this.post('/api/worktree/stop-dev', { worktreePath }),
|
||||
listDevServers: () => this.post('/api/worktree/list-dev-servers', {}),
|
||||
getDevServerLogs: (worktreePath: string): Promise<DevServerLogsResponse> =>
|
||||
this.get(`/api/worktree/dev-server-logs?worktreePath=${encodeURIComponent(worktreePath)}`),
|
||||
onDevServerLogEvent: (callback: (event: DevServerLogEvent) => void) => {
|
||||
const unsub1 = this.subscribeToEvent('dev-server:started', (payload) =>
|
||||
callback({ type: 'dev-server:started', payload: payload as DevServerStartedEvent })
|
||||
);
|
||||
const unsub2 = this.subscribeToEvent('dev-server:output', (payload) =>
|
||||
callback({ type: 'dev-server:output', payload: payload as DevServerOutputEvent })
|
||||
);
|
||||
const unsub3 = this.subscribeToEvent('dev-server:stopped', (payload) =>
|
||||
callback({ type: 'dev-server:stopped', payload: payload as DevServerStoppedEvent })
|
||||
);
|
||||
return () => {
|
||||
unsub1();
|
||||
unsub2();
|
||||
unsub3();
|
||||
};
|
||||
},
|
||||
getPRInfo: (worktreePath: string, branchName: string) =>
|
||||
this.post('/api/worktree/pr-info', { worktreePath, branchName }),
|
||||
// Init script methods
|
||||
|
||||
37
apps/ui/src/types/electron.d.ts
vendored
37
apps/ui/src/types/electron.d.ts
vendored
@@ -978,6 +978,43 @@ export interface WorktreeAPI {
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Get buffered logs for a dev server
|
||||
getDevServerLogs: (worktreePath: string) => Promise<{
|
||||
success: boolean;
|
||||
result?: {
|
||||
worktreePath: string;
|
||||
port: number;
|
||||
logs: string;
|
||||
startedAt: string;
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Subscribe to dev server log events (started, output, stopped)
|
||||
onDevServerLogEvent: (
|
||||
callback: (
|
||||
event:
|
||||
| {
|
||||
type: 'dev-server:started';
|
||||
payload: { worktreePath: string; port: number; url: string; timestamp: string };
|
||||
}
|
||||
| {
|
||||
type: 'dev-server:output';
|
||||
payload: { worktreePath: string; content: string; timestamp: string };
|
||||
}
|
||||
| {
|
||||
type: 'dev-server:stopped';
|
||||
payload: {
|
||||
worktreePath: string;
|
||||
port: number;
|
||||
exitCode: number | null;
|
||||
error?: string;
|
||||
timestamp: string;
|
||||
};
|
||||
}
|
||||
) => void
|
||||
) => () => void;
|
||||
|
||||
// Get PR info and comments for a branch
|
||||
getPRInfo: (
|
||||
worktreePath: string,
|
||||
|
||||
@@ -42,6 +42,9 @@ export type EventType =
|
||||
| 'ideation:idea-converted'
|
||||
| 'worktree:init-started'
|
||||
| 'worktree:init-output'
|
||||
| 'worktree:init-completed';
|
||||
| 'worktree:init-completed'
|
||||
| 'dev-server:started'
|
||||
| 'dev-server:output'
|
||||
| 'dev-server:stopped';
|
||||
|
||||
export type EventCallback = (type: EventType, payload: unknown) => void;
|
||||
|
||||
Reference in New Issue
Block a user