Add text version of the response Timeline tab

This commit is contained in:
Gregory Schier
2026-01-15 08:14:21 -08:00
parent 9a75bc2ae7
commit 7a5bca7aae
10 changed files with 169 additions and 33 deletions

View File

@@ -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<TabItem[]>(
() => [
@@ -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: <CountBadge count={responseEvents.data?.length ?? 0} />,
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) {
<ResponseCookies response={activeResponse} />
</TabContent>
<TabContent value={TAB_TIMELINE}>
<HttpResponseTimeline response={activeResponse} />
<HttpResponseTimeline response={activeResponse} viewMode={timelineViewMode} />
</TabContent>
</Tabs>
</div>

View File

@@ -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 <Inner key={response.id} response={response} />;
export function HttpResponseTimeline({ response, viewMode }: Props) {
return <Inner key={response.id} response={response} viewMode={viewMode} />;
}
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 <div className="p-4 text-text-subtlest">Loading events...</div>;
} else if (error) {
return <div className="p-4 text-danger">{String(error)}</div>;
} else if (!events || events.length === 0) {
return <div className="p-4 text-text-subtlest">No events recorded</div>;
} else {
return (
<Editor language="timeline" defaultValue={plainText} readOnly stateKey={null} hideGutter />
);
}
}
return (
<EventViewer
events={events ?? []}
@@ -110,10 +133,10 @@ function EventDetails({
// Render content based on view mode and event type
const renderContent = () => {
// Raw view - show plaintext representation
// Raw view - show plaintext representation (without prefix)
if (showRaw) {
const rawText = formatEventRaw(event.event);
return <Editor language="text" defaultValue={rawText} readOnly stateKey={null} />;
const rawText = formatEventText(event.event, false);
return <Editor language="text" defaultValue={rawText} readOnly stateKey={null} hideGutter />;
}
// Headers - show name and value
@@ -204,43 +227,58 @@ function EventDetails({
};
return (
<div className="flex flex-col gap-2 h-full">
<EventDetailHeader title={title} timestamp={event.createdAt} actions={actions} onClose={onClose} />
<EventDetailHeader
title={title}
timestamp={event.createdAt}
actions={actions}
onClose={onClose}
/>
{renderContent()}
</div>
);
}
/** 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'];

View File

@@ -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;

View File

@@ -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,
};

View File

@@ -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);
}

View File

@@ -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)
});

View File

@@ -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"

View File

@@ -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

View File

@@ -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
})