Merge main into turchinc/main (PR #324)

This commit is contained in:
Gregory Schier
2025-12-28 13:58:12 -08:00
101 changed files with 5239 additions and 931 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

@@ -128,7 +128,7 @@ function ExportDataDialogContent({
))}
</tbody>
</table>
<DetailsBanner color="secondary" open summary="Extra Settings">
<DetailsBanner color="secondary" defaultOpen summary="Extra Settings">
<Checkbox
checked={includePrivateEnvironments}
onChange={setIncludePrivateEnvironments}

View File

@@ -188,7 +188,10 @@ function HttpRequestCard({ request }: { request: HttpRequest }) {
<span>&bull;</span>
<HttpResponseDurationTag response={latestResponse} />
<span>&bull;</span>
<SizeTag contentLength={latestResponse.contentLength ?? 0} />
<SizeTag
contentLength={latestResponse.contentLength ?? 0}
contentLengthCompressed={latestResponse.contentLength}
/>
</HStack>
</button>
) : (

View File

@@ -34,11 +34,11 @@ export function HeadersEditor({
const validInheritedHeaders =
inheritedHeaders?.filter((pair) => pair.enabled && (pair.name || pair.value)) ?? [];
return (
<div className="@container w-full h-full grid grid-rows-[auto_minmax(0,1fr)]">
<div className="@container w-full h-full grid grid-rows-[auto_minmax(0,1fr)] gap-y-1.5">
{validInheritedHeaders.length > 0 ? (
<DetailsBanner
color="secondary"
className="text-sm mb-1.5"
className="text-sm"
summary={
<HStack>
Inherited <CountBadge count={validInheritedHeaders.length} />

View File

@@ -4,11 +4,14 @@ import type { ComponentType, CSSProperties } from 'react';
import { lazy, Suspense, useCallback, useMemo } from 'react';
import { useLocalStorage } from 'react-use';
import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse';
import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents';
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
import { useResponseBodyBytes, useResponseBodyText } from '../hooks/useResponseBodyText';
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';
@@ -22,7 +25,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 { RequestBodyViewer } from './RequestBodyViewer';
import { ResponseHeaders } from './ResponseHeaders';
import { ResponseInfo } from './ResponseInfo';
import { AudioViewer } from './responseViewers/AudioViewer';
@@ -30,6 +35,7 @@ import { CsvViewer } from './responseViewers/CsvViewer';
import { EventStreamViewer } from './responseViewers/EventStreamViewer';
import { HTMLOrTextViewer } from './responseViewers/HTMLOrTextViewer';
import { ImageViewer } from './responseViewers/ImageViewer';
import { MultipartViewer } from './responseViewers/MultipartViewer';
import { SvgViewer } from './responseViewers/SvgViewer';
import { VideoViewer } from './responseViewers/VideoViewer';
@@ -44,8 +50,10 @@ interface Props {
}
const TAB_BODY = 'body';
const TAB_REQUEST = 'request';
const TAB_HEADERS = 'headers';
const TAB_INFO = 'info';
const TAB_TIMELINE = 'timeline';
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
@@ -57,6 +65,8 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null);
const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence;
const responseEvents = useHttpResponseEvents(activeResponse);
const tabs = useMemo<TabItem[]>(
() => [
{
@@ -71,21 +81,41 @@ 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',
rightSlot: (
<CountBadge
count={activeResponse?.headers.filter((h) => h.name && h.value).length ?? 0}
count2={activeResponse?.headers.length ?? 0}
count={activeResponse?.requestHeaders.length ?? 0}
/>
),
},
{
value: TAB_TIMELINE,
label: 'Timeline',
rightSlot: <CountBadge count={responseEvents.data?.length ?? 0} />,
},
{
value: TAB_INFO,
label: 'Info',
},
],
[activeResponse?.headers, mimeType, setViewMode, viewMode],
[
activeResponse?.headers,
activeResponse?.requestContentLength,
activeResponse?.requestHeaders.length,
mimeType,
responseEvents.data?.length,
setViewMode,
viewMode,
],
);
const activeTab = activeTabs?.[activeRequestId];
const setActiveTab = useCallback(
@@ -133,7 +163,10 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
<span>&bull;</span>
<HttpResponseDurationTag response={activeResponse} />
<span>&bull;</span>
<SizeTag contentLength={activeResponse.contentLength ?? 0} />
<SizeTag
contentLength={activeResponse.contentLength ?? 0}
contentLengthCompressed={activeResponse.contentLengthCompressed}
/>
<div className="ml-auto">
<RecentHttpResponsesDropdown
@@ -146,18 +179,20 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
)}
</HStack>
{activeResponse?.error ? (
<Banner color="danger" className="m-2">
{activeResponse.error}
</Banner>
) : (
<div className="overflow-hidden flex flex-col min-h-0">
{activeResponse?.error && (
<Banner color="danger" className="mx-3 mt-1 flex-shrink-0">
{activeResponse.error}
</Banner>
)}
{/* Show tabs if we have any data (headers, body, etc.) even if there's an error */}
<Tabs
key={activeRequestId} // Freshen tabs on request change
value={activeTab}
onChangeValue={setActiveTab}
tabs={tabs}
label="Response"
className="ml-3 mr-3 mb-3"
className="ml-3 mr-3 mb-3 min-h-0 flex-1"
tabListClassName="mt-0.5"
>
<TabContent value={TAB_BODY}>
@@ -177,22 +212,24 @@ 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/) ? (
<SvgViewer response={activeResponse} />
<HttpSvgViewer response={activeResponse} />
) : mimeType?.match(/^image/i) ? (
<EnsureCompleteResponse response={activeResponse} Component={ImageViewer} />
) : mimeType?.match(/^audio/i) ? (
<EnsureCompleteResponse response={activeResponse} Component={AudioViewer} />
) : mimeType?.match(/^video/i) ? (
<EnsureCompleteResponse response={activeResponse} Component={VideoViewer} />
) : mimeType?.match(/^multipart/i) && viewMode === 'pretty' ? (
<HttpMultipartViewer response={activeResponse} />
) : mimeType?.match(/pdf/i) ? (
<EnsureCompleteResponse response={activeResponse} Component={PdfViewer} />
) : mimeType?.match(/csv|tab-separated/i) ? (
<CsvViewer className="pb-2" response={activeResponse} />
<HttpCsvViewer className="pb-2" response={activeResponse} />
) : (
<HTMLOrTextViewer
textViewerClassName="-mr-2 bg-surface" // Pull to the right
@@ -204,14 +241,22 @@ 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>
<TabContent value={TAB_INFO}>
<ResponseInfo response={activeResponse} />
</TabContent>
<TabContent value={TAB_TIMELINE}>
<HttpResponseTimeline response={activeResponse} />
</TabContent>
</Tabs>
)}
</div>
</div>
)}
</div>
@@ -240,3 +285,28 @@ function EnsureCompleteResponse({
return <Component bodyPath={response.bodyPath} />;
}
function HttpSvgViewer({ response }: { response: HttpResponse }) {
const body = useResponseBodyText({ response, filter: null });
if (!body.data) return null;
return <SvgViewer text={body.data} />;
}
function HttpCsvViewer({ response, className }: { response: HttpResponse; className?: string }) {
const body = useResponseBodyText({ response, filter: null });
return <CsvViewer text={body.data ?? null} className={className} />;
}
function HttpMultipartViewer({ response }: { response: HttpResponse }) {
const body = useResponseBodyBytes({ response });
if (body.data == null) return null;
const contentTypeHeader = getContentTypeFromHeaders(response.headers);
const boundary = contentTypeHeader?.split('boundary=')[1] ?? 'unknown';
return <MultipartViewer data={body.data} boundary={boundary} idPrefix={response.id} />;
}

View File

@@ -0,0 +1,323 @@
import type {
HttpResponse,
HttpResponseEvent,
HttpResponseEventData,
} from '@yaakapp-internal/models';
import classNames from 'classnames';
import { format } from 'date-fns';
import { type ReactNode, useMemo, useState } from 'react';
import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents';
import { AutoScroller } from './core/AutoScroller';
import { Banner } from './core/Banner';
import { HttpMethodTagRaw } from './core/HttpMethodTag';
import { HttpStatusTagRaw } from './core/HttpStatusTag';
import { Icon, type IconProps } from './core/Icon';
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
import { Separator } from './core/Separator';
import { SplitLayout } from './core/SplitLayout';
interface Props {
response: HttpResponse;
}
export function HttpResponseTimeline({ response }: Props) {
return <Inner key={response.id} response={response} />;
}
function Inner({ response }: Props) {
const [activeEventIndex, setActiveEventIndex] = useState<number | null>(null);
const { data: events, error, isLoading } = useHttpResponseEvents(response);
const activeEvent = useMemo(
() => (activeEventIndex == null ? null : events?.[activeEventIndex]),
[activeEventIndex, events],
);
if (isLoading) {
return <div className="p-3 text-text-subtlest italic">Loading events...</div>;
}
if (error) {
return (
<Banner color="danger" className="m-3">
{String(error)}
</Banner>
);
}
if (!events || events.length === 0) {
return <div className="p-3 text-text-subtlest italic">No events recorded</div>;
}
return (
<SplitLayout
layout="vertical"
name="http_response_events"
defaultRatio={0.25}
minHeightPx={10}
firstSlot={() => (
<AutoScroller
data={events}
render={(event, i) => (
<EventRow
key={event.id}
event={event}
isActive={i === activeEventIndex}
onClick={() => {
if (i === activeEventIndex) setActiveEventIndex(null);
else setActiveEventIndex(i);
}}
/>
)}
/>
)}
secondSlot={
activeEvent
? () => (
<div className="grid grid-rows-[auto_minmax(0,1fr)]">
<div className="pb-3 px-2">
<Separator />
</div>
<div className="mx-2 overflow-y-auto">
<EventDetails event={activeEvent} />
</div>
</div>
)
: null
}
/>
);
}
function EventRow({
onClick,
isActive,
event,
}: {
onClick: () => void;
isActive: boolean;
event: HttpResponseEvent;
}) {
const display = getEventDisplay(event.event);
const { icon, color, summary } = display;
return (
<div className="px-1">
<button
type="button"
onClick={onClick}
className={classNames(
'w-full grid grid-cols-[auto_minmax(0,1fr)_auto] gap-2 items-center text-left',
'px-1.5 h-xs font-mono text-editor cursor-default group focus:outline-none focus:text-text rounded',
isActive && '!bg-surface-active !text-text',
'text-text-subtle hover:text',
)}
>
<Icon color={color} icon={icon} size="sm" />
<div className="w-full truncate">{summary}</div>
<div className="opacity-50">{format(`${event.createdAt}Z`, 'HH:mm:ss.SSS')}</div>
</button>
</div>
);
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function EventDetails({ event }: { event: HttpResponseEvent }) {
const { label } = getEventDisplay(event.event);
const timestamp = format(new Date(`${event.createdAt}Z`), 'HH:mm:ss.SSS');
const e = event.event;
// Headers - show name and value with Editor for JSON
if (e.type === 'header_up' || e.type === 'header_down') {
return (
<div className="flex flex-col gap-2 h-full">
<DetailHeader
title={e.type === 'header_down' ? 'Header Received' : 'Header Sent'}
timestamp={timestamp}
/>
<KeyValueRows>
<KeyValueRow label="Header">{e.name}</KeyValueRow>
<KeyValueRow label="Value">{e.value}</KeyValueRow>
</KeyValueRows>
</div>
);
}
// Request URL - show method and path separately
if (e.type === 'send_url') {
return (
<div className="flex flex-col gap-2">
<DetailHeader title="Request" timestamp={timestamp} />
<KeyValueRows>
<KeyValueRow label="Method">
<HttpMethodTagRaw forceColor method={e.method} />
</KeyValueRow>
<KeyValueRow label="Path">{e.path}</KeyValueRow>
</KeyValueRows>
</div>
);
}
// Response status - show version and status separately
if (e.type === 'receive_url') {
return (
<div className="flex flex-col gap-2">
<DetailHeader title="Response" timestamp={timestamp} />
<KeyValueRows>
<KeyValueRow label="HTTP Version">{e.version}</KeyValueRow>
<KeyValueRow label="Status">
<HttpStatusTagRaw status={e.status} />
</KeyValueRow>
</KeyValueRows>
</div>
);
}
// Redirect - show status, URL, and behavior
if (e.type === 'redirect') {
return (
<div className="flex flex-col gap-2">
<DetailHeader title="Redirect" timestamp={timestamp} />
<KeyValueRows>
<KeyValueRow label="Status">
<HttpStatusTagRaw status={e.status} />
</KeyValueRow>
<KeyValueRow label="Location">{e.url}</KeyValueRow>
<KeyValueRow label="Behavior">
{e.behavior === 'drop_body' ? 'Drop body, change to GET' : 'Preserve method and body'}
</KeyValueRow>
</KeyValueRows>
</div>
);
}
// Settings - show as key/value
if (e.type === 'setting') {
return (
<div className="flex flex-col gap-2">
<DetailHeader title="Apply Setting" timestamp={timestamp} />
<KeyValueRows>
<KeyValueRow label="Setting">{e.name}</KeyValueRow>
<KeyValueRow label="Value">{e.value}</KeyValueRow>
</KeyValueRows>
</div>
);
}
// Chunks - show formatted bytes
if (e.type === 'chunk_sent' || e.type === 'chunk_received') {
const direction = e.type === 'chunk_sent' ? 'Sent' : 'Received';
return (
<div className="flex flex-col gap-2">
<DetailHeader title={`Data ${direction}`} timestamp={timestamp} />
<div className="font-mono text-editor">{formatBytes(e.bytes)}</div>
</div>
);
}
// Default - use summary
const { summary } = getEventDisplay(event.event);
return (
<div className="flex flex-col gap-1">
<DetailHeader title={label} timestamp={timestamp} />
<div className="font-mono text-editor">{summary}</div>
</div>
);
}
function DetailHeader({ title, timestamp }: { title: string; timestamp: string }) {
return (
<div className="flex items-center justify-between gap-2">
<h3 className="font-semibold select-auto cursor-auto">{title}</h3>
<span className="text-text-subtlest font-mono text-editor">{timestamp}</span>
</div>
);
}
type EventDisplay = {
icon: IconProps['icon'];
color: IconProps['color'];
label: string;
summary: ReactNode;
};
function getEventDisplay(event: HttpResponseEventData): EventDisplay {
switch (event.type) {
case 'setting':
return {
icon: 'settings',
color: 'secondary',
label: 'Setting',
summary: `${event.name} = ${event.value}`,
};
case 'info':
return {
icon: 'info',
color: 'secondary',
label: 'Info',
summary: event.message,
};
case 'redirect':
return {
icon: 'arrow_big_right_dash',
color: 'warning',
label: 'Redirect',
summary: `Redirecting ${event.status} ${event.url}${event.behavior === 'drop_body' ? ' (drop body)' : ''}`,
};
case 'send_url':
return {
icon: 'arrow_big_up_dash',
color: 'primary',
label: 'Request',
summary: `${event.method} ${event.path}`,
};
case 'receive_url':
return {
icon: 'arrow_big_down_dash',
color: 'info',
label: 'Response',
summary: `${event.version} ${event.status}`,
};
case 'header_up':
return {
icon: 'arrow_big_up_dash',
color: 'primary',
label: 'Header',
summary: `${event.name}: ${event.value}`,
};
case 'header_down':
return {
icon: 'arrow_big_down_dash',
color: 'info',
label: 'Header',
summary: `${event.name}: ${event.value}`,
};
case 'chunk_sent':
return {
icon: 'info',
color: 'secondary',
label: 'Chunk',
summary: `${formatBytes(event.bytes)} chunk sent`,
};
case 'chunk_received':
return {
icon: 'info',
color: 'secondary',
label: 'Chunk',
summary: `${formatBytes(event.bytes)} chunk received`,
};
default:
return {
icon: 'info',
color: 'secondary',
label: 'Unknown',
summary: 'Unknown event',
};
}
}

View File

@@ -0,0 +1,102 @@
import type { HttpResponse } from '@yaakapp-internal/models';
import { lazy, Suspense } from 'react';
import { useHttpRequestBody } from '../hooks/useHttpRequestBody';
import { getMimeTypeFromContentType, languageFromContentType } from '../lib/contentType';
import { EmptyStateText } from './EmptyStateText';
import { LoadingIcon } from './core/LoadingIcon';
import { AudioViewer } from './responseViewers/AudioViewer';
import { CsvViewer } from './responseViewers/CsvViewer';
import { ImageViewer } from './responseViewers/ImageViewer';
import { MultipartViewer } from './responseViewers/MultipartViewer';
import { SvgViewer } from './responseViewers/SvgViewer';
import { TextViewer } from './responseViewers/TextViewer';
import { VideoViewer } from './responseViewers/VideoViewer';
import { WebPageViewer } from './responseViewers/WebPageViewer';
const PdfViewer = lazy(() =>
import('./responseViewers/PdfViewer').then((m) => ({ default: m.PdfViewer })),
);
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, body } = 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 mimeType = contentType ? getMimeTypeFromContentType(contentType).essence : null;
const language = languageFromContentType(contentType, bodyText);
// Route to appropriate viewer based on content type
if (mimeType?.match(/^multipart/i)) {
const boundary = contentType?.split('boundary=')[1] ?? 'unknown';
// Create a copy because parseMultipart may detach the buffer
const bodyCopy = new Uint8Array(body);
return (
<MultipartViewer data={bodyCopy} boundary={boundary} idPrefix={`request.${response.id}`} />
);
}
if (mimeType?.match(/^image\/svg/i)) {
return <SvgViewer text={bodyText} />;
}
if (mimeType?.match(/^image/i)) {
return <ImageViewer data={body.buffer} />;
}
if (mimeType?.match(/^audio/i)) {
return <AudioViewer data={body} />;
}
if (mimeType?.match(/^video/i)) {
return <VideoViewer data={body} />;
}
if (mimeType?.match(/csv|tab-separated/i)) {
return <CsvViewer text={bodyText} />;
}
if (mimeType?.match(/^text\/html/i)) {
return <WebPageViewer html={bodyText} />;
}
if (mimeType?.match(/pdf/i)) {
return (
<Suspense fallback={<LoadingIcon />}>
<PdfViewer data={body} />
</Suspense>
);
}
return (
<TextViewer text={bodyText} language={language} stateKey={`request.body.${response.id}`} />
);
}

View File

@@ -1,5 +1,7 @@
import type { HttpResponse } from '@yaakapp-internal/models';
import { useMemo } from 'react';
import { CountBadge } from './core/CountBadge';
import { DetailsBanner } from './core/DetailsBanner';
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
interface Props {
@@ -7,20 +9,69 @@ interface Props {
}
export function ResponseHeaders({ response }: Props) {
const sortedHeaders = useMemo(
() => [...response.headers].sort((a, b) => a.name.localeCompare(b.name)),
const responseHeaders = useMemo(
() =>
[...response.headers].sort((a, b) =>
a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()),
),
[response.headers],
);
const requestHeaders = useMemo(
() =>
[...response.requestHeaders].sort((a, b) =>
a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()),
),
[response.requestHeaders],
);
return (
<div className="overflow-auto h-full pb-4">
<KeyValueRows>
{sortedHeaders.map((h, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: none
<KeyValueRow labelColor="primary" key={i} label={h.name}>
{h.value}
</KeyValueRow>
))}
</KeyValueRows>
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
<DetailsBanner
storageKey={`${response.requestId}.request_headers`}
summary={
<h2 className="flex items-center">
Request <CountBadge showZero count={requestHeaders.length} />
</h2>
}
>
{requestHeaders.length === 0 ? (
<NoHeaders />
) : (
<KeyValueRows>
{requestHeaders.map((h, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: none
<KeyValueRow labelColor="primary" key={i} label={h.name}>
{h.value}
</KeyValueRow>
))}
</KeyValueRows>
)}
</DetailsBanner>
<DetailsBanner
defaultOpen
storageKey={`${response.requestId}.response_headers`}
summary={
<h2 className="flex items-center">
Response <CountBadge showZero count={responseHeaders.length} />
</h2>
}
>
{responseHeaders.length === 0 ? (
<NoHeaders />
) : (
<KeyValueRows>
{responseHeaders.map((h, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: none
<KeyValueRow labelColor="primary" key={i} label={h.name}>
{h.value}
</KeyValueRow>
))}
</KeyValueRows>
)}
</DetailsBanner>
</div>
);
}
function NoHeaders() {
return <span className="text-text-subtlest text-sm italic">No Headers</span>;
}

View File

@@ -12,10 +12,10 @@ export function ResponseInfo({ response }: Props) {
<div className="overflow-auto h-full pb-4">
<KeyValueRows>
<KeyValueRow labelColor="info" label="Version">
{response.version}
{response.version ?? <span className="text-text-subtlest">--</span>}
</KeyValueRow>
<KeyValueRow labelColor="info" label="Remote Address">
{response.remoteAddr}
{response.remoteAddr ?? <span className="text-text-subtlest">--</span>}
</KeyValueRow>
<KeyValueRow
labelColor="info"

View File

@@ -20,7 +20,7 @@ export default function RouteError({ error }: { error: unknown }) {
{stack && (
<DetailsBanner
color="secondary"
className="mt-3 select-auto text-xs"
className="mt-3 select-auto text-xs max-h-[40vh]"
summary="Stack Trace"
>
<div className="mt-2 text-xs">{stack}</div>

View File

@@ -53,7 +53,7 @@ function CertificateEditor({ certificate, index, onUpdate, onRemove }: Certifica
return (
<DetailsBanner
open={defaultOpen.current}
defaultOpen={defaultOpen.current}
summary={
<HStack alignItems="center" justifyContent="between" space={2} className="w-full">
<HStack space={1.5}>

View File

@@ -61,7 +61,6 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
'x-theme-button',
`x-theme-button--${variant}`,
`x-theme-button--${variant}--${color}`,
'text-text',
'border', // They all have borders to ensure the same width
'max-w-full min-w-0', // Help with truncation
'hocus:opacity-100', // Force opacity for certain hover effects
@@ -81,7 +80,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
variant === 'solid' && color === 'custom' && 'focus-visible:outline-2 outline-border-focus',
variant === 'solid' &&
color !== 'custom' &&
'enabled:hocus:text-text enabled:hocus:bg-surface-highlight outline-border-subtle',
'text-text enabled:hocus:text-text enabled:hocus:bg-surface-highlight outline-border-subtle',
variant === 'solid' && color !== 'custom' && color !== 'default' && 'bg-surface',
// Borders

View File

@@ -3,12 +3,15 @@ import classNames from 'classnames';
interface Props {
count: number | true;
count2?: number | true;
className?: string;
color?: Color;
showZero?: boolean;
}
export function CountBadge({ count, className, color }: Props) {
if (count === 0) return null;
export function CountBadge({ count, count2, className, color, showZero }: Props) {
if (count === 0 && !showZero) return null;
return (
<div
aria-hidden
@@ -30,6 +33,16 @@ export function CountBadge({ count, className, color }: Props) {
) : (
count
)}
{count2 != null && (
<>
/
{count2 === true ? (
<div aria-hidden className="rounded-full h-1 w-1 bg-[currentColor]" />
) : (
count2
)}
</>
)}
</div>
);
}

View File

@@ -1,19 +1,48 @@
import classNames from 'classnames';
import { atom, useAtom } from 'jotai';
import type { HTMLAttributes, ReactNode } from 'react';
import { useMemo } from 'react';
import { atomWithKVStorage } from '../../lib/atoms/atomWithKVStorage';
import type { BannerProps } from './Banner';
import { Banner } from './Banner';
interface Props extends HTMLAttributes<HTMLDetailsElement> {
summary: ReactNode;
color?: BannerProps['color'];
open?: boolean;
defaultOpen?: boolean;
storageKey?: string;
}
export function DetailsBanner({ className, color, summary, children, ...extraProps }: Props) {
export function DetailsBanner({
className,
color,
summary,
children,
defaultOpen,
storageKey,
...extraProps
}: Props) {
// biome-ignore lint/correctness/useExhaustiveDependencies: We only want to recompute the atom when storageKey changes
const openAtom = useMemo(
() =>
storageKey
? atomWithKVStorage<boolean>(['details_banner', storageKey], defaultOpen ?? false)
: atom(defaultOpen ?? false),
[storageKey],
);
const [isOpen, setIsOpen] = useAtom(openAtom);
const handleToggle = (e: React.SyntheticEvent<HTMLDetailsElement>) => {
if (storageKey) {
setIsOpen(e.currentTarget.open);
}
};
return (
<Banner color={color} className={className}>
<details className="group list-none" {...extraProps}>
<summary className="!cursor-default !select-none list-none flex items-center gap-2 focus:outline-none opacity-70 hover:opacity-100 focus:opacity-100">
<details className="group list-none" open={isOpen} onToggle={handleToggle} {...extraProps}>
<summary className="!cursor-default !select-none list-none flex items-center gap-3 focus:outline-none opacity-70">
<div
className={classNames(
'transition-transform',

View File

@@ -11,7 +11,7 @@
}
@tokens {
Text { ![$] Text? | "$" (@eof | ![{] Text?) }
Text { ![$] Text? | "$" (@eof | ![{] Text? | "{" ![[] Text?) }
}
@external propSource highlight from "./highlight"

View File

@@ -1,7 +1,8 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
export const Template = 1,
export const
Template = 1,
Tag = 2,
TagOpen = 3,
TagContent = 4,
TagClose = 5,
Text = 6;
Text = 6

View File

@@ -0,0 +1,106 @@
import { describe, expect, test } from 'vitest';
import { parser } from './twig';
function getNodeNames(input: string): string[] {
const tree = parser.parse(input);
const nodes: string[] = [];
const cursor = tree.cursor();
do {
if (cursor.name !== 'Template') {
nodes.push(cursor.name);
}
} while (cursor.next());
return nodes;
}
function hasTag(input: string): boolean {
return getNodeNames(input).includes('Tag');
}
function hasError(input: string): boolean {
return getNodeNames(input).includes('⚠');
}
describe('twig grammar', () => {
describe('${[var]} format (valid template tags)', () => {
test('parses simple variable as Tag', () => {
expect(hasTag('${[var]}')).toBe(true);
expect(hasError('${[var]}')).toBe(false);
});
test('parses variable with whitespace as Tag', () => {
expect(hasTag('${[ var ]}')).toBe(true);
expect(hasError('${[ var ]}')).toBe(false);
});
test('parses embedded variable as Tag', () => {
expect(hasTag('hello ${[name]} world')).toBe(true);
expect(hasError('hello ${[name]} world')).toBe(false);
});
test('parses function call as Tag', () => {
expect(hasTag('${[fn()]}')).toBe(true);
expect(hasError('${[fn()]}')).toBe(false);
});
});
describe('${var} format (should be plain text, not tags)', () => {
test('parses ${var} as plain Text without errors', () => {
expect(hasTag('${var}')).toBe(false);
expect(hasError('${var}')).toBe(false);
});
test('parses embedded ${var} as plain Text', () => {
expect(hasTag('hello ${name} world')).toBe(false);
expect(hasError('hello ${name} world')).toBe(false);
});
test('parses JSON with ${var} as plain Text', () => {
const json = '{"key": "${value}"}';
expect(hasTag(json)).toBe(false);
expect(hasError(json)).toBe(false);
});
test('parses multiple ${var} as plain Text', () => {
expect(hasTag('${a} and ${b}')).toBe(false);
expect(hasError('${a} and ${b}')).toBe(false);
});
});
describe('mixed content', () => {
test('distinguishes ${var} from ${[var]} in same string', () => {
const input = '${plain} and ${[tag]}';
expect(hasTag(input)).toBe(true);
expect(hasError(input)).toBe(false);
});
test('parses JSON with ${[var]} as having Tag', () => {
const json = '{"key": "${[value]}"}';
expect(hasTag(json)).toBe(true);
expect(hasError(json)).toBe(false);
});
});
describe('edge cases', () => {
test('handles $ at end of string', () => {
expect(hasError('hello$')).toBe(false);
expect(hasTag('hello$')).toBe(false);
});
test('handles ${ at end of string without crash', () => {
// Incomplete syntax may produce errors, but should not crash
expect(() => parser.parse('hello${')).not.toThrow();
});
test('handles ${[ without closing without crash', () => {
// Unclosed tag may produce partial match, but should not crash
expect(() => parser.parse('${[unclosed')).not.toThrow();
});
test('handles empty ${[]}', () => {
// Empty tags may or may not be valid depending on grammar
// Just ensure no crash
expect(() => parser.parse('${[]}')).not.toThrow();
});
});
});

View File

@@ -1,20 +1,18 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import { LocalTokenGroup, LRParser } from '@lezer/lr';
import { highlight } from './highlight';
import {LRParser, LocalTokenGroup} from "@lezer/lr"
import {highlight} from "./highlight"
export const parser = LRParser.deserialize({
version: 14,
states:
"!^QQOPOOOOOO'#C_'#C_OYOQO'#C^OOOO'#Cc'#CcQQOPOOOOOO'#Cd'#CdO_OQO,58xOOOO-E6a-E6aOOOO-E6b-E6bOOOO1G.d1G.d",
stateData: 'g~OUROYPO~OSTO~OSTOTXO~O',
goto: 'nXPPY^PPPbhTROSTQOSQSORVSQUQRWU',
nodeNames: '⚠ Template Tag TagOpen TagContent TagClose Text',
states: "!^QQOPOOOOOO'#C_'#C_OYOQO'#C^OOOO'#Cc'#CcQQOPOOOOOO'#Cd'#CdO_OQO,58xOOOO-E6a-E6aOOOO-E6b-E6bOOOO1G.d1G.d",
stateData: "g~OUROYPO~OSTO~OSTOTXO~O",
goto: "nXPPY^PPPbhTROSTQOSQSORVSQUQRWU",
nodeNames: "⚠ Template Tag TagOpen TagContent TagClose Text",
maxTerm: 10,
propSources: [highlight],
skippedNodes: [0],
repeatNodeCount: 2,
tokenData:
"#]~RTOtbtu!hu;'Sb;'S;=`!]<%lOb~gTU~Otbtuvu;'Sb;'S;=`!]<%lOb~yUO#ob#p;'Sb;'S;=`!]<%l~b~Ob~~!c~!`P;=`<%lb~!hOU~~!kVO#ob#o#p#Q#p;'Sb;'S;=`!]<%l~b~Ob~~!c~#TP!}#O#W~#]OY~",
tokenizers: [1, new LocalTokenGroup('b~RP#P#QU~XP#q#r[~aOT~~', 17, 4)],
topRules: { Template: [0, 1] },
tokenPrec: 0,
});
tokenData: "#{~RTOtbtu!zu;'Sb;'S;=`!o<%lOb~gTU~Otbtuvu;'Sb;'S;=`!o<%lOb~yVO#ob#o#p!`#p;'Sb;'S;=`!o<%l~b~Ob~~!u~!cSO!}b#O;'Sb;'S;=`!o<%lOb~!rP;=`<%lb~!zOU~~!}VO#ob#o#p#d#p;'Sb;'S;=`!o<%l~b~Ob~~!u~#gTO!}b!}#O#v#O;'Sb;'S;=`!o<%lOb~#{OY~",
tokenizers: [1, new LocalTokenGroup("b~RP#P#QU~XP#q#r[~aOT~~", 17, 4)],
topRules: {"Template":[0,1]},
tokenPrec: 0
})

View File

@@ -3,12 +3,14 @@ import type { ReactNode } from 'react';
interface Props {
children: ReactNode;
className?: string;
}
export function FormattedError({ children }: Props) {
export function FormattedError({ children, className }: Props) {
return (
<pre
className={classNames(
className,
'cursor-text select-auto',
'[&_*]:cursor-text [&_*]:select-auto',
'font-mono text-sm w-full bg-surface-highlight p-3 rounded',

View File

@@ -41,10 +41,12 @@ export function HttpMethodTagRaw({
className,
method,
short,
forceColor,
}: {
method: string;
className?: string;
short?: boolean;
forceColor?: boolean;
}) {
let label = method.toUpperCase();
if (short) {
@@ -54,7 +56,8 @@ export function HttpMethodTagRaw({
const m = method.toUpperCase();
const colored = useAtomValue(settingsAtom).coloredMethods;
const settings = useAtomValue(settingsAtom);
const colored = forceColor || settings.coloredMethods;
return (
<span

View File

@@ -1,4 +1,5 @@
import type { HttpResponse } from '@yaakapp-internal/models';
import type { HttpResponseState } from '@yaakapp-internal/models';
import classNames from 'classnames';
interface Props {
@@ -8,25 +9,40 @@ interface Props {
short?: boolean;
}
export function HttpStatusTag({ response, className, showReason, short }: Props) {
const { status, state } = response;
export function HttpStatusTag({ response, ...props }: Props) {
const { status, state, statusReason } = response;
return <HttpStatusTagRaw status={status} state={state} statusReason={statusReason} {...props} />;
}
export function HttpStatusTagRaw({
status,
state,
className,
showReason,
statusReason,
short,
}: Omit<Props, 'response'> & {
status: number | string;
state?: HttpResponseState;
statusReason?: string | null;
}) {
let colorClass: string;
let label = `${status}`;
const statusN = typeof status === 'number' ? status : parseInt(status, 10);
if (state === 'initialized') {
label = short ? 'CONN' : 'CONNECTING';
colorClass = 'text-text-subtle';
} else if (status < 100) {
} else if (statusN < 100) {
label = short ? 'ERR' : 'ERROR';
colorClass = 'text-danger';
} else if (status < 200) {
} else if (statusN < 200) {
colorClass = 'text-info';
} else if (status < 300) {
} else if (statusN < 300) {
colorClass = 'text-success';
} else if (status < 400) {
} else if (statusN < 400) {
colorClass = 'text-primary';
} else if (status < 500) {
} else if (statusN < 500) {
colorClass = 'text-warning';
} else {
colorClass = 'text-danger';
@@ -34,7 +50,7 @@ export function HttpStatusTag({ response, className, showReason, short }: Props)
return (
<span className={classNames(className, 'font-mono min-w-0', colorClass)}>
{label} {showReason && 'statusReason' in response ? response.statusReason : null}
{label} {showReason && statusReason}
</span>
);
}

View File

@@ -12,7 +12,9 @@ import {
ArrowDownIcon,
ArrowDownToDotIcon,
ArrowDownToLineIcon,
ArrowLeftIcon,
ArrowRightCircleIcon,
ArrowRightIcon,
ArrowUpDownIcon,
ArrowUpFromDotIcon,
ArrowUpFromLineIcon,
@@ -54,6 +56,7 @@ import {
EyeIcon,
EyeOffIcon,
FileCodeIcon,
FileIcon,
FileTextIcon,
FilterIcon,
FlameIcon,
@@ -108,7 +111,7 @@ import {
Rows2Icon,
SaveIcon,
SearchIcon,
SendHorizonalIcon,
SendHorizontalIcon,
SettingsIcon,
ShieldAlertIcon,
ShieldCheckIcon,
@@ -142,6 +145,8 @@ const icons = {
arrow_down: ArrowDownIcon,
arrow_down_to_dot: ArrowDownToDotIcon,
arrow_down_to_line: ArrowDownToLineIcon,
arrow_left: ArrowLeftIcon,
arrow_right: ArrowRightIcon,
arrow_right_circle: ArrowRightCircleIcon,
arrow_up: ArrowUpIcon,
arrow_up_down: ArrowUpDownIcon,
@@ -183,7 +188,9 @@ const icons = {
external_link: ExternalLinkIcon,
eye: EyeIcon,
eye_closed: EyeOffIcon,
file: FileIcon,
file_code: FileCodeIcon,
file_text: FileTextIcon,
filter: FilterIcon,
flame: FlameIcon,
flask: FlaskConicalIcon,
@@ -238,7 +245,7 @@ const icons = {
rows_2: Rows2Icon,
save: SaveIcon,
search: SearchIcon,
send_horizontal: SendHorizonalIcon,
send_horizontal: SendHorizontalIcon,
settings: SettingsIcon,
shield: ShieldIcon,
shield_check: ShieldCheckIcon,

View File

@@ -10,7 +10,7 @@ interface Props {
export function KeyValueRows({ children }: Props) {
children = Array.isArray(children) ? children : [children];
return (
<table className="text-xs font-mono min-w-0 w-full mb-auto">
<table className="text-editor font-mono min-w-0 w-full mb-auto">
<tbody className="divide-y divide-surface-highlight">
{children.map((child, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: none

View File

@@ -678,7 +678,7 @@ export function PairEditorRow({
size="xs"
icon={isLast || disabled ? 'empty' : 'chevron_down'}
title="Select form data type"
className="text-text-subtle"
className="text-text-subtlest"
/>
</Dropdown>
)}
@@ -798,7 +798,13 @@ function FileActionsDropdown({
items={fileItems}
itemsAfter={itemsAfter}
>
<IconButton iconSize="sm" size="xs" icon="chevron_down" title="Select form data type" />
<IconButton
iconSize="sm"
size="xs"
icon="chevron_down"
title="Select form data type"
className="text-text-subtlest"
/>
</RadioDropdown>
);
}

View File

@@ -2,11 +2,18 @@ import { formatSize } from '@yaakapp-internal/lib/formatSize';
interface Props {
contentLength: number;
contentLengthCompressed?: number | null;
}
export function SizeTag({ contentLength }: Props) {
export function SizeTag({ contentLength, contentLengthCompressed }: Props) {
return (
<span className="font-mono" title={`${contentLength} bytes`}>
<span
className="font-mono"
title={
`${contentLength} bytes` +
(contentLengthCompressed ? `\n${contentLengthCompressed} bytes compressed` : '')
}
>
{formatSize(contentLength)}
</span>
);

View File

@@ -1,11 +1,26 @@
import { convertFileSrc } from '@tauri-apps/api/core';
import { useEffect, useState } from 'react';
interface Props {
bodyPath: string;
bodyPath?: string;
data?: Uint8Array;
}
export function AudioViewer({ bodyPath }: Props) {
const src = convertFileSrc(bodyPath);
export function AudioViewer({ bodyPath, data }: Props) {
const [src, setSrc] = useState<string>();
useEffect(() => {
if (bodyPath) {
setSrc(convertFileSrc(bodyPath));
} else if (data) {
const blob = new Blob([data], { type: 'audio/mpeg' });
const url = URL.createObjectURL(blob);
setSrc(url);
return () => URL.revokeObjectURL(url);
} else {
setSrc(undefined);
}
}, [bodyPath, data]);
// biome-ignore lint/a11y/useMediaCaption: none
return <audio className="w-full" controls src={src} />;

View File

@@ -1,41 +1,50 @@
import type { HttpResponse } from '@yaakapp-internal/models';
import classNames from 'classnames';
import Papa from 'papaparse';
import { useMemo } from 'react';
import { useResponseBodyText } from '../../hooks/useResponseBodyText';
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from '../core/Table';
interface Props {
response: HttpResponse;
text: string | null;
className?: string;
}
export function CsvViewer({ response, className }: Props) {
const body = useResponseBodyText({ response, filter: null });
export function CsvViewer({ text, className }: Props) {
return (
<div className="overflow-auto h-full">
<CsvViewerInner text={text} className={className} />
</div>
);
}
export function CsvViewerInner({ text, className }: { text: string | null; className?: string }) {
const parsed = useMemo(() => {
if (body.data == null) return null;
return Papa.parse<string[]>(body.data);
}, [body]);
if (text == null) return null;
return Papa.parse<Record<string, string>>(text, { header: true, skipEmptyLines: true });
}, [text]);
if (parsed === null) return null;
return (
<div className="overflow-auto h-full">
<table className={classNames(className, 'text-sm')}>
<tbody>
<Table className={classNames(className, 'text-sm')}>
<TableHead>
<TableRow>
{parsed.meta.fields?.map((field) => (
<TableHeaderCell key={field}>{field}</TableHeaderCell>
))}
</TableRow>
</TableHead>
<TableBody>
{parsed.data.map((row, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: none
<tr key={i} className={classNames('border-l border-t', i > 0 && 'border-b')}>
{row.map((col, j) => (
// biome-ignore lint/suspicious/noArrayIndexKey: none
<td key={j} className="border-r px-1.5">
{col}
</td>
<TableRow key={i}>
{parsed.meta.fields?.map((key) => (
<TableCell key={key}>{row[key] ?? ''}</TableCell>
))}
</tr>
</TableRow>
))}
</tbody>
</table>
</TableBody>
</Table>
</div>
);
}

View File

@@ -1,7 +1,9 @@
import type { HttpResponse } from '@yaakapp-internal/models';
import { useMemo, useState } from 'react';
import { useResponseBodyText } from '../../hooks/useResponseBodyText';
import { languageFromContentType } from '../../lib/contentType';
import { getContentTypeFromHeaders } from '../../lib/model_util';
import type { EditorProps } from '../core/Editor/Editor';
import { EmptyStateText } from '../EmptyStateText';
import { TextViewer } from './TextViewer';
import { WebPageViewer } from './WebPageViewer';
@@ -22,19 +24,54 @@ export function HTMLOrTextViewer({ response, pretty, textViewerClassName }: Prop
}
if (language === 'html' && pretty) {
return <WebPageViewer response={response} />;
return <WebPageViewer html={rawTextBody.data ?? ''} baseUrl={response.url} />;
}
if (rawTextBody.data == null) {
return <EmptyStateText>Empty response</EmptyStateText>;
}
return (
<TextViewer
language={language}
<HttpTextViewer
response={response}
text={rawTextBody.data}
language={language}
pretty={pretty}
className={textViewerClassName}
response={response}
requestId={response.requestId}
/>
);
}
interface HttpTextViewerProps {
response: HttpResponse;
text: string;
language: EditorProps['language'];
pretty: boolean;
className?: string;
}
function HttpTextViewer({ response, text, language, pretty, className }: HttpTextViewerProps) {
const [currentFilter, setCurrentFilter] = useState<string | null>(null);
const filteredBody = useResponseBodyText({ response, filter: currentFilter });
const filterCallback = useMemo(
() => (filter: string) => {
setCurrentFilter(filter);
return {
data: filteredBody.data,
isPending: filteredBody.isPending,
error: !!filteredBody.error,
};
},
[filteredBody],
);
return (
<TextViewer
text={text}
language={language}
stateKey={`response.body.${response.id}`}
pretty={pretty}
className={className}
onFilter={filterCallback}
/>
);
}

View File

@@ -1,10 +1,39 @@
import { convertFileSrc } from '@tauri-apps/api/core';
import classNames from 'classnames';
import { useEffect, useState } from 'react';
interface Props {
bodyPath: string;
}
type Props = { className?: string } & (
| {
bodyPath: string;
}
| {
data: ArrayBuffer;
}
);
export function ImageViewer({ bodyPath }: Props) {
const src = convertFileSrc(bodyPath);
return <img src={src} alt="Response preview" className="max-w-full max-h-full pb-2" />;
export function ImageViewer({ className, ...props }: Props) {
const [src, setSrc] = useState<string>();
const bodyPath = 'bodyPath' in props ? props.bodyPath : null;
const data = 'data' in props ? props.data : null;
useEffect(() => {
if (bodyPath != null) {
setSrc(convertFileSrc(bodyPath));
} else if (data != null) {
const blob = new Blob([data], { type: 'image/png' });
const url = URL.createObjectURL(blob);
setSrc(url);
return () => URL.revokeObjectURL(url);
} else {
setSrc(undefined);
}
}, [bodyPath, data]);
return (
<img
src={src}
alt="Response preview"
className={classNames(className, 'max-w-full max-h-full')}
/>
);
}

View File

@@ -1,21 +1,15 @@
import type { HttpResponse } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { useResponseBodyText } from '../../hooks/useResponseBodyText';
import { JsonAttributeTree } from '../core/JsonAttributeTree';
interface Props {
response: HttpResponse;
text: string;
className?: string;
}
export function JsonViewer({ response, className }: Props) {
const rawBody = useResponseBodyText({ response, filter: null });
if (rawBody.isLoading || rawBody.data == null) return null;
export function JsonViewer({ text, className }: Props) {
let parsed = {};
try {
parsed = JSON.parse(rawBody.data);
parsed = JSON.parse(text);
} catch {
// Nothing yet
}

View File

@@ -0,0 +1,138 @@
import { type MultipartPart, parseMultipart } from '@mjackson/multipart-parser';
import { lazy, Suspense, useMemo, useState } from 'react';
import { languageFromContentType } from '../../lib/contentType';
import { Banner } from '../core/Banner';
import { Icon } from '../core/Icon';
import { LoadingIcon } from '../core/LoadingIcon';
import { TabContent, Tabs } from '../core/Tabs/Tabs';
import { AudioViewer } from './AudioViewer';
import { CsvViewer } from './CsvViewer';
import { ImageViewer } from './ImageViewer';
import { SvgViewer } from './SvgViewer';
import { TextViewer } from './TextViewer';
import { VideoViewer } from './VideoViewer';
import { WebPageViewer } from './WebPageViewer';
const PdfViewer = lazy(() => import('./PdfViewer').then((m) => ({ default: m.PdfViewer })));
interface Props {
data: Uint8Array;
boundary: string;
idPrefix?: string;
}
export function MultipartViewer({ data, boundary, idPrefix = 'multipart' }: Props) {
const [tab, setTab] = useState<string>();
const parseResult = useMemo(() => {
try {
const maxFileSize = 1024 * 1024 * 10; // 10MB
const parsed = parseMultipart(data, { boundary, maxFileSize });
const parts = Array.from(parsed);
return { parts, error: null };
} catch (err) {
return { parts: [], error: err instanceof Error ? err.message : String(err) };
}
}, [data, boundary]);
const { parts, error } = parseResult;
if (error) {
return (
<Banner color="danger" className="m-3">
Failed to parse multipart data: {error}
</Banner>
);
}
if (parts.length === 0) {
return (
<Banner color="info" className="m-3">
No multipart parts found
</Banner>
);
}
return (
<Tabs
value={tab}
addBorders
label="Multipart"
layout="horizontal"
tabListClassName="border-r border-r-border"
onChangeValue={setTab}
tabs={parts.map((part) => ({
label: part.name ?? '',
value: part.name ?? '',
rightSlot:
part.filename && part.headers.contentType.mediaType?.startsWith('image/') ? (
<div className="h-5 w-5 overflow-auto flex items-center justify-end">
<ImageViewer
data={part.arrayBuffer}
className="ml-auto w-auto rounded overflow-hidden"
/>
</div>
) : part.filename ? (
<Icon icon="file" />
) : null,
}))}
>
{parts.map((part, i) => (
<TabContent
// biome-ignore lint/suspicious/noArrayIndexKey: Nothing else to key on
key={idPrefix + part.name + i}
value={part.name ?? ''}
className="pl-3 !pt-0"
>
<Part part={part} />
</TabContent>
))}
</Tabs>
);
}
function Part({ part }: { part: MultipartPart }) {
const mimeType = part.headers.contentType.mediaType ?? null;
const contentTypeHeader = part.headers.get('content-type');
const { uint8Array, content, detectedLanguage } = useMemo(() => {
const uint8Array = new Uint8Array(part.arrayBuffer);
const content = new TextDecoder().decode(part.arrayBuffer);
const detectedLanguage = languageFromContentType(contentTypeHeader, content);
return { uint8Array, content, detectedLanguage };
}, [part, contentTypeHeader]);
if (mimeType?.match(/^image\/svg/i)) {
return <SvgViewer text={content} className="pb-2" />;
}
if (mimeType?.match(/^image/i)) {
return <ImageViewer data={part.arrayBuffer} className="pb-2" />;
}
if (mimeType?.match(/^audio/i)) {
return <AudioViewer data={uint8Array} />;
}
if (mimeType?.match(/^video/i)) {
return <VideoViewer data={uint8Array} />;
}
if (mimeType?.match(/csv|tab-separated/i)) {
return <CsvViewer text={content} />;
}
if (mimeType?.match(/^text\/html/i) || detectedLanguage === 'html') {
return <WebPageViewer html={content} />;
}
if (mimeType?.match(/pdf/i)) {
return (
<Suspense fallback={<LoadingIcon />}>
<PdfViewer data={uint8Array} />
</Suspense>
);
}
return <TextViewer text={content} language={detectedLanguage} stateKey={null} />;
}

View File

@@ -3,7 +3,7 @@ import 'react-pdf/dist/Page/AnnotationLayer.css';
import { convertFileSrc } from '@tauri-apps/api/core';
import './PdfViewer.css';
import type { PDFDocumentProxy } from 'pdfjs-dist';
import { useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Document, Page } from 'react-pdf';
import { useContainerSize } from '../../hooks/useContainerQuery';
@@ -15,7 +15,8 @@ import('react-pdf').then(({ pdfjs }) => {
});
interface Props {
bodyPath: string;
bodyPath?: string;
data?: Uint8Array;
}
const options = {
@@ -23,17 +24,29 @@ const options = {
standardFontDataUrl: '/standard_fonts/',
};
export function PdfViewer({ bodyPath }: Props) {
export function PdfViewer({ bodyPath, data }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [numPages, setNumPages] = useState<number>();
const [src, setSrc] = useState<string | { data: Uint8Array }>();
const { width: containerWidth } = useContainerSize(containerRef);
useEffect(() => {
if (bodyPath) {
setSrc(convertFileSrc(bodyPath));
} else if (data) {
// Create a copy to avoid "Buffer is already detached" errors
// This happens when the ArrayBuffer is transferred/detached elsewhere
const dataCopy = new Uint8Array(data);
setSrc({ data: dataCopy });
} else {
setSrc(undefined);
}
}, [bodyPath, data]);
const onDocumentLoadSuccess = ({ numPages: nextNumPages }: PDFDocumentProxy): void => {
setNumPages(nextNumPages);
};
const src = convertFileSrc(bodyPath);
return (
<div ref={containerRef} className="w-full h-full overflow-y-auto">
<Document

View File

@@ -1,30 +1,30 @@
import type { HttpResponse } from '@yaakapp-internal/models';
import { useEffect, useState } from 'react';
import { useResponseBodyText } from '../../hooks/useResponseBodyText';
interface Props {
response: HttpResponse;
text: string;
className?: string;
}
export function SvgViewer({ response }: Props) {
const rawTextBody = useResponseBodyText({ response, filter: null });
export function SvgViewer({ text, className }: Props) {
const [src, setSrc] = useState<string | null>(null);
useEffect(() => {
if (!rawTextBody.data) {
if (!text) {
return setSrc(null);
}
const blob = new Blob([rawTextBody.data], { type: 'image/svg+xml;charset=utf-8' });
const blob = new Blob([text], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
setSrc(url);
return () => URL.revokeObjectURL(url);
}, [rawTextBody.data]);
}, [text]);
if (src == null) {
return null;
}
return <img src={src} alt="Response preview" className="max-w-full max-h-full pb-2" />;
return (
<img src={src} alt="Response preview" className={className ?? 'max-w-full max-h-full pb-2'} />
);
}

View File

@@ -1,11 +1,9 @@
import type { HttpResponse } from '@yaakapp-internal/models';
import classNames from 'classnames';
import type { ReactNode } from 'react';
import { useCallback, useMemo } from 'react';
import { createGlobalState } from 'react-use';
import { useDebouncedValue } from '../../hooks/useDebouncedValue';
import { useFormatText } from '../../hooks/useFormatText';
import { useResponseBodyText } from '../../hooks/useResponseBodyText';
import type { EditorProps } from '../core/Editor/Editor';
import { hyperlink } from '../core/Editor/hyperlink/extension';
import { Editor } from '../core/Editor/LazyEditor';
@@ -15,29 +13,37 @@ import { Input } from '../core/Input';
const extraExtensions = [hyperlink];
interface Props {
pretty: boolean;
className?: string;
text: string;
language: EditorProps['language'];
response: HttpResponse;
requestId: string;
stateKey: string | null;
pretty?: boolean;
className?: string;
onFilter?: (filter: string) => {
data: string | null | undefined;
isPending: boolean;
error: boolean;
};
}
const useFilterText = createGlobalState<Record<string, string | null>>({});
export function TextViewer({ language, text, response, requestId, pretty, className }: Props) {
export function TextViewer({ language, text, stateKey, pretty, className, onFilter }: Props) {
const [filterTextMap, setFilterTextMap] = useFilterText();
const filterText = filterTextMap[requestId] ?? null;
const filterText = stateKey ? (filterTextMap[stateKey] ?? null) : null;
const debouncedFilterText = useDebouncedValue(filterText);
const setFilterText = useCallback(
(v: string | null) => {
setFilterTextMap((m) => ({ ...m, [requestId]: v }));
if (!stateKey) return;
setFilterTextMap((m) => ({ ...m, [stateKey]: v }));
},
[setFilterTextMap, requestId],
[setFilterTextMap, stateKey],
);
const isSearching = filterText != null;
const filteredResponse = useResponseBodyText({ response, filter: debouncedFilterText ?? null });
const filteredResponse =
onFilter && debouncedFilterText
? onFilter(debouncedFilterText)
: { data: null, isPending: false, error: false };
const toggleSearch = useCallback(() => {
if (isSearching) {
@@ -47,7 +53,7 @@ export function TextViewer({ language, text, response, requestId, pretty, classN
}
}, [isSearching, setFilterText]);
const canFilter = language === 'json' || language === 'xml' || language === 'html';
const canFilter = onFilter && (language === 'json' || language === 'xml' || language === 'html');
const actions = useMemo<ReactNode[]>(() => {
const nodes: ReactNode[] = [];
@@ -58,7 +64,7 @@ export function TextViewer({ language, text, response, requestId, pretty, classN
nodes.push(
<div key="input" className="w-full !opacity-100">
<Input
key={requestId}
key={stateKey ?? 'filter'}
validate={!filteredResponse.error}
hideLabel
autoFocus
@@ -70,7 +76,7 @@ export function TextViewer({ language, text, response, requestId, pretty, classN
defaultValue={filterText}
onKeyDown={(e) => e.key === 'Escape' && toggleSearch()}
onChange={setFilterText}
stateKey={`filter.${response.id}`}
stateKey={stateKey ? `filter.${stateKey}` : null}
/>
</div>,
);
@@ -96,13 +102,12 @@ export function TextViewer({ language, text, response, requestId, pretty, classN
filteredResponse.isPending,
isSearching,
language,
requestId,
response,
stateKey,
setFilterText,
toggleSearch,
]);
const formattedBody = useFormatText({ text, language, pretty });
const formattedBody = useFormatText({ text, language, pretty: pretty ?? false });
if (formattedBody == null) {
return null;
}
@@ -132,8 +137,7 @@ export function TextViewer({ language, text, response, requestId, pretty, classN
language={language}
actions={actions}
extraExtensions={extraExtensions}
// State key for storing fold state
stateKey={`response.body.${response.id}`}
stateKey={stateKey}
/>
);
}

View File

@@ -1,11 +1,26 @@
import { convertFileSrc } from '@tauri-apps/api/core';
import { useEffect, useState } from 'react';
interface Props {
bodyPath: string;
bodyPath?: string;
data?: Uint8Array;
}
export function VideoViewer({ bodyPath }: Props) {
const src = convertFileSrc(bodyPath);
export function VideoViewer({ bodyPath, data }: Props) {
const [src, setSrc] = useState<string>();
useEffect(() => {
if (bodyPath) {
setSrc(convertFileSrc(bodyPath));
} else if (data) {
const blob = new Blob([data], { type: 'video/mp4' });
const url = URL.createObjectURL(blob);
setSrc(url);
return () => URL.revokeObjectURL(url);
} else {
setSrc(undefined);
}
}, [bodyPath, data]);
// biome-ignore lint/a11y/useMediaCaption: none
return <video className="w-full" controls src={src} />;

View File

@@ -1,26 +1,22 @@
import type { HttpResponse } from '@yaakapp-internal/models';
import { useMemo } from 'react';
import { useResponseBodyText } from '../../hooks/useResponseBodyText';
interface Props {
response: HttpResponse;
html: string;
baseUrl?: string;
}
export function WebPageViewer({ response }: Props) {
const { url } = response;
const body = useResponseBodyText({ response, filter: null }).data ?? '';
export function WebPageViewer({ html, baseUrl }: Props) {
const contentForIframe: string | undefined = useMemo(() => {
if (body.includes('<head>')) {
return body.replace(/<head>/gi, `<head><base href="${url}"/>`);
if (baseUrl && html.includes('<head>')) {
return html.replace(/<head>/gi, `<head><base href="${baseUrl}"/>`);
}
return body;
}, [url, body]);
return html;
}, [baseUrl, html]);
return (
<div className="h-full pb-3">
<iframe
key={body ? 'has-body' : 'no-body'}
key={html ? 'has-body' : 'no-body'}
title="Yaak response preview"
srcDoc={contentForIframe}
sandbox="allow-scripts allow-forms"

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

@@ -0,0 +1,28 @@
import { invoke } from '@tauri-apps/api/core';
import type { HttpResponse, HttpResponseEvent } from '@yaakapp-internal/models';
import { httpResponseEventsAtom, replaceModelsInStore } from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai';
import { useEffect, useMemo } from 'react';
export function useHttpResponseEvents(response: HttpResponse | null) {
const allEvents = useAtomValue(httpResponseEventsAtom);
useEffect(() => {
if (response?.id == null) {
replaceModelsInStore('http_response_event', []);
return;
}
invoke<HttpResponseEvent[]>('cmd_get_http_response_events', { responseId: response.id }).then(
(events) => replaceModelsInStore('http_response_event', events),
);
}, [response?.id]);
// Filter events for the current response
const events = useMemo(
() => allEvents.filter((e) => e.responseId === response?.id),
[allEvents, response?.id],
);
return { data: events, error: null, isLoading: false };
}

View File

@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import type { HttpResponse } from '@yaakapp-internal/models';
import { getResponseBodyText } from '../lib/responseBody';
import { getResponseBodyBytes, getResponseBodyText } from '../lib/responseBody';
export function useResponseBodyText({
response,
@@ -21,3 +21,11 @@ export function useResponseBodyText({
queryFn: () => getResponseBodyText({ response, filter }),
});
}
export function useResponseBodyBytes({ response }: { response: HttpResponse }) {
return useQuery({
placeholderData: (prev) => prev, // Keep previous data on refetch
queryKey: ['response_body_bytes', response.id, response.updatedAt, response.contentLength],
queryFn: () => getResponseBodyBytes(response),
});
}

View File

@@ -13,7 +13,7 @@ export function languageFromContentType(
return 'xml';
}
if (justContentType.includes('html')) {
const detected = detectFromContent(content);
const detected = languageFromContent(content);
if (detected === 'xml') {
// If it's detected as XML, but is already HTML, don't change it
return 'html';
@@ -22,16 +22,16 @@ export function languageFromContentType(
}
if (justContentType.includes('javascript')) {
// Sometimes `application/javascript` returns JSON, so try detecting that
return detectFromContent(content, 'javascript');
return languageFromContent(content, 'javascript');
}
if (justContentType.includes('markdown')) {
return 'markdown';
}
return detectFromContent(content, 'text');
return languageFromContent(content, 'text');
}
function detectFromContent(
export function languageFromContent(
content: string | null,
fallback?: EditorProps['language'],
): EditorProps['language'] {

View File

@@ -1 +1 @@
export const encodings = ['*', 'gzip', 'compress', 'deflate', 'br', 'identity'];
export const encodings = ['*', 'gzip', 'compress', 'deflate', 'br', 'zstd', 'identity'];

View File

@@ -1,3 +1,4 @@
import { readFile } from '@tauri-apps/plugin-fs';
import type { HttpResponse } from '@yaakapp-internal/models';
import type { FilterResponse } from '@yaakapp-internal/plugins';
import type { ServerSentEvent } from '@yaakapp-internal/sse';
@@ -30,3 +31,10 @@ export async function getResponseBodyEventSource(
filePath: response.bodyPath,
});
}
export async function getResponseBodyBytes(
response: HttpResponse,
): Promise<Uint8Array<ArrayBuffer> | null> {
if (!response.bodyPath) return null;
return readFile(response.bodyPath);
}

View File

@@ -18,6 +18,7 @@ type TauriCmd =
| 'cmd_format_json'
| 'cmd_get_http_authentication_config'
| 'cmd_get_http_authentication_summaries'
| 'cmd_get_http_response_events'
| 'cmd_get_sse_events'
| 'cmd_get_themes'
| 'cmd_get_workspace_meta'
@@ -26,6 +27,7 @@ type TauriCmd =
| 'cmd_grpc_request_actions'
| 'cmd_http_request_actions'
| 'cmd_http_collection_actions'
| 'cmd_http_request_body'
| 'cmd_http_response_body'
| 'cmd_import_data'
| 'cmd_install_plugin'

View File

@@ -20,6 +20,7 @@
"@gilbarbara/deep-equal": "^0.3.1",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.3",
"@mjackson/multipart-parser": "^0.10.1",
"@prantlf/jsonlint": "^16.0.0",
"@replit/codemirror-emacs": "^6.1.0",
"@replit/codemirror-vim": "^6.3.0",