mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-06-12 01:14:27 +02: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 { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
|
||||||
import { useResponseBodyBytes, useResponseBodyText } from '../hooks/useResponseBodyText';
|
import { useResponseBodyBytes, useResponseBodyText } from '../hooks/useResponseBodyText';
|
||||||
import { useResponseViewMode } from '../hooks/useResponseViewMode';
|
import { useResponseViewMode } from '../hooks/useResponseViewMode';
|
||||||
|
import { useTimelineViewMode } from '../hooks/useTimelineViewMode';
|
||||||
import { getMimeTypeFromContentType } from '../lib/contentType';
|
import { getMimeTypeFromContentType } from '../lib/contentType';
|
||||||
import { getCookieCounts, getContentTypeFromHeaders } from '../lib/model_util';
|
import { getContentTypeFromHeaders, getCookieCounts } from '../lib/model_util';
|
||||||
import { ConfirmLargeResponse } from './ConfirmLargeResponse';
|
import { ConfirmLargeResponse } from './ConfirmLargeResponse';
|
||||||
import { ConfirmLargeResponseRequest } from './ConfirmLargeResponseRequest';
|
import { ConfirmLargeResponseRequest } from './ConfirmLargeResponseRequest';
|
||||||
import { Banner } from './core/Banner';
|
import { Banner } from './core/Banner';
|
||||||
@@ -54,18 +55,18 @@ const TAB_HEADERS = 'headers';
|
|||||||
const TAB_COOKIES = 'cookies';
|
const TAB_COOKIES = 'cookies';
|
||||||
const TAB_TIMELINE = 'timeline';
|
const TAB_TIMELINE = 'timeline';
|
||||||
|
|
||||||
|
export type TimelineViewMode = 'timeline' | 'text';
|
||||||
|
|
||||||
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||||
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
|
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
|
||||||
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
|
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
|
||||||
|
const [timelineViewMode, setTimelineViewMode] = useTimelineViewMode();
|
||||||
const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null);
|
const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null);
|
||||||
const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence;
|
const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence;
|
||||||
|
|
||||||
const responseEvents = useHttpResponseEvents(activeResponse);
|
const responseEvents = useHttpResponseEvents(activeResponse);
|
||||||
|
|
||||||
const cookieCounts = useMemo(
|
const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]);
|
||||||
() => getCookieCounts(responseEvents.data),
|
|
||||||
[responseEvents.data],
|
|
||||||
);
|
|
||||||
|
|
||||||
const tabs = useMemo<TabItem[]>(
|
const tabs = useMemo<TabItem[]>(
|
||||||
() => [
|
() => [
|
||||||
@@ -77,7 +78,9 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
|||||||
onChange: setViewMode,
|
onChange: setViewMode,
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Response', value: 'pretty' },
|
{ 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,
|
value: TAB_TIMELINE,
|
||||||
label: 'Timeline',
|
|
||||||
rightSlot: <CountBadge count={responseEvents.data?.length ?? 0} />,
|
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,
|
responseEvents.data?.length,
|
||||||
setViewMode,
|
setViewMode,
|
||||||
viewMode,
|
viewMode,
|
||||||
|
timelineViewMode,
|
||||||
|
setTimelineViewMode,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -252,7 +264,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
|||||||
<ResponseCookies response={activeResponse} />
|
<ResponseCookies response={activeResponse} />
|
||||||
</TabContent>
|
</TabContent>
|
||||||
<TabContent value={TAB_TIMELINE}>
|
<TabContent value={TAB_TIMELINE}>
|
||||||
<HttpResponseTimeline response={activeResponse} />
|
<HttpResponseTimeline response={activeResponse} viewMode={timelineViewMode} />
|
||||||
</TabContent>
|
</TabContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,28 +3,51 @@ import type {
|
|||||||
HttpResponseEvent,
|
HttpResponseEvent,
|
||||||
HttpResponseEventData,
|
HttpResponseEventData,
|
||||||
} from '@yaakapp-internal/models';
|
} from '@yaakapp-internal/models';
|
||||||
import { type ReactNode, useState } from 'react';
|
import { type ReactNode, useMemo, useState } from 'react';
|
||||||
import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents';
|
import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents';
|
||||||
import { Editor } from './core/Editor/LazyEditor';
|
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 { EventViewerRow } from './core/EventViewerRow';
|
||||||
import { HttpMethodTagRaw } from './core/HttpMethodTag';
|
import { HttpMethodTagRaw } from './core/HttpMethodTag';
|
||||||
import { HttpStatusTagRaw } from './core/HttpStatusTag';
|
import { HttpStatusTagRaw } from './core/HttpStatusTag';
|
||||||
import { Icon, type IconProps } from './core/Icon';
|
import { Icon, type IconProps } from './core/Icon';
|
||||||
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
|
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
|
||||||
|
import type { TimelineViewMode } from './HttpResponsePane';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
response: HttpResponse;
|
response: HttpResponse;
|
||||||
|
viewMode: TimelineViewMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HttpResponseTimeline({ response }: Props) {
|
export function HttpResponseTimeline({ response, viewMode }: Props) {
|
||||||
return <Inner key={response.id} response={response} />;
|
return <Inner key={response.id} response={response} viewMode={viewMode} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Inner({ response }: Props) {
|
function Inner({ response, viewMode }: Props) {
|
||||||
const [showRaw, setShowRaw] = useState(false);
|
const [showRaw, setShowRaw] = useState(false);
|
||||||
const { data: events, error, isLoading } = useHttpResponseEvents(response);
|
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 (
|
return (
|
||||||
<EventViewer
|
<EventViewer
|
||||||
events={events ?? []}
|
events={events ?? []}
|
||||||
@@ -110,10 +133,10 @@ function EventDetails({
|
|||||||
|
|
||||||
// Render content based on view mode and event type
|
// Render content based on view mode and event type
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
// Raw view - show plaintext representation
|
// Raw view - show plaintext representation (without prefix)
|
||||||
if (showRaw) {
|
if (showRaw) {
|
||||||
const rawText = formatEventRaw(event.event);
|
const rawText = formatEventText(event.event, false);
|
||||||
return <Editor language="text" defaultValue={rawText} readOnly stateKey={null} />;
|
return <Editor language="text" defaultValue={rawText} readOnly stateKey={null} hideGutter />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Headers - show name and value
|
// Headers - show name and value
|
||||||
@@ -204,43 +227,58 @@ function EventDetails({
|
|||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 h-full">
|
<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()}
|
{renderContent()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Format event as raw plaintext for debugging */
|
type EventTextParts = { prefix: '>' | '<' | '*'; text: string };
|
||||||
function formatEventRaw(event: HttpResponseEventData): string {
|
|
||||||
|
/** Get the prefix and text for an event */
|
||||||
|
function getEventTextParts(event: HttpResponseEventData): EventTextParts {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'send_url':
|
case 'send_url':
|
||||||
return `${event.method} ${event.path}`;
|
return { prefix: '>', text: `${event.method} ${event.path}` };
|
||||||
case 'receive_url':
|
case 'receive_url':
|
||||||
return `${event.version} ${event.status}`;
|
return { prefix: '<', text: `${event.version} ${event.status}` };
|
||||||
case 'header_up':
|
case 'header_up':
|
||||||
return `${event.name}: ${event.value}`;
|
return { prefix: '>', text: `${event.name}: ${event.value}` };
|
||||||
case 'header_down':
|
case 'header_down':
|
||||||
return `${event.name}: ${event.value}`;
|
return { prefix: '<', text: `${event.name}: ${event.value}` };
|
||||||
case 'redirect':
|
case 'redirect': {
|
||||||
return `${event.status} Redirect: ${event.url}`;
|
const behavior = event.behavior === 'drop_body' ? 'drop body' : 'preserve';
|
||||||
|
return { prefix: '*', text: `Redirect ${event.status} -> ${event.url} (${behavior})` };
|
||||||
|
}
|
||||||
case 'setting':
|
case 'setting':
|
||||||
return `${event.name} = ${event.value}`;
|
return { prefix: '*', text: `Setting ${event.name}=${event.value}` };
|
||||||
case 'info':
|
case 'info':
|
||||||
return `${event.message}`;
|
return { prefix: '*', text: event.message };
|
||||||
case 'chunk_sent':
|
case 'chunk_sent':
|
||||||
return `[${formatBytes(event.bytes)} sent]`;
|
return { prefix: '*', text: `[${formatBytes(event.bytes)} sent]` };
|
||||||
case 'chunk_received':
|
case 'chunk_received':
|
||||||
return `[${formatBytes(event.bytes)} received]`;
|
return { prefix: '*', text: `[${formatBytes(event.bytes)} received]` };
|
||||||
case 'dns_resolved':
|
case 'dns_resolved':
|
||||||
if (event.overridden) {
|
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:
|
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 = {
|
type EventDisplay = {
|
||||||
icon: IconProps['icon'];
|
icon: IconProps['icon'];
|
||||||
color: IconProps['color'];
|
color: IconProps['color'];
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export interface EditorProps {
|
|||||||
heightMode?: 'auto' | 'full';
|
heightMode?: 'auto' | 'full';
|
||||||
hideGutter?: boolean;
|
hideGutter?: boolean;
|
||||||
id?: string;
|
id?: string;
|
||||||
language?: EditorLanguage | 'pairs' | 'url' | null;
|
language?: EditorLanguage | 'pairs' | 'url' | 'timeline' | null;
|
||||||
graphQLSchema?: GraphQLSchema | null;
|
graphQLSchema?: GraphQLSchema | null;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import type { EditorProps } from './Editor';
|
|||||||
import { jsonParseLinter } from './json-lint';
|
import { jsonParseLinter } from './json-lint';
|
||||||
import { pairs } from './pairs/extension';
|
import { pairs } from './pairs/extension';
|
||||||
import { text } from './text/extension';
|
import { text } from './text/extension';
|
||||||
|
import { timeline } from './timeline/extension';
|
||||||
import type { TwigCompletionOption } from './twig/completion';
|
import type { TwigCompletionOption } from './twig/completion';
|
||||||
import { twig } from './twig/extension';
|
import { twig } from './twig/extension';
|
||||||
import { pathParametersPlugin } from './twig/pathParameters';
|
import { pathParametersPlugin } from './twig/pathParameters';
|
||||||
@@ -95,6 +96,7 @@ const syntaxExtensions: Record<
|
|||||||
url: url,
|
url: url,
|
||||||
pairs: pairs,
|
pairs: pairs,
|
||||||
text: text,
|
text: text,
|
||||||
|
timeline: timeline,
|
||||||
markdown: markdown,
|
markdown: markdown,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
})
|
||||||
@@ -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<TimelineViewMode>({
|
||||||
|
namespace: 'no_sync',
|
||||||
|
key: 'timeline_view_mode',
|
||||||
|
fallback: DEFAULT_VIEW_MODE,
|
||||||
|
});
|
||||||
|
|
||||||
|
return [value ?? DEFAULT_VIEW_MODE, set] as const;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user