Refactor: Consolidate event viewers into unified EventViewer component

Migrate EventStreamViewer, HttpResponseTimeline, GrpcResponsePane, and
WebsocketResponsePane to use a shared EventViewer component with generic
event type support, render props for rows and details, and keyboard
navigation (↑/↓/j/k/Escape). This reduces duplication and provides a
consistent event viewing experience across all response types.
This commit is contained in:
Gregory Schier
2026-01-09 15:33:02 -08:00
parent 2bf7cf5eeb
commit 928099c6fd
8 changed files with 854 additions and 625 deletions

View File

@@ -5,15 +5,13 @@ import { Fragment, useMemo, useState } from 'react';
import { useFormatText } from '../../hooks/useFormatText';
import { useResponseBodyEventSource } from '../../hooks/useResponseBodyEventSource';
import { isJSON } from '../../lib/contentType';
import { AutoScroller } from '../core/AutoScroller';
import { Banner } from '../core/Banner';
import { Button } from '../core/Button';
import type { EditorProps } from '../core/Editor/Editor';
import { Editor } from '../core/Editor/LazyEditor';
import { EventViewer } from '../core/EventViewer';
import { EventViewerRow } from '../core/EventViewerRow';
import { Icon } from '../core/Icon';
import { InlineCode } from '../core/InlineCode';
import { Separator } from '../core/Separator';
import { SplitLayout } from '../core/SplitLayout';
import { HStack, VStack } from '../core/Stacks';
interface Props {
@@ -33,134 +31,103 @@ export function EventStreamViewer({ response }: Props) {
function ActualEventStreamViewer({ response }: Props) {
const [showLarge, setShowLarge] = useState<boolean>(false);
const [showingLarge, setShowingLarge] = useState<boolean>(false);
const [activeEventIndex, setActiveEventIndex] = useState<number | null>(null);
const events = useResponseBodyEventSource(response);
const activeEvent = useMemo(
() => (activeEventIndex == null ? null : events.data?.[activeEventIndex]),
[activeEventIndex, events],
);
const language = useMemo<'text' | 'json'>(() => {
if (!activeEvent?.data) return 'text';
return isJSON(activeEvent?.data) ? 'json' : 'text';
}, [activeEvent?.data]);
return (
<SplitLayout
layout="vertical"
name="grpc_events"
<EventViewer
events={events.data ?? []}
getEventKey={(_, index) => String(index)}
error={events.error ? String(events.error) : null}
splitLayoutName="sse_events"
defaultRatio={0.4}
minHeightPx={20}
firstSlot={() => (
<AutoScroller
data={events.data ?? []}
header={
events.error && (
<Banner color="danger" className="m-3">
{String(events.error)}
</Banner>
)
renderRow={({ event, index, isActive, onClick }) => (
<EventViewerRow
isActive={isActive}
onClick={onClick}
icon={<Icon color="info" title="Server Message" icon="arrow_big_down_dash" />}
content={
<HStack space={2} className="items-center">
<EventLabels event={event} index={index} isActive={isActive} />
<span className="truncate text-xs">{event.data.slice(0, 1000)}</span>
</HStack>
}
render={(event, i) => (
<EventRow
event={event}
isActive={i === activeEventIndex}
index={i}
onClick={() => {
if (i === activeEventIndex) setActiveEventIndex(null);
else setActiveEventIndex(i);
}}
/>
)}
timestamp={new Date().toISOString().slice(0, -1)} // SSE events don't have timestamps
/>
)}
renderDetail={({ event, index }) => (
<EventDetail
event={event}
index={index}
showLarge={showLarge}
showingLarge={showingLarge}
setShowLarge={setShowLarge}
setShowingLarge={setShowingLarge}
/>
)}
secondSlot={
activeEvent
? () => (
<div className="grid grid-rows-[auto_minmax(0,1fr)]">
<div className="pb-3 px-2">
<Separator />
</div>
<div className="flex flex-col pl-2">
<HStack space={1.5} className="mb-2 font-semibold">
<EventLabels
className="text-sm"
event={activeEvent}
index={activeEventIndex ?? 0}
/>
Message Received
</HStack>
{!showLarge && activeEvent.data.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest">
Message previews larger than 1MB are hidden
<div>
<Button
onClick={() => {
setShowingLarge(true);
setTimeout(() => {
setShowLarge(true);
setShowingLarge(false);
}, 500);
}}
isLoading={showingLarge}
color="secondary"
variant="border"
size="xs"
>
Try Showing
</Button>
</div>
</VStack>
) : (
<FormattedEditor language={language} text={activeEvent.data} />
)}
</div>
</div>
)
: null
}
/>
);
}
function EventDetail({
event,
index,
showLarge,
showingLarge,
setShowLarge,
setShowingLarge,
}: {
event: ServerSentEvent;
index: number;
showLarge: boolean;
showingLarge: boolean;
setShowLarge: (v: boolean) => void;
setShowingLarge: (v: boolean) => void;
}) {
const language = useMemo<'text' | 'json'>(() => {
if (!event?.data) return 'text';
return isJSON(event?.data) ? 'json' : 'text';
}, [event?.data]);
return (
<div className="flex flex-col h-full">
<HStack space={1.5} className="mb-2 font-semibold">
<EventLabels className="text-sm" event={event} index={index} />
Message Received
</HStack>
{!showLarge && event.data.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest">
Message previews larger than 1MB are hidden
<div>
<Button
onClick={() => {
setShowingLarge(true);
setTimeout(() => {
setShowLarge(true);
setShowingLarge(false);
}, 500);
}}
isLoading={showingLarge}
color="secondary"
variant="border"
size="xs"
>
Try Showing
</Button>
</div>
</VStack>
) : (
<FormattedEditor language={language} text={event.data} />
)}
</div>
);
}
function FormattedEditor({ text, language }: { text: string; language: EditorProps['language'] }) {
const formatted = useFormatText({ text, language, pretty: true });
if (formatted == null) return null;
return <Editor readOnly defaultValue={formatted} language={language} stateKey={null} />;
}
function EventRow({
onClick,
isActive,
event,
className,
index,
}: {
onClick: () => void;
isActive: boolean;
event: ServerSentEvent;
className?: string;
index: number;
}) {
return (
<button
type="button"
onClick={onClick}
className={classNames(
className,
'w-full grid grid-cols-[auto_auto_minmax(0,3fr)] gap-2 items-center text-left',
'-mx-1.5 px-1.5 h-xs font-mono group focus:outline-none focus:text-text rounded',
isActive && '!bg-surface-active !text-text',
'text-text-subtle hover:text',
)}
>
<Icon color="info" title="Server Message" icon="arrow_big_down_dash" />
<EventLabels className="text-sm" event={event} isActive={isActive} index={index} />
<div className={classNames('w-full truncate text-xs')}>{event.data.slice(0, 1000)}</div>
</button>
);
}
function EventLabels({
className,
event,
@@ -169,7 +136,7 @@ function EventLabels({
}: {
event: ServerSentEvent;
index: number;
className: string;
className?: string;
isActive?: boolean;
}) {
return (