Give the actions bar one inset in both of Yaak Proxy's panes

The request half's tab content ran to the card's bottom edge while the
response half's stopped above it, so the Pretty/Raw bar sat 7px from
the bottom on one side and 19px on the other. The request half is now
padded below as the response is. The bar's inset is one constant that
viewers can no longer override, and the form view draws the switch in
one bar with its bulk-view toggle instead of shifting the bar past it,
so every viewer's bar sits 11px from the pane's right and 19px from its
bottom.

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 700003e9a2
commit 29a173a07e
6 files changed
+121 -34

No files matched your search

+11 -16
View File
@@ -43,8 +43,8 @@ import { useSaveFlowBody } from "../lib/flowActions";
interface Props {
flow: Flow;
which: FlowBody;
/** A URL-encoded body's fields, shown as its formatted view */
form?: ReactNode;
/** A URL-encoded body's fields, its formatted view, with the switch in the fields' own bar */
form?: (actions: ReactNode) => ReactNode;
/** Leaves saying the body was cut short to the tab, which says it its own way */
hideCutNote?: boolean;
}
@@ -178,7 +178,7 @@ export function FlowBodyViewer({ flow, which, form, hideCutNote = false }: Props
);
let viewer: ReactNode;
let textViewer = false;
let ownsBar = 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} />;
@@ -196,8 +196,9 @@ export function FlowBodyViewer({ flow, which, form, hideCutNote = false }: Props
viewer = <ImageBody bytes={bytes} mime={imageType} />;
} else if (text == null) {
viewer = <BinaryBody flow={flow} which={which} bytes={bytes} />;
} else if (prettyView === "form" && pretty) {
viewer = form;
} else if (prettyView === "form" && pretty && form != null) {
ownsBar = true;
viewer = form(modeSwitch);
} else if (prettyView === "table" && pretty) {
viewer = <CsvViewer text={text} className="pb-2" />;
} else if (prettyView === "preview" && pretty) {
@@ -207,7 +208,7 @@ export function FlowBodyViewer({ flow, which, form, hideCutNote = false }: Props
<WebPageViewer html={text} baseUrl={flow.url} />
);
} else {
textViewer = true;
ownsBar = true;
viewer = (
<FlowTextViewer
flow={flow}
@@ -220,19 +221,13 @@ export function FlowBodyViewer({ flow, which, form, hideCutNote = false }: Props
);
}
// The text viewer draws the switch in its own bar, beside its filter; the rest get a bar here
if (modeSwitch != null && !textViewer) {
// The text viewer and the form draw the switch in their own bar, beside their own actions; the
// rest get a bar here. Each fills the tab content, so every bar has the same inset.
if (modeSwitch != null && !ownsBar) {
viewer = (
<div className={classNames(ACTIONS_BAR_CONTAINER, "h-full w-full")}>
{viewer}
<ActionsBar
className={classNames(
// Beside the form's own bulk-view toggle in the corner, until that joins the bar
prettyView === "form" && pretty && "bottom-0! right-[calc(var(--height-sm)+0.25rem)]!",
)}
>
{modeSwitch}
</ActionsBar>
<ActionsBar>{modeSwitch}</ActionsBar>
</div>
);
}
+22 -5
View File
@@ -6,6 +6,7 @@ import {
Dropdown,
EmptyStateText,
type ExtraPaneTab,
FormUrlencodedEditor,
type HttpBodyResult,
HttpRequestPaneView,
HttpResponsePaneView,
@@ -148,28 +149,44 @@ function FlowRequestHalf({ flow }: { flow: Flow }) {
];
const { defaultTab, onTabChange } = useShapeTab("request", flow, available);
const forceUpdateKey = `${flow.updatedAt}.${body.data == null ? "" : "loaded"}`;
return (
<HttpRequestPaneView
className="px-3"
// Padded below as the response half's tabs are, so their content, and the actions bar
// over it, sit the same distance from the card's edges
className="px-3 pb-3"
request={request}
fullHeight
forceUpdateKey={`${flow.updatedAt}.${body.data == null ? "" : "loaded"}`}
forceUpdateKey={forceUpdateKey}
tabsStorageKey="proxy_request_tabs"
bodyLoader={loader}
extraTabs={extraTabs}
defaultTab={defaultTab}
onTabChange={onTabChange}
urlLine={<UrlLine flow={flow} />}
renderBody={(editor) => (
renderBody={() => (
<FlowRequestBody
body={body}
// The response's viewer choice, with the form's fields as a URL-encoded body's
// formatted view
// formatted view, the switch in one bar with their bulk-view toggle
viewer={
<FlowBodyViewer
flow={flow}
which="request"
form={request.bodyType === BODY_TYPE_FORM_URLENCODED ? editor : undefined}
form={
request.bodyType === BODY_TYPE_FORM_URLENCODED
? (actions) => (
<FormUrlencodedEditor
readOnly
requestId={request.id}
form={request.body.form}
forceUpdateKey={forceUpdateKey}
actions={actions}
/>
)
: undefined
}
hideCutNote
/>
}
+12 -5
View File
@@ -7,9 +7,15 @@ 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;
/**
* Where every bar sits in its container, the same for every viewer: flush with the content's
* right edge, which a text viewer's scrollbar sits just past, and lifted off the bottom edge
* above its horizontal scrollbar. Not overridable, so bars line up from pane to pane.
*/
const ACTIONS_BAR_INSET = "bottom-2 right-0 left-0";
interface Props {
children: ReactNode;
className?: string;
}
/**
@@ -17,14 +23,15 @@ interface Props {
* small bordered buttons on the surface colour, so they read over text, each held to the height
* and radius of an `sm` icon button and centred on the row. 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.
* clicks through to the content between its actions. Place the container over the whole tab
* content, not a viewer's inner box, so the bar's inset is the pane's.
*/
export function ActionsBar({ children, className }: Props) {
export function ActionsBar({ children }: Props) {
return (
<div
className={classNames(
className,
"absolute bottom-2 left-0 right-0 z-10 flex items-center justify-end gap-1",
"absolute z-10 flex items-center justify-end gap-1",
ACTIONS_BAR_INSET,
"pointer-events-none",
"*:pointer-events-auto *:bg-surface *:shadow *:transition-opacity *:transform-gpu",
// Every action as tall and as round as a small icon button, so they line up
@@ -1,4 +1,4 @@
import { useCallback, useMemo } from "react";
import { type ReactNode, useCallback, useMemo } from "react";
import type { Pair, PairEditorProps } from "../pairs/PairEditor";
import { PairOrBulkEditor } from "../pairs/PairOrBulkEditor";
import type { HttpFormFieldView, HttpRequestBodyView } from "./types";
@@ -11,6 +11,8 @@ type Props = {
onChange?: (body: HttpRequestBodyView) => void;
/** Show the fields without a way to change them */
readOnly?: boolean;
/** Drawn in one bar with the bulk-edit toggle, like a view-mode switch */
actions?: ReactNode;
};
export function FormUrlencodedEditor({
@@ -19,6 +21,7 @@ export function FormUrlencodedEditor({
forceUpdateKey,
onChange,
readOnly,
actions,
}: Props) {
const pairs = useMemo<Pair[]>(
() =>
@@ -52,6 +55,7 @@ export function FormUrlencodedEditor({
pairs={pairs}
onChange={handleChange}
readOnly={readOnly}
actions={actions}
forceUpdateKey={forceUpdateKey}
stateKey={`urlencoded.${requestId}`}
/>
@@ -0,0 +1,40 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, test } from "vite-plus/test";
import type { HostInputProps } from "../EditorHost";
import { EditorHostProvider } from "../EditorHost";
import { PairOrBulkEditor } from "./PairOrBulkEditor";
function StubInput({ name, defaultValue }: HostInputProps) {
return <div data-input={name}>{defaultValue}</div>;
}
function render(actions?: string) {
return renderToStaticMarkup(
<EditorHostProvider value={{ Input: StubInput }}>
<PairOrBulkEditor
readOnly
preferenceName="test"
stateKey="test"
forceUpdateKey=""
pairs={[{ id: "p1", enabled: true, name: "a", value: "1" }]}
onChange={() => {}}
actions={actions == null ? undefined : <span data-action>{actions}</span>}
/>
</EditorHostProvider>,
);
}
describe("PairOrBulkEditor", () => {
test("with actions, its bulk toggle shares one actions bar with them", () => {
const markup = render("SWITCH");
expect(markup).toContain("group/actions");
// The toggle, then the given actions, inside the bar
expect(markup).toMatch(/group-hover\/actions[^>]*>.*Enable bulk edit.*SWITCH/);
});
test("without actions, its toggle keeps its own corner", () => {
const markup = render();
expect(markup).not.toContain("group/actions");
expect(markup).toContain("Enable bulk edit");
});
});
@@ -1,4 +1,6 @@
import classNames from "classnames";
import type { ReactNode } from "react";
import { ACTIONS_BAR_CONTAINER, ActionsBar } from "../ActionsBar";
import { useEditorHost } from "../EditorHost";
import { IconButton } from "../IconButton";
import { BulkPairEditor } from "./BulkPairEditor";
@@ -8,9 +10,14 @@ import { PairEditor } from "./PairEditor";
interface Props extends PairEditorProps {
preferenceName: string;
forcedEnvironmentId?: string;
/**
* Drawn after the bulk-edit toggle in one `ActionsBar`, like a view-mode switch. Giving it puts
* the toggle in that bar rather than its own corner; once every site has moved, it always is
*/
actions?: ReactNode;
}
export function PairOrBulkEditor({ preferenceName, ...props }: Props) {
export function PairOrBulkEditor({ preferenceName, actions, ...props }: Props) {
const { useStoredValue } = useEditorHost();
const [useBulk, setUseBulk] = useStoredValue<boolean>(
["bulk_edit", preferenceName],
@@ -18,20 +25,37 @@ export function PairOrBulkEditor({ preferenceName, ...props }: Props) {
"global",
);
const editor = useBulk ? <BulkPairEditor {...props} /> : <PairEditor {...props} />;
const toggleProps = {
size: "sm",
variant: "border",
title: useBulk ? "Enable form edit" : "Enable bulk edit",
onClick: () => setUseBulk((b) => !b),
icon: useBulk ? "table" : "file_code",
} as const;
if (actions != null) {
return (
<div className={classNames(ACTIONS_BAR_CONTAINER, "h-full w-full")}>
{editor}
<ActionsBar>
<IconButton {...toggleProps} />
{actions}
</ActionsBar>
</div>
);
}
return (
<div className="relative h-full w-full group/wrapper">
{useBulk ? <BulkPairEditor {...props} /> : <PairEditor {...props} />}
{editor}
<div className="absolute right-0 bottom-0">
<IconButton
size="sm"
variant="border"
title={useBulk ? "Enable form edit" : "Enable bulk edit"}
{...toggleProps}
className={classNames(
"transition-opacity opacity-0 group-hover:opacity-80 hover:opacity-100! shadow",
"bg-surface hover:text group-hover/wrapper:opacity-100",
)}
onClick={() => setUseBulk((b) => !b)}
icon={useBulk ? "table" : "file_code"}
/>
</div>
</div>