Split up HTTP sending logic (#320)

This commit is contained in:
Gregory Schier
2025-12-20 14:10:55 -08:00
committed by GitHub
parent cfbfd66eef
commit 46933059f6
41 changed files with 2708 additions and 732 deletions

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

@@ -76,7 +76,8 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
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}
/>
),
},
@@ -85,7 +86,13 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
label: 'Info',
},
],
[activeResponse?.headers, mimeType, setViewMode, viewMode],
[
activeResponse?.headers,
mimeType,
setViewMode,
viewMode,
activeResponse?.requestHeaders.length,
],
);
const activeTab = activeTabs?.[activeRequestId];
const setActiveTab = useCallback(
@@ -133,7 +140,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,72 +156,87 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
)}
</HStack>
{activeResponse?.error ? (
<Banner color="danger" className="m-2">
{activeResponse.error}
</Banner>
) : (
<Tabs
key={activeRequestId} // Freshen tabs on request change
value={activeTab}
onChangeValue={setActiveTab}
tabs={tabs}
label="Response"
className="ml-3 mr-3 mb-3"
tabListClassName="mt-0.5"
>
<TabContent value={TAB_BODY}>
<ErrorBoundary name="Http Response Viewer">
<Suspense>
<ConfirmLargeResponse response={activeResponse}>
{activeResponse.state === 'initialized' ? (
<EmptyStateText>
<VStack space={3}>
<HStack space={3}>
<LoadingIcon className="text-text-subtlest" />
Sending Request
</HStack>
<Button size="sm" variant="border" onClick={() => cancel.mutate()}>
Cancel
</Button>
</VStack>
</EmptyStateText>
) : activeResponse.state === 'closed' &&
activeResponse.contentLength === 0 ? (
<EmptyStateText>Empty </EmptyStateText>
) : mimeType?.match(/^text\/event-stream/i) && viewMode === 'pretty' ? (
<EventStreamViewer response={activeResponse} />
) : mimeType?.match(/^image\/svg/) ? (
<SvgViewer 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(/pdf/i) ? (
<EnsureCompleteResponse response={activeResponse} Component={PdfViewer} />
) : mimeType?.match(/csv|tab-separated/i) ? (
<CsvViewer className="pb-2" response={activeResponse} />
) : (
<HTMLOrTextViewer
textViewerClassName="-mr-2 bg-surface" // Pull to the right
response={activeResponse}
pretty={viewMode === 'pretty'}
/>
)}
</ConfirmLargeResponse>
</Suspense>
</ErrorBoundary>
</TabContent>
<TabContent value={TAB_HEADERS}>
<ResponseHeaders response={activeResponse} />
</TabContent>
<TabContent value={TAB_INFO}>
<ResponseInfo response={activeResponse} />
</TabContent>
</Tabs>
)}
<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 */}
{(activeResponse?.headers.length > 0 ||
activeResponse?.bodyPath ||
!activeResponse?.error) && (
<Tabs
key={activeRequestId} // Freshen tabs on request change
value={activeTab}
onChangeValue={setActiveTab}
tabs={tabs}
label="Response"
className="ml-3 mr-3 mb-3 min-h-0 flex-1"
tabListClassName="mt-0.5"
>
<TabContent value={TAB_BODY}>
<ErrorBoundary name="Http Response Viewer">
<Suspense>
<ConfirmLargeResponse response={activeResponse}>
{activeResponse.state === 'initialized' ? (
<EmptyStateText>
<VStack space={3}>
<HStack space={3}>
<LoadingIcon className="text-text-subtlest" />
Sending Request
</HStack>
<Button size="sm" variant="border" onClick={() => cancel.mutate()}>
Cancel
</Button>
</VStack>
</EmptyStateText>
) : activeResponse.state === 'closed' &&
activeResponse.contentLength === 0 ? (
<EmptyStateText>Empty </EmptyStateText>
) : mimeType?.match(/^text\/event-stream/i) && viewMode === 'pretty' ? (
<EventStreamViewer response={activeResponse} />
) : mimeType?.match(/^image\/svg/) ? (
<SvgViewer 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(/pdf/i) ? (
<EnsureCompleteResponse response={activeResponse} Component={PdfViewer} />
) : mimeType?.match(/csv|tab-separated/i) ? (
<CsvViewer className="pb-2" response={activeResponse} />
) : (
<HTMLOrTextViewer
textViewerClassName="-mr-2 bg-surface" // Pull to the right
response={activeResponse}
pretty={viewMode === 'pretty'}
/>
)}
</ConfirmLargeResponse>
</Suspense>
</ErrorBoundary>
</TabContent>
<TabContent value={TAB_HEADERS}>
<ResponseHeaders response={activeResponse} />
</TabContent>
<TabContent value={TAB_INFO}>
<ResponseInfo response={activeResponse} />
</TabContent>
</Tabs>
)}
</div>
</div>
)}
</div>

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,57 @@ 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
defaultOpen
storageKey={`${response.requestId}.response_headers`}
summary={
<h2 className="flex items-center">
Response <CountBadge count={responseHeaders.length} />
</h2>
}
>
<KeyValueRows>
{responseHeaders.map((h, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: none
<KeyValueRow labelColor="primary" key={i} label={h.name}>
{h.value}
</KeyValueRow>
))}
</KeyValueRows>
</DetailsBanner>
<DetailsBanner
storageKey={`${response.requestId}.request_headers`}
summary={
<h2 className="flex items-center">
Request <CountBadge count={requestHeaders.length} />
</h2>
}
>
<KeyValueRows>
{requestHeaders.map((h, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: none
<KeyValueRow labelColor="primary" key={i} label={h.name}>
{h.value}
</KeyValueRow>
))}
</KeyValueRows>
</DetailsBanner>
</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,11 +3,12 @@ import classNames from 'classnames';
interface Props {
count: number | true;
count2?: number | true;
className?: string;
color?: Color;
}
export function CountBadge({ count, className, color }: Props) {
export function CountBadge({ count, count2, className, color }: Props) {
if (count === 0) return null;
return (
<div
@@ -30,6 +31,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

@@ -12,7 +12,9 @@ import {
ArrowDownIcon,
ArrowDownToDotIcon,
ArrowDownToLineIcon,
ArrowLeftIcon,
ArrowRightCircleIcon,
ArrowRightIcon,
ArrowUpDownIcon,
ArrowUpFromDotIcon,
ArrowUpFromLineIcon,
@@ -142,6 +144,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,

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 +1 @@
export const encodings = ['*', 'gzip', 'compress', 'deflate', 'br', 'identity'];
export const encodings = ['*', 'gzip', 'compress', 'deflate', 'br', 'zstd', 'identity'];