mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-05-16 20:57:12 +02:00
Split codebase (#455)
This commit is contained in:
427
apps/yaak-client/components/HttpResponsePane.tsx
Normal file
427
apps/yaak-client/components/HttpResponsePane.tsx
Normal file
@@ -0,0 +1,427 @@
|
||||
import type { HttpResponse, HttpResponseEvent } from "@yaakapp-internal/models";
|
||||
import { Banner, HStack, Icon, LoadingIcon, VStack } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { ComponentType, CSSProperties } from "react";
|
||||
import { lazy, Suspense, useMemo } from "react";
|
||||
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 { useTimelineViewMode } from "../hooks/useTimelineViewMode";
|
||||
import { getMimeTypeFromContentType } from "../lib/contentType";
|
||||
import { getContentTypeFromHeaders, getCookieCounts } from "../lib/model_util";
|
||||
import { ConfirmLargeResponse } from "./ConfirmLargeResponse";
|
||||
import { ConfirmLargeResponseRequest } from "./ConfirmLargeResponseRequest";
|
||||
import { Button } from "./core/Button";
|
||||
import { CountBadge } from "./core/CountBadge";
|
||||
import { HotkeyList } from "./core/HotkeyList";
|
||||
import { HttpResponseDurationTag } from "./core/HttpResponseDurationTag";
|
||||
import { HttpStatusTag } from "./core/HttpStatusTag";
|
||||
import { PillButton } from "./core/PillButton";
|
||||
import { SizeTag } from "./core/SizeTag";
|
||||
import type { TabItem } from "./core/Tabs/Tabs";
|
||||
import { TabContent, Tabs } from "./core/Tabs/Tabs";
|
||||
import { Tooltip } from "./core/Tooltip";
|
||||
import { EmptyStateText } from "./EmptyStateText";
|
||||
import { ErrorBoundary } from "./ErrorBoundary";
|
||||
import { HttpResponseTimeline } from "./HttpResponseTimeline";
|
||||
import { RecentHttpResponsesDropdown } from "./RecentHttpResponsesDropdown";
|
||||
import { RequestBodyViewer } from "./RequestBodyViewer";
|
||||
import { ResponseCookies } from "./ResponseCookies";
|
||||
import { ResponseHeaders } from "./ResponseHeaders";
|
||||
import { AudioViewer } from "./responseViewers/AudioViewer";
|
||||
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";
|
||||
|
||||
const PdfViewer = lazy(() =>
|
||||
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
|
||||
);
|
||||
|
||||
interface Props {
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
activeRequestId: string;
|
||||
}
|
||||
|
||||
const TAB_BODY = "body";
|
||||
const TAB_REQUEST = "request";
|
||||
const TAB_HEADERS = "headers";
|
||||
const TAB_COOKIES = "cookies";
|
||||
const TAB_TIMELINE = "timeline";
|
||||
|
||||
export type TimelineViewMode = "timeline" | "text";
|
||||
|
||||
interface RedirectDropWarning {
|
||||
droppedBodyCount: number;
|
||||
droppedHeaders: string[];
|
||||
}
|
||||
|
||||
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
|
||||
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
|
||||
const [timelineViewMode, setTimelineViewMode] = useTimelineViewMode();
|
||||
const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null);
|
||||
const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence;
|
||||
|
||||
const responseEvents = useHttpResponseEvents(activeResponse);
|
||||
const redirectDropWarning = useMemo(
|
||||
() => getRedirectDropWarning(responseEvents.data),
|
||||
[responseEvents.data],
|
||||
);
|
||||
const shouldShowRedirectDropWarning =
|
||||
activeResponse?.state === "closed" && redirectDropWarning != null;
|
||||
|
||||
const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]);
|
||||
|
||||
const tabs = useMemo<TabItem[]>(
|
||||
() => [
|
||||
{
|
||||
value: TAB_BODY,
|
||||
label: "Response",
|
||||
options: {
|
||||
value: viewMode,
|
||||
onChange: setViewMode,
|
||||
items: [
|
||||
{ label: "Response", value: "pretty" },
|
||||
...(mimeType?.startsWith("image")
|
||||
? []
|
||||
: [{ label: "Response (Raw)", shortLabel: "Raw", value: "raw" }]),
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
value: TAB_REQUEST,
|
||||
label: "Request",
|
||||
rightSlot:
|
||||
(activeResponse?.requestContentLength ?? 0) > 0 ? <CountBadge count={true} /> : null,
|
||||
},
|
||||
{
|
||||
value: TAB_HEADERS,
|
||||
label: "Headers",
|
||||
rightSlot: (
|
||||
<CountBadge
|
||||
count={activeResponse?.requestHeaders.length ?? 0}
|
||||
count2={activeResponse?.headers.length ?? 0}
|
||||
showZero
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: TAB_COOKIES,
|
||||
label: "Cookies",
|
||||
rightSlot:
|
||||
cookieCounts.sent > 0 || cookieCounts.received > 0 ? (
|
||||
<CountBadge count={cookieCounts.sent} count2={cookieCounts.received} showZero />
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
value: TAB_TIMELINE,
|
||||
rightSlot: <CountBadge count={responseEvents.data?.length ?? 0} />,
|
||||
options: {
|
||||
value: timelineViewMode,
|
||||
onChange: (v) => setTimelineViewMode((v as TimelineViewMode) ?? "timeline"),
|
||||
items: [
|
||||
{ label: "Timeline", value: "timeline" },
|
||||
{ label: "Timeline (Text)", shortLabel: "Timeline", value: "text" },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
activeResponse?.headers,
|
||||
activeResponse?.requestContentLength,
|
||||
activeResponse?.requestHeaders.length,
|
||||
cookieCounts.sent,
|
||||
cookieCounts.received,
|
||||
mimeType,
|
||||
responseEvents.data?.length,
|
||||
setViewMode,
|
||||
viewMode,
|
||||
timelineViewMode,
|
||||
setTimelineViewMode,
|
||||
],
|
||||
);
|
||||
|
||||
const cancel = useCancelHttpResponse(activeResponse?.id ?? null);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
className={classNames(
|
||||
className,
|
||||
"x-theme-responsePane",
|
||||
"max-h-full h-full",
|
||||
"bg-surface rounded-md border border-border-subtle overflow-hidden",
|
||||
"relative",
|
||||
)}
|
||||
>
|
||||
{activeResponse == null ? (
|
||||
<HotkeyList hotkeys={["request.send", "model.create", "sidebar.focus", "url_bar.focus"]} />
|
||||
) : (
|
||||
<div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1">
|
||||
<HStack
|
||||
className={classNames(
|
||||
"text-text-subtle w-full flex-shrink-0",
|
||||
// Remove a bit of space because the tabs have lots too
|
||||
"-mb-1.5",
|
||||
)}
|
||||
>
|
||||
{activeResponse && (
|
||||
<div
|
||||
className={classNames(
|
||||
"grid grid-cols-[auto_minmax(4rem,1fr)_auto]",
|
||||
"cursor-default select-none",
|
||||
"whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars",
|
||||
)}
|
||||
>
|
||||
<HStack space={2} className="w-full flex-shrink-0">
|
||||
{activeResponse.state !== "closed" && <LoadingIcon size="sm" />}
|
||||
<HttpStatusTag showReason response={activeResponse} />
|
||||
<span>•</span>
|
||||
<HttpResponseDurationTag response={activeResponse} />
|
||||
<span>•</span>
|
||||
<SizeTag
|
||||
contentLength={activeResponse.contentLength ?? 0}
|
||||
contentLengthCompressed={activeResponse.contentLengthCompressed}
|
||||
/>
|
||||
</HStack>
|
||||
{shouldShowRedirectDropWarning ? (
|
||||
<Tooltip
|
||||
tabIndex={0}
|
||||
className="my-auto pl-3 flex-shrink-0 max-w-full justify-self-end overflow-hidden"
|
||||
content={
|
||||
<VStack alignItems="start" space={1} className="text-xs">
|
||||
<span className="font-medium text-warning">
|
||||
Redirect changed this request
|
||||
</span>
|
||||
{redirectDropWarning.droppedBodyCount > 0 && (
|
||||
<span>
|
||||
Body dropped on {redirectDropWarning.droppedBodyCount}{" "}
|
||||
{redirectDropWarning.droppedBodyCount === 1
|
||||
? "redirect hop"
|
||||
: "redirect hops"}
|
||||
</span>
|
||||
)}
|
||||
{redirectDropWarning.droppedHeaders.length > 0 && (
|
||||
<span>
|
||||
Headers dropped:{" "}
|
||||
<span className="font-mono">
|
||||
{redirectDropWarning.droppedHeaders.join(", ")}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="text-text-subtle">See Timeline for details.</span>
|
||||
</VStack>
|
||||
}
|
||||
>
|
||||
<span className="inline-flex min-w-0">
|
||||
<PillButton
|
||||
color="warning"
|
||||
className="font-sans text-sm !flex-shrink max-w-full"
|
||||
innerClassName="flex items-center"
|
||||
leftSlot={<Icon icon="alert_triangle" size="xs" color="warning" />}
|
||||
>
|
||||
<span className="truncate">
|
||||
{getRedirectWarningLabel(redirectDropWarning)}
|
||||
</span>
|
||||
</PillButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="justify-self-end flex-shrink-0">
|
||||
<RecentHttpResponsesDropdown
|
||||
responses={responses}
|
||||
activeResponse={activeResponse}
|
||||
onPinnedResponseId={setPinnedResponseId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
<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
|
||||
tabs={tabs}
|
||||
label="Response"
|
||||
className="ml-3 mr-3 mb-3 min-h-0 flex-1"
|
||||
tabListClassName="mt-0.5 -mb-1.5"
|
||||
storageKey="http_response_tabs"
|
||||
activeTabKey={activeRequestId}
|
||||
>
|
||||
<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) === 0 ? (
|
||||
<EmptyStateText>Empty</EmptyStateText>
|
||||
) : mimeType?.match(/^text\/event-stream/i) && viewMode === "pretty" ? (
|
||||
<EventStreamViewer response={activeResponse} />
|
||||
) : mimeType?.match(/^image\/svg/) ? (
|
||||
<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) && viewMode === "pretty" ? (
|
||||
<HttpCsvViewer 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_REQUEST}>
|
||||
<ConfirmLargeResponseRequest response={activeResponse}>
|
||||
<RequestBodyViewer response={activeResponse} />
|
||||
</ConfirmLargeResponseRequest>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_HEADERS}>
|
||||
<ResponseHeaders response={activeResponse} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_COOKIES}>
|
||||
<ResponseCookies response={activeResponse} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_TIMELINE}>
|
||||
<HttpResponseTimeline response={activeResponse} viewMode={timelineViewMode} />
|
||||
</TabContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getRedirectDropWarning(
|
||||
events: HttpResponseEvent[] | undefined,
|
||||
): RedirectDropWarning | null {
|
||||
if (events == null || events.length === 0) return null;
|
||||
|
||||
let droppedBodyCount = 0;
|
||||
const droppedHeaders = new Set<string>();
|
||||
for (const e of events) {
|
||||
const event = e.event;
|
||||
if (event.type !== "redirect") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.dropped_body) {
|
||||
droppedBodyCount += 1;
|
||||
}
|
||||
for (const headerName of event.dropped_headers ?? []) {
|
||||
pushHeaderName(droppedHeaders, headerName);
|
||||
}
|
||||
}
|
||||
|
||||
if (droppedBodyCount === 0 && droppedHeaders.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
droppedBodyCount,
|
||||
droppedHeaders: Array.from(droppedHeaders).sort(),
|
||||
};
|
||||
}
|
||||
|
||||
function pushHeaderName(headers: Set<string>, headerName: string): void {
|
||||
const existing = Array.from(headers).find((h) => h.toLowerCase() === headerName.toLowerCase());
|
||||
if (existing == null) {
|
||||
headers.add(headerName);
|
||||
}
|
||||
}
|
||||
|
||||
function getRedirectWarningLabel(warning: RedirectDropWarning): string {
|
||||
if (warning.droppedBodyCount > 0 && warning.droppedHeaders.length > 0) {
|
||||
return "Dropped body and headers";
|
||||
}
|
||||
if (warning.droppedBodyCount > 0) {
|
||||
return "Dropped body";
|
||||
}
|
||||
return "Dropped headers";
|
||||
}
|
||||
|
||||
function EnsureCompleteResponse({
|
||||
response,
|
||||
Component,
|
||||
}: {
|
||||
response: HttpResponse;
|
||||
Component: ComponentType<{ bodyPath: string }>;
|
||||
}) {
|
||||
if (response.bodyPath === null) {
|
||||
return <div>Empty response body</div>;
|
||||
}
|
||||
|
||||
// Wait until the response has been fully-downloaded
|
||||
if (response.state !== "closed") {
|
||||
return (
|
||||
<EmptyStateText>
|
||||
<LoadingIcon />
|
||||
</EmptyStateText>
|
||||
);
|
||||
}
|
||||
|
||||
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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user