diff --git a/src-web/components/GrpcResponsePane.tsx b/src-web/components/GrpcResponsePane.tsx index 4d36f6ea..e95d3e2f 100644 --- a/src-web/components/GrpcResponsePane.tsx +++ b/src-web/components/GrpcResponsePane.tsx @@ -9,14 +9,12 @@ import { useGrpcEvents, } from '../hooks/usePinnedGrpcConnection'; import { useStateWithDeps } from '../hooks/useStateWithDeps'; -import { copyToClipboard } from '../lib/copy'; import { Button } from './core/Button'; import { Editor } from './core/Editor/LazyEditor'; -import { EventViewer } from './core/EventViewer'; +import { EventDetailHeader, EventViewer } from './core/EventViewer'; import { EventViewerRow } from './core/EventViewerRow'; import { HotkeyList } from './core/HotkeyList'; import { Icon, type IconProps } from './core/Icon'; -import { IconButton } from './core/IconButton'; import { KeyValueRow, KeyValueRows } from './core/KeyValueRow'; import { LoadingIcon } from './core/LoadingIcon'; import { HStack, VStack } from './core/Stacks'; @@ -157,19 +155,11 @@ function GrpcEventDetail({ setShowingLarge: (v: boolean) => void; }) { if (event.eventType === 'client_message' || event.eventType === 'server_message') { + const title = `Message ${event.eventType === 'client_message' ? 'Sent' : 'Received'}`; + return (
-
-
- Message {event.eventType === 'client_message' ? 'Sent' : 'Received'} -
- copyToClipboard(event.content)} - /> -
+ {!showLarge && event.content.length > 1000 * 1000 ? ( Message previews larger than 1MB are hidden @@ -207,14 +197,12 @@ function GrpcEventDetail({ // Error or connection_end - show metadata/trailers return (
-
-
{event.content}
- {event.error && ( -
- {event.error} -
- )} -
+ + {event.error && ( +
+ {event.error} +
+ )}
{Object.keys(event.metadata).length === 0 ? ( diff --git a/src-web/components/HttpResponseTimeline.tsx b/src-web/components/HttpResponseTimeline.tsx index a9946703..556991da 100644 --- a/src-web/components/HttpResponseTimeline.tsx +++ b/src-web/components/HttpResponseTimeline.tsx @@ -3,18 +3,15 @@ import type { HttpResponseEvent, HttpResponseEventData, } from '@yaakapp-internal/models'; -import { format } from 'date-fns'; import { type ReactNode, useState } from 'react'; import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents'; -import { Button } from './core/Button'; import { Editor } from './core/Editor/LazyEditor'; -import { EventViewer } from './core/EventViewer'; +import { EventDetailHeader, EventViewer, type EventDetailAction } 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 { HStack } from './core/Stacks'; interface Props { response: HttpResponse; @@ -73,20 +70,30 @@ function EventDetails({ 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; + const actions: EventDetailAction[] = [ + { + key: 'toggle-raw', + label: showRaw ? 'Formatted' : 'Text', + onClick: () => setShowRaw(!showRaw), + }, + ]; + + // Determine the title based on event type + const title = + e.type === 'header_up' + ? 'Header Sent' + : e.type === 'header_down' + ? 'Header Received' + : label; + // Raw view - show plaintext representation if (showRaw) { const rawText = formatEventRaw(event.event); return (
- +
); @@ -96,12 +103,7 @@ function EventDetails({ if (e.type === 'header_up' || e.type === 'header_down') { return (
- + {e.name} {e.value} @@ -114,12 +116,7 @@ function EventDetails({ if (e.type === 'send_url') { return (
- + @@ -134,12 +131,7 @@ function EventDetails({ if (e.type === 'receive_url') { return (
- + {e.version} @@ -154,12 +146,7 @@ function EventDetails({ if (e.type === 'redirect') { return (
- + @@ -177,12 +164,7 @@ function EventDetails({ if (e.type === 'setting') { return (
- + {e.name} {e.value} @@ -196,11 +178,10 @@ function EventDetails({ const direction = e.type === 'chunk_sent' ? 'Sent' : 'Received'; return (
-
{formatBytes(e.bytes)}
@@ -211,57 +192,33 @@ function EventDetails({ const { summary } = getEventDisplay(event.event); return (
- +
{summary}
); } -function DetailHeader({ - title, - timestamp, - showRaw, - setShowRaw, -}: { - title: string; - timestamp: string; - showRaw: boolean; - setShowRaw: (v: boolean) => void; -}) { - return ( -
- -

{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}`; + return `${event.method} ${event.path}`; case 'receive_url': - return `< ${event.version} ${event.status}`; + return `${event.version} ${event.status}`; case 'header_up': - return `> ${event.name}: ${event.value}`; + return `${event.name}: ${event.value}`; case 'header_down': - return `< ${event.name}: ${event.value}`; + return `${event.name}: ${event.value}`; case 'redirect': - return `< ${event.status} Redirect: ${event.url}`; + return `${event.status} Redirect: ${event.url}`; case 'setting': - return `[setting] ${event.name} = ${event.value}`; + return `${event.name} = ${event.value}`; case 'info': - return `[info] ${event.message}`; + return `${event.message}`; case 'chunk_sent': - return `> [${formatBytes(event.bytes)} sent]`; + return `[${formatBytes(event.bytes)} sent]`; case 'chunk_received': - return `< [${formatBytes(event.bytes)} received]`; + return `[${formatBytes(event.bytes)} received]`; default: return '[unknown event]'; } diff --git a/src-web/components/WebsocketResponsePane.tsx b/src-web/components/WebsocketResponsePane.tsx index 76a704d6..26ac650a 100644 --- a/src-web/components/WebsocketResponsePane.tsx +++ b/src-web/components/WebsocketResponsePane.tsx @@ -11,14 +11,12 @@ import { } from '../hooks/usePinnedWebsocketConnection'; import { useStateWithDeps } from '../hooks/useStateWithDeps'; import { languageFromContentType } from '../lib/contentType'; -import { copyToClipboard } from '../lib/copy'; import { Button } from './core/Button'; import { Editor } from './core/Editor/LazyEditor'; -import { EventViewer } from './core/EventViewer'; +import { EventDetailHeader, EventViewer, type EventDetailAction } 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 { HStack, VStack } from './core/Stacks'; import { WebsocketStatusTag } from './core/WebsocketStatusTag'; @@ -173,24 +171,25 @@ function WebsocketEventDetail({ ? 'Connection Open' : `Message ${event.isServer ? 'Received' : 'Sent'}`; + const actions: EventDetailAction[] = + message !== '' + ? [ + { + key: 'toggle-hexdump', + label: hexDump ? 'Show Message' : 'Show Hexdump', + onClick: () => setHexDump(!hexDump), + }, + ] + : []; + return (
-
-
{title}
- {message !== '' && ( - - - copyToClipboard(formattedMessage ?? '')} - /> - - )} -
+ {!showLarge && event.message.length > 1000 * 1000 ? ( Message previews larger than 1MB are hidden diff --git a/src-web/components/core/AutoScroller.tsx b/src-web/components/core/AutoScroller.tsx index f0688192..f0b0b30b 100644 --- a/src-web/components/core/AutoScroller.tsx +++ b/src-web/components/core/AutoScroller.tsx @@ -63,7 +63,7 @@ export function AutoScroller({ }, [autoScroll, data.length]); return ( -
+
{!autoScroll && (
{ /** Array of events to display */ @@ -137,8 +141,8 @@ export function EventViewer({ defaultRatio={defaultRatio} minHeightPx={10} firstSlot={({ style }) => ( -
- {header} +
+ {header ?? } ({ secondSlot={ activeEvent != null && renderDetail ? ({ style }) => ( -
+
@@ -181,3 +185,55 @@ export function EventViewer({
); } + +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} + )} + +
+ ); +} diff --git a/src-web/components/responseViewers/EventStreamViewer.tsx b/src-web/components/responseViewers/EventStreamViewer.tsx index 4d833d3a..4f99227e 100644 --- a/src-web/components/responseViewers/EventStreamViewer.tsx +++ b/src-web/components/responseViewers/EventStreamViewer.tsx @@ -8,7 +8,7 @@ import { isJSON } from '../../lib/contentType'; import { Button } from '../core/Button'; import type { EditorProps } from '../core/Editor/Editor'; import { Editor } from '../core/Editor/LazyEditor'; -import { EventViewer } from '../core/EventViewer'; +import { EventDetailHeader, EventViewer } from '../core/EventViewer'; import { EventViewerRow } from '../core/EventViewerRow'; import { Icon } from '../core/Icon'; import { InlineCode } from '../core/InlineCode'; @@ -54,10 +54,9 @@ function ActualEventStreamViewer({ response }: Props) { timestamp={new Date().toISOString().slice(0, -1)} // SSE events don't have timestamps /> )} - renderDetail={({ event, index }) => ( + renderDetail={({ event }) => ( void; @@ -90,10 +87,7 @@ function EventDetail({ return (
- - - Message Received - + {!showLarge && event.data.length > 1000 * 1000 ? ( Message previews larger than 1MB are hidden