import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { ChevronDown, Loader2, Maximize2, Minimize2, Search } from "lucide-react"; import type { DevServerLogEntry } from "../hooks/useDevServerLogs"; interface DevServerLogViewerProps { entries: DevServerLogEntry[]; loading: boolean; loadingMore: boolean; hasMore: boolean; total: number | null; onLoadMore: () => void; /** Whether the dev server is currently running (affects auto-scroll behavior) */ isRunning: boolean; } // eslint-disable-next-line no-control-regex -- ANSI escape stripping is required for readable terminal logs. const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*m/g; function stripAnsi(value: string): string { return value.replace(ANSI_ESCAPE_PATTERN, ""); } function formatTime(value: string): string { if (!value) { return ""; } const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) { return ""; } return parsed.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, }); } function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function highlightText(value: string, search: string): ReactNode { if (!search) { return value; } const matcher = new RegExp(`(${escapeRegExp(search)})`, "ig"); const parts = value.split(matcher); const normalizedSearch = search.toLowerCase(); return ( <> {parts.map((part, index) => ( part.toLowerCase() === normalizedSearch ? {part} : {part} ))} > ); } export function DevServerLogViewer({ entries, loading, loadingMore, hasMore, total, onLoadMore, isRunning, }: DevServerLogViewerProps) { const containerRef = useRef(null); const prevEntryCountRef = useRef(entries.length); const prevRunningRef = useRef(isRunning); const [isFullscreen, setIsFullscreen] = useState(false); const [isUserScrolling, setIsUserScrolling] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const filteredEntries = useMemo(() => { const normalizedSearch = searchQuery.trim().toLowerCase(); if (!normalizedSearch) { return entries; } return entries.filter((entry) => stripAnsi(entry.text).toLowerCase().includes(normalizedSearch)); }, [entries, searchQuery]); const matchCount = filteredEntries.length; const scrollToBottom = useCallback(() => { const container = containerRef.current; if (!container) { return; } container.scrollTop = container.scrollHeight; setIsUserScrolling(false); }, []); useEffect(() => { const previousRunning = prevRunningRef.current; const previousEntries = prevEntryCountRef.current; const hasNewEntries = entries.length > previousEntries; if (isRunning && (!previousRunning || (!isUserScrolling && hasNewEntries))) { scrollToBottom(); } prevRunningRef.current = isRunning; prevEntryCountRef.current = entries.length; }, [entries.length, isRunning, isUserScrolling, scrollToBottom]); const handleScroll = useCallback(() => { const container = containerRef.current; if (!container) { return; } const threshold = 50; const atBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - threshold; setIsUserScrolling(!atBottom); }, []); useEffect(() => { if (loading || entries.length === 0) { return; } // Keep current behavior consistent when filtering or loading more while at bottom. if (!isUserScrolling && isRunning) { scrollToBottom(); } }, [entries, isRunning, isUserScrolling, loading, scrollToBottom]); if (loading && entries.length === 0) { return ( Loading logs… ); } return ( Logs {total !== null ? `${entries.length}/${total}` : `${entries.length}`} lines setSearchQuery(event.target.value)} placeholder="Search logs" data-testid="devserver-log-search-input" /> {searchQuery.trim().length > 0 && ( {matchCount} match{matchCount === 1 ? "" : "es"} )} setIsFullscreen((prev) => !prev)} data-testid="devserver-log-fullscreen-toggle" aria-label={isFullscreen ? "Exit fullscreen logs" : "Enter fullscreen logs"} > {isFullscreen ? : } {hasMore && ( {loadingMore ? ( <> Loading older logs… > ) : ( "Load older logs" )} )} {!loading && filteredEntries.length === 0 && ( {entries.length === 0 ? "No logs yet. Start the dev server to see output." : "No log lines match your search."} )} {filteredEntries.map((entry) => { const plainText = stripAnsi(entry.text); const timestamp = formatTime(entry.timestamp); return ( {timestamp && ( {timestamp} )} {entry.stream === "stderr" && ( ERR )} {highlightText(plainText, searchQuery.trim())} ); })} {isUserScrolling && isRunning && ( New logs )} ); } export type { DevServerLogViewerProps };
{entries.length === 0 ? "No logs yet. Start the dev server to see output." : "No log lines match your search."}