From 7a5bca7aae494b393e772917d54f420cb087ca8a Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Thu, 15 Jan 2026 08:14:21 -0800 Subject: [PATCH] Add text version of the response Timeline tab --- src-web/components/HttpResponsePane.tsx | 28 ++++-- src-web/components/HttpResponseTimeline.tsx | 86 +++++++++++++------ src-web/components/core/Editor/Editor.tsx | 2 +- src-web/components/core/Editor/extensions.ts | 2 + .../core/Editor/timeline/extension.ts | 12 +++ .../core/Editor/timeline/highlight.ts | 7 ++ .../core/Editor/timeline/timeline.grammar | 21 +++++ .../core/Editor/timeline/timeline.terms.ts | 12 +++ .../core/Editor/timeline/timeline.ts | 18 ++++ src-web/hooks/useTimelineViewMode.ts | 14 +++ 10 files changed, 169 insertions(+), 33 deletions(-) create mode 100644 src-web/components/core/Editor/timeline/extension.ts create mode 100644 src-web/components/core/Editor/timeline/highlight.ts create mode 100644 src-web/components/core/Editor/timeline/timeline.grammar create mode 100644 src-web/components/core/Editor/timeline/timeline.terms.ts create mode 100644 src-web/components/core/Editor/timeline/timeline.ts create mode 100644 src-web/hooks/useTimelineViewMode.ts diff --git a/src-web/components/HttpResponsePane.tsx b/src-web/components/HttpResponsePane.tsx index 8d525356..5f00d389 100644 --- a/src-web/components/HttpResponsePane.tsx +++ b/src-web/components/HttpResponsePane.tsx @@ -7,8 +7,9 @@ import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents'; import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse'; import { useResponseBodyBytes, useResponseBodyText } from '../hooks/useResponseBodyText'; import { useResponseViewMode } from '../hooks/useResponseViewMode'; +import { useTimelineViewMode } from '../hooks/useTimelineViewMode'; import { getMimeTypeFromContentType } from '../lib/contentType'; -import { getCookieCounts, getContentTypeFromHeaders } from '../lib/model_util'; +import { getContentTypeFromHeaders, getCookieCounts } from '../lib/model_util'; import { ConfirmLargeResponse } from './ConfirmLargeResponse'; import { ConfirmLargeResponseRequest } from './ConfirmLargeResponseRequest'; import { Banner } from './core/Banner'; @@ -54,18 +55,18 @@ const TAB_HEADERS = 'headers'; const TAB_COOKIES = 'cookies'; const TAB_TIMELINE = 'timeline'; +export type TimelineViewMode = 'timeline' | 'text'; + export function HttpResponsePane({ style, className, activeRequestId }: Props) { const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId); const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId); + const [timelineViewMode, setTimelineViewMode] = useTimelineViewMode(); const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null); const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence; const responseEvents = useHttpResponseEvents(activeResponse); - const cookieCounts = useMemo( - () => getCookieCounts(responseEvents.data), - [responseEvents.data], - ); + const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]); const tabs = useMemo( () => [ @@ -77,7 +78,9 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { onChange: setViewMode, items: [ { label: 'Response', value: 'pretty' }, - ...(mimeType?.startsWith('image') ? [] : [{ label: 'Raw', value: 'raw' }]), + ...(mimeType?.startsWith('image') + ? [] + : [{ label: 'Response (Raw)', shortLabel: 'Raw', value: 'raw' }]), ], }, }, @@ -108,8 +111,15 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { }, { value: TAB_TIMELINE, - label: 'Timeline', rightSlot: , + options: { + value: timelineViewMode, + onChange: (v) => setTimelineViewMode((v as TimelineViewMode) ?? 'timeline'), + items: [ + { label: 'Timeline', value: 'timeline' }, + { label: 'Timeline (Text)', shortLabel: 'Timeline', value: 'text' }, + ], + }, }, ], [ @@ -122,6 +132,8 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { responseEvents.data?.length, setViewMode, viewMode, + timelineViewMode, + setTimelineViewMode, ], ); @@ -252,7 +264,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { - + diff --git a/src-web/components/HttpResponseTimeline.tsx b/src-web/components/HttpResponseTimeline.tsx index f6eb9093..b520eb6f 100644 --- a/src-web/components/HttpResponseTimeline.tsx +++ b/src-web/components/HttpResponseTimeline.tsx @@ -3,28 +3,51 @@ import type { HttpResponseEvent, HttpResponseEventData, } from '@yaakapp-internal/models'; -import { type ReactNode, useState } from 'react'; +import { type ReactNode, useMemo, useState } from 'react'; import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents'; import { Editor } from './core/Editor/LazyEditor'; -import { EventDetailHeader, EventViewer, type EventDetailAction } from './core/EventViewer'; +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 }: Props) { - return ; +export function HttpResponseTimeline({ response, viewMode }: Props) { + return ; } -function Inner({ response }: Props) { +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 ( { - // Raw view - show plaintext representation + // Raw view - show plaintext representation (without prefix) if (showRaw) { - const rawText = formatEventRaw(event.event); - return ; + const rawText = formatEventText(event.event, false); + return ; } // Headers - show name and value @@ -204,43 +227,58 @@ function EventDetails({ }; return (
- + {renderContent()}
); } -/** Format event as raw plaintext for debugging */ -function formatEventRaw(event: HttpResponseEventData): string { +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 `${event.method} ${event.path}`; + return { prefix: '>', text: `${event.method} ${event.path}` }; case 'receive_url': - return `${event.version} ${event.status}`; + return { prefix: '<', text: `${event.version} ${event.status}` }; case 'header_up': - return `${event.name}: ${event.value}`; + return { prefix: '>', text: `${event.name}: ${event.value}` }; case 'header_down': - return `${event.name}: ${event.value}`; - case 'redirect': - return `${event.status} Redirect: ${event.url}`; + 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 `${event.name} = ${event.value}`; + return { prefix: '*', text: `Setting ${event.name}=${event.value}` }; case 'info': - return `${event.message}`; + return { prefix: '*', text: event.message }; case 'chunk_sent': - return `[${formatBytes(event.bytes)} sent]`; + return { prefix: '*', text: `[${formatBytes(event.bytes)} sent]` }; case 'chunk_received': - return `[${formatBytes(event.bytes)} received]`; + return { prefix: '*', text: `[${formatBytes(event.bytes)} received]` }; case 'dns_resolved': if (event.overridden) { - return `DNS override ${event.hostname} → ${event.addresses.join(', ')}`; + return { prefix: '*', text: `DNS override ${event.hostname} -> ${event.addresses.join(', ')}` }; } - return `DNS resolved ${event.hostname} → ${event.addresses.join(', ')} (${event.duration}ms)`; + return { prefix: '*', text: `DNS resolved ${event.hostname} to ${event.addresses.join(', ')} (${event.duration}ms)` }; default: - return '[unknown event]'; + 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']; diff --git a/src-web/components/core/Editor/Editor.tsx b/src-web/components/core/Editor/Editor.tsx index 855a6699..829211fe 100644 --- a/src-web/components/core/Editor/Editor.tsx +++ b/src-web/components/core/Editor/Editor.tsx @@ -77,7 +77,7 @@ export interface EditorProps { heightMode?: 'auto' | 'full'; hideGutter?: boolean; id?: string; - language?: EditorLanguage | 'pairs' | 'url' | null; + language?: EditorLanguage | 'pairs' | 'url' | 'timeline' | null; graphQLSchema?: GraphQLSchema | null; onBlur?: () => void; onChange?: (value: string) => void; diff --git a/src-web/components/core/Editor/extensions.ts b/src-web/components/core/Editor/extensions.ts index 2bf9b8b5..b9593e9a 100644 --- a/src-web/components/core/Editor/extensions.ts +++ b/src-web/components/core/Editor/extensions.ts @@ -48,6 +48,7 @@ import type { EditorProps } from './Editor'; import { jsonParseLinter } from './json-lint'; import { pairs } from './pairs/extension'; import { text } from './text/extension'; +import { timeline } from './timeline/extension'; import type { TwigCompletionOption } from './twig/completion'; import { twig } from './twig/extension'; import { pathParametersPlugin } from './twig/pathParameters'; @@ -95,6 +96,7 @@ const syntaxExtensions: Record< url: url, pairs: pairs, text: text, + timeline: timeline, markdown: markdown, }; diff --git a/src-web/components/core/Editor/timeline/extension.ts b/src-web/components/core/Editor/timeline/extension.ts new file mode 100644 index 00000000..74d20b93 --- /dev/null +++ b/src-web/components/core/Editor/timeline/extension.ts @@ -0,0 +1,12 @@ +import { LanguageSupport, LRLanguage } from '@codemirror/language'; +import { parser } from './timeline'; + +export const timelineLanguage = LRLanguage.define({ + name: 'timeline', + parser, + languageData: {}, +}); + +export function timeline() { + return new LanguageSupport(timelineLanguage); +} diff --git a/src-web/components/core/Editor/timeline/highlight.ts b/src-web/components/core/Editor/timeline/highlight.ts new file mode 100644 index 00000000..b872957b --- /dev/null +++ b/src-web/components/core/Editor/timeline/highlight.ts @@ -0,0 +1,7 @@ +import { styleTags, tags as t } from '@lezer/highlight'; + +export const highlight = styleTags({ + OutgoingText: t.propertyName, // > lines - primary color (matches timeline icons) + IncomingText: t.tagName, // < lines - info color (matches timeline icons) + InfoText: t.comment, // * lines - subtle color (matches timeline icons) +}); diff --git a/src-web/components/core/Editor/timeline/timeline.grammar b/src-web/components/core/Editor/timeline/timeline.grammar new file mode 100644 index 00000000..f8def8d0 --- /dev/null +++ b/src-web/components/core/Editor/timeline/timeline.grammar @@ -0,0 +1,21 @@ +@top Timeline { line* } + +line { OutgoingLine | IncomingLine | InfoLine | PlainLine } + +@skip {} { + OutgoingLine { OutgoingText Newline } + IncomingLine { IncomingText Newline } + InfoLine { InfoText Newline } + PlainLine { PlainText Newline } +} + +@tokens { + OutgoingText { "> " ![\n]* } + IncomingText { "< " ![\n]* } + InfoText { "* " ![\n]* } + PlainText { ![\n]+ } + Newline { "\n" } + @precedence { OutgoingText, IncomingText, InfoText, PlainText } +} + +@external propSource highlight from "./highlight" diff --git a/src-web/components/core/Editor/timeline/timeline.terms.ts b/src-web/components/core/Editor/timeline/timeline.terms.ts new file mode 100644 index 00000000..1b3bff48 --- /dev/null +++ b/src-web/components/core/Editor/timeline/timeline.terms.ts @@ -0,0 +1,12 @@ +// This file was generated by lezer-generator. You probably shouldn't edit it. +export const + Timeline = 1, + OutgoingLine = 2, + OutgoingText = 3, + Newline = 4, + IncomingLine = 5, + IncomingText = 6, + InfoLine = 7, + InfoText = 8, + PlainLine = 9, + PlainText = 10 diff --git a/src-web/components/core/Editor/timeline/timeline.ts b/src-web/components/core/Editor/timeline/timeline.ts new file mode 100644 index 00000000..235578d6 --- /dev/null +++ b/src-web/components/core/Editor/timeline/timeline.ts @@ -0,0 +1,18 @@ +// This file was generated by lezer-generator. You probably shouldn't edit it. +import {LRParser} from "@lezer/lr" +import {highlight} from "./highlight" +export const parser = LRParser.deserialize({ + version: 14, + states: "!pQQOPOOO`OPO'#C^OeOPO'#CaOjOPO'#CcOoOPO'#CeOOOO'#Ci'#CiOOOO'#Cg'#CgQQOPOOOOOO,58x,58xOOOO,58{,58{OOOO,58},58}OOOO,59P,59POOOO-E6e-E6e", + stateData: "z~ORPOUQOWROYSO~OSWO~OSXO~OSYO~OSZO~ORUWYW~", + goto: "m^PP_PP_P_P_PcPiTTOVQVOR[VTUOV", + nodeNames: "⚠ Timeline OutgoingLine OutgoingText Newline IncomingLine IncomingText InfoLine InfoText PlainLine PlainText", + maxTerm: 13, + propSources: [highlight], + skippedNodes: [0], + repeatNodeCount: 1, + tokenData: "%h~RZOYtYZ!]Zztz{!b{!^t!^!_#d!_!`t!`!a$f!a;'St;'S;=`!V<%lOt~ySY~OYtZ;'St;'S;=`!V<%lOt~!YP;=`<%lt~!bOS~~!gUY~OYtZptpq!yq;'St;'S;=`!V<%lOt~#QSW~Y~OY!yZ;'S!y;'S;=`#^<%lO!y~#aP;=`<%l!y~#iUY~OYtZptpq#{q;'St;'S;=`!V<%lOt~$SSU~Y~OY#{Z;'S#{;'S;=`$`<%lO#{~$cP;=`<%l#{~$kUY~OYtZptpq$}q;'St;'S;=`!V<%lOt~%USR~Y~OY$}Z;'S$};'S;=`%b<%lO$}~%eP;=`<%l$}", + tokenizers: [0], + topRules: {"Timeline":[0,1]}, + tokenPrec: 36 +}) diff --git a/src-web/hooks/useTimelineViewMode.ts b/src-web/hooks/useTimelineViewMode.ts new file mode 100644 index 00000000..6c23212d --- /dev/null +++ b/src-web/hooks/useTimelineViewMode.ts @@ -0,0 +1,14 @@ +import type { TimelineViewMode } from '../components/HttpResponsePane'; +import { useKeyValue } from './useKeyValue'; + +const DEFAULT_VIEW_MODE: TimelineViewMode = 'timeline'; + +export function useTimelineViewMode() { + const { set, value } = useKeyValue({ + namespace: 'no_sync', + key: 'timeline_view_mode', + fallback: DEFAULT_VIEW_MODE, + }); + + return [value ?? DEFAULT_VIEW_MODE, set] as const; +}