diff --git a/src-web/components/GrpcResponsePane.tsx b/src-web/components/GrpcResponsePane.tsx index bdcc545c..4d36f6ea 100644 --- a/src-web/components/GrpcResponsePane.tsx +++ b/src-web/components/GrpcResponsePane.tsx @@ -1,9 +1,7 @@ import type { GrpcEvent, GrpcRequest } from '@yaakapp-internal/models'; -import classNames from 'classnames'; -import { format } from 'date-fns'; import { useAtomValue, useSetAtom } from 'jotai'; import type { CSSProperties } from 'react'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { activeGrpcConnectionAtom, activeGrpcConnections, @@ -12,17 +10,15 @@ import { } from '../hooks/usePinnedGrpcConnection'; import { useStateWithDeps } from '../hooks/useStateWithDeps'; import { copyToClipboard } from '../lib/copy'; -import { AutoScroller } from './core/AutoScroller'; -import { Banner } from './core/Banner'; import { Button } from './core/Button'; import { Editor } from './core/Editor/LazyEditor'; +import { EventViewer } from './core/EventViewer'; +import { EventViewerRow } from './core/EventViewerRow'; import { HotkeyList } from './core/HotkeyList'; -import { Icon } from './core/Icon'; +import { Icon, type IconProps } from './core/Icon'; import { IconButton } from './core/IconButton'; import { KeyValueRow, KeyValueRows } from './core/KeyValueRow'; import { LoadingIcon } from './core/LoadingIcon'; -import { Separator } from './core/Separator'; -import { SplitLayout } from './core/SplitLayout'; import { HStack, VStack } from './core/Stacks'; import { EmptyStateText } from './EmptyStateText'; import { ErrorBoundary } from './ErrorBoundary'; @@ -42,7 +38,7 @@ interface Props { } export function GrpcResponsePane({ style, methodType, activeRequest }: Props) { - const [activeEventId, setActiveEventId] = useState(null); + const [activeEventIndex, setActiveEventIndex] = useState(null); const [showLarge, setShowLarge] = useStateWithDeps(false, [activeRequest.id]); const [showingLarge, setShowingLarge] = useState(false); const connections = useAtomValue(activeGrpcConnections); @@ -51,8 +47,8 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) { const setPinnedGrpcConnectionId = useSetAtom(pinnedGrpcConnectionIdAtom); const activeEvent = useMemo( - () => events.find((m) => m.id === activeEventId) ?? null, - [activeEventId, events], + () => (activeEventIndex != null ? events[activeEventIndex] : null), + [activeEventIndex, events], ); // Set the active message to the first message received if unary @@ -61,223 +57,198 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) { if (events.length === 0 || activeEvent != null || methodType !== 'unary') { return; } - setActiveEventId(events.find((m) => m.eventType === 'server_message')?.id ?? null); + const firstServerMessageIndex = events.findIndex((m) => m.eventType === 'server_message'); + if (firstServerMessageIndex !== -1) { + setActiveEventIndex(firstServerMessageIndex); + } }, [events.length]); + if (activeConnection == null) { + return ( + + ); + } + + const header = ( + + + {events.length} Messages + {activeConnection.state !== 'closed' && ( + + )} + +
+ +
+
+ ); + return ( - - activeConnection == null ? ( - - ) : ( -
- - - {events.length} Messages - {activeConnection.state !== 'closed' && ( - - )} - -
- -
-
- - - {activeConnection.error} - - ) - } - render={(event) => ( - { - if (event.id === activeEventId) setActiveEventId(null); - else setActiveEventId(event.id); - }} - /> - )} - /> - -
- ) - } - secondSlot={ - activeEvent != null && activeConnection != null - ? () => ( -
-
- -
-
- {activeEvent.eventType === 'client_message' || - activeEvent.eventType === 'server_message' ? ( - <> -
-
- Message {activeEvent.eventType === 'client_message' ? 'Sent' : 'Received'} -
- copyToClipboard(activeEvent.content)} - /> -
- {!showLarge && activeEvent.content.length > 1000 * 1000 ? ( - - Message previews larger than 1MB are hidden -
- -
-
- ) : ( - - )} - - ) : ( -
-
-
- {activeEvent.content} -
- {activeEvent.error && ( -
- {activeEvent.error} -
- )} -
-
- {Object.keys(activeEvent.metadata).length === 0 ? ( - - No{' '} - {activeEvent.eventType === 'connection_end' ? 'trailers' : 'metadata'} - - ) : ( - - {Object.entries(activeEvent.metadata).map(([key, value]) => ( - - {value} - - ))} - - )} -
-
- )} -
-
- ) - : null +
+ + event.id} + error={activeConnection.error} + header={header} + splitLayoutName="grpc_events" + defaultRatio={0.4} + renderRow={({ event, isActive, onClick }) => ( + + )} + renderDetail={({ event }) => ( + + )} + /> + +
+ ); +} + +function GrpcEventRow({ + event, + isActive, + onClick, +}: { + event: GrpcEvent; + isActive: boolean; + onClick: () => void; +}) { + const { eventType, status, content, error } = event; + const display = getEventDisplay(eventType, status); + + return ( + } + content={ + + {content.slice(0, 1000)} + {error && ({error})} + } + timestamp={event.createdAt} /> ); } -function EventRow({ - onClick, - isActive, +function GrpcEventDetail({ event, + showLarge, + showingLarge, + setShowLarge, + setShowingLarge, }: { - onClick?: () => void; - isActive?: boolean; event: GrpcEvent; + showLarge: boolean; + showingLarge: boolean; + setShowLarge: (v: boolean) => void; + setShowingLarge: (v: boolean) => void; }) { - const { eventType, status, createdAt, content, error } = event; - const ref = useRef(null); - - return ( -
- +
+ + ) : ( + )} - > - 0) - ? 'danger' - : eventType === 'connection_end' - ? 'success' - : undefined - } - title={ - eventType === 'server_message' - ? 'Server message' - : eventType === 'client_message' - ? 'Client message' - : eventType === 'error' || (status != null && status > 0) - ? 'Error' - : eventType === 'connection_end' - ? 'Connection response' - : undefined - } - icon={ - eventType === 'server_message' - ? 'arrow_big_down_dash' - : eventType === 'client_message' - ? 'arrow_big_up_dash' - : eventType === 'error' || (status != null && status > 0) - ? 'alert_triangle' - : eventType === 'connection_end' - ? 'check' - : 'info' - } - /> -
- {content.slice(0, 1000)} - {error && ({error})} -
-
- {format(`${createdAt}Z`, 'HH:mm:ss.SSS')} -
- + + ); + } + + // Error or connection_end - show metadata/trailers + return ( +
+
+
{event.content}
+ {event.error && ( +
+ {event.error} +
+ )} +
+
+ {Object.keys(event.metadata).length === 0 ? ( + + No {event.eventType === 'connection_end' ? 'trailers' : 'metadata'} + + ) : ( + + {Object.entries(event.metadata).map(([key, value]) => ( + + {value} + + ))} + + )} +
); } + +function getEventDisplay( + eventType: GrpcEvent['eventType'], + status: GrpcEvent['status'], +): { icon: IconProps['icon']; color: IconProps['color']; title: string } { + if (eventType === 'server_message') { + return { icon: 'arrow_big_down_dash', color: 'info', title: 'Server message' }; + } + if (eventType === 'client_message') { + return { icon: 'arrow_big_up_dash', color: 'primary', title: 'Client message' }; + } + if (eventType === 'error' || (status != null && status > 0)) { + return { icon: 'alert_triangle', color: 'danger', title: 'Error' }; + } + if (eventType === 'connection_end') { + return { icon: 'check', color: 'success', title: 'Connection response' }; + } + return { icon: 'info', color: undefined, title: 'Event' }; +} diff --git a/src-web/components/HttpResponseTimeline.tsx b/src-web/components/HttpResponseTimeline.tsx index fe5fbde3..a9946703 100644 --- a/src-web/components/HttpResponseTimeline.tsx +++ b/src-web/components/HttpResponseTimeline.tsx @@ -3,18 +3,18 @@ import type { HttpResponseEvent, HttpResponseEventData, } from '@yaakapp-internal/models'; -import classNames from 'classnames'; import { format } from 'date-fns'; -import { type ReactNode, useMemo, useState } from 'react'; +import { type ReactNode, useState } from 'react'; import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents'; -import { AutoScroller } from './core/AutoScroller'; -import { Banner } from './core/Banner'; +import { Button } from './core/Button'; +import { Editor } from './core/Editor/LazyEditor'; +import { EventViewer } from './core/EventViewer'; +import { EventViewerRow } from './core/EventViewerRow'; import { HttpMethodTagRaw } from './core/HttpMethodTag'; import { HttpStatusTagRaw } from './core/HttpStatusTag'; import { Icon, type IconProps } from './core/Icon'; import { KeyValueRow, KeyValueRows } from './core/KeyValueRow'; -import { Separator } from './core/Separator'; -import { SplitLayout } from './core/SplitLayout'; +import { HStack } from './core/Stacks'; interface Props { response: HttpResponse; @@ -25,113 +25,73 @@ export function HttpResponseTimeline({ response }: Props) { } function Inner({ response }: Props) { - const [activeEventIndex, setActiveEventIndex] = useState(null); + const [showRaw, setShowRaw] = useState(false); const { data: events, error, isLoading } = useHttpResponseEvents(response); - const activeEvent = useMemo( - () => (activeEventIndex == null ? null : events?.[activeEventIndex]), - [activeEventIndex, events], - ); - - if (isLoading) { - return
Loading events...
; - } - - if (error) { - return ( - - {String(error)} - - ); - } - - if (!events || events.length === 0) { - return
No events recorded
; - } - return ( - event.id} + error={error ? String(error) : null} + isLoading={isLoading} + loadingMessage="Loading events..." + emptyMessage="No events recorded" + splitLayoutName="http_response_events" defaultRatio={0.25} - minHeightPx={10} - firstSlot={() => ( - ( - { - if (i === activeEventIndex) setActiveEventIndex(null); - else setActiveEventIndex(i); - }} - /> - )} - /> + renderRow={({ event, isActive, onClick }) => { + const display = getEventDisplay(event.event); + return ( + } + content={display.summary} + timestamp={event.createdAt} + /> + ); + }} + renderDetail={({ event }) => ( + )} - secondSlot={ - activeEvent - ? () => ( -
-
- -
-
- -
-
- ) - : null - } /> ); } -function EventRow({ - onClick, - isActive, - event, -}: { - onClick: () => void; - isActive: boolean; - event: HttpResponseEvent; -}) { - const display = getEventDisplay(event.event); - const { icon, color, summary } = display; - - return ( -
- -
- ); -} - function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } -function EventDetails({ event }: { event: HttpResponseEvent }) { +function EventDetails({ + event, + showRaw, + setShowRaw, +}: { + event: HttpResponseEvent; + showRaw: boolean; + setShowRaw: (v: boolean) => void; +}) { const { label } = getEventDisplay(event.event); const timestamp = format(new Date(`${event.createdAt}Z`), 'HH:mm:ss.SSS'); const e = event.event; + // Raw view - show plaintext representation + if (showRaw) { + const rawText = formatEventRaw(event.event); + return ( +
+ + +
+ ); + } + // Headers - show name and value with Editor for JSON if (e.type === 'header_up' || e.type === 'header_down') { return ( @@ -139,6 +99,8 @@ function EventDetails({ event }: { event: HttpResponseEvent }) { {e.name} @@ -152,7 +114,12 @@ function EventDetails({ event }: { event: HttpResponseEvent }) { if (e.type === 'send_url') { return (
- + @@ -167,7 +134,12 @@ function EventDetails({ event }: { event: HttpResponseEvent }) { if (e.type === 'receive_url') { return (
- + {e.version} @@ -182,7 +154,12 @@ function EventDetails({ event }: { event: HttpResponseEvent }) { if (e.type === 'redirect') { return (
- + @@ -200,7 +177,12 @@ function EventDetails({ event }: { event: HttpResponseEvent }) { if (e.type === 'setting') { return (
- + {e.name} {e.value} @@ -214,7 +196,12 @@ function EventDetails({ event }: { event: HttpResponseEvent }) { const direction = e.type === 'chunk_sent' ? 'Sent' : 'Received'; return (
- +
{formatBytes(e.bytes)}
); @@ -224,21 +211,62 @@ function EventDetails({ event }: { event: HttpResponseEvent }) { const { summary } = getEventDisplay(event.event); return (
- +
{summary}
); } -function DetailHeader({ title, timestamp }: { title: string; timestamp: string }) { +function DetailHeader({ + title, + timestamp, + showRaw, + setShowRaw, +}: { + title: string; + timestamp: string; + showRaw: boolean; + setShowRaw: (v: boolean) => void; +}) { return (
-

{title}

+ +

{title}

+ +
{timestamp}
); } +/** Format event as raw plaintext for debugging */ +function formatEventRaw(event: HttpResponseEventData): string { + switch (event.type) { + case 'send_url': + return `> ${event.method} ${event.path}`; + case 'receive_url': + return `< ${event.version} ${event.status}`; + case 'header_up': + return `> ${event.name}: ${event.value}`; + case 'header_down': + return `< ${event.name}: ${event.value}`; + case 'redirect': + return `< ${event.status} Redirect: ${event.url}`; + case 'setting': + return `[setting] ${event.name} = ${event.value}`; + case 'info': + return `[info] ${event.message}`; + case 'chunk_sent': + return `> [${formatBytes(event.bytes)} sent]`; + case 'chunk_received': + return `< [${formatBytes(event.bytes)} received]`; + default: + return '[unknown event]'; + } +} + type EventDisplay = { icon: IconProps['icon']; color: IconProps['color']; diff --git a/src-web/components/WebsocketResponsePane.tsx b/src-web/components/WebsocketResponsePane.tsx index 78ca7627..76a704d6 100644 --- a/src-web/components/WebsocketResponsePane.tsx +++ b/src-web/components/WebsocketResponsePane.tsx @@ -1,9 +1,7 @@ import type { WebsocketEvent, WebsocketRequest } from '@yaakapp-internal/models'; -import classNames from 'classnames'; -import { format } from 'date-fns'; import { hexy } from 'hexy'; import { useAtomValue } from 'jotai'; -import { useMemo, useRef, useState } from 'react'; +import { useMemo, useState } from 'react'; import { useFormatText } from '../hooks/useFormatText'; import { activeWebsocketConnectionAtom, @@ -14,16 +12,14 @@ import { import { useStateWithDeps } from '../hooks/useStateWithDeps'; import { languageFromContentType } from '../lib/contentType'; import { copyToClipboard } from '../lib/copy'; -import { AutoScroller } from './core/AutoScroller'; -import { Banner } from './core/Banner'; import { Button } from './core/Button'; import { Editor } from './core/Editor/LazyEditor'; +import { EventViewer } from './core/EventViewer'; +import { EventViewerRow } from './core/EventViewerRow'; import { HotkeyList } from './core/HotkeyList'; import { Icon } from './core/Icon'; import { IconButton } from './core/IconButton'; import { LoadingIcon } from './core/LoadingIcon'; -import { Separator } from './core/Separator'; -import { SplitLayout } from './core/SplitLayout'; import { HStack, VStack } from './core/Stacks'; import { WebsocketStatusTag } from './core/WebsocketStatusTag'; import { EmptyStateText } from './EmptyStateText'; @@ -35,227 +31,198 @@ interface Props { } export function WebsocketResponsePane({ activeRequest }: Props) { - const [activeEventId, setActiveEventId] = useState(null); const [showLarge, setShowLarge] = useStateWithDeps(false, [activeRequest.id]); const [showingLarge, setShowingLarge] = useState(false); - const [hexDumps, setHexDumps] = useState>({}); + const [hexDumps, setHexDumps] = useState>({}); const activeConnection = useAtomValue(activeWebsocketConnectionAtom); const connections = useAtomValue(activeWebsocketConnectionsAtom); const events = useWebsocketEvents(activeConnection?.id ?? null); - const activeEvent = useMemo( - () => events.find((m) => m.id === activeEventId) ?? null, - [activeEventId, events], + if (activeConnection == null) { + return ( + + ); + } + + const header = ( + + + {activeConnection.state !== 'closed' && ( + + )} + + + {events.length} Messages + + + + + ); - const hexDump = hexDumps[activeEventId ?? 'n/a'] ?? activeEvent?.messageType === 'binary'; - - const message = useMemo(() => { - if (hexDump) { - return activeEvent?.message ? hexy(activeEvent?.message) : ''; - } - return activeEvent?.message - ? new TextDecoder('utf-8').decode(Uint8Array.from(activeEvent.message)) - : ''; - }, [activeEvent?.message, hexDump]); - - const language = languageFromContentType(null, message); - const formattedMessage = useFormatText({ language, text: message, pretty: true }); - return ( - - activeConnection == null ? ( - + event.id} + error={activeConnection.error} + header={header} + splitLayoutName="websocket_events" + defaultRatio={0.4} + renderRow={({ event, isActive, onClick }) => ( + + )} + renderDetail={({ event, index }) => ( + setHexDumps({ ...hexDumps, [index]: v })} + showLarge={showLarge} + showingLarge={showingLarge} + setShowLarge={setShowLarge} + setShowingLarge={setShowingLarge} /> - ) : ( -
- - - {activeConnection.state !== 'closed' && ( - - )} - - - {events.length} Messages - - - - - - - - {activeConnection.error} - - ) - } - render={(event) => ( - { - if (event.id === activeEventId) setActiveEventId(null); - else setActiveEventId(event.id); - }} - /> - )} - /> - -
- ) - } - secondSlot={ - activeEvent != null && activeConnection != null - ? () => ( -
-
- -
-
-
-
- {activeEvent.messageType === 'close' - ? 'Connection Closed' - : activeEvent.messageType === 'open' - ? 'Connection open' - : `Message ${activeEvent.isServer ? 'Received' : 'Sent'}`} -
- {message !== '' && ( - - - copyToClipboard(formattedMessage ?? '')} - /> - - )} -
- {!showLarge && activeEvent.message.length > 1000 * 1000 ? ( - - Message previews larger than 1MB are hidden -
- -
-
- ) : activeEvent.message.length === 0 ? ( - No Content - ) : ( - - )} -
-
- ) - : null - } - /> + )} + /> + ); } -function EventRow({ - onClick, - isActive, +function WebsocketEventRow({ event, + isActive, + onClick, }: { - onClick?: () => void; - isActive?: boolean; event: WebsocketEvent; + isActive: boolean; + onClick: () => void; }) { - const { createdAt, message: messageBytes, isServer, messageType } = event; - const ref = useRef(null); + const { message: messageBytes, isServer, messageType } = event; const message = messageBytes ? new TextDecoder('utf-8').decode(Uint8Array.from(messageBytes)) : ''; + const iconColor = + messageType === 'close' || messageType === 'open' ? 'secondary' : isServer ? 'info' : 'primary'; + + const icon = + messageType === 'close' || messageType === 'open' + ? 'info' + : isServer + ? 'arrow_big_down_dash' + : 'arrow_big_up_dash'; + + const content = + messageType === 'close' ? ( + 'Disconnected from server' + ) : messageType === 'open' ? ( + 'Connected to server' + ) : message === '' ? ( + No content + ) : ( + {message.slice(0, 1000)} + ); + return ( -
- + copyToClipboard(formattedMessage ?? '')} + /> + )} - > - + {!showLarge && event.message.length > 1000 * 1000 ? ( + + Message previews larger than 1MB are hidden +
+ +
+
+ ) : event.message.length === 0 ? ( + No Content + ) : ( + -
- {messageType === 'close' ? ( - 'Disconnected from server' - ) : messageType === 'open' ? ( - 'Connected to server' - ) : message === '' ? ( - No content - ) : ( - message.slice(0, 1000) - )} - {/*{error && ({error})}*/} -
-
- {format(`${createdAt}Z`, 'HH:mm:ss.SSS')} -
- + )}
); } diff --git a/src-web/components/core/AutoScroller.tsx b/src-web/components/core/AutoScroller.tsx index 2787c2f4..f0688192 100644 --- a/src-web/components/core/AutoScroller.tsx +++ b/src-web/components/core/AutoScroller.tsx @@ -1,4 +1,4 @@ -import { useVirtualizer } from '@tanstack/react-virtual'; +import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual'; import type { ReactElement, ReactNode, UIEvent } from 'react'; import { useCallback, useLayoutEffect, useRef, useState } from 'react'; import { IconButton } from './IconButton'; @@ -7,9 +7,19 @@ interface Props { data: T[]; render: (item: T, index: number) => ReactElement; header?: ReactNode; + /** Make container focusable for keyboard navigation */ + focusable?: boolean; + /** Callback to expose the virtualizer for keyboard navigation */ + onVirtualizerReady?: (virtualizer: Virtualizer) => void; } -export function AutoScroller({ data, render, header }: Props) { +export function AutoScroller({ + data, + render, + header, + focusable = false, + onVirtualizerReady, +}: Props) { const containerRef = useRef(null); const [autoScroll, setAutoScroll] = useState(true); @@ -20,6 +30,11 @@ export function AutoScroller({ data, render, header }: Props) { estimateSize: () => 27, // react-virtual requires a height, so we'll give it one }); + // Expose virtualizer to parent for keyboard navigation + useLayoutEffect(() => { + onVirtualizerReady?.(rowVirtualizer); + }, [rowVirtualizer, onVirtualizerReady]); + // Scroll to new items const handleScroll = useCallback( (e: UIEvent) => { @@ -63,7 +78,12 @@ export function AutoScroller({ data, render, header }: Props) {
)} {header ?? } -
+
{ + /** 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 + } + /> +
+ ); +} diff --git a/src-web/components/core/EventViewerRow.tsx b/src-web/components/core/EventViewerRow.tsx new file mode 100644 index 00000000..dea4ad2a --- /dev/null +++ b/src-web/components/core/EventViewerRow.tsx @@ -0,0 +1,38 @@ +import classNames from 'classnames'; +import { format } from 'date-fns'; +import type { ReactNode } from 'react'; + +interface EventViewerRowProps { + isActive: boolean; + onClick: () => void; + icon: ReactNode; + content: ReactNode; + timestamp: string; +} + +export function EventViewerRow({ + isActive, + onClick, + icon, + content, + timestamp, +}: EventViewerRowProps) { + return ( +
+ +
+ ); +} diff --git a/src-web/components/responseViewers/EventStreamViewer.tsx b/src-web/components/responseViewers/EventStreamViewer.tsx index 976f0a6b..4d833d3a 100644 --- a/src-web/components/responseViewers/EventStreamViewer.tsx +++ b/src-web/components/responseViewers/EventStreamViewer.tsx @@ -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(false); const [showingLarge, setShowingLarge] = useState(false); - const [activeEventIndex, setActiveEventIndex] = useState(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 ( - String(index)} + error={events.error ? String(events.error) : null} + splitLayoutName="sse_events" defaultRatio={0.4} - minHeightPx={20} - firstSlot={() => ( - - {String(events.error)} - - ) + renderRow={({ event, index, isActive, onClick }) => ( + } + content={ + + + {event.data.slice(0, 1000)} + } - render={(event, i) => ( - { - if (i === activeEventIndex) setActiveEventIndex(null); - else setActiveEventIndex(i); - }} - /> - )} + timestamp={new Date().toISOString().slice(0, -1)} // SSE events don't have timestamps + /> + )} + renderDetail={({ event, index }) => ( + )} - secondSlot={ - activeEvent - ? () => ( -
-
- -
-
- - - Message Received - - {!showLarge && activeEvent.data.length > 1000 * 1000 ? ( - - Message previews larger than 1MB are hidden -
- -
-
- ) : ( - - )} -
-
- ) - : 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 ( +
+ + + Message Received + + {!showLarge && event.data.length > 1000 * 1000 ? ( + + Message previews larger than 1MB are hidden +
+ +
+
+ ) : ( + + )} +
+ ); +} + function FormattedEditor({ text, language }: { text: string; language: EditorProps['language'] }) { const formatted = useFormatText({ text, language, pretty: true }); if (formatted == null) return null; return ; } -function EventRow({ - onClick, - isActive, - event, - className, - index, -}: { - onClick: () => void; - isActive: boolean; - event: ServerSentEvent; - className?: string; - index: number; -}) { - return ( - - ); -} - function EventLabels({ className, event, @@ -169,7 +136,7 @@ function EventLabels({ }: { event: ServerSentEvent; index: number; - className: string; + className?: string; isActive?: boolean; }) { return ( diff --git a/src-web/hooks/useEventViewerKeyboard.ts b/src-web/hooks/useEventViewerKeyboard.ts new file mode 100644 index 00000000..063117cd --- /dev/null +++ b/src-web/hooks/useEventViewerKeyboard.ts @@ -0,0 +1,70 @@ +import type { Virtualizer } from '@tanstack/react-virtual'; +import { useCallback } from 'react'; +import { useKey } from 'react-use'; + +interface UseEventViewerKeyboardProps { + totalCount: number; + activeIndex: number | null; + setActiveIndex: (index: number | null) => void; + virtualizer?: Virtualizer | null; + isContainerFocused: () => boolean; + enabled?: boolean; +} + +export function useEventViewerKeyboard({ + totalCount, + activeIndex, + setActiveIndex, + virtualizer, + isContainerFocused, + enabled = true, +}: UseEventViewerKeyboardProps) { + const selectPrev = useCallback(() => { + if (totalCount === 0) return; + + const newIndex = activeIndex == null ? 0 : Math.max(0, activeIndex - 1); + setActiveIndex(newIndex); + virtualizer?.scrollToIndex(newIndex, { align: 'auto' }); + }, [activeIndex, setActiveIndex, totalCount, virtualizer]); + + const selectNext = useCallback(() => { + if (totalCount === 0) return; + + const newIndex = activeIndex == null ? 0 : Math.min(totalCount - 1, activeIndex + 1); + setActiveIndex(newIndex); + virtualizer?.scrollToIndex(newIndex, { align: 'auto' }); + }, [activeIndex, setActiveIndex, totalCount, virtualizer]); + + useKey( + (e) => e.key === 'ArrowUp' || e.key === 'k', + (e) => { + if (!enabled || !isContainerFocused()) return; + e.preventDefault(); + selectPrev(); + }, + undefined, + [enabled, isContainerFocused, selectPrev], + ); + + useKey( + (e) => e.key === 'ArrowDown' || e.key === 'j', + (e) => { + if (!enabled || !isContainerFocused()) return; + e.preventDefault(); + selectNext(); + }, + undefined, + [enabled, isContainerFocused, selectNext], + ); + + useKey( + (e) => e.key === 'Escape', + (e) => { + if (!enabled || !isContainerFocused()) return; + e.preventDefault(); + setActiveIndex(null); + }, + undefined, + [enabled, isContainerFocused, setActiveIndex], + ); +}