Files
yaak/src-web/components/responseViewers/EventStreamViewer.tsx
2024-12-31 07:31:43 -08:00

232 lines
7.3 KiB
TypeScript

import { useVirtualizer } from '@tanstack/react-virtual';
import type { HttpResponse } from '@yaakapp-internal/models';
import type { ServerSentEvent } from '@yaakapp-internal/sse';
import classNames from 'classnames';
import { motion } from 'framer-motion';
import React, { Fragment, useMemo, useRef, useState } from 'react';
import { useFormatText } from '../../hooks/useFormatText';
import { useResponseBodyEventSource } from '../../hooks/useResponseBodyEventSource';
import { isJSON } from '../../lib/contentType';
import { Button } from '../core/Button';
import type { EditorProps } from '../core/Editor/Editor';
import { Editor } from '../core/Editor/Editor';
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 {
response: HttpResponse;
}
export function EventStreamViewer({ response }: Props) {
return (
<Fragment
key={response.id} // force a refresh when the response changes
>
<ActualEventStreamViewer response={response} />
</Fragment>
);
}
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"
defaultRatio={0.4}
minHeightPx={20}
firstSlot={() => (
<EventStreamEventsVirtual
events={events.data ?? []}
activeEventIndex={activeEventIndex}
setActiveEventIndex={setActiveEventIndex}
/>
)}
secondSlot={
activeEvent
? () => (
<div className="grid grid-rows-[auto_minmax(0,1fr)]">
<div className="pb-3 px-2">
<Separator />
</div>
<div className="pl-2 overflow-y-auto">
<HStack space={1.5} className="mb-2 select-text cursor-text 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 FormattedEditor({ text, language }: { text: string; language: EditorProps['language'] }) {
const formatted = useFormatText({ text, language, pretty: true });
if (formatted.data == null) return null;
return <Editor readOnly defaultValue={formatted.data} language={language} stateKey={null} />;
}
function EventStreamEventsVirtual({
events,
activeEventIndex,
setActiveEventIndex,
}: {
events: ServerSentEvent[];
activeEventIndex: number | null;
setActiveEventIndex: (eventId: number | null) => void;
}) {
// The scrollable element for your list
const parentRef = useRef<HTMLDivElement>(null);
// The virtualizer
const rowVirtualizer = useVirtualizer({
count: events.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 28, // react-virtual requires a height, so we'll give it one
});
return (
<div ref={parentRef} className="overflow-y-auto">
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{rowVirtualizer.getVirtualItems().map((virtualItem) => {
const event = events[virtualItem.index]!;
return (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
<EventStreamEvent
event={event}
isActive={virtualItem.index === activeEventIndex}
index={virtualItem.index}
onClick={() => {
if (virtualItem.index === activeEventIndex) setActiveEventIndex(null);
else setActiveEventIndex(virtualItem.index);
}}
/>
</div>
);
})}
</div>
</div>
);
}
function EventStreamEvent({
onClick,
isActive,
event,
className,
index,
}: {
onClick: () => void;
isActive: boolean;
event: ServerSentEvent;
className?: string;
index: number;
}) {
return (
<motion.button
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
onClick={onClick}
className={classNames(
className,
'w-full grid grid-cols-[auto_auto_minmax(0,3fr)] gap-2 items-center text-left',
'px-1.5 py-1 font-mono cursor-default group focus:outline-none rounded',
isActive && '!bg-surface-highlight !text-text',
'text-text-subtle hover:text',
)}
>
<Icon className={classNames('text-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>
</motion.button>
);
}
function EventLabels({
className,
event,
index,
isActive,
}: {
event: ServerSentEvent;
index: number;
className: string;
isActive?: boolean;
}) {
return (
<HStack space={1.5} alignItems="center" className={className}>
<InlineCode className={classNames('py-0', isActive && 'bg-text-subtlest text-text')}>
{event.id ?? index}
</InlineCode>
{event.eventType && (
<InlineCode className={classNames('py-0', isActive && 'bg-text-subtlest text-text')}>
{event.eventType}
</InlineCode>
)}
</HStack>
);
}