mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-01-11 22:40:26 +01:00
Store and show request body in UI (#327)
This commit is contained in:
58
src-web/components/ConfirmLargeResponseRequest.tsx
Normal file
58
src-web/components/ConfirmLargeResponseRequest.tsx
Normal 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}</>;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
52
src-web/components/RequestBodyViewer.tsx
Normal file
52
src-web/components/RequestBodyViewer.tsx
Normal 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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
32
src-web/hooks/useHttpRequestBody.ts
Normal file
32
src-web/hooks/useHttpRequestBody.ts
Normal 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 };
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user