mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-18 23:43:55 +01:00
Add text version of the response Timeline tab
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
12
src-web/components/core/Editor/timeline/extension.ts
Normal file
12
src-web/components/core/Editor/timeline/extension.ts
Normal 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);
|
||||
}
|
||||
7
src-web/components/core/Editor/timeline/highlight.ts
Normal file
7
src-web/components/core/Editor/timeline/highlight.ts
Normal 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)
|
||||
});
|
||||
21
src-web/components/core/Editor/timeline/timeline.grammar
Normal file
21
src-web/components/core/Editor/timeline/timeline.grammar
Normal 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"
|
||||
12
src-web/components/core/Editor/timeline/timeline.terms.ts
Normal file
12
src-web/components/core/Editor/timeline/timeline.terms.ts
Normal 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
|
||||
18
src-web/components/core/Editor/timeline/timeline.ts
Normal file
18
src-web/components/core/Editor/timeline/timeline.ts
Normal 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
|
||||
})
|
||||
Reference in New Issue
Block a user