import type { HttpResponse, HttpResponseEvent, HttpResponseEventData, } from '@yaakapp-internal/models'; import { type ReactNode, useMemo, useState } from 'react'; import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents'; import { Editor } from './core/Editor/LazyEditor'; import { type EventDetailAction, EventDetailHeader, 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 type { TimelineViewMode } from './HttpResponsePane'; interface Props { response: HttpResponse; viewMode: TimelineViewMode; } export function HttpResponseTimeline({ response, viewMode }: Props) { return ; } function Inner({ response, viewMode }: Props) { const [showRaw, setShowRaw] = useState(false); const { data: events, error, isLoading } = useHttpResponseEvents(response); // Generate plain text representation of all events (with prefixes for timeline view) const plainText = useMemo(() => { if (!events || events.length === 0) return ''; return events.map((event) => formatEventText(event.event, true)).join('\n'); }, [events]); // Plain text view - show all events as text in an editor if (viewMode === 'text') { if (isLoading) { return
Loading events...
; } else if (error) { return
{String(error)}
; } else if (!events || events.length === 0) { return
No events recorded
; } else { return ( ); } } return ( event.id} error={error ? String(error) : null} isLoading={isLoading} loadingMessage="Loading events..." emptyMessage="No events recorded" splitLayoutName="http_response_events" defaultRatio={0.25} renderRow={({ event, isActive, onClick }) => { const display = getEventDisplay(event.event); return ( } content={display.summary} timestamp={event.createdAt} /> ); }} renderDetail={({ event, onClose }) => ( )} /> ); } 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, showRaw, setShowRaw, onClose, }: { event: HttpResponseEvent; showRaw: boolean; setShowRaw: (v: boolean) => void; onClose: () => void; }) { const { label } = getEventDisplay(event.event); 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 = (() => { switch (e.type) { case 'header_up': return 'Header Sent'; case 'header_down': return 'Header Received'; case 'send_url': return 'Request'; case 'receive_url': return 'Response'; case 'redirect': return 'Redirect'; case 'setting': return 'Apply Setting'; case 'chunk_sent': return 'Data Sent'; case 'chunk_received': return 'Data Received'; case 'dns_resolved': return e.overridden ? 'DNS Override' : 'DNS Resolution'; default: return label; } })(); // Render content based on view mode and event type const renderContent = () => { // Raw view - show plaintext representation (without prefix) if (showRaw) { const rawText = formatEventText(event.event, false); return ; } // Headers - show name and value if (e.type === 'header_up' || e.type === 'header_down') { return ( {e.name} {e.value} ); } // Request URL - show method and path separately if (e.type === 'send_url') { return ( {e.path} ); } // Response status - show version and status separately if (e.type === 'receive_url') { return ( {e.version} ); } // Redirect - show status, URL, and behavior if (e.type === 'redirect') { return ( {e.url} {e.behavior === 'drop_body' ? 'Drop body, change to GET' : 'Preserve method and body'} ); } // Settings - show as key/value if (e.type === 'setting') { return ( {e.name} {e.value} ); } // Chunks - show formatted bytes if (e.type === 'chunk_sent' || e.type === 'chunk_received') { return
{formatBytes(e.bytes)}
; } // DNS Resolution - show hostname, addresses, and timing if (e.type === 'dns_resolved') { return ( {e.hostname} {e.addresses.join(', ')} {e.overridden ? ( -- ) : ( `${String(e.duration)}ms` )} {e.overridden ? Workspace Override : null} ); } // Default - use summary const { summary } = getEventDisplay(event.event); return
{summary}
; }; return (
{renderContent()}
); } type EventTextParts = { prefix: '>' | '<' | '*'; text: string }; /** Get the prefix and text for an event */ function getEventTextParts(event: HttpResponseEventData): EventTextParts { switch (event.type) { case 'send_url': return { prefix: '>', text: `${event.method} ${event.path}` }; case 'receive_url': return { prefix: '<', text: `${event.version} ${event.status}` }; case 'header_up': return { prefix: '>', text: `${event.name}: ${event.value}` }; case 'header_down': return { prefix: '<', text: `${event.name}: ${event.value}` }; case 'redirect': { const behavior = event.behavior === 'drop_body' ? 'drop body' : 'preserve'; return { prefix: '*', text: `Redirect ${event.status} -> ${event.url} (${behavior})` }; } case 'setting': return { prefix: '*', text: `Setting ${event.name}=${event.value}` }; case 'info': return { prefix: '*', text: event.message }; case 'chunk_sent': return { prefix: '*', text: `[${formatBytes(event.bytes)} sent]` }; case 'chunk_received': return { prefix: '*', text: `[${formatBytes(event.bytes)} received]` }; case 'dns_resolved': if (event.overridden) { return { prefix: '*', text: `DNS override ${event.hostname} -> ${event.addresses.join(', ')}` }; } return { prefix: '*', text: `DNS resolved ${event.hostname} to ${event.addresses.join(', ')} (${event.duration}ms)` }; default: return { prefix: '*', text: '[unknown event]' }; } } /** Format event as plaintext, optionally with curl-style prefix (> outgoing, < incoming, * info) */ function formatEventText(event: HttpResponseEventData, includePrefix: boolean): string { const { prefix, text } = getEventTextParts(event); return includePrefix ? `${prefix} ${text}` : text; } type EventDisplay = { icon: IconProps['icon']; color: IconProps['color']; label: string; summary: ReactNode; }; function getEventDisplay(event: HttpResponseEventData): EventDisplay { switch (event.type) { case 'setting': return { icon: 'settings', color: 'secondary', label: 'Setting', summary: `${event.name} = ${event.value}`, }; case 'info': return { icon: 'info', color: 'secondary', label: 'Info', summary: event.message, }; case 'redirect': return { icon: 'arrow_big_right_dash', color: 'success', label: 'Redirect', summary: `Redirecting ${event.status} ${event.url}${event.behavior === 'drop_body' ? ' (drop body)' : ''}`, }; case 'send_url': return { icon: 'arrow_big_up_dash', color: 'primary', label: 'Request', summary: `${event.method} ${event.path}`, }; case 'receive_url': return { icon: 'arrow_big_down_dash', color: 'info', label: 'Response', summary: `${event.version} ${event.status}`, }; case 'header_up': return { icon: 'arrow_big_up_dash', color: 'primary', label: 'Header', summary: `${event.name}: ${event.value}`, }; case 'header_down': return { icon: 'arrow_big_down_dash', color: 'info', label: 'Header', summary: `${event.name}: ${event.value}`, }; case 'chunk_sent': return { icon: 'info', color: 'secondary', label: 'Chunk', summary: `${formatBytes(event.bytes)} chunk sent`, }; case 'chunk_received': return { icon: 'info', color: 'secondary', label: 'Chunk', summary: `${formatBytes(event.bytes)} chunk received`, }; case 'dns_resolved': return { icon: 'globe', color: event.overridden ? 'success' : 'secondary', label: event.overridden ? 'DNS Override' : 'DNS', summary: event.overridden ? `${event.hostname} → ${event.addresses.join(', ')} (overridden)` : `${event.hostname} → ${event.addresses.join(', ')} (${event.duration}ms)`, }; default: return { icon: 'info', color: 'secondary', label: 'Unknown', summary: 'Unknown event', }; } }