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'; import { IconButton } from './IconButton'; import classNames from 'classnames'; 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; onClose: () => void }) => 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); const [isPanelOpen, setIsPanelOpen] = useState(false); // 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, closePanel: () => setIsPanelOpen(false), openPanel: () => setIsPanelOpen(true), }); // Handle virtualizer ready callback const handleVirtualizerReady = useCallback( (virtualizer: Virtualizer) => { virtualizerRef.current = virtualizer; }, [], ); // Handle row click - select and open panel, scroll into view const handleRowClick = useCallback( (index: number) => { setActiveIndex(index); setIsPanelOpen(true); // Scroll to ensure selected item is visible after panel opens requestAnimationFrame(() => { virtualizerRef.current?.scrollToIndex(index, { align: 'auto' }); }); }, [setActiveIndex], ); const handleClose = useCallback(() => { setIsPanelOpen(false); }, []); 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 && isPanelOpen ? ({ style }) => (
{renderDetail({ event: activeEvent, index: activeIndex ?? 0, onClose: handleClose })}
) : 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: string; prefix?: ReactNode; timestamp?: string; actions?: EventDetailAction[]; copyText?: string; onClose?: () => void; } export function EventDetailHeader({ title, prefix, timestamp, actions, copyText, onClose, }: EventDetailHeaderProps) { const formattedTime = timestamp ? format(new Date(`${timestamp}Z`), 'HH:mm:ss.SSS') : null; return (
{prefix}

{title}

{actions?.map((action) => ( ))} {copyText != null && ( )} {formattedTime && ( {formattedTime} )}
0 && "border-l border-l-surface-highlight ml-2 pl-3")}>
); }