Store and show request body in UI (#327)

This commit is contained in:
Gregory Schier
2025-12-28 08:07:42 -08:00
committed by GitHub
parent 6a0d5d2337
commit 26a3e88715
33 changed files with 1221 additions and 337 deletions

View File

@@ -0,0 +1,58 @@
import type { HttpResponse } from '@yaakapp-internal/models';
import { type ReactNode, useMemo } from 'react';
import { getRequestBodyText as getHttpResponseRequestBodyText } from '../hooks/useHttpRequestBody';
import { useToggle } from '../hooks/useToggle';
import { isProbablyTextContentType } from '../lib/contentType';
import { getContentTypeFromHeaders } from '../lib/model_util';
import { CopyButton } from './CopyButton';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { InlineCode } from './core/InlineCode';
import { SizeTag } from './core/SizeTag';
import { HStack } from './core/Stacks';
interface Props {
children: ReactNode;
response: HttpResponse;
}
const LARGE_BYTES = 2 * 1000 * 1000;
export function ConfirmLargeResponseRequest({ children, response }: Props) {
const [showLargeResponse, toggleShowLargeResponse] = useToggle();
const isProbablyText = useMemo(() => {
const contentType = getContentTypeFromHeaders(response.headers);
return isProbablyTextContentType(contentType);
}, [response.headers]);
const contentLength = response.requestContentLength ?? 0;
const isLarge = contentLength > LARGE_BYTES;
if (!showLargeResponse && isLarge) {
return (
<Banner color="primary" className="flex flex-col gap-3">
<p>
Showing content over{' '}
<InlineCode>
<SizeTag contentLength={LARGE_BYTES} />
</InlineCode>{' '}
may impact performance
</p>
<HStack wrap space={2}>
<Button color="primary" size="xs" onClick={toggleShowLargeResponse}>
Reveal Request Body
</Button>
{isProbablyText && (
<CopyButton
color="secondary"
variant="border"
size="xs"
text={() => getHttpResponseRequestBodyText(response).then((d) => d?.bodyText ?? '')}
/>
)}
</HStack>
</Banner>
);
}
return <>{children}</>;
}

View File

@@ -10,6 +10,7 @@ import { useResponseViewMode } from '../hooks/useResponseViewMode';
import { getMimeTypeFromContentType } from '../lib/contentType';
import { getContentTypeFromHeaders } from '../lib/model_util';
import { ConfirmLargeResponse } from './ConfirmLargeResponse';
import { ConfirmLargeResponseRequest } from './ConfirmLargeResponseRequest';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { CountBadge } from './core/CountBadge';
@@ -23,8 +24,9 @@ import type { TabItem } from './core/Tabs/Tabs';
import { TabContent, Tabs } from './core/Tabs/Tabs';
import { EmptyStateText } from './EmptyStateText';
import { ErrorBoundary } from './ErrorBoundary';
import { HttpResponseTimeline } from './HttpResponseTimeline';
import { RecentHttpResponsesDropdown } from './RecentHttpResponsesDropdown';
import { ResponseTimeline } from './ResponseEvents';
import { RequestBodyViewer } from './RequestBodyViewer';
import { ResponseHeaders } from './ResponseHeaders';
import { ResponseInfo } from './ResponseInfo';
import { AudioViewer } from './responseViewers/AudioViewer';
@@ -46,9 +48,10 @@ interface Props {
}
const TAB_BODY = 'body';
const TAB_REQUEST = 'request';
const TAB_HEADERS = 'headers';
const TAB_INFO = 'info';
const TAB_TIMELINE = 'events';
const TAB_TIMELINE = 'timeline';
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
@@ -76,6 +79,12 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
],
},
},
{
value: TAB_REQUEST,
label: 'Request',
rightSlot:
(activeResponse?.requestContentLength ?? 0) > 0 ? <CountBadge count={true} /> : null,
},
{
value: TAB_HEADERS,
label: 'Headers',
@@ -98,11 +107,12 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
],
[
activeResponse?.headers,
activeResponse?.requestContentLength,
activeResponse?.requestHeaders.length,
mimeType,
responseEvents.data?.length,
setViewMode,
viewMode,
activeResponse?.requestHeaders.length,
responseEvents.data?.length,
],
);
const activeTab = activeTabs?.[activeRequestId];
@@ -200,8 +210,8 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
</VStack>
</EmptyStateText>
) : activeResponse.state === 'closed' &&
activeResponse.contentLength === 0 ? (
<EmptyStateText>Empty </EmptyStateText>
(activeResponse.contentLength ?? 0) === 0 ? (
<EmptyStateText>Empty</EmptyStateText>
) : mimeType?.match(/^text\/event-stream/i) && viewMode === 'pretty' ? (
<EventStreamViewer response={activeResponse} />
) : mimeType?.match(/^image\/svg/) ? (
@@ -227,6 +237,11 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
</Suspense>
</ErrorBoundary>
</TabContent>
<TabContent value={TAB_REQUEST}>
<ConfirmLargeResponseRequest response={activeResponse}>
<RequestBodyViewer response={activeResponse} />
</ConfirmLargeResponseRequest>
</TabContent>
<TabContent value={TAB_HEADERS}>
<ResponseHeaders response={activeResponse} />
</TabContent>
@@ -234,7 +249,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
<ResponseInfo response={activeResponse} />
</TabContent>
<TabContent value={TAB_TIMELINE}>
<ResponseTimeline response={activeResponse} />
<HttpResponseTimeline response={activeResponse} />
</TabContent>
</Tabs>
</div>

View File

@@ -5,7 +5,7 @@ import type {
} from '@yaakapp-internal/models';
import classNames from 'classnames';
import { format } from 'date-fns';
import { Fragment, type ReactNode, useMemo, useState } from 'react';
import { type ReactNode, useMemo, useState } from 'react';
import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents';
import { AutoScroller } from './core/AutoScroller';
import { Banner } from './core/Banner';
@@ -20,12 +20,8 @@ interface Props {
response: HttpResponse;
}
export function ResponseTimeline({ response }: Props) {
return (
<Fragment key={response.id}>
<Inner response={response} />
</Fragment>
);
export function HttpResponseTimeline({ response }: Props) {
return <Inner key={response.id} response={response} />;
}
function Inner({ response }: Props) {
@@ -252,20 +248,6 @@ type EventDisplay = {
function getEventDisplay(event: HttpResponseEventData): EventDisplay {
switch (event.type) {
case 'start_request':
return {
icon: 'info',
color: 'secondary',
label: 'Start',
summary: 'Request started',
};
case 'end_request':
return {
icon: 'info',
color: 'secondary',
label: 'End',
summary: 'Request complete',
};
case 'setting':
return {
icon: 'settings',
@@ -321,14 +303,14 @@ function getEventDisplay(event: HttpResponseEventData): EventDisplay {
icon: 'info',
color: 'secondary',
label: 'Chunk',
summary: `${event.bytes} bytes sent`,
summary: `${formatBytes(event.bytes)} chunk sent`,
};
case 'chunk_received':
return {
icon: 'info',
color: 'secondary',
label: 'Chunk',
summary: `${event.bytes} bytes received`,
summary: `${formatBytes(event.bytes)} chunk received`,
};
default:
return {

View File

@@ -0,0 +1,52 @@
import type { HttpResponse } from '@yaakapp-internal/models';
import { useHttpRequestBody } from '../hooks/useHttpRequestBody';
import { languageFromContentType } from '../lib/contentType';
import { EmptyStateText } from './EmptyStateText';
import { Editor } from './core/Editor/LazyEditor';
import { LoadingIcon } from './core/LoadingIcon';
interface Props {
response: HttpResponse;
}
export function RequestBodyViewer({ response }: Props) {
return <RequestBodyViewerInner key={response.id} response={response} />;
}
function RequestBodyViewerInner({ response }: Props) {
const { data, isLoading, error } = useHttpRequestBody(response);
if (isLoading) {
return (
<EmptyStateText>
<LoadingIcon />
</EmptyStateText>
);
}
if (error) {
return <EmptyStateText>Error loading request body: {error.message}</EmptyStateText>;
}
if (data?.bodyText == null || data.bodyText.length === 0) {
return <EmptyStateText>No request body</EmptyStateText>;
}
const { bodyText } = data;
// Try to detect language from content-type header that was sent
const contentTypeHeader = response.requestHeaders.find(
(h) => h.name.toLowerCase() === 'content-type',
);
const contentType = contentTypeHeader?.value ?? null;
const language = languageFromContentType(contentType, bodyText);
return (
<Editor
readOnly
defaultValue={bodyText}
language={language}
stateKey={`request.body.${response.id}`}
/>
);
}

View File

@@ -0,0 +1,32 @@
import { useQuery } from '@tanstack/react-query';
import type { HttpResponse } from '@yaakapp-internal/models';
import { invokeCmd } from '../lib/tauri';
export function useHttpRequestBody(response: HttpResponse | null) {
return useQuery({
placeholderData: (prev) => prev, // Keep previous data on refetch
queryKey: ['request_body', response?.id, response?.state, response?.requestContentLength],
enabled: (response?.requestContentLength ?? 0) > 0,
queryFn: async () => {
return getRequestBodyText(response);
},
});
}
export async function getRequestBodyText(response: HttpResponse | null) {
if (response?.id == null) {
return null;
}
const data = await invokeCmd<number[] | null>('cmd_http_request_body', {
responseId: response.id,
});
if (data == null) {
return null;
}
const body = new Uint8Array(data);
const bodyText = new TextDecoder('utf-8', { fatal: false }).decode(body);
return { body, bodyText };
}

View File

@@ -25,6 +25,7 @@ type TauriCmd =
| 'cmd_grpc_reflect'
| 'cmd_grpc_request_actions'
| 'cmd_http_request_actions'
| 'cmd_http_request_body'
| 'cmd_http_response_body'
| 'cmd_import_data'
| 'cmd_install_plugin'