mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Add a shared actions bar and a Pretty/Raw switch for Yaak Proxy's bodies
The actions row moves out of the code editor into ActionsBar, a plain component a wrapper places over its content. FormatAction reformats an editor through its ref, and BodyViewModeSwitch names its first state for the body's format (Pretty, Table, Parts, Preview, Form) next to Raw. Yaak Proxy's request and response body tabs now share one viewer choice, with the switch in each tab's bar beside the text viewer's filter. The choice is remembered for both panes; bodies over the large size open raw. "Response (Raw)" leaves the proxy's tab menu, while Yaak's response pane keeps it until the remaining sites move. SegmentedControl moves from the client into packages/ui. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fd89201ccb
commit
7ece5fa5df
21 files changed
+729
-99
No files matched your search
@@ -6,7 +6,7 @@ import type {
|
||||
Workspace,
|
||||
} from "@yaakapp-internal/models";
|
||||
import { patchModel } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon, InlineCode } from "@yaakapp-internal/ui";
|
||||
import { HStack, Icon, InlineCode, SegmentedControl } from "@yaakapp-internal/ui";
|
||||
import { useCallback } from "react";
|
||||
import { openFolderSettings } from "../commands/openFolderSettings";
|
||||
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
|
||||
@@ -21,7 +21,6 @@ import { IconButton } from "./core/IconButton";
|
||||
import { Input, type InputProps } from "./core/Input";
|
||||
import { Link } from "./core/Link";
|
||||
import { RadioDropdown } from "./core/RadioDropdown";
|
||||
import { SegmentedControl } from "./core/SegmentedControl";
|
||||
import { DynamicForm } from "./DynamicForm";
|
||||
import { EmptyStateText } from "./EmptyStateText";
|
||||
|
||||
|
||||
@@ -5,7 +5,15 @@ import {
|
||||
type ImportSource,
|
||||
type Workspace,
|
||||
} from "@yaakapp-internal/models";
|
||||
import { Banner, FormattedError, HStack, Icon, VStack, IconTooltip } from "@yaakapp-internal/ui";
|
||||
import {
|
||||
Banner,
|
||||
FormattedError,
|
||||
HStack,
|
||||
Icon,
|
||||
VStack,
|
||||
IconTooltip,
|
||||
SegmentedControl,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { type ComponentProps, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { errorMessage } from "../lib/errorMessage";
|
||||
@@ -22,7 +30,6 @@ import { CheckboxTree } from "./core/CheckboxTree";
|
||||
import { Chip } from "./core/Chip";
|
||||
import { DialogFooter } from "./core/Dialog";
|
||||
import { Select } from "./core/Select";
|
||||
import { SegmentedControl } from "./core/SegmentedControl";
|
||||
import { pluralize } from "@yaakapp-internal/lib/pluralize";
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { SegmentedControl } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useRef, useState } from "react";
|
||||
import type { EditorProps } from "./core/Editor/Editor";
|
||||
import { Editor } from "./core/Editor/LazyEditor";
|
||||
import { SegmentedControl } from "./core/SegmentedControl";
|
||||
import { Markdown } from "./Markdown";
|
||||
|
||||
type ViewMode = "edit" | "preview";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import type { AgentSetupStatus, AppMetaData } from "@yaakapp-internal/rpc-schema";
|
||||
import { Banner, Heading, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { Banner, Heading, Icon, InlineCode, SegmentedControl, VStack } from "@yaakapp-internal/ui";
|
||||
import { useEffect } from "react";
|
||||
import { useKeyValue } from "../../hooks/useKeyValue";
|
||||
import { usePluginInfos } from "../../hooks/usePluginInfo";
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
import { appInfo } from "../../lib/appInfo";
|
||||
import { rpc } from "../../lib/rpc";
|
||||
import { Link } from "../core/Link";
|
||||
import { SegmentedControl } from "../core/SegmentedControl";
|
||||
import {
|
||||
SettingCommand,
|
||||
SettingSnippet,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, test, vi } from "vite-plus/test";
|
||||
import { flowPrettyView, shownViewMode } from "./FlowBodyViewer";
|
||||
|
||||
// The package's index reaches for the window as it loads; the view rule is all this needs.
|
||||
vi.mock("@yaakapp-internal/ui", async () => {
|
||||
const { prettyViewFor } = await vi.importActual<
|
||||
typeof import("../../../packages/ui/src/components/http/BodyViewModeSwitch")
|
||||
>("../../../packages/ui/src/components/http/BodyViewModeSwitch");
|
||||
return { prettyViewFor, LARGE_BODY_BYTES: 2_000_000 };
|
||||
});
|
||||
|
||||
const text = (mime: string, language: string | null, which: "request" | "response" = "response") =>
|
||||
flowPrettyView({
|
||||
which,
|
||||
mime,
|
||||
text: "x",
|
||||
binary: false,
|
||||
language: language as never,
|
||||
hasForm: false,
|
||||
});
|
||||
|
||||
describe("flowPrettyView", () => {
|
||||
test("names the formatted view per format, the same on both halves", () => {
|
||||
for (const which of ["request", "response"] as const) {
|
||||
expect(text("application/json", "json", which)).toBe("formatted");
|
||||
expect(text("text/csv", "text", which)).toBe("table");
|
||||
expect(text("image/svg+xml", "xml", which)).toBe("preview");
|
||||
}
|
||||
expect(text("text/plain", "text")).toBeNull();
|
||||
});
|
||||
|
||||
test("HTML previews as a response and formats as a request", () => {
|
||||
expect(text("text/html", "html", "response")).toBe("preview");
|
||||
expect(text("text/html", "html", "request")).toBe("formatted");
|
||||
});
|
||||
|
||||
test("multipart has parts whether or not it reads as text", () => {
|
||||
const base = { which: "request" as const, mime: "multipart/form-data", language: null };
|
||||
expect(flowPrettyView({ ...base, text: null, binary: false, hasForm: false })).toBe("parts");
|
||||
});
|
||||
|
||||
test("a URL-encoded form's fields are its formatted view, where the tab has them", () => {
|
||||
const base = {
|
||||
which: "request" as const,
|
||||
mime: "application/x-www-form-urlencoded",
|
||||
text: "a=1",
|
||||
binary: false,
|
||||
language: "text" as const,
|
||||
};
|
||||
expect(flowPrettyView({ ...base, hasForm: true })).toBe("form");
|
||||
expect(flowPrettyView({ ...base, hasForm: false })).toBeNull();
|
||||
});
|
||||
|
||||
test("images, binary and undecodable bodies have one view", () => {
|
||||
const base = { which: "response" as const, language: null, hasForm: false };
|
||||
expect(flowPrettyView({ ...base, mime: "image/png", text: null, binary: true })).toBeNull();
|
||||
expect(
|
||||
flowPrettyView({ ...base, mime: "application/json", text: null, binary: true }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
flowPrettyView({ ...base, mime: "application/octet-stream", text: null, binary: false }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shownViewMode", () => {
|
||||
test("follows the preference", () => {
|
||||
expect(shownViewMode({ key: "a", large: false, preferred: "pretty", chosen: null })).toBe(
|
||||
"pretty",
|
||||
);
|
||||
expect(shownViewMode({ key: "a", large: false, preferred: "raw", chosen: null })).toBe("raw");
|
||||
});
|
||||
|
||||
test("a large body opens raw, and formats once asked, for that body alone", () => {
|
||||
expect(shownViewMode({ key: "a", large: true, preferred: "pretty", chosen: null })).toBe("raw");
|
||||
const chosen = { key: "a", mode: "pretty" as const };
|
||||
expect(shownViewMode({ key: "a", large: true, preferred: "pretty", chosen })).toBe("pretty");
|
||||
expect(shownViewMode({ key: "b", large: true, preferred: "pretty", chosen })).toBe("raw");
|
||||
});
|
||||
});
|
||||
@@ -2,22 +2,32 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { formatSize } from "@yaakapp-internal/lib/formatSize";
|
||||
import type { Flow, FlowBody } from "@yaakapp-internal/proxy-lib";
|
||||
import {
|
||||
ACTIONS_BAR_CONTAINER,
|
||||
ActionsBar,
|
||||
Banner,
|
||||
BodyViewModeSwitch,
|
||||
Button,
|
||||
CsvViewer,
|
||||
HexdumpViewer,
|
||||
Icon,
|
||||
ImageViewer,
|
||||
LARGE_BODY_BYTES,
|
||||
languageFromContentType,
|
||||
LoadingIcon,
|
||||
MultipartViewer,
|
||||
type PrettyView,
|
||||
prettyViewFor,
|
||||
type ResponseViewMode,
|
||||
SvgViewer,
|
||||
TextViewer,
|
||||
WebPageViewer,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import type { CodeEditorProps } from "@yaakapp-internal/ui/editor";
|
||||
import classNames from "classnames";
|
||||
import { useAtom } from "jotai";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { bodyViewModeAtom } from "../lib/atoms";
|
||||
import {
|
||||
bodyAsText,
|
||||
bodyImageType,
|
||||
@@ -33,15 +43,97 @@ import { useSaveFlowBody } from "../lib/flowActions";
|
||||
interface Props {
|
||||
flow: Flow;
|
||||
which: FlowBody;
|
||||
pretty: boolean;
|
||||
/** A URL-encoded body's fields, shown as its formatted view */
|
||||
form?: ReactNode;
|
||||
/** Leaves saying the body was cut short to the tab, which says it its own way */
|
||||
hideCutNote?: boolean;
|
||||
}
|
||||
|
||||
export function useFlowBody(flow: Flow, which: FlowBody, enabled = true) {
|
||||
return useQuery({ ...flowBodyQueryOptions(flow, which), enabled });
|
||||
}
|
||||
|
||||
export function FlowBodyViewer({ flow, which, pretty }: Props) {
|
||||
/** A mode chosen for one large body, which holds for it alone */
|
||||
interface ChosenViewMode {
|
||||
key: string;
|
||||
mode: ResponseViewMode;
|
||||
}
|
||||
|
||||
/** The mode a body shows in: the preference, except raw for a large one until chosen for it */
|
||||
export function shownViewMode({
|
||||
key,
|
||||
large,
|
||||
preferred,
|
||||
chosen,
|
||||
}: {
|
||||
key: string;
|
||||
large: boolean;
|
||||
preferred: ResponseViewMode;
|
||||
chosen: ChosenViewMode | null;
|
||||
}): ResponseViewMode {
|
||||
if (chosen?.key === key) return chosen.mode;
|
||||
return large ? "raw" : preferred;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatted or raw, as last chosen, except that a body big enough to slow the card opens raw,
|
||||
* and choosing for it holds for that body alone
|
||||
*/
|
||||
export function useBodyViewMode(
|
||||
key: string,
|
||||
large: boolean,
|
||||
): [ResponseViewMode, (mode: ResponseViewMode) => void] {
|
||||
const [preferred, setPreferred] = useAtom(bodyViewModeAtom);
|
||||
const [chosen, setChosen] = useState<ChosenViewMode | null>(null);
|
||||
const mode = shownViewMode({ key, large, preferred, chosen });
|
||||
const setMode = useCallback(
|
||||
(next: ResponseViewMode) => {
|
||||
if (large) {
|
||||
setChosen({ key, mode: next });
|
||||
} else {
|
||||
setChosen(null);
|
||||
setPreferred(next);
|
||||
}
|
||||
},
|
||||
[key, large, setPreferred],
|
||||
);
|
||||
return [mode, setMode];
|
||||
}
|
||||
|
||||
/**
|
||||
* The formatted view a body has, or null when it has only the one: parts for multipart, the
|
||||
* fields for a URL-encoded form, or else what the shared rule gives its text. Images and binary
|
||||
* bodies have only their one view.
|
||||
*/
|
||||
export function flowPrettyView({
|
||||
which,
|
||||
mime,
|
||||
text,
|
||||
binary,
|
||||
language,
|
||||
hasForm,
|
||||
}: {
|
||||
which: FlowBody;
|
||||
mime: string;
|
||||
text: string | null;
|
||||
/** An image, or what failed to decode */
|
||||
binary: boolean;
|
||||
language: CodeEditorProps["language"] | null;
|
||||
hasForm: boolean;
|
||||
}): PrettyView | null {
|
||||
if (binary) return null;
|
||||
if (mime.startsWith("multipart/")) return "parts";
|
||||
if (text == null) return null;
|
||||
if (hasForm && mime === "application/x-www-form-urlencoded") return "form";
|
||||
return prettyViewFor({ mimeType: mime, language, previewHtml: which === "response" });
|
||||
}
|
||||
|
||||
export function FlowBodyViewer({ flow, which, form, hideCutNote = false }: Props) {
|
||||
const body = useFlowBody(flow, which);
|
||||
const [mode, setMode] = useBodyViewMode(
|
||||
`${flow.id}.${which}`,
|
||||
(body.data?.bytes.length ?? 0) > LARGE_BODY_BYTES,
|
||||
);
|
||||
|
||||
if (body.isPending) {
|
||||
return (
|
||||
@@ -69,12 +161,28 @@ export function FlowBodyViewer({ flow, which, pretty }: Props) {
|
||||
const imageType = decodeError == null ? bodyImageType(bytes, truncated, contentType) : null;
|
||||
const text =
|
||||
decodeError == null && imageType == null ? bodyAsText(bytes, truncated, contentType) : null;
|
||||
const language = text == null ? null : languageFromContentType(contentType, text);
|
||||
const prettyView = flowPrettyView({
|
||||
which,
|
||||
mime,
|
||||
text,
|
||||
binary: decodeError != null || imageType != null,
|
||||
language,
|
||||
hasForm: form != null,
|
||||
});
|
||||
// The one view a body without a formatted one has is its raw one
|
||||
const pretty = prettyView != null && mode === "pretty";
|
||||
const modeSwitch =
|
||||
prettyView == null ? null : (
|
||||
<BodyViewModeSwitch pretty={prettyView} value={mode} onChange={setMode} />
|
||||
);
|
||||
|
||||
let viewer: ReactNode;
|
||||
let textViewer = false;
|
||||
if (decodeError != null) {
|
||||
// What failed to decode is as it was on the wire, compressed, so never text.
|
||||
viewer = <BinaryBody flow={flow} which={which} bytes={bytes} />;
|
||||
} else if (mime.startsWith("multipart/") && pretty) {
|
||||
} else if (prettyView === "parts" && pretty) {
|
||||
const boundary = contentType?.match(/;\s*boundary="?([^";]+)"?/i)?.[1] ?? "unknown";
|
||||
viewer = (
|
||||
<MultipartViewer
|
||||
@@ -88,22 +196,41 @@ export function FlowBodyViewer({ flow, which, pretty }: Props) {
|
||||
viewer = <ImageBody bytes={bytes} mime={imageType} />;
|
||||
} else if (text == null) {
|
||||
viewer = <BinaryBody flow={flow} which={which} bytes={bytes} />;
|
||||
} else if (mime.startsWith("image/svg")) {
|
||||
viewer = <SvgViewer text={text} />;
|
||||
} else if (/csv|tab-separated/.test(mime) && pretty) {
|
||||
} else if (prettyView === "form" && pretty) {
|
||||
viewer = form;
|
||||
} else if (prettyView === "table" && pretty) {
|
||||
viewer = <CsvViewer text={text} className="pb-2" />;
|
||||
} else if (prettyView === "preview" && pretty) {
|
||||
viewer = mime.startsWith("image/svg") ? (
|
||||
<SvgViewer text={text} />
|
||||
) : (
|
||||
<WebPageViewer html={text} baseUrl={flow.url} />
|
||||
);
|
||||
} else {
|
||||
const language = languageFromContentType(contentType, text);
|
||||
if (language === "html" && pretty && which === "response") {
|
||||
viewer = <WebPageViewer html={text} baseUrl={flow.url} />;
|
||||
} else {
|
||||
viewer = (
|
||||
<FlowTextViewer flow={flow} which={which} text={text} language={language} pretty={pretty} />
|
||||
);
|
||||
}
|
||||
textViewer = true;
|
||||
viewer = (
|
||||
<FlowTextViewer
|
||||
flow={flow}
|
||||
which={which}
|
||||
text={text}
|
||||
language={language}
|
||||
pretty={pretty}
|
||||
modeSwitch={modeSwitch}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!truncated && decodeError == null) return viewer;
|
||||
// The text viewer draws the switch in its own bar, beside its filter; the rest get a bar here
|
||||
if (modeSwitch != null && !textViewer) {
|
||||
viewer = (
|
||||
<div className={classNames(ACTIONS_BAR_CONTAINER, "h-full w-full")}>
|
||||
{viewer}
|
||||
<ActionsBar>{modeSwitch}</ActionsBar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if ((!truncated || hideCutNote) && decodeError == null) return viewer;
|
||||
|
||||
return (
|
||||
<div className="h-full grid grid-rows-[auto_auto_minmax(0,1fr)]">
|
||||
@@ -168,7 +295,16 @@ function FlowTextViewer({
|
||||
text,
|
||||
language,
|
||||
pretty,
|
||||
}: Props & { text: string; language: CodeEditorProps["language"] }) {
|
||||
modeSwitch,
|
||||
}: {
|
||||
flow: Flow;
|
||||
which: FlowBody;
|
||||
text: string;
|
||||
language: CodeEditorProps["language"];
|
||||
pretty: boolean;
|
||||
/** Formatted or raw, in the viewer's bar after its filter; null for a body with one view */
|
||||
modeSwitch: ReactNode;
|
||||
}) {
|
||||
const runFilter = useCallback(
|
||||
(expression: string) => filterText(flow.id, which, expression, language),
|
||||
[flow.id, which, language],
|
||||
@@ -183,6 +319,8 @@ function FlowTextViewer({
|
||||
className="-mr-2 bg-surface"
|
||||
formatText={formatText}
|
||||
filterText={runFilter}
|
||||
ownActionsBar
|
||||
pinnedActions={modeSwitch}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,14 +13,13 @@ import {
|
||||
IconButton,
|
||||
parseCookieHeaders,
|
||||
ResponseCookies,
|
||||
type ResponseViewMode,
|
||||
SplitLayout,
|
||||
Tooltip,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { BODY_TYPE_BINARY } from "@yaakapp-internal/ui/httpTypes";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { BODY_TYPE_FORM_URLENCODED } from "@yaakapp-internal/ui/httpTypes";
|
||||
import { findHeader } from "../lib/body";
|
||||
import {
|
||||
browserTabMemory,
|
||||
@@ -166,12 +165,15 @@ function FlowRequestHalf({ flow }: { flow: Flow }) {
|
||||
renderBody={(editor) => (
|
||||
<FlowRequestBody
|
||||
body={body}
|
||||
editor={editor}
|
||||
// The proxy's own viewer, with its hexdump and save, rather than a size alone
|
||||
binaryViewer={
|
||||
request.bodyType === BODY_TYPE_BINARY ? (
|
||||
<FlowBodyViewer flow={flow} which="request" pretty />
|
||||
) : null
|
||||
// The response's viewer choice, with the form's fields as a URL-encoded body's
|
||||
// formatted view
|
||||
viewer={
|
||||
<FlowBodyViewer
|
||||
flow={flow}
|
||||
which="request"
|
||||
form={request.bodyType === BODY_TYPE_FORM_URLENCODED ? editor : undefined}
|
||||
hideCutNote
|
||||
/>
|
||||
}
|
||||
requestSize={flow.requestSize}
|
||||
/>
|
||||
@@ -199,7 +201,6 @@ function UrlLine({ flow }: { flow: Flow }) {
|
||||
}
|
||||
|
||||
function FlowResponseHalf({ flow, className }: { flow: Flow; className: string }) {
|
||||
const [viewMode, setViewMode] = useState<ResponseViewMode>("pretty");
|
||||
// A stream's body is its messages, read by the Messages tab; reading it whole again on every
|
||||
// message would only hold copies of it
|
||||
const loadBody = flow.streamKind == null;
|
||||
@@ -268,16 +269,12 @@ function FlowResponseHalf({ flow, className }: { flow: Flow; className: string }
|
||||
body={loader}
|
||||
requestBody={null}
|
||||
emptyState={<EmptyStateText>Waiting for a response</EmptyStateText>}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
tabsStorageKey="proxy_response_tabs"
|
||||
activeTabKey={flow.id}
|
||||
defaultTab={defaultTab}
|
||||
onTabChange={onTabChange}
|
||||
extraTabs={extraTabs}
|
||||
renderBodyViewer={() => (
|
||||
<FlowBodyViewer flow={flow} which="response" pretty={viewMode === "pretty"} />
|
||||
)}
|
||||
renderBodyViewer={() => <FlowBodyViewer flow={flow} which="response" />}
|
||||
statusLineCenter={
|
||||
flow.responseTruncated ? (
|
||||
<span
|
||||
|
||||
@@ -21,17 +21,9 @@ const data = (truncated = false): FlowBodyData => ({
|
||||
storedSize: 7,
|
||||
});
|
||||
|
||||
function render(
|
||||
body: Parameters<typeof FlowRequestBody>[0]["body"],
|
||||
binaryViewer: string | null = null,
|
||||
) {
|
||||
function render(body: Parameters<typeof FlowRequestBody>[0]["body"]) {
|
||||
return renderToStaticMarkup(
|
||||
<FlowRequestBody
|
||||
body={body}
|
||||
editor={<div>EDITOR</div>}
|
||||
binaryViewer={binaryViewer == null ? null : <div>{binaryViewer}</div>}
|
||||
requestSize={2048}
|
||||
/>,
|
||||
<FlowRequestBody body={body} viewer={<div>EDITOR</div>} requestSize={2048} />,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,9 +41,8 @@ describe("FlowRequestBody", () => {
|
||||
expect(html).toContain("LOADING");
|
||||
});
|
||||
|
||||
test("the editor once loaded, the proxy's viewer for a binary body", () => {
|
||||
test("the viewer once loaded", () => {
|
||||
expect(render({ data: data(), isLoading: false, error: null })).toContain("EDITOR");
|
||||
expect(render({ data: data(), isLoading: false, error: null }, "HEXDUMP")).toContain("HEXDUMP");
|
||||
});
|
||||
|
||||
test("a cut body says how much was kept", () => {
|
||||
|
||||
@@ -13,19 +13,17 @@ import type { FlowBodyData } from "../lib/flowViews";
|
||||
interface Props {
|
||||
/** The request body as the engine's body RPC returned it */
|
||||
body: HttpBodyResult<FlowBodyData | null>;
|
||||
/** The shared editor for the body's type */
|
||||
editor: ReactNode;
|
||||
/** The proxy's own viewer, for a body that isn't text; null for one that is */
|
||||
binaryViewer: ReactNode | null;
|
||||
/** The body in the viewer for its type, formatted or raw */
|
||||
viewer: ReactNode;
|
||||
/** Bytes of the request body on the wire */
|
||||
requestSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The request half's Body tab: the body in its editor, or why it isn't there yet. A body that
|
||||
* The request half's Body tab: the body in its viewer, or why it isn't there yet. A body that
|
||||
* failed to load says why, as the response's viewer does, rather than reading as no body.
|
||||
*/
|
||||
export function FlowRequestBody({ body, editor, binaryViewer, requestSize }: Props) {
|
||||
export function FlowRequestBody({ body, viewer, requestSize }: Props) {
|
||||
if (body.error != null) {
|
||||
return (
|
||||
<Banner color="danger" className="text-sm">
|
||||
@@ -40,7 +38,6 @@ export function FlowRequestBody({ body, editor, binaryViewer, requestSize }: Pro
|
||||
</EmptyStateText>
|
||||
);
|
||||
}
|
||||
if (binaryViewer != null) return binaryViewer;
|
||||
if (body.data?.truncated) {
|
||||
return (
|
||||
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
|
||||
@@ -51,9 +48,9 @@ export function FlowRequestBody({ body, editor, binaryViewer, requestSize }: Pro
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Banner>
|
||||
<div className="min-h-0">{editor}</div>
|
||||
<div className="min-h-0">{viewer}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return editor;
|
||||
return viewer;
|
||||
}
|
||||
@@ -75,6 +75,27 @@ const graphqlRequest = JSON.stringify({
|
||||
operationName: "GetViewer",
|
||||
});
|
||||
const formRequest = "grant_type=client_credentials&client_id=yaak-proxy&scope=read+write";
|
||||
const csvResponse = [
|
||||
"id,email,plan,created_at",
|
||||
"1,ada@example.com,pro,2026-10-01",
|
||||
"2,grace@example.com,team,2026-10-14",
|
||||
"3,linus@example.com,free,2026-11-02",
|
||||
].join("\n");
|
||||
const multipartBoundary = "yaakBoundary7MA4YWxk";
|
||||
const multipartRequest = [
|
||||
`--${multipartBoundary}`,
|
||||
'Content-Disposition: form-data; name="title"',
|
||||
"",
|
||||
"Quarterly report",
|
||||
`--${multipartBoundary}`,
|
||||
'Content-Disposition: form-data; name="meta"; filename="meta.json"',
|
||||
"Content-Type: application/json",
|
||||
"",
|
||||
'{"pages":12,"draft":false}',
|
||||
`--${multipartBoundary}--`,
|
||||
"",
|
||||
].join("\r\n");
|
||||
const textResponse = "Uploaded 2 parts";
|
||||
const htmlResponse =
|
||||
"<!doctype html><html><body style='font-family:sans-serif'><h1>Signed in</h1><p>You can close this window.</p></body></html>";
|
||||
// A 24x24 PNG gradient
|
||||
@@ -198,6 +219,41 @@ export const fixtures: FlowFixture[] = [
|
||||
requestBody: body(graphqlRequest),
|
||||
responseBody: body('{"data":{"viewer":{"login":"yaak"}}}'),
|
||||
},
|
||||
{
|
||||
label: "CSV GET",
|
||||
flow: {
|
||||
...base,
|
||||
id: "fl_csv",
|
||||
url: "https://api.example.com/users/export.csv",
|
||||
contentType: "text/csv",
|
||||
requestHeaders: [{ name: "Accept", value: "text/csv" }],
|
||||
responseHeaders: [{ name: "Content-Type", value: "text/csv; charset=utf-8" }],
|
||||
responseSize: csvResponse.length,
|
||||
},
|
||||
requestBody: body(""),
|
||||
responseBody: body(csvResponse),
|
||||
},
|
||||
{
|
||||
label: "Multipart POST",
|
||||
flow: {
|
||||
...base,
|
||||
id: "fl_multipart",
|
||||
method: "POST",
|
||||
url: "https://api.example.com/reports",
|
||||
contentType: "text/plain",
|
||||
requestHeaders: [
|
||||
{
|
||||
name: "Content-Type",
|
||||
value: `multipart/form-data; boundary=${multipartBoundary}`,
|
||||
},
|
||||
],
|
||||
responseHeaders: [{ name: "Content-Type", value: "text/plain; charset=utf-8" }],
|
||||
requestSize: multipartRequest.length,
|
||||
responseSize: textResponse.length,
|
||||
},
|
||||
requestBody: body(multipartRequest),
|
||||
responseBody: body(textResponse),
|
||||
},
|
||||
{
|
||||
label: "gRPC call",
|
||||
flow: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Flow } from "@yaakapp-internal/proxy-lib";
|
||||
import type { SplitLayoutLayout } from "@yaakapp-internal/ui";
|
||||
import type { ResponseViewMode, SplitLayoutLayout } from "@yaakapp-internal/ui";
|
||||
import { atom } from "jotai";
|
||||
import { atomWithStorage } from "jotai/utils";
|
||||
|
||||
@@ -22,3 +22,11 @@ export const helperDialogOpenAtom = atom(false);
|
||||
|
||||
/** Stacked by default; the header's toggle switches to side by side */
|
||||
export const flowLayoutAtom = atomWithStorage<SplitLayoutLayout>("flow_layout", "vertical");
|
||||
|
||||
/** Bodies show formatted or raw, one choice for both halves of every flow */
|
||||
export const bodyViewModeAtom = atomWithStorage<ResponseViewMode>(
|
||||
"body_view_mode",
|
||||
"pretty",
|
||||
undefined,
|
||||
{ getOnInit: true },
|
||||
);
|
||||
@@ -0,0 +1,36 @@
|
||||
import classNames from "classnames";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/** For the positioned element an `ActionsBar` sits in: hovering it brings the bar's actions in */
|
||||
export const ACTIONS_BAR_CONTAINER = "relative group/actions";
|
||||
|
||||
/** Set on an action's outermost element to keep it in view, as a control that shows state does */
|
||||
export const PINNED_ACTION = { "data-pinned": true } as const;
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A row of actions over the bottom-right corner of the content in an `ACTIONS_BAR_CONTAINER`:
|
||||
* small bordered buttons on the surface colour, so they read over text. Each fades in while the
|
||||
* container is hovered or the action has focus, unless it carries `PINNED_ACTION`. The row lets
|
||||
* clicks through to the content between its actions.
|
||||
*/
|
||||
export function ActionsBar({ children, className }: Props) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
"absolute bottom-2 left-0 right-0 z-10 flex items-center justify-end gap-1",
|
||||
"pointer-events-none",
|
||||
"*:pointer-events-auto *:bg-surface *:shadow *:transition-opacity *:transform-gpu",
|
||||
"[&>:not([data-pinned])]:opacity-0",
|
||||
"group-hover/actions:*:opacity-100! *:focus-within:opacity-100! *:hover:opacity-100!",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, test, vi } from "vite-plus/test";
|
||||
import { FormatAction, type FormattableEditor, formatEditor } from "./FormatAction";
|
||||
|
||||
/** An editor as far as formatting reaches into one, recording its changes and blurs */
|
||||
function fakeEditor(text: string) {
|
||||
const changes: string[] = [];
|
||||
const blur = vi.fn();
|
||||
let state = EditorState.create({ doc: text });
|
||||
const editor: FormattableEditor & { changes: string[]; blur: typeof blur } = {
|
||||
get state() {
|
||||
return state;
|
||||
},
|
||||
dispatch: ((spec: { changes: { from: number; to: number; insert: string } }) => {
|
||||
state = state.update(spec).state;
|
||||
changes.push(state.doc.toString());
|
||||
}) as FormattableEditor["dispatch"],
|
||||
contentDOM: { blur } as unknown as HTMLElement,
|
||||
changes,
|
||||
blur,
|
||||
};
|
||||
return editor;
|
||||
}
|
||||
|
||||
describe("formatEditor", () => {
|
||||
test("replaces the document with its formatted version and blurs", async () => {
|
||||
const editor = fakeEditor('{"a":1}');
|
||||
await formatEditor(editor, async (t) => JSON.stringify(JSON.parse(t), null, 2));
|
||||
expect(editor.state.doc.toString()).toBe('{\n "a": 1\n}');
|
||||
expect(editor.changes).toHaveLength(1);
|
||||
expect(editor.blur).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("leaves a document that is already formatted alone", async () => {
|
||||
const editor = fakeEditor("same");
|
||||
await formatEditor(editor, async (t) => t);
|
||||
expect(editor.changes).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("drops a result for text that changed while it was formatting", async () => {
|
||||
const editor = fakeEditor("before");
|
||||
await formatEditor(editor, async () => {
|
||||
editor.dispatch({ changes: { from: 0, to: 6, insert: "typed" } });
|
||||
return "formatted";
|
||||
});
|
||||
expect(editor.state.doc.toString()).toBe("typed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FormatAction", () => {
|
||||
test("is offered for an editable editor only", () => {
|
||||
const editor = { current: null };
|
||||
const format = async (t: string) => t;
|
||||
expect(renderToStaticMarkup(<FormatAction editor={editor} format={format} />)).toContain(
|
||||
"Reformat contents",
|
||||
);
|
||||
expect(renderToStaticMarkup(<FormatAction editor={editor} format={format} readOnly />)).toBe(
|
||||
"",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import type { RefObject } from "react";
|
||||
import { IconButton } from "./IconButton";
|
||||
|
||||
/** What reformatting needs of an editor; the `EditorView` an editor's `setRef` hands over has it */
|
||||
export type FormattableEditor = Pick<EditorView, "state" | "dispatch" | "contentDOM">;
|
||||
|
||||
/**
|
||||
* Replaces the editor's document with `format`'s version of it, and blurs it, since the cursor
|
||||
* resets anyway. The change goes through the editor's own `onChange`, as typing does.
|
||||
*/
|
||||
export async function formatEditor(
|
||||
editor: FormattableEditor,
|
||||
format: (text: string) => Promise<string>,
|
||||
): Promise<void> {
|
||||
const before = editor.state.doc.toString();
|
||||
const formatted = await format(before);
|
||||
// Typed into while formatting: the result is out of date
|
||||
if (editor.state.doc.toString() !== before) return;
|
||||
if (formatted === before) return;
|
||||
editor.dispatch({ changes: { from: 0, to: editor.state.doc.length, insert: formatted } });
|
||||
editor.contentDOM.blur();
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** The editor to reformat, as its `setRef` set it */
|
||||
editor: RefObject<FormattableEditor | null>;
|
||||
format: (text: string) => Promise<string>;
|
||||
/** A read-only editor can't be reformatted, so offers nothing */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
/** Reformats an editable editor's contents, for an `ActionsBar` */
|
||||
export function FormatAction({ editor, format, readOnly }: Props) {
|
||||
if (readOnly) return null;
|
||||
return (
|
||||
<IconButton
|
||||
showConfirm
|
||||
size="sm"
|
||||
title="Reformat contents"
|
||||
icon="magic_wand"
|
||||
variant="border"
|
||||
onClick={async () => {
|
||||
if (editor.current == null) return;
|
||||
await formatEditor(editor.current, format);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+38
-13
@@ -1,19 +1,33 @@
|
||||
import type { IconProps } from "@yaakapp-internal/ui";
|
||||
import { HStack, useStateWithDeps, IconTooltip, Label } from "@yaakapp-internal/ui";
|
||||
import { generateId } from "@yaakapp-internal/lib/generateId";
|
||||
import classNames from "classnames";
|
||||
import { type ReactNode, useRef } from "react";
|
||||
import { Button } from "./Button";
|
||||
import { useStateWithDeps } from "../hooks/useStateWithDeps";
|
||||
import { Button, CONTROL_TOOLTIP_DELAY } from "./Button";
|
||||
import type { IconProps } from "./Icon";
|
||||
import { IconButton, type IconButtonProps } from "./IconButton";
|
||||
import { generateId } from "@yaakapp-internal/lib/generateId";
|
||||
import { IconTooltip } from "./IconTooltip";
|
||||
import { Label } from "./Label";
|
||||
import { HStack } from "./Stacks";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
interface Props<T extends string> {
|
||||
options: { value: T; label: string; icon?: IconProps["icon"] }[];
|
||||
export interface SegmentedControlOption<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
icon?: IconProps["icon"];
|
||||
/** Shown on hover over a text option, leaving its label as its name */
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
export interface SegmentedControlProps<T extends string> {
|
||||
options: SegmentedControlOption<T>[];
|
||||
onChange: (value: T) => void;
|
||||
value: T;
|
||||
name: string;
|
||||
size?: IconButtonProps["size"];
|
||||
label: string;
|
||||
className?: string;
|
||||
/** Replaces the full width the control takes by default */
|
||||
containerClassName?: string;
|
||||
hideLabel?: boolean;
|
||||
labelClassName?: string;
|
||||
help?: ReactNode;
|
||||
@@ -29,7 +43,8 @@ export function SegmentedControl<T extends string>({
|
||||
labelClassName,
|
||||
help,
|
||||
className,
|
||||
}: Props<T>) {
|
||||
containerClassName = "w-full",
|
||||
}: SegmentedControlProps<T>) {
|
||||
const [selectedValue, setSelectedValue] = useStateWithDeps<T>(value, [value]);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const id = useRef(`input-${generateId()}`);
|
||||
@@ -38,8 +53,13 @@ export function SegmentedControl<T extends string>({
|
||||
const inlineHelp =
|
||||
hideLabel && help ? <IconTooltip tabIndex={-1} content={help} iconSize="xs" /> : null;
|
||||
|
||||
const focusOption = (index: number) => {
|
||||
const option = containerRef.current?.querySelectorAll<HTMLElement>('[role="radio"]')[index];
|
||||
option?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full grid">
|
||||
<div className={classNames(containerClassName, "grid")}>
|
||||
<Label
|
||||
htmlFor={id.current}
|
||||
help={hideLabel ? undefined : help}
|
||||
@@ -67,16 +87,14 @@ export function SegmentedControl<T extends string>({
|
||||
if (options[newIndex]) {
|
||||
setSelectedValue(options[newIndex].value);
|
||||
}
|
||||
const child = containerRef.current?.children[newIndex] as HTMLButtonElement;
|
||||
child.focus();
|
||||
focusOption(newIndex);
|
||||
} else if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
const newIndex = Math.abs((selectedIndex - 1) % options.length);
|
||||
if (options[newIndex]) {
|
||||
setSelectedValue(options[newIndex].value);
|
||||
}
|
||||
const child = containerRef.current?.children[newIndex] as HTMLButtonElement;
|
||||
child.focus();
|
||||
focusOption(newIndex);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -85,7 +103,7 @@ export function SegmentedControl<T extends string>({
|
||||
const isActive = value === o.value;
|
||||
const rightSlot = i === options.length - 1 ? inlineHelp : null;
|
||||
if (o.icon == null) {
|
||||
return (
|
||||
const button = (
|
||||
<Button
|
||||
key={o.label}
|
||||
aria-checked={isActive}
|
||||
@@ -104,6 +122,13 @@ export function SegmentedControl<T extends string>({
|
||||
{o.label}
|
||||
</Button>
|
||||
);
|
||||
return o.tooltip == null ? (
|
||||
button
|
||||
) : (
|
||||
<Tooltip key={o.label} wrap content={o.tooltip} delay={CONTROL_TOOLTIP_DELAY}>
|
||||
{button}
|
||||
</Tooltip>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<IconButton
|
||||
@@ -0,0 +1,75 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { ActionsBar } from "../ActionsBar";
|
||||
import { IconButton } from "../IconButton";
|
||||
import { BodyViewModeSwitch, prettyViewFor, viewModeLabels } from "./BodyViewModeSwitch";
|
||||
|
||||
describe("prettyViewFor", () => {
|
||||
test("names the formatted view for each format", () => {
|
||||
const cases: [string | null, string | null, boolean, string | null][] = [
|
||||
["application/json", "json", false, "formatted"],
|
||||
["application/xml", "xml", false, "formatted"],
|
||||
["text/html", "html", false, "formatted"],
|
||||
["text/html", "html", true, "preview"],
|
||||
["text/csv", "text", false, "table"],
|
||||
["text/tab-separated-values", "text", false, "table"],
|
||||
["multipart/form-data", null, false, "parts"],
|
||||
["image/svg+xml", "xml", false, "preview"],
|
||||
];
|
||||
for (const [mimeType, language, previewHtml, expected] of cases) {
|
||||
expect(prettyViewFor({ mimeType, language, previewHtml }), `${mimeType}`).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
test("a body with one view has no switch", () => {
|
||||
expect(prettyViewFor({ mimeType: "text/plain", language: "text" })).toBeNull();
|
||||
expect(prettyViewFor({ mimeType: null, language: "text" })).toBeNull();
|
||||
expect(prettyViewFor({ mimeType: "application/javascript", language: "javascript" })).toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("a body's text decides over a missing content type", () => {
|
||||
expect(prettyViewFor({ mimeType: null, language: "json" })).toBe("formatted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("BodyViewModeSwitch", () => {
|
||||
test("its first state is named for the format, its second is Raw", () => {
|
||||
expect(viewModeLabels("formatted")).toEqual(["Pretty", "Raw"]);
|
||||
expect(viewModeLabels("table")).toEqual(["Table", "Raw"]);
|
||||
expect(viewModeLabels("parts")).toEqual(["Parts", "Raw"]);
|
||||
expect(viewModeLabels("preview")).toEqual(["Preview", "Raw"]);
|
||||
expect(viewModeLabels("form")).toEqual(["Form", "Raw"]);
|
||||
});
|
||||
|
||||
test("marks the chosen state, and stays in view in an actions bar", () => {
|
||||
const pretty = renderToStaticMarkup(
|
||||
<BodyViewModeSwitch pretty="table" value="pretty" onChange={() => {}} />,
|
||||
);
|
||||
expect(pretty).toContain("data-pinned");
|
||||
expect(pretty).toMatch(/aria-checked="true"[^>]*>.*?Table/);
|
||||
expect(pretty).toMatch(/aria-checked="false"[^>]*>.*?Raw/);
|
||||
|
||||
const raw = renderToStaticMarkup(
|
||||
<BodyViewModeSwitch pretty="table" value="raw" onChange={() => {}} />,
|
||||
);
|
||||
expect(raw).toMatch(/aria-checked="false"[^>]*>.*?Table/);
|
||||
expect(raw).toMatch(/aria-checked="true"[^>]*>.*?Raw/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ActionsBar", () => {
|
||||
test("fades its actions in on hover, except a pinned one", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<ActionsBar>
|
||||
<IconButton icon="filter" title="Filter" />
|
||||
<BodyViewModeSwitch pretty="formatted" value="pretty" onChange={() => {}} />
|
||||
</ActionsBar>,
|
||||
);
|
||||
expect(markup).toContain("[&>:not([data-pinned])]:opacity-0");
|
||||
expect(markup).toContain("group-hover/actions:*:opacity-100!");
|
||||
expect(markup).toContain('title="Filter"');
|
||||
expect(markup).toContain("data-pinned");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { PINNED_ACTION } from "../ActionsBar";
|
||||
import { SegmentedControl } from "../SegmentedControl";
|
||||
import type { ResponseViewMode } from "./ResponseBodyViewer";
|
||||
|
||||
/** What a body's formatted view shows, which names it in the switch */
|
||||
export type PrettyView = "formatted" | "table" | "parts" | "preview" | "form";
|
||||
|
||||
const PRETTY_OPTIONS: Record<PrettyView, { label: string; tooltip: string }> = {
|
||||
formatted: { label: "Pretty", tooltip: "Formatted" },
|
||||
table: { label: "Table", tooltip: "As a table" },
|
||||
parts: { label: "Parts", tooltip: "Split into parts" },
|
||||
preview: { label: "Preview", tooltip: "Rendered" },
|
||||
form: { label: "Form", tooltip: "As fields" },
|
||||
};
|
||||
|
||||
const RAW_OPTION = { label: "Raw", tooltip: "As received" };
|
||||
|
||||
/** The switch's labels for a body whose formatted view is `pretty`, its first state first */
|
||||
export function viewModeLabels(pretty: PrettyView): [string, string] {
|
||||
return [PRETTY_OPTIONS[pretty].label, RAW_OPTION.label];
|
||||
}
|
||||
|
||||
/**
|
||||
* The formatted view a text body has, or null when it has only the one view: a table for CSV,
|
||||
* parts for multipart, a rendering for SVG (and HTML, where `previewHtml`), and formatted text
|
||||
* for JSON, XML and HTML.
|
||||
*/
|
||||
export function prettyViewFor({
|
||||
mimeType,
|
||||
language,
|
||||
previewHtml = false,
|
||||
}: {
|
||||
/** The media type, without parameters */
|
||||
mimeType: string | null;
|
||||
/** The language the text viewer would show the body in */
|
||||
language: string | null | undefined;
|
||||
/** HTML renders as a page, rather than formatted text */
|
||||
previewHtml?: boolean;
|
||||
}): PrettyView | null {
|
||||
const mime = mimeType?.toLowerCase() ?? "";
|
||||
if (mime.startsWith("multipart/")) return "parts";
|
||||
if (/csv|tab-separated/.test(mime)) return "table";
|
||||
if (mime.startsWith("image/svg")) return "preview";
|
||||
if (language === "html") return previewHtml ? "preview" : "formatted";
|
||||
if (language === "json" || language === "xml") return "formatted";
|
||||
return null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** The body's formatted view, named in the first state */
|
||||
pretty: PrettyView;
|
||||
value: ResponseViewMode;
|
||||
onChange: (value: ResponseViewMode) => void;
|
||||
}
|
||||
|
||||
/** Shows a body formatted for its type, or raw as received; for an `ActionsBar`, always in view */
|
||||
export function BodyViewModeSwitch({ pretty, value, onChange }: Props) {
|
||||
const prettyOption = PRETTY_OPTIONS[pretty];
|
||||
return (
|
||||
<div {...PINNED_ACTION} className="rounded-md">
|
||||
<SegmentedControl<ResponseViewMode>
|
||||
name="body_view_mode"
|
||||
label="View"
|
||||
hideLabel
|
||||
size="2xs"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
containerClassName="w-auto"
|
||||
className="p-0.5! gap-0.5! rounded-md! bg-surface! border border-border-subtle"
|
||||
options={[
|
||||
{ value: "pretty", ...prettyOption },
|
||||
{ value: "raw", ...RAW_OPTION },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,8 @@ interface Props {
|
||||
renderCopyButton?: (getText: () => Promise<string | null>) => ReactNode;
|
||||
}
|
||||
|
||||
const LARGE_BYTES = 2 * 1000 * 1000;
|
||||
/** Bytes above which a body is held back until asked for, and opens raw once shown */
|
||||
export const LARGE_BODY_BYTES = 2 * 1000 * 1000;
|
||||
|
||||
/** Holds back a body big enough to slow the window down, until asked to show it */
|
||||
export function ConfirmLargeResponse({
|
||||
@@ -40,14 +41,14 @@ export function ConfirmLargeResponse({
|
||||
const [showLargeResponse, toggleShowLargeResponse] = useToggle();
|
||||
const isProbablyText = useMemo(() => isProbablyTextContentType(contentType), [contentType]);
|
||||
|
||||
const isLarge = (contentLength ?? 0) > LARGE_BYTES;
|
||||
const isLarge = (contentLength ?? 0) > LARGE_BODY_BYTES;
|
||||
if (!showLargeResponse && isLarge) {
|
||||
return (
|
||||
<Banner color="primary" className="flex flex-col gap-3">
|
||||
<p>
|
||||
Showing {noun} over{" "}
|
||||
<InlineCode>
|
||||
<SizeTag contentLength={LARGE_BYTES} />
|
||||
<SizeTag contentLength={LARGE_BODY_BYTES} />
|
||||
</InlineCode>{" "}
|
||||
may impact performance
|
||||
</p>
|
||||
|
||||
@@ -43,8 +43,12 @@ export interface HttpResponsePaneViewProps {
|
||||
className?: string;
|
||||
/** Shown before there's a response */
|
||||
emptyState: ReactNode;
|
||||
viewMode: ResponseViewMode;
|
||||
onViewModeChange: (viewMode: ResponseViewMode) => void;
|
||||
viewMode?: ResponseViewMode;
|
||||
/**
|
||||
* Offers the raw view in the Response tab's menu. Left out where the body tab has a view-mode
|
||||
* switch of its own, as the proxy's does
|
||||
*/
|
||||
onViewModeChange?: (viewMode: ResponseViewMode) => void;
|
||||
/** Where the tab order and the active tab are remembered */
|
||||
tabsStorageKey?: string;
|
||||
/** Remembers the active tab per request or flow */
|
||||
@@ -95,7 +99,7 @@ export function HttpResponsePaneView({
|
||||
style,
|
||||
className,
|
||||
emptyState,
|
||||
viewMode,
|
||||
viewMode = "pretty",
|
||||
onViewModeChange,
|
||||
tabsStorageKey = "http_response_tabs",
|
||||
activeTabKey,
|
||||
@@ -165,21 +169,23 @@ export function HttpResponsePaneView({
|
||||
? cookieCounts.received
|
||||
: cookieCounts.sent + cookieCounts.received;
|
||||
return [
|
||||
{
|
||||
value: RESPONSE_TAB_BODY,
|
||||
empty: dimEmptyTabs && noBody,
|
||||
options: {
|
||||
value: viewMode,
|
||||
onChange: (v) => onViewModeChange(v === "raw" ? "raw" : "pretty"),
|
||||
items: [
|
||||
{ label: "Response", value: "pretty" },
|
||||
...(mimeType?.startsWith("image")
|
||||
? []
|
||||
: [{ label: "Response (Raw)", shortLabel: "Raw", value: "raw" }]),
|
||||
],
|
||||
itemsAfter: itemsAfter.length > 0 ? itemsAfter : undefined,
|
||||
},
|
||||
},
|
||||
onViewModeChange == null && itemsAfter.length === 0
|
||||
? { value: RESPONSE_TAB_BODY, label: "Response", empty: dimEmptyTabs && noBody }
|
||||
: {
|
||||
value: RESPONSE_TAB_BODY,
|
||||
empty: dimEmptyTabs && noBody,
|
||||
options: {
|
||||
value: viewMode,
|
||||
onChange: (v: string | null) => onViewModeChange?.(v === "raw" ? "raw" : "pretty"),
|
||||
items: [
|
||||
{ label: "Response", value: "pretty" },
|
||||
...(onViewModeChange == null || mimeType?.startsWith("image")
|
||||
? []
|
||||
: [{ label: "Response (Raw)", shortLabel: "Raw", value: "raw" }]),
|
||||
],
|
||||
itemsAfter: itemsAfter.length > 0 ? itemsAfter : undefined,
|
||||
},
|
||||
},
|
||||
...(requestShownApart
|
||||
? []
|
||||
: [
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import classNames from "classnames";
|
||||
import type { ReactNode } from "react";
|
||||
import { Children, useCallback, useMemo, useState } from "react";
|
||||
import { type TextFilterApi, useTextFilter } from "../../hooks/useTextFilter";
|
||||
import { docFingerprint } from "../../lib/docFingerprint";
|
||||
import { ACTIONS_BAR_CONTAINER, ActionsBar } from "../ActionsBar";
|
||||
import type { CodeEditorProps } from "../editor/CodeEditor";
|
||||
import { jsonBreadcrumbExtension } from "../editor/json/breadcrumbExtension";
|
||||
import type { JsonPathSegment } from "../editor/json/jsonPath";
|
||||
@@ -20,6 +22,13 @@ export interface TextViewerProps {
|
||||
pretty?: boolean;
|
||||
className?: string;
|
||||
footerActions?: ReactNode;
|
||||
/** Last of the actions and kept while filtering, like a view-mode switch */
|
||||
pinnedActions?: ReactNode;
|
||||
/**
|
||||
* Draws the actions in an `ActionsBar` over the viewer rather than in the editor's own row.
|
||||
* Only the proxy's body tabs set it for now; once every site has moved it goes away
|
||||
*/
|
||||
ownActionsBar?: boolean;
|
||||
extensions?: Extension[];
|
||||
/** Pretty-prints the text when `pretty` is set */
|
||||
formatText?: (text: string, language: CodeEditorProps["language"]) => Promise<string>;
|
||||
@@ -42,6 +51,8 @@ export function TextViewer({
|
||||
pretty,
|
||||
className,
|
||||
footerActions,
|
||||
pinnedActions,
|
||||
ownActionsBar = false,
|
||||
extensions,
|
||||
formatText,
|
||||
filterText,
|
||||
@@ -121,8 +132,9 @@ export function TextViewer({
|
||||
|
||||
const actions = useMemo<ReactNode[]>(() => {
|
||||
const nodes: ReactNode[] = isSearching ? [] : Children.toArray(footerActions);
|
||||
const pinned = Children.toArray(pinnedActions);
|
||||
|
||||
if (!canFilter) return nodes;
|
||||
if (!canFilter) return [...nodes, ...pinned];
|
||||
|
||||
if (isSearching) {
|
||||
nodes.push(
|
||||
@@ -173,10 +185,11 @@ export function TextViewer({
|
||||
);
|
||||
}
|
||||
|
||||
return nodes;
|
||||
return [...nodes, ...pinned];
|
||||
}, [
|
||||
canFilter,
|
||||
footerActions,
|
||||
pinnedActions,
|
||||
filter,
|
||||
filterResult.isPending,
|
||||
filterResult.isFetching,
|
||||
@@ -221,16 +234,19 @@ export function TextViewer({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<div
|
||||
className={classNames("h-full w-full", ownActionsBar ? ACTIONS_BAR_CONTAINER : "relative")}
|
||||
>
|
||||
{renderEditor({
|
||||
readOnly: true,
|
||||
className,
|
||||
defaultValue: body,
|
||||
language,
|
||||
actions,
|
||||
actions: ownActionsBar ? undefined : actions,
|
||||
extensions: extraExtensions,
|
||||
stateKey,
|
||||
})}
|
||||
{ownActionsBar && actions.length > 0 && <ActionsBar>{actions}</ActionsBar>}
|
||||
{showBreadcrumbs && (
|
||||
<div className="absolute top-0 right-3 max-w-[70%] pointer-events-none">
|
||||
<ResponseBreadcrumbBar
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { ACTIONS_BAR_CONTAINER, ActionsBar, PINNED_ACTION } from "./components/ActionsBar";
|
||||
export type { BannerProps } from "./components/Banner";
|
||||
export { Banner } from "./components/Banner";
|
||||
export type { ButtonProps } from "./components/Button";
|
||||
@@ -60,7 +61,13 @@ export type {
|
||||
export { eventDeltas, formatEventDelta, formatEventTime } from "./components/events/streamEvents";
|
||||
export type { ExternalLinkProps } from "./components/ExternalLink";
|
||||
export { ExternalLink } from "./components/ExternalLink";
|
||||
export { ConfirmLargeResponse } from "./components/http/ConfirmLargeResponse";
|
||||
export type { PrettyView } from "./components/http/BodyViewModeSwitch";
|
||||
export {
|
||||
BodyViewModeSwitch,
|
||||
prettyViewFor,
|
||||
viewModeLabels,
|
||||
} from "./components/http/BodyViewModeSwitch";
|
||||
export { ConfirmLargeResponse, LARGE_BODY_BYTES } from "./components/http/ConfirmLargeResponse";
|
||||
export { FormMultipartEditor } from "./components/http/FormMultipartEditor";
|
||||
export { FormUrlencodedEditor } from "./components/http/FormUrlencodedEditor";
|
||||
export { HeadersEditor } from "./components/http/HeadersEditor";
|
||||
@@ -125,6 +132,8 @@ export type { SelectProps } from "./components/Select";
|
||||
export { Select } from "./components/Select";
|
||||
export type { ErrorBoundaryProps } from "./components/ErrorBoundary";
|
||||
export { ErrorBoundary } from "./components/ErrorBoundary";
|
||||
export type { FormattableEditor } from "./components/FormatAction";
|
||||
export { FormatAction, formatEditor } from "./components/FormatAction";
|
||||
export { FormattedError } from "./components/FormattedError";
|
||||
export { HeaderSize } from "./components/HeaderSize";
|
||||
export type { HeadersTableProps } from "./components/HeadersTable";
|
||||
@@ -147,6 +156,8 @@ export { Overlay } from "./components/Overlay";
|
||||
export type { PlainInputProps } from "./components/PlainInput";
|
||||
export { PlainInput } from "./components/PlainInput";
|
||||
export { Portal } from "./components/Portal";
|
||||
export type { SegmentedControlOption, SegmentedControlProps } from "./components/SegmentedControl";
|
||||
export { SegmentedControl } from "./components/SegmentedControl";
|
||||
export type { SeparatorAction } from "./components/Separator";
|
||||
export { Separator } from "./components/Separator";
|
||||
export type { SettingRowBaseProps, SettingSnippetLanguage } from "./components/settings/SettingRow";
|
||||
|
||||
Reference in new issue
Block a user