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:
Gregory SchierandClaude Opus 5.5 authored and Stetta task tk_mw9vgwsgiachkwju committed 2026-11-10 10:52:36 -07:00
1 parent fd89201ccb
commit 7ece5fa5df
21 files changed
+729 -99

No files matched your search

+36
View File
@@ -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);
}}
/>
);
}
@@ -0,0 +1,158 @@
import { generateId } from "@yaakapp-internal/lib/generateId";
import classNames from "classnames";
import { type ReactNode, useRef } from "react";
import { useStateWithDeps } from "../hooks/useStateWithDeps";
import { Button, CONTROL_TOOLTIP_DELAY } from "./Button";
import type { IconProps } from "./Icon";
import { IconButton, type IconButtonProps } from "./IconButton";
import { IconTooltip } from "./IconTooltip";
import { Label } from "./Label";
import { HStack } from "./Stacks";
import { Tooltip } from "./Tooltip";
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;
}
export function SegmentedControl<T extends string>({
value,
onChange,
options,
size = "xs",
label,
hideLabel,
labelClassName,
help,
className,
containerClassName = "w-full",
}: SegmentedControlProps<T>) {
const [selectedValue, setSelectedValue] = useStateWithDeps<T>(value, [value]);
const containerRef = useRef<HTMLDivElement>(null);
const id = useRef(`input-${generateId()}`);
// A visually hidden label has nowhere to show the help, so the last option carries it
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={classNames(containerClassName, "grid")}>
<Label
htmlFor={id.current}
help={hideLabel ? undefined : help}
visuallyHidden={hideLabel}
className={classNames(labelClassName)}
>
{label}
</Label>
<HStack
id={id.current}
ref={containerRef}
role="group"
dir="ltr"
space={1}
className={classNames(
className,
"bg-surface-highlight rounded-lg mb-auto mr-auto",
"transition-opacity transform-gpu p-1",
)}
onKeyDown={(e) => {
const selectedIndex = options.findIndex((o) => o.value === selectedValue);
if (e.key === "ArrowRight") {
e.preventDefault();
const newIndex = Math.abs((selectedIndex + 1) % options.length);
if (options[newIndex]) {
setSelectedValue(options[newIndex].value);
}
focusOption(newIndex);
} else if (e.key === "ArrowLeft") {
e.preventDefault();
const newIndex = Math.abs((selectedIndex - 1) % options.length);
if (options[newIndex]) {
setSelectedValue(options[newIndex].value);
}
focusOption(newIndex);
}
}}
>
{options.map((o, i) => {
const isSelected = selectedValue === o.value;
const isActive = value === o.value;
const rightSlot = i === options.length - 1 ? inlineHelp : null;
if (o.icon == null) {
const button = (
<Button
key={o.label}
aria-checked={isActive}
size={size}
variant="solid"
color={isActive ? "secondary" : undefined}
role="radio"
tabIndex={isSelected ? 0 : -1}
className={classNames(
isActive && "text-text!",
"focus:ring-1 focus:ring-border-focus",
)}
rightSlot={rightSlot}
onClick={() => onChange(o.value)}
>
{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
key={o.label}
aria-checked={isActive}
size={size}
variant="solid"
color={isActive ? "secondary" : undefined}
role="radio"
tabIndex={isSelected ? 0 : -1}
className={classNames(
isActive && "text-text!",
"px-1.5! w-auto!",
"focus:ring-border-focus",
)}
title={o.label}
icon={o.icon}
rightSlot={rightSlot}
onClick={() => onChange(o.value)}
/>
);
}
})}
</HStack>
</div>
);
}
@@ -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("[&amp;&gt;: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
+12 -1
View File
@@ -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";