import type { Virtualizer } from '@tanstack/react-virtual'; import { format } from 'date-fns'; import type { ReactNode } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react'; import { useEventViewerKeyboard } from '../../hooks/useEventViewerKeyboard'; import { CopyIconButton } from '../CopyIconButton'; import { AutoScroller } from './AutoScroller'; import { Banner } from './Banner'; import { Button } from './Button'; import { Separator } from './Separator'; import { SplitLayout } from './SplitLayout'; import { HStack } from './Stacks'; interface EventViewerProps { /** Array of events to display */ events: T[]; /** Get unique key for each event */ getEventKey: (event: T, index: number) => string; /** Render the event row - receives event, index, isActive, and onClick */ renderRow: (props: { event: T; index: number; isActive: boolean; onClick: () => void; }) => ReactNode; /** Render the detail pane for the selected event */ renderDetail?: (props: { event: T; index: number }) => ReactNode; /** Optional header above the event list (e.g., connection status) */ header?: ReactNode; /** Error message to display as a banner */ error?: string | null; /** Name for SplitLayout state persistence */ splitLayoutName: string; /** Default ratio for the split (0.0 - 1.0) */ defaultRatio?: number; /** Enable keyboard navigation (arrow keys) */ enableKeyboardNav?: boolean; /** Loading state */ isLoading?: boolean; /** Message to show while loading */ loadingMessage?: string; /** Message to show when no events */ emptyMessage?: string; /** Callback when active index changes (for controlled state in parent) */ onActiveIndexChange?: (index: number | null) => void; } export function EventViewer({ events, getEventKey, renderRow, renderDetail, header, error, splitLayoutName, defaultRatio = 0.4, enableKeyboardNav = true, isLoading = false, loadingMessage = 'Loading events...', emptyMessage = 'No events recorded', onActiveIndexChange, }: EventViewerProps) { const [activeIndex, setActiveIndexInternal] = useState(null); // Wrap setActiveIndex to notify parent const setActiveIndex = useCallback( (indexOrUpdater: number | null | ((prev: number | null) => number | null)) => { setActiveIndexInternal((prev) => { const newIndex = typeof indexOrUpdater === 'function' ? indexOrUpdater(prev) : indexOrUpdater; onActiveIndexChange?.(newIndex); return newIndex; }); }, [onActiveIndexChange], ); const containerRef = useRef(null); const virtualizerRef = useRef | null>(null); const activeEvent = useMemo( () => (activeIndex != null ? events[activeIndex] : null), [activeIndex, events], ); // Check if the event list container is focused const isContainerFocused = useCallback(() => { return containerRef.current?.contains(document.activeElement) ?? false; }, []); // Keyboard navigation useEventViewerKeyboard({ totalCount: events.length, activeIndex, setActiveIndex, virtualizer: virtualizerRef.current, isContainerFocused, enabled: enableKeyboardNav, }); // Handle virtualizer ready callback const handleVirtualizerReady = useCallback( (virtualizer: Virtualizer) => { virtualizerRef.current = virtualizer; }, [], ); // Toggle selection on click const handleRowClick = useCallback( (index: number) => { setActiveIndex((prev) => (prev === index ? null : index)); }, [setActiveIndex], ); if (isLoading) { return
{loadingMessage}
; } if (events.length === 0 && !error) { return
{emptyMessage}
; } return (
(
{header ?? } {error} ) } render={(event, index) => (
{renderRow({ event, index, isActive: index === activeIndex, onClick: () => handleRowClick(index), })}
)} />
)} secondSlot={ activeEvent != null && renderDetail ? ({ style }) => (
{renderDetail({ event: activeEvent, index: activeIndex ?? 0 })}
) : null } />
); } export interface EventDetailAction { /** Unique key for React */ key: string; /** Button label */ label: string; /** Optional icon */ icon?: ReactNode; /** Click handler */ onClick: () => void; } interface EventDetailHeaderProps { /** Title/label for the event */ title: string; /** Timestamp string (ISO format) - will be formatted as HH:mm:ss.SSS */ timestamp?: string; /** Optional action buttons to show before timestamp */ actions?: EventDetailAction[]; /** Text to copy when copy button is clicked - renders a copy icon button after actions */ copyText?: string; } /** Standardized header for event detail panes */ export function EventDetailHeader({ title, timestamp, actions, copyText, }: EventDetailHeaderProps) { const formattedTime = timestamp ? format(new Date(`${timestamp}Z`), 'HH:mm:ss.SSS') : null; return (

{title}

{actions?.map((action) => ( ))} {copyText != null && ( )} {formattedTime && ( {formattedTime} )}
); }