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 { 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 all URL parts separately if (e.type === "send_url") { const auth = e.username || e.password ? `${e.username}:${e.password}@` : ""; const isDefaultPort = (e.scheme === "http" && e.port === 80) || (e.scheme === "https" && e.port === 443); const portStr = isDefaultPort ? "" : `:${e.port}`; const query = e.query ? `?${e.query}` : ""; const fragment = e.fragment ? `#${e.fragment}` : ""; const fullUrl = `${e.scheme}://${auth}${e.host}${portStr}${e.path}${query}${fragment}`; return ( {fullUrl} {e.method} {e.scheme} {e.username ? {e.username} : null} {e.password ? {e.password} : null} {e.host} {!isDefaultPort ? {e.port} : null} {e.path} {e.query ? {e.query} : null} {e.fragment ? {e.fragment} : null} ); } // 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") { const droppedHeaders = e.dropped_headers ?? []; return ( {e.url} {e.behavior === "drop_body" ? "Drop body, change to GET" : "Preserve method and body"} {e.dropped_body ? "Yes" : "No"} {droppedHeaders.length > 0 ? droppedHeaders.join(", ") : "--"} ); } // 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}${event.query ? `?${event.query}` : ""}${event.fragment ? `#${event.fragment}` : ""}`, }; 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"; const droppedHeaders = event.dropped_headers ?? []; const dropped = [ event.dropped_body ? "body dropped" : null, droppedHeaders.length > 0 ? `headers dropped: ${droppedHeaders.join(", ")}` : null, ] .filter(Boolean) .join(", "); return { prefix: "*", text: `Redirect ${event.status} -> ${event.url} (${behavior}${dropped ? `, ${dropped}` : ""})`, }; } 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": { const droppedHeaders = event.dropped_headers ?? []; const dropped = [ event.dropped_body ? "drop body" : null, droppedHeaders.length > 0 ? `drop ${droppedHeaders.length} ${droppedHeaders.length === 1 ? "header" : "headers"}` : null, ] .filter(Boolean) .join(", "); return { icon: "arrow_big_right_dash", color: "success", label: "Redirect", summary: `Redirecting ${event.status} ${event.url}${dropped ? ` (${dropped})` : ""}`, }; } case "send_url": return { icon: "arrow_big_up_dash", color: "primary", label: "Request", summary: `${event.method} ${event.path}${event.query ? `?${event.query}` : ""}${event.fragment ? `#${event.fragment}` : ""}`, }; 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", }; } }