import { ArrowDown, ChevronDown, ChevronUp, Search, WrapText, X } from "lucide-react"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { LogEvent, LogStream } from "@/contracts"; import { createAnsiProcessor, type AnsiProcessor, type AnsiSpan } from "@/client/lib"; import { isLogViewerPinnedToLatest } from "@/client/components/log-viewer-scroll"; const STREAM_COLORS: Record = { stdout: "text-zinc-300", stderr: "text-red-400", system: "text-accent-400", }; const STREAM_DOT: Record = { stdout: "bg-zinc-400", stderr: "bg-red-400", system: "bg-accent-400", }; const CONNECTION_DOT: Record = { connected: "bg-emerald-500", connecting: "bg-amber-500 animate-pulse", reconnecting: "bg-amber-500 animate-pulse", closed: "bg-zinc-500", idle: "bg-zinc-500", }; const ALL_STREAMS: readonly LogStream[] = ["stdout", "stderr", "system"]; const BASE_PADDING_BOTTOM_PX = 16; const JUMP_PADDING_BOTTOM_PX = 48; interface HighlightRange { start: number; end: number; matchIndex: number; } interface FilteredEntry { log: LogEvent; spans: AnsiSpan[]; index: number; } interface ProcessedLogCache { proc: AnsiProcessor; results: AnsiSpan[][]; firstId: string | null; lastId: string | null; } interface MatchPosition { entryIndex: number; charStart: number; charEnd: number; } const isUnstyled = (s: AnsiSpan) => !s.fg && !s.bg && !s.bold && !s.dim && !s.italic && !s.underline && !s.strikethrough; const spanStyle = (span: AnsiSpan): React.CSSProperties | undefined => { if (isUnstyled(span)) return undefined; const s: React.CSSProperties = {}; if (span.fg) s.color = span.fg; if (span.bg) s.backgroundColor = span.bg; if (span.bold) s.fontWeight = "bold"; if (span.dim) s.opacity = 0.5; if (span.italic) s.fontStyle = "italic"; const deco = [span.underline && "underline", span.strikethrough && "line-through"].filter(Boolean).join(" "); if (deco) s.textDecorationLine = deco; return s; }; const canAppendProcessedLogs = (cache: ProcessedLogCache, logs: LogEvent[]) => { if (cache.results.length === 0 || logs.length <= cache.results.length) { return false; } if (logs[0]!.id !== cache.firstId) { return false; } return logs[cache.results.length - 1]!.id === cache.lastId; }; /** * Render a single chunk's ANSI spans, optionally splitting them at search * highlight boundaries. */ const AnsiLine = memo( function AnsiLine({ spans, className, highlights, currentMatchIndex, }: { spans: AnsiSpan[]; className: string; highlights?: HighlightRange[]; currentMatchIndex?: number; }) { // Fast path: no highlights, single unstyled span if (!highlights && spans.length === 1 && isUnstyled(spans[0]!)) { return {spans[0]!.text}; } // No highlights — render spans without splitting if (!highlights) { return ( {spans.map((span, i) => { const s = spanStyle(span); return s ? ( {span.text} ) : ( span.text ); })} ); } // With highlights — split spans at highlight boundaries const elements: React.ReactNode[] = []; let charOffset = 0; let hi = 0; // highlight pointer let key = 0; for (const span of spans) { const s = spanStyle(span); const spanEnd = charOffset + span.text.length; let pos = 0; // position within span.text while (pos < span.text.length && hi < highlights.length) { const h = highlights[hi]!; const absPos = charOffset + pos; // Before highlight start — emit plain segment if (absPos < h.start) { const segEnd = Math.min(span.text.length, h.start - charOffset); const seg = span.text.slice(pos, segEnd); if (seg) elements.push( s ? ( {seg} ) : ( seg ), ); pos = segEnd; continue; } // Inside highlight — emit marked segment if (absPos < h.end) { const segEnd = Math.min(span.text.length, h.end - charOffset); const seg = span.text.slice(pos, segEnd); if (seg) { elements.push( {seg} , ); } pos = segEnd; if (charOffset + pos >= h.end) hi++; continue; } // Past this highlight — advance hi++; } // Remaining text after all highlights if (pos < span.text.length) { const seg = span.text.slice(pos); elements.push( s ? ( {seg} ) : ( seg ), ); } charOffset = spanEnd; } return {elements}; }, (prev, next) => { if (prev.spans !== next.spans || prev.className !== next.className) return false; if (prev.highlights !== next.highlights) return false; if (!prev.highlights) return true; if (prev.currentMatchIndex === next.currentMatchIndex) return true; const affects = (idx: number | undefined) => idx !== undefined && prev.highlights!.some((h) => h.matchIndex === idx); return !affects(prev.currentMatchIndex) && !affects(next.currentMatchIndex); }, ); /** Search bar – owns local input state + debounce; parent only sees the settled query. */ const SearchBar = memo(function SearchBar({ searchActive, matchCount, currentMatchIndex, onQueryChange, onNext, onPrev, onClose, }: { searchActive: boolean; matchCount: number; currentMatchIndex: number; onQueryChange: (query: string) => void; onNext: () => void; onPrev: () => void; onClose: () => void; }) { const inputRef = useRef(null); const [query, setQuery] = useState(""); const debounceRef = useRef | undefined>(undefined); useEffect(() => { clearTimeout(debounceRef.current); if (!query) { onQueryChange(""); return; } debounceRef.current = setTimeout(() => onQueryChange(query), 500); return () => clearTimeout(debounceRef.current); }, [query, onQueryChange]); useEffect(() => { inputRef.current?.focus(); }, []); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); if (e.shiftKey) onPrev(); else onNext(); } if (e.key === "Escape") { e.preventDefault(); onClose(); } }, [onNext, onPrev, onClose], ); return (
); }); const LogViewer = memo(function LogViewer({ logs, logStreamStatus }: { logs: LogEvent[]; logStreamStatus: string }) { // -- Refs ---------------------------------------------------------------- const logContainerRef = useRef(null); const autoScrollRef = useRef(true); const scrollingToBottomRef = useRef(false); const sectionRef = useRef(null); const matchLineRefs = useRef>(new Map()); const paddingBottomRef = useRef(BASE_PADDING_BOTTOM_PX); // -- Scroll state -------------------------------------------------------- const [atBottom, setAtBottom] = useState(true); const handleScroll = useCallback(() => { const container = logContainerRef.current; if (!container) return; const isAtBottom = isLogViewerPinnedToLatest({ scrollTop: container.scrollTop, clientHeight: container.clientHeight, scrollHeight: container.scrollHeight, paddingBottom: paddingBottomRef.current, }); setAtBottom(isAtBottom); if (isAtBottom) { scrollingToBottomRef.current = false; autoScrollRef.current = true; return; } if (!scrollingToBottomRef.current) { autoScrollRef.current = false; } }, []); const scrollToBottom = useCallback(() => { const container = logContainerRef.current; if (!container) return; scrollingToBottomRef.current = true; autoScrollRef.current = true; container.scrollTo({ top: container.scrollHeight, behavior: "smooth" }); }, []); const handleScrollTakeover = useCallback(() => { if (!scrollingToBottomRef.current) return; const container = logContainerRef.current; scrollingToBottomRef.current = false; if (!container) { autoScrollRef.current = false; return; } const isAtBottom = isLogViewerPinnedToLatest({ scrollTop: container.scrollTop, clientHeight: container.clientHeight, scrollHeight: container.scrollHeight, paddingBottom: paddingBottomRef.current, }); setAtBottom(isAtBottom); autoScrollRef.current = isAtBottom; }, []); // Auto-scroll on new logs useLayoutEffect(() => { const container = logContainerRef.current; if (!container || !autoScrollRef.current) return; container.scrollTop = container.scrollHeight; const isAtBottom = isLogViewerPinnedToLatest({ scrollTop: container.scrollTop, clientHeight: container.clientHeight, scrollHeight: container.scrollHeight, paddingBottom: paddingBottomRef.current, }); setAtBottom(isAtBottom); if (isAtBottom) { scrollingToBottomRef.current = false; } }, [logs]); // -- Incremental ANSI processing ----------------------------------------- const processorRef = useRef({ proc: createAnsiProcessor(), results: [], firstId: null, lastId: null, }); // Incremental processing — imperative ref update (idempotent, StrictMode-safe) const cache = processorRef.current; if (logs.length === 0) { if (cache.results.length > 0) { processorRef.current = { proc: createAnsiProcessor(), results: [], firstId: null, lastId: null }; } } else if ( cache.firstId === logs[0]!.id && cache.lastId === logs[logs.length - 1]!.id && cache.results.length === logs.length ) { // Already fully processed (double-invocation or unchanged logs) — no-op } else if (canAppendProcessedLogs(cache, logs)) { for (let i = cache.results.length; i < logs.length; i++) { cache.results.push(cache.proc.feed(logs[i]!.chunk)); } cache.lastId = logs[logs.length - 1]!.id; } else { const proc = createAnsiProcessor(); const results = logs.map((log) => proc.feed(log.chunk)); processorRef.current = { proc, results, firstId: logs[0]!.id, lastId: logs[logs.length - 1]!.id }; } const processedLogs = processorRef.current.results; // -- Stream filtering ---------------------------------------------------- const [visibleStreams, setVisibleStreams] = useState>(new Set(ALL_STREAMS)); const streamCounts = useMemo(() => { const counts: Record = { stdout: 0, stderr: 0, system: 0 }; for (const log of logs) counts[log.stream]++; return counts; }, [logs]); const toggleStream = useCallback((stream: LogStream) => { setVisibleStreams((prev) => { const next = new Set(prev); if (next.has(stream)) next.delete(stream); else next.add(stream); return next; }); }, []); const filteredEntries = useMemo(() => { const entries: FilteredEntry[] = []; for (let i = 0; i < logs.length; i++) { if (visibleStreams.has(logs[i]!.stream)) { entries.push({ log: logs[i]!, spans: processedLogs[i]!, index: i }); } } return entries; }, [logs, processedLogs, visibleStreams]); // -- Text wrap ----------------------------------------------------------- const [textWrap, setTextWrap] = useState(true); // -- Search -------------------------------------------------------------- const [searchOpen, setSearchOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [currentMatchIndex, setCurrentMatchIndex] = useState(0); const searchMatches = useMemo(() => { if (!searchQuery) return []; const matches: MatchPosition[] = []; const needle = searchQuery.toLowerCase(); for (let ei = 0; ei < filteredEntries.length; ei++) { const text = filteredEntries[ei]!.spans.map((s) => s.text) .join("") .toLowerCase(); let pos = 0; while (pos <= text.length - needle.length) { const idx = text.indexOf(needle, pos); if (idx === -1) break; matches.push({ entryIndex: ei, charStart: idx, charEnd: idx + needle.length }); pos = idx + 1; } } return matches; }, [filteredEntries, searchQuery]); // Clamp match index useEffect(() => { if (searchMatches.length === 0) { setCurrentMatchIndex(0); } else if (currentMatchIndex >= searchMatches.length) { setCurrentMatchIndex(searchMatches.length - 1); } }, [searchMatches.length, currentMatchIndex]); // Scroll to current match useEffect(() => { if (searchMatches.length === 0) return; const match = searchMatches[currentMatchIndex]; if (!match) return; const el = matchLineRefs.current.get(match.entryIndex); if (el) { scrollingToBottomRef.current = false; el.scrollIntoView({ block: "nearest", behavior: "smooth" }); autoScrollRef.current = false; } }, [currentMatchIndex, searchMatches]); // Highlight map: entryIndex → HighlightRange[] (stable across match navigation) const lineHighlights = useMemo(() => { if (!searchQuery || searchMatches.length === 0) return null; const map = new Map(); for (let mi = 0; mi < searchMatches.length; mi++) { const m = searchMatches[mi]!; let list = map.get(m.entryIndex); if (!list) { list = []; map.set(m.entryIndex, list); } list.push({ start: m.charStart, end: m.charEnd, matchIndex: mi }); } return map; }, [searchQuery, searchMatches]); const goToNextMatch = useCallback(() => { if (searchMatches.length === 0) return; setCurrentMatchIndex((i) => (i + 1) % searchMatches.length); }, [searchMatches.length]); const goToPrevMatch = useCallback(() => { if (searchMatches.length === 0) return; setCurrentMatchIndex((i) => (i - 1 + searchMatches.length) % searchMatches.length); }, [searchMatches.length]); const openSearch = useCallback(() => setSearchOpen(true), []); const closeSearch = useCallback(() => { setSearchOpen(false); setSearchQuery(""); setCurrentMatchIndex(0); matchLineRefs.current.clear(); }, []); const handleQueryChange = useCallback((q: string) => { setSearchQuery(q); setCurrentMatchIndex(0); }, []); // Ctrl/Cmd+F shortcut (guarded to viewport) useEffect(() => { const handler = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === "f") { const section = sectionRef.current; if (!section) return; const rect = section.getBoundingClientRect(); if (rect.bottom < 0 || rect.top > window.innerHeight) return; e.preventDefault(); openSearch(); } }; document.addEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler); }, [openSearch]); // -- Render -------------------------------------------------------------- const showJumpButton = !atBottom && filteredEntries.length > 0; paddingBottomRef.current = showJumpButton ? JUMP_PADDING_BOTTOM_PX : BASE_PADDING_BOTTOM_PX; return (
{/* Header */}
{/* Top row: title + search icon + connection status */}

Logs

{/* Stream filter chips */}
{ALL_STREAMS.map((stream) => { const active = visibleStreams.has(stream); return ( ); })}
{/* Search toggle */} {!searchOpen ? ( ) : null} {/* Wrap toggle */} {/* Connection status */} {logStreamStatus}
{/* Search bar (separate row when open) */} {searchOpen ? ( ) : null}
{/* Log scroll area + jump-to-bottom */}
{filteredEntries.length === 0 ? (

{logs.length === 0 ? "No log output yet." : "No logs match the current filters."}

) : ( filteredEntries.map((entry, i) => (
{ if (el) matchLineRefs.current.set(i, el); else matchLineRefs.current.delete(i); } : undefined } className={`flex gap-3${textWrap ? " min-w-0" : " whitespace-nowrap"}`} > {entry.log.seq}
)) )}
{/* Jump to bottom */} {showJumpButton ? ( ) : null}
); }); export { LogViewer };