import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { ChevronDown, Loader2, Maximize2, Minimize2, Search } from "lucide-react"; import "./DevServerLogViewer.css"; import type { DevServerLogEntry } from "../hooks/useDevServerLogs"; import { linkifyReactChildren } from "../utils/filePathLinkify"; 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; } type LogSeverity = "info" | "warn" | "error"; type LogSeverityFilter = "all" | LogSeverity; // 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 getEntrySeverity(entry: DevServerLogEntry): LogSeverity { if (entry.stream === "stderr") { return "error"; } const normalizedText = stripAnsi(entry.text).toLowerCase(); if (/\b(warn|warning)\b/.test(normalizedText)) { return "warn"; } if (/\b(error|fatal)\b/.test(normalizedText)) { return "error"; } return "info"; } 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 [severityFilter, setSeverityFilter] = useState("all"); const filteredBySeverity = useMemo(() => { if (severityFilter === "all") { return entries; } return entries.filter((entry) => getEntrySeverity(entry) === severityFilter); }, [entries, severityFilter]); const filteredEntries = useMemo(() => { const normalizedSearch = searchQuery.trim().toLowerCase(); if (!normalizedSearch) { return filteredBySeverity; } return filteredBySeverity.filter((entry) => stripAnsi(entry.text).toLowerCase().includes(normalizedSearch)); }, [filteredBySeverity, 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
{searchQuery.trim().length > 0 && ( {matchCount} match{matchCount === 1 ? "" : "es"} )}
{hasMore && (
)}
{!loading && filteredEntries.length === 0 && (

{entries.length === 0 ? "No logs yet. Start the dev server to see output." : (filteredBySeverity.length === 0 ? "No log lines match the selected severity." : "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 )} {linkifyReactChildren(highlightText(plainText, searchQuery.trim()))}
); })}
{isUserScrolling && isRunning && ( )}
); } export type { DevServerLogViewerProps };