mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-18 17:35:15 +02:00
The body commands took a path from the client, so a token holder could read any file the process could. They now take a response id and resolve the location themselves, and the UI never sees a path at all: the desktop host asks the backend where the file is, and the bridge fetches /responses/:id/body. Ephemeral responses (GraphQL introspection) never reach the database, so resolution falls back to the path send writes them to.
462 lines
18 KiB
TypeScript
462 lines
18 KiB
TypeScript
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 { useCopyHttpResponse } from "../hooks/useCopyHttpResponse";
|
|
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
|
|
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
|
|
import { useResponseBodyBytes, useResponseBodyText } from "../hooks/useResponseBodyText";
|
|
import { useResponseBodyUrl } from "../hooks/useResponseBodyUrl";
|
|
import { useResponseViewMode } from "../hooks/useResponseViewMode";
|
|
import { useSaveResponse } from "../hooks/useSaveResponse";
|
|
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 saveResponse = useSaveResponse(activeResponse ?? null);
|
|
const copyResponse = useCopyHttpResponse(activeResponse ?? null);
|
|
|
|
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" }]),
|
|
],
|
|
itemsAfter: [
|
|
{
|
|
label: "Save to File",
|
|
onSelect: saveResponse.mutate,
|
|
leftSlot: <Icon icon="save" />,
|
|
hidden: activeResponse == null || !!activeResponse.error,
|
|
disabled: activeResponse?.state !== "closed" && (activeResponse?.status ?? 0) >= 100,
|
|
},
|
|
{
|
|
label: "Copy Body",
|
|
onSelect: copyResponse.mutate,
|
|
leftSlot: <Icon icon="copy" />,
|
|
hidden: activeResponse == null || !!activeResponse.error,
|
|
disabled: activeResponse?.state !== "closed" && (activeResponse?.status ?? 0) >= 100,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
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,
|
|
activeResponse?.error,
|
|
activeResponse?.requestContentLength,
|
|
activeResponse?.requestHeaders.length,
|
|
activeResponse?.state,
|
|
activeResponse?.status,
|
|
cookieCounts.sent,
|
|
cookieCounts.received,
|
|
copyResponse.mutate,
|
|
mimeType,
|
|
responseEvents.data?.length,
|
|
saveResponse.mutate,
|
|
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 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 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 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 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 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 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<{ url: string }>;
|
|
}) {
|
|
// Wait until the response has been fully-downloaded before asking for it
|
|
const complete = response.state === "closed";
|
|
const bodyUrl = useResponseBodyUrl(complete ? response : null);
|
|
|
|
if (!complete || bodyUrl.isPending) {
|
|
return (
|
|
<EmptyStateText>
|
|
<LoadingIcon />
|
|
</EmptyStateText>
|
|
);
|
|
}
|
|
|
|
if (bodyUrl.error) {
|
|
return <Banner color="danger">{String(bodyUrl.error)}</Banner>;
|
|
}
|
|
|
|
if (bodyUrl.data == null) {
|
|
return <div>Empty response body</div>;
|
|
}
|
|
|
|
return <Component url={bodyUrl.data} />;
|
|
}
|
|
|
|
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} />;
|
|
}
|