Merge remote-tracking branch 'origin/main' into stetta/tk_mwaxu5pizxqxbmzj

# Conflicts:
#	apps/yaak-proxy/components/FlowCard.tsx
This commit is contained in:
Gregory Schier authored and Stetta task tk_mwaxu5pizxqxbmzj committed 2026-11-10 11:05:49 -07:00
commit a4355b956a
36 files changed
+1875 -218

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");
});
});
+157 -17
View File
@@ -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, 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;
}
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 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} />;
} 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,43 @@ 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 && form != null) {
ownsBar = true;
viewer = form(modeSwitch);
} 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} />
);
}
ownsBar = true;
viewer = (
<FlowTextViewer
flow={flow}
which={which}
text={text}
language={language}
pretty={pretty}
modeSwitch={modeSwitch}
/>
);
}
if (!truncated && decodeError == null) return viewer;
// 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>{modeSwitch}</ActionsBar>
</div>
);
}
if ((!truncated || hideCutNote) && decodeError == null) return viewer;
return (
<div className="h-full grid grid-rows-[auto_auto_minmax(0,1fr)]">
@@ -112,7 +241,7 @@ export function FlowBodyViewer({ flow, which, pretty }: Props) {
{decodeError}. Showing the body as it was on the wire.
</Banner>
)}
{truncated && (
{truncated && !hideCutNote && (
<Banner color="notice" className="text-sm mb-2">
{wireTruncated || decodeError != null
? `Showing the first ${formatSize(storedSize)} of ${formatSize(wireSize)}, the capture limit. The app received all of it.`
@@ -168,7 +297,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 +321,8 @@ function FlowTextViewer({
className="-mr-2 bg-surface"
formatText={formatText}
filterText={runFilter}
ownActionsBar
pinnedActions={modeSwitch}
/>
);
}
+32 -22
View File
@@ -6,6 +6,7 @@ import {
Dropdown,
EmptyStateText,
type ExtraPaneTab,
FormUrlencodedEditor,
type HttpBodyResult,
HttpRequestPaneView,
HttpResponsePaneView,
@@ -13,14 +14,13 @@ import {
IconButton,
parseCookieHeaders,
ResponseCookies,
type ResponseViewMode,
SplitLayout,
Tooltip,
} from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue, useSetAtom, useStore } 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 { placeTabsAtom } from "../lib/atoms";
import { findHeader } from "../lib/body";
import {
@@ -143,7 +143,6 @@ function FlowRequestHalf({ flow }: { flow: Flow }) {
tab: {
value: REQUEST_TAB_COOKIES,
label: "Cookies",
empty: sentCount === 0,
rightSlot: <CountBadge count={sentCount} />,
},
content: <ResponseCookies response={cookies} side="sent" />,
@@ -160,13 +159,16 @@ function FlowRequestHalf({ flow }: { flow: Flow }) {
];
const { activeTab, 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
dimEmptyTabs
forceUpdateKey={`${flow.updatedAt}.${body.data == null ? "" : "loaded"}`}
forceUpdateKey={forceUpdateKey}
tabsStorageKey="proxy_request_tabs"
bodyLoader={loader}
extraTabs={extraTabs}
@@ -174,15 +176,30 @@ function FlowRequestHalf({ flow }: { flow: Flow }) {
activeTab={activeTab}
onTabChange={onTabChange}
urlLine={<UrlLine flow={flow} />}
renderBody={(editor) => (
renderBody={() => (
<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, the switch in one bar with their bulk-view toggle
viewer={
<FlowBodyViewer
flow={flow}
which="request"
form={
request.bodyType === BODY_TYPE_FORM_URLENCODED
? (actions) => (
<FormUrlencodedEditor
readOnly
requestId={request.id}
form={request.body.form}
forceUpdateKey={forceUpdateKey}
actions={actions}
/>
)
: undefined
}
hideCutNote
/>
}
requestSize={flow.requestSize}
/>
@@ -210,7 +227,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;
@@ -242,7 +258,6 @@ function FlowResponseHalf({ flow, className }: { flow: Flow; className: string }
tab: {
value: RESPONSE_TAB_MESSAGES,
label: messagesLabel,
empty: flow.messageCount === 0,
rightSlot: (
<CountBadge
count={flow.messageCount}
@@ -273,23 +288,18 @@ function FlowResponseHalf({ flow, className }: { flow: Flow; className: string }
<HttpResponsePaneView
bare
requestShownApart
dimEmptyTabs
className={classNames(className, "border-border-subtle")}
response={response}
body={loader}
requestBody={null}
emptyState={<EmptyStateText>Waiting for a response</EmptyStateText>}
viewMode={viewMode}
onViewModeChange={setViewMode}
tabsStorageKey="proxy_response_tabs"
activeTabKey={flow.id}
defaultTab={defaultTab}
activeTab={activeTab}
onTabChange={onTabChange}
extraTabs={extraTabs}
renderBodyViewer={() => (
<FlowBodyViewer flow={flow} which="response" pretty={viewMode === "pretty"} />
)}
renderBodyViewer={() => <FlowBodyViewer flow={flow} which="response" />}
statusLineCenter={
flow.responseTruncated ? (
<span
+247 -3
View File
@@ -14,7 +14,14 @@ import {
} from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai";
import type { CSSProperties, KeyboardEvent, MouseEvent, ReactElement, WheelEvent } from "react";
import type {
CSSProperties,
KeyboardEvent,
MouseEvent,
ReactElement,
RefObject,
WheelEvent,
} from "react";
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import {
appliedFilterAtom,
@@ -28,6 +35,7 @@ import {
type Column,
type ColumnId,
cellFilterTerm,
columnShifts,
customValue,
gridTemplate,
gridWidth,
@@ -65,7 +73,7 @@ import {
import { useShowToast } from "../lib/toast";
import { AppIcon } from "./AppIcon";
import { FlowListEmpty, PausedRow } from "./FlowListEmpty";
import { FlowListHeader } from "./FlowListHeader";
import { type ColumnDragEvent, FlowListHeader } from "./FlowListHeader";
const BOTTOM_PADDING = 6;
/** Rows from the top at which older flows start loading, or from the bottom, sorted, more */
@@ -305,6 +313,11 @@ export function FlowList({ style, className }: Props) {
el.scrollLeft += e.deltaX;
}, []);
// A dragged column: its ghost, and the others sliding aside to make room for it
const rootRef = useRef<HTMLDivElement>(null);
const getScrollElement = useCallback(() => scrollRef.current, []);
const onColumnDrag = useColumnDragView(rootRef, headerScrollRef, scrollRef);
// A list too short to scroll still loads older flows on scrolling up.
const handleWheel = useCallback(
(e: WheelEvent<HTMLDivElement>) => {
@@ -439,10 +452,11 @@ export function FlowList({ style, className }: Props) {
return (
<div
ref={rootRef}
style={style}
className={classNames(
className,
"grid grid-rows-[auto_minmax(0,1fr)] grid-cols-[minmax(0,1fr)] min-h-0 text-sm",
"relative grid grid-rows-[auto_minmax(0,1fr)] grid-cols-[minmax(0,1fr)] min-h-0 text-sm",
)}
>
{/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- scrolls the list sideways */}
@@ -462,11 +476,14 @@ export function FlowList({ style, className }: Props) {
onSort={handleSort}
onSetSort={handleSetSort}
onResetSort={follow}
scrollElement={getScrollElement}
onColumnDrag={onColumnDrag}
/>
</div>
<div className="relative min-h-0">
<div
ref={scrollRef}
data-flow-rows
role="listbox"
aria-label="Flows"
tabIndex={0}
@@ -576,6 +593,233 @@ function useArrivals(flows: Flow[], following: boolean): Map<string, number> {
}, [flows, following]);
}
/** How long the columns take to slide aside for a dragged column, or back */
const SLIDE_MS = 150;
/**
* Shows a column's drag without rendering a row. Its ghost is its header and the cells of the
* rows in view, copied once when the drag starts and following the pointer sideways. The column
* hides where it is, and the columns between it and where it would drop slide aside by its width
* to leave a gap there, all by one style element: a rule a column, header and cells together,
* which rows arriving meanwhile follow too. Dropped, the rules go as the order changes, in the
* same task, so nothing animates or jumps; put back, the columns slide home first.
*/
function useColumnDragView(
rootRef: RefObject<HTMLDivElement | null>,
headerRef: RefObject<HTMLDivElement | null>,
listRef: RefObject<HTMLDivElement | null>,
): (event: ColumnDragEvent) => void {
const drag = useRef<ColumnDragView | null>(null);
// Columns sliding home after a drag was put back, until they are there
const settling = useRef<{ style: HTMLStyleElement; timer: number } | null>(null);
const settle = useCallback(() => {
const s = settling.current;
settling.current = null;
if (s == null) return;
clearTimeout(s.timer);
s.style.remove();
}, []);
const onColumnDrag = useCallback(
(event: ColumnDragEvent) => {
const d = drag.current;
switch (event.type) {
case "move":
d?.ghost?.move(event.x);
return;
case "target":
if (d == null) return;
d.to = event.to;
d.style.textContent = columnDragCss(d);
return;
case "end": {
drag.current = null;
if (d == null) return;
d.ghost?.remove();
if (event.dropped || d.to == null) {
d.style.remove();
return;
}
d.to = null;
d.style.textContent = columnDragCss(d);
settling.current = { style: d.style, timer: window.setTimeout(settle, SLIDE_MS) };
return;
}
case "start": {
settle();
d?.ghost?.remove();
d?.style.remove();
drag.current = null;
const root = rootRef.current;
const header = headerRef.current;
const list = listRef.current;
if (root == null || header == null || list == null) return;
const cells = Array.from(
header.querySelectorAll<HTMLElement>("[data-flow-header] > [data-column]"),
);
const ids = cells.map((c) => c.dataset.column as ColumnId);
const from = ids.indexOf(event.id);
if (from === -1) return;
// Copied before it hides
const ghost = snapshotColumn(event.id, event.x, root, header, list);
const style = document.createElement("style");
document.head.append(style);
const widths = cells.map((c) => c.getBoundingClientRect().width);
drag.current = { ghost, ids, widths, from, to: null, style };
style.textContent = columnDragCss(drag.current);
}
}
},
[rootRef, headerRef, listRef, settle],
);
useEffect(
() => () => {
settle();
drag.current?.ghost?.remove();
drag.current?.style.remove();
drag.current = null;
},
[settle],
);
return onColumnDrag;
}
interface ColumnDragView {
ghost: ColumnGhost | null;
/** The columns shown and their widths, the dragged one's place among them, and its target */
ids: ColumnId[];
widths: number[];
from: number;
to: number | null;
style: HTMLStyleElement;
}
/** The rules for a drag: the column hidden, and those it passes slid aside */
function columnDragCss({ ids, widths, from, to }: ColumnDragView): string {
// A row's cell is the box in its grid: the column's child, or inside the `contents` span a
// tooltip wraps it in, as a box that is only its contents can't move
const cells = (is: string) =>
`[data-flow-rows] ${is} > :not(.contents), [data-flow-rows] ${is} > .contents > *`;
const column = (id: ColumnId) => {
const is = `[data-column="${CSS.escape(id)}"]`;
return `[data-flow-header] > ${is}, ${cells(is)}`;
};
const rules = [
`[data-flow-header] > [data-column], ${cells("[data-column]")} { transition: transform ${SLIDE_MS}ms ease; }`,
];
const dragged = ids[from];
if (dragged != null) rules.push(`${column(dragged)} { visibility: hidden; }`);
const shifts = columnShifts(ids.length, from, widths[from] ?? 0, to);
shifts.forEach((dx, i) => {
const id = ids[i];
if (dx !== 0 && id != null) rules.push(`${column(id)} { transform: translateX(${dx}px); }`);
});
// A failed flow's reason spans Type and Size together; while they part, it keeps to Type's
// width, so it doesn't slide over the column past Size.
const type = ids.indexOf("type");
if (type !== -1 && ids[type + 1] === "size") {
const size = type + 1;
if (from === size || shifts[type] !== shifts[size]) {
const is = '[data-flow-rows] [data-column="type"]';
rules.push(
`${is} > .col-span-2, ${is} > .contents > .col-span-2 { width: ${widths[type] ?? 0}px; }`,
);
}
}
return rules.join("\n");
}
interface ColumnGhost {
/** Follows the pointer, across the window */
move: (x: number) => void;
remove: () => void;
}
function snapshotColumn(
id: ColumnId,
startX: number,
root: HTMLElement,
header: HTMLElement,
list: HTMLElement,
): ColumnGhost | null {
const cell = header.querySelector<HTMLElement>(`[data-column="${id}"]`);
if (cell == null) return null;
const rootBox = root.getBoundingClientRect();
const cellBox = cell.getBoundingClientRect();
const listBox = list.getBoundingClientRect();
const listHeight = list.clientHeight;
const el = document.createElement("div");
el.setAttribute("aria-hidden", "true");
el.className = classNames(
"pointer-events-none absolute top-0 z-20 overflow-hidden rounded-sm",
// Lifted: a shadow, and just enough opacity to read as held, not enough to show text through
"bg-surface border border-border-subtle shadow-lg opacity-95",
);
Object.assign(el.style, {
left: `${cellBox.left - rootBox.left}px`,
width: `${cellBox.width}px`,
height: `${rootBox.height}px`,
willChange: "transform",
});
// The header's label, without its resize handle
const head = place(cellBox.top - rootBox.top, cellBox.height, "text-xs text-text-subtlest");
const label = cell.cloneNode(true) as HTMLElement;
for (const handle of label.querySelectorAll(".cursor-col-resize")) handle.remove();
label.style.width = "100%";
head.append(label);
el.append(head);
// The rows in view, clipped to the list as it shows them
const rows = document.createElement("div");
rows.className = "absolute left-0 right-0 overflow-hidden";
Object.assign(rows.style, { top: `${listBox.top - rootBox.top}px`, height: `${listHeight}px` });
for (const row of list.querySelectorAll<HTMLElement>('[role="option"]')) {
const rowBox = row.getBoundingClientRect();
if (rowBox.bottom <= listBox.top || rowBox.top >= listBox.top + listHeight) continue;
const source = row.querySelector(`[data-column="${id}"]`);
if (source == null) continue;
const copy = place(
rowBox.top - listBox.top,
rowBox.height,
classNames(
"whitespace-nowrap tabular-nums",
row.getAttribute("aria-selected") === "true" && "text-text bg-surface-highlight",
row.classList.contains("text-text-subtlest") && "text-text-subtlest",
),
);
for (const child of source.children) {
const clone = child.cloneNode(true) as HTMLElement;
clone.style.width = "100%";
copy.append(clone);
}
rows.append(copy);
}
el.append(rows);
root.append(el);
// Kept within the list's visible width
const min = listBox.left;
const max = Math.max(min, listBox.left + list.clientWidth - cellBox.width);
const move = (x: number) => {
const left = Math.min(max, Math.max(min, cellBox.left + x - startX));
el.style.transform = `translateX(${left - cellBox.left}px)`;
};
move(startX);
return { move, remove: () => el.remove() };
}
/** A line of the ghost, `top` pixels down its box */
function place(top: number, height: number, className: string): HTMLElement {
const el = document.createElement("div");
el.className = classNames("absolute left-0 right-0 flex items-center", className);
Object.assign(el.style, { top: `${top}px`, height: `${height}px` });
return el;
}
interface RowProps {
flow: Flow;
index: number;
+350 -56
View File
@@ -1,3 +1,11 @@
import {
DndContext,
type DragStartEvent,
PointerSensor,
useDraggable,
useSensor,
useSensors,
} from "@dnd-kit/core";
import type { FlowSortKey } from "@yaakapp-internal/proxy-lib";
import {
ContextMenu,
@@ -9,7 +17,16 @@ import {
} from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue, useSetAtom } from "jotai";
import { memo, type MouseEvent, useCallback, useRef, useState } from "react";
import {
memo,
type MouseEvent,
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import {
addSortKey,
allColumns,
@@ -19,9 +36,13 @@ import {
type ColumnPrefs,
type CustomColumnId,
canHide,
dropBoundary,
hideOtherColumns,
isCustomColumn,
moveColumn,
moveColumnBy,
removeCustomColumn,
resetOrder,
resetWidth,
resetWidths,
resizeColumn,
@@ -31,6 +52,13 @@ import {
import { columnPrefsAtom, hasAppsAtom } from "../lib/store";
import { addColumnDialogAtom } from "./AddColumnDialog";
/** Pixels a press on a label moves before it drags the column rather than sorting by it */
const DRAG_DISTANCE = 4;
/** How near the list's side edge, in pixels, a dragged column scrolls the list that way */
const EDGE = 40;
/** The most the list scrolls sideways a frame, at the very edge */
const EDGE_SPEED = 18;
interface Props {
columns: Column[];
template: string;
@@ -43,13 +71,29 @@ interface Props {
/** Sets the sort from the menus; empty goes back to arrival order */
onSetSort: (sort: FlowSortKey[]) => void;
onResetSort: () => void;
/** The rows' scrolling element, which a column dragged to its edge scrolls sideways */
scrollElement: () => HTMLElement | null;
/** A column's drag, for the list to show: its ghost, and the columns making room for it */
onColumnDrag: (event: ColumnDragEvent) => void;
}
/**
* A column's drag: its start, the pointer's moves (`x`, across the window), the boundary it
* would drop at when that changes (`to`, null where it would stay), and its end, dropped there
* or put back
*/
export type ColumnDragEvent =
| { type: "start"; id: ColumnId; x: number }
| { type: "move"; x: number }
| { type: "target"; to: number | null }
| { type: "end"; dropped: boolean };
/**
* The list's only control surface: a click on a label sorts by it alone, a second reverses it
* and a third goes back to arrival order; Option-click adds it to the sort as a further key.
* Dragging the edge after a label resizes its column and a double-click on it resets it. A
* right-click on a label opens the column's menu; the picker sits in the empty corner on hover.
* Dragging a label moves its column, and Escape puts it back. Dragging the edge after a label
* resizes its column and a double-click on it resets it. A right-click on a label opens the
* column's menu; the picker sits in the empty corner on hover.
*/
export const FlowListHeader = memo(function FlowListHeader({
columns,
@@ -60,6 +104,8 @@ export const FlowListHeader = memo(function FlowListHeader({
onSort,
onSetSort: setSort,
onResetSort,
scrollElement,
onColumnDrag,
}: Props) {
const setPrefs = useSetAtom(columnPrefsAtom);
const unavailable = unavailableColumns(useAtomValue(hasAppsAtom));
@@ -67,6 +113,12 @@ export const FlowListHeader = memo(function FlowListHeader({
const [menu, setMenu] = useState<{ x: number; y: number; column: ColumnDef | null } | null>(null);
// Widths when a drag began, so each move sets the width from where it started.
const startWidth = useRef(0);
const { sensors, dndHandlers, gridRef, onPointerDownCapture } = useColumnDrag({
columns,
scrollElement,
onColumnDrag,
onDrop: (id, to) => setPrefs((p) => moveColumn(p, id, to, unavailable)),
});
// Taking out the sort's only key goes back to arrival order, as Reset Sort does
const removeColumn = (id: CustomColumnId) => {
@@ -110,11 +162,17 @@ export const FlowListHeader = memo(function FlowListHeader({
{ type: "separator" },
{ label: "Reset Sort", disabled: sort.length === 0, onSelect: onResetSort },
{ label: "Reset Column Widths", onSelect: () => setPrefs((p) => resetWidths(p)) },
{
label: "Reset Column Order",
disabled: prefs.order.length === 0,
onSelect: () => setPrefs((p) => resetOrder(p)),
},
];
const columnItems = (c: ColumnDef): DropdownItem[] => {
const sortBy = (descending: boolean) => setSort([{ column: c.id, descending }]);
const addTo = (descending: boolean) => setSort(addSortKey(sort, c.id, descending));
const at = columns.findIndex((col) => col.id === c.id);
return [
{ label: "Sort Ascending", onSelect: () => sortBy(false) },
{ label: "Sort Descending", onSelect: () => sortBy(true) },
@@ -122,6 +180,17 @@ export const FlowListHeader = memo(function FlowListHeader({
{ label: "Add to Sort Descending", onSelect: () => addTo(true) },
{ label: "Clear Sorting", disabled: sort.length === 0, onSelect: onResetSort },
{ type: "separator" },
{
label: "Move Left",
disabled: at <= 0,
onSelect: () => setPrefs((p) => moveColumnBy(p, c.id, -1, unavailable)),
},
{
label: "Move Right",
disabled: at === -1 || at >= columns.length - 1,
onSelect: () => setPrefs((p) => moveColumnBy(p, c.id, 1, unavailable)),
},
{ type: "separator" },
{
label: "Hide Column",
disabled: !canHide(prefs, c.id, unavailable),
@@ -163,6 +232,9 @@ export const FlowListHeader = memo(function FlowListHeader({
<div style={{ width, minWidth: "100%" }} className="px-1.5">
{/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- a right-click opens the column's menu */}
<div
ref={gridRef}
data-flow-header
onPointerDownCapture={onPointerDownCapture}
onContextMenu={openMenu}
style={{ gridTemplateColumns: template }}
className={classNames(
@@ -182,60 +254,21 @@ export const FlowListHeader = memo(function FlowListHeader({
/>
</Dropdown>
</span>
{columns.map((c) => {
const at = sort.findIndex((k) => k.column === c.id);
const key = sort[at];
const label = (
<>
{key != null && (
<Icon
icon="chevron_down"
size="xs"
className={classNames("shrink-0", !key.descending && "rotate-180")}
/>
)}
{key != null && sort.length > 1 && (
<span className="shrink-0 -ml-0.5 text-2xs tabular-nums">{at + 1}</span>
)}
<span className="truncate">{c.id === "app" ? "" : c.label}</span>
</>
);
const labelClass = classNames(
"flex items-center gap-0.5 min-w-0 w-full h-full whitespace-nowrap",
c.align === "right" && "justify-end",
key != null && "text-text-subtle",
);
return (
<span
<DndContext sensors={sensors} autoScroll={false} {...dndHandlers}>
{columns.map((c) => (
<HeaderCell
key={c.id}
data-column={c.id}
className="relative h-full flex items-center min-w-0 px-1.5"
>
<button
type="button"
onClick={(e) => onSort(c.id, e.altKey)}
// No tooltip: the header sits under the search field, and a tooltip here covered it
aria-label={`Sort by ${c.label}`}
className={classNames(labelClass, "hover:text-text-subtle")}
>
{label}
</button>
{/* On the column's right edge, the boundary with the next, resizing this one */}
<ResizeHandle
side="right"
justify="center"
className="absolute top-0 translate-x-1/2"
onResizeStart={() => {
startWidth.current = c.current;
}}
onResizeMove={(e) =>
setPrefs((p) => resizeColumn(p, c.id, startWidth.current + e.x - e.xStart))
}
onReset={() => setPrefs((p) => resetWidth(p, c.id))}
/>
</span>
);
})}
column={c}
sort={sort}
onSort={onSort}
onResizeStart={() => {
startWidth.current = c.current;
}}
onResizeMove={(dx) => setPrefs((p) => resizeColumn(p, c.id, startWidth.current + dx))}
onResetWidth={() => setPrefs((p) => resetWidth(p, c.id))}
/>
))}
</DndContext>
<ContextMenu
triggerPosition={menu}
items={menu == null ? [] : menu.column == null ? pickerItems : columnItems(menu.column)}
@@ -245,3 +278,264 @@ export const FlowListHeader = memo(function FlowListHeader({
</div>
);
});
interface HeaderCellProps {
column: Column;
sort: FlowSortKey[];
onSort: (column: ColumnId, add: boolean) => void;
onResizeStart: () => void;
/** How far the edge moved since the resize began */
onResizeMove: (dx: number) => void;
onResetWidth: () => void;
}
function HeaderCell({
column: c,
sort,
onSort,
onResizeStart,
onResizeMove,
onResetWidth,
}: HeaderCellProps) {
// The label drags the column; the resize handle beside it is outside it, so it still resizes.
const { listeners, setNodeRef } = useDraggable({ id: c.id });
const sorted = sort.some((k) => k.column === c.id);
return (
<span data-column={c.id} className="relative h-full flex items-center min-w-0 px-1.5">
<button
ref={setNodeRef}
type="button"
{...listeners}
// A press that moves drags instead; dnd-kit swallows the click that ends a drag.
onClick={(e) => onSort(c.id, e.altKey)}
// No tooltip: the header sits under the search field, and a tooltip here covered it
aria-label={`Sort by ${c.label}`}
className={classNames(
"flex items-center gap-0.5 min-w-0 w-full h-full whitespace-nowrap",
"hover:text-text-subtle",
c.align === "right" && "justify-end",
sorted && "text-text-subtle",
)}
>
<ColumnLabel column={c} sort={sort} />
</button>
{/* On the column's right edge, the boundary with the next, resizing this one */}
<ResizeHandle
side="right"
justify="center"
className="absolute top-0 translate-x-1/2"
onResizeStart={onResizeStart}
onResizeMove={(e) => onResizeMove(e.x - e.xStart)}
onReset={onResetWidth}
/>
</span>
);
}
/** A column's name, after its sort direction and, sorted by several, its place in the sort */
function ColumnLabel({ column: c, sort }: { column: Column; sort: FlowSortKey[] }) {
const at = sort.findIndex((k) => k.column === c.id);
const key = sort[at];
return (
<>
{key != null && (
<Icon
icon="chevron_down"
size="xs"
className={classNames("shrink-0", !key.descending && "rotate-180")}
/>
)}
{key != null && sort.length > 1 && (
<span className="shrink-0 -ml-0.5 text-2xs tabular-nums">{at + 1}</span>
)}
<span className="truncate">{c.id === "app" ? "" : c.label}</span>
</>
);
}
/**
* Dragging a column's label: where it would drop, the boundary between the labels nearest the
* pointer as they were laid out when the drag began, and the list scrolling sideways while the
* pointer is near its edge. The list shows the drag; the columns only change order on the drop,
* so the rows lay out once.
*/
function useColumnDrag({
columns,
scrollElement,
onColumnDrag,
onDrop,
}: {
columns: Column[];
scrollElement: () => HTMLElement | null;
onColumnDrag: (event: ColumnDragEvent) => void;
/** Moves the column to the boundary `to` between those shown */
onDrop: (id: ColumnId, to: number) => void;
}) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: DRAG_DISTANCE } }),
);
const gridRef = useRef<HTMLDivElement>(null);
// Live while dragging, read each frame
const state = useRef<{
/** The column dragged, and its place among those shown */
id: ColumnId;
from: number;
/** The labels' edges when the drag began, from the header's left, as the columns slide */
cells: { left: number; right: number }[];
/** The columns shown when it began, which the edges and `from` describe */
shown: string;
/** The pointer, across the window */
x: number;
/** The boundary it would move to, null where it would stay */
to: number | null;
/** The pointer the ghost was last moved to */
ghostX: number;
frame: number;
} | null>(null);
const latest = useRef({ columns, scrollElement, onColumnDrag, onDrop });
useLayoutEffect(() => {
latest.current = { columns, scrollElement, onColumnDrag, onDrop };
});
/** Ends the drag, dropped or put back */
const stop = useCallback((dropped: boolean) => {
const s = state.current;
state.current = null;
if (s == null) return null;
cancelAnimationFrame(s.frame);
latest.current.onColumnDrag({ type: "end", dropped });
return s;
}, []);
const onDragStart = useCallback((e: DragStartEvent) => {
const grid = gridRef.current;
const from = latest.current.columns.findIndex((c) => c.id === e.active.id);
if (grid == null || from === -1) return;
const id = e.active.id as ColumnId;
const x = pointer.current;
// First, as it ends the last drag's columns sliding home, so the edges read below are where
// the columns are laid out, not where they were sliding
latest.current.onColumnDrag({ type: "start", id, x });
const gridLeft = grid.getBoundingClientRect().left;
const cells = Array.from(grid.querySelectorAll<HTMLElement>(":scope > [data-column]")).map(
(el) => {
const box = el.getBoundingClientRect();
return { left: box.left - gridLeft, right: box.right - gridLeft };
},
);
const shown = shownKey(latest.current.columns);
state.current = { id, from, cells, shown, x, to: null, ghostX: x, frame: 0 };
target(state.current);
const tick = () => {
const s = state.current;
if (s == null) return;
// Near an edge, the list scrolls that way, faster the nearer; the header follows it.
const list = latest.current.scrollElement();
if (list != null) {
const { left, right } = list.getBoundingClientRect();
const near =
s.x < left + EDGE ? s.x - left - EDGE : s.x > right - EDGE ? s.x - right + EDGE : 0;
if (near !== 0) {
const speed = Math.min(1, Math.abs(near) / EDGE) * EDGE_SPEED;
list.scrollLeft += Math.sign(near) * Math.max(1, speed);
}
}
target(s);
if (s.x !== s.ghostX) {
s.ghostX = s.x;
latest.current.onColumnDrag({ type: "move", x: s.x });
}
s.frame = requestAnimationFrame(tick);
};
state.current.frame = requestAnimationFrame(tick);
// oxlint-disable-next-line react-hooks/exhaustive-deps -- `target` reads only refs
}, []);
/** Drops the column where the pointer is, once; a second call finds no drag */
const drop = useCallback(() => {
// Where the pointer is now, as a release can come before the next frame reads it
if (state.current != null) target(state.current);
// The list lets go of the drag and the order changes in the same task, so nothing jumps.
const s = stop(true);
if (s?.to != null) latest.current.onDrop(s.id, s.to);
// oxlint-disable-next-line react-hooks/exhaustive-deps -- `target` reads only refs
}, [stop]);
const cancel = useCallback(() => {
stop(false);
}, [stop]);
// Columns shown or hidden under a drag, as App is when the first flow with an app arrives,
// leave its edges and target describing columns that are gone; it is put back instead.
const shown = shownKey(columns);
useEffect(() => {
if (state.current != null && state.current.shown !== shown) cancel();
}, [shown, cancel]);
// The pointer from a press on the header until its release, read before dnd-kit sees it.
// dnd-kit starts a drag on the move that crosses `DRAG_DISTANCE` and reports moves and the
// release only once it has rendered the drag, so a release right after a move would drop
// where the pointer was a frame before, or not at all. The release drops it here instead.
const pointer = useRef(0);
const track = useCallback((e: PointerEvent) => {
pointer.current = e.clientX;
if (state.current != null) state.current.x = e.clientX;
}, []);
const unlisten = useRef<(() => void) | null>(null);
const onPointerDownCapture = useCallback(
(e: ReactPointerEvent) => {
unlisten.current?.();
pointer.current = e.clientX;
const up = (e: PointerEvent) => {
unlisten.current?.();
track(e);
drop();
};
const lost = () => {
unlisten.current?.();
cancel();
};
window.addEventListener("pointermove", track, true);
window.addEventListener("pointerup", up, true);
window.addEventListener("pointercancel", lost, true);
unlisten.current = () => {
unlisten.current = null;
window.removeEventListener("pointermove", track, true);
window.removeEventListener("pointerup", up, true);
window.removeEventListener("pointercancel", lost, true);
};
},
[track, drop, cancel],
);
// A drag never outlives the header
useEffect(
() => () => {
unlisten.current?.();
cancel();
},
[cancel],
);
/** Sets where the drag would drop, telling the list when that changes */
function target(s: NonNullable<typeof state.current>) {
const gridLeft = gridRef.current?.getBoundingClientRect().left ?? 0;
const cells = s.cells.map((c) => ({ left: gridLeft + c.left, right: gridLeft + c.right }));
const to = dropBoundary(s.x, cells, s.from);
if (to === s.to) return;
s.to = to;
latest.current.onColumnDrag({ type: "target", to });
}
return {
sensors,
gridRef,
onPointerDownCapture,
dndHandlers: { onDragStart, onDragEnd: drop, onDragCancel: cancel },
};
}
/** Which columns show, in order, to tell when that changes */
function shownKey(columns: Column[]): string {
return columns.map((c) => c.id).join(" ");
}
@@ -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;
}
+81
View File
@@ -75,6 +75,38 @@ 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 tsvResponse = [
"sku\tname\tstock\tnote",
"A-100\tDesk lamp\t42\tWarm white, dimmable, ships with a long braided fabric cable",
"A-101\tMonitor arm\t7\t",
"A-102\tKeyboard tray\t0\tBack in stock next week",
].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}`,
'Content-Disposition: form-data; name="rows"; filename="rows.csv"',
"Content-Type: text/csv",
"",
"region,revenue\nnorth,1200\nsouth,980",
`--${multipartBoundary}--`,
"",
].join("\r\n");
const textResponse = "Uploaded 3 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 +230,55 @@ 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: "TSV GET",
flow: {
...base,
id: "fl_tsv",
url: "https://api.example.com/inventory.tsv",
contentType: "text/tab-separated-values",
requestHeaders: [{ name: "Accept", value: "text/tab-separated-values" }],
responseHeaders: [{ name: "Content-Type", value: "text/tab-separated-values" }],
responseSize: tsvResponse.length,
},
requestBody: body(""),
responseBody: body(tsvResponse),
},
{
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: {
+9 -1
View File
@@ -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";
import { currentPlace, EMPTY_PLACE, type PlaceStack, stackOf, updatePlace } from "./places";
@@ -63,3 +63,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 },
);
+198
View File
@@ -4,21 +4,27 @@ import customSortFixture from "../../../crates-proxy/yaak-proxy-lib/tests/fixtur
import {
addCustomColumn,
addSortKey,
allColumns,
cellFilterTerm,
columnShifts,
type ColumnPrefs,
customPath,
customPathProblem,
customValue,
defaultColumnPrefs,
dropBoundary,
fieldReference,
fieldReferenceChanges,
gridTemplate,
gridWidth,
hideOtherColumns,
moveColumn,
moveColumnBy,
nextSort,
operationOf,
parseColumnPrefs,
removeCustomColumn,
resetOrder,
resetWidth,
resetWidths,
resizeColumn,
@@ -121,6 +127,7 @@ describe("column preferences", () => {
visible: { number: false, "field:header.x-cache": true },
widths: { host: 60 },
custom: ["field:header.x-cache"],
order: [],
sort: [{ column: "status", descending: true }],
});
expect(
@@ -138,6 +145,7 @@ describe("column preferences", () => {
visible: {},
widths: { size: 90 },
custom: ["field:query.page"],
order: [],
sort: [{ column: "time", descending: false }],
});
expect(parseColumnPrefs(null)).toEqual(defaultColumnPrefs);
@@ -181,6 +189,196 @@ describe("column preferences", () => {
});
});
describe("column order", () => {
const defaults = ids(defaultColumnPrefs);
test("a column moves to the boundary it was dropped at", () => {
expect(ids(moveColumn(defaultColumnPrefs, "status", 2))).toEqual([
"number",
"app",
"status",
"method",
"host",
"path",
"query",
"type",
"size",
"time",
]);
// Rightwards, before the column at the boundary
expect(ids(moveColumn(defaultColumnPrefs, "method", 5)).slice(2, 6)).toEqual([
"host",
"path",
"method",
"query",
]);
// To either end
expect(ids(moveColumn(defaultColumnPrefs, "time", 0))[0]).toBe("time");
expect(ids(moveColumn(defaultColumnPrefs, "number", defaults.length)).at(-1)).toBe("number");
// The picker lists them in the same order
const moved = moveColumn(defaultColumnPrefs, "time", 0);
expect(allColumns(moved)[0]?.id).toBe("time");
});
test("a drop where the column already is changes nothing", () => {
expect(moveColumn(defaultColumnPrefs, "host", 3)).toBe(defaultColumnPrefs);
expect(moveColumn(defaultColumnPrefs, "host", 4)).toBe(defaultColumnPrefs);
expect(moveColumn(defaultColumnPrefs, "host", -1)).toBe(defaultColumnPrefs);
expect(moveColumn(defaultColumnPrefs, "host", defaults.length + 1)).toBe(defaultColumnPrefs);
// A hidden column has no place among those shown
expect(moveColumn(defaultColumnPrefs, "latency", 0)).toBe(defaultColumnPrefs);
});
test("Move Left and Move Right go one place, and not past the ends", () => {
expect(ids(moveColumnBy(defaultColumnPrefs, "host", -1)).slice(2, 5)).toEqual([
"host",
"method",
"path",
]);
expect(ids(moveColumnBy(defaultColumnPrefs, "host", 1)).slice(3, 6)).toEqual([
"path",
"host",
"query",
]);
expect(moveColumnBy(defaultColumnPrefs, "number", -1)).toBe(defaultColumnPrefs);
expect(moveColumnBy(defaultColumnPrefs, "time", 1)).toBe(defaultColumnPrefs);
// Where App has nothing to show, Number is next to Method
expect(
shownColumns(moveColumnBy(defaultColumnPrefs, "method", -1, ["app"]), ["app"]).map(
(c) => c.id,
)[0],
).toBe("method");
});
test("a hidden column keeps its place and comes back to it", () => {
let prefs = moveColumn(defaultColumnPrefs, "time", 0);
prefs = toggleColumn(prefs, "status");
prefs = moveColumn(prefs, "host", 0);
expect(ids(prefs)).not.toContain("status");
prefs = toggleColumn(prefs, "status");
expect(ids(prefs)).toEqual([
"host",
"time",
"number",
"app",
"method",
"path",
"query",
"status",
"type",
"size",
]);
});
test("never reordered, a built-in column turned on takes its default place", () => {
// Request Size's default place is before Size
const prefs = toggleColumn(defaultColumnPrefs, "requestSize");
expect(prefs.order).toEqual([]);
expect(ids(prefs).slice(-4)).toEqual(["type", "requestSize", "size", "time"]);
// Among the built-in ones, so a custom column stays at the end
const custom = addCustomColumn(defaultColumnPrefs, "header.x-cache");
expect(ids(custom).at(-1)).toBe("field:header.x-cache");
const both = toggleColumn(custom, "requestSize");
expect(both.order).toEqual([]);
expect(ids(both).slice(-4)).toEqual(["requestSize", "size", "time", "field:header.x-cache"]);
});
test("once reordered, a column turned on goes at the end, unless it had a place", () => {
let prefs = moveColumn(defaultColumnPrefs, "time", 0);
prefs = toggleColumn(prefs, "requestSize");
expect(ids(prefs).at(-1)).toBe("requestSize");
// Moved, hidden and shown again, it goes back where it was moved to
const before = ids(prefs).indexOf("size");
prefs = moveColumn(prefs, "requestSize", before);
prefs = toggleColumn(toggleColumn(prefs, "requestSize"), "requestSize");
expect(ids(prefs).slice(-3)).toEqual(["type", "requestSize", "size"]);
// A custom column added goes at the end too
expect(ids(addCustomColumn(prefs, "query.page")).at(-1)).toBe("field:query.page");
});
test("a custom column is added at the end and leaves the order when removed", () => {
const moved = moveColumn(defaultColumnPrefs, "size", 0);
let prefs = addCustomColumn(moved, "header.x-cache");
expect(ids(prefs).at(-1)).toBe("field:header.x-cache");
prefs = moveColumn(prefs, "field:header.x-cache", 1);
expect(ids(prefs).slice(0, 2)).toEqual(["size", "field:header.x-cache"]);
// Hidden and added again, it keeps its place
prefs = addCustomColumn(toggleColumn(prefs, "field:header.x-cache"), "header.x-cache");
expect(ids(prefs)[1]).toBe("field:header.x-cache");
prefs = removeCustomColumn(prefs, "field:header.x-cache");
expect(prefs.order).not.toContain("field:header.x-cache");
expect(ids(prefs)).toEqual(ids(moved));
// Added again, it starts over at the end
expect(ids(addCustomColumn(prefs, "header.x-cache")).at(-1)).toBe("field:header.x-cache");
});
test("the order persists, and columns it doesn't list follow it in their default order", () => {
let prefs = addCustomColumn(defaultColumnPrefs, "query.page");
prefs = moveColumn(prefs, "field:query.page", 0);
expect(parseColumnPrefs(JSON.parse(JSON.stringify(prefs))).order).toEqual(prefs.order);
const parsed = parseColumnPrefs({
custom: ["field:query.page"],
order: ["time", "gone", 3, "time", "field:header.x-gone", "field:query.page", "host"],
});
expect(parsed.order).toEqual(["time", "field:query.page", "host"]);
expect(ids(parsed)).toEqual([
"time",
"field:query.page",
"host",
"number",
"app",
"method",
"path",
"query",
"status",
"type",
"size",
]);
expect(parseColumnPrefs({ order: "time" }).order).toEqual([]);
});
test("a drag's target is the boundary nearest the pointer, null in its own slot", () => {
// Four columns 100px wide from x 24, after the mark column; the third is dragged
const cells = [24, 124, 224, 324].map((left) => ({ left, right: left + 100 }));
const at = (x: number) => dropBoundary(x, cells, 2);
// Over the mark column or the first's left half: the first boundary
expect(at(5)).toBe(0);
expect(at(60)).toBe(0);
expect(at(80)).toBe(1);
// The left neighbour's near half, its own column, the right neighbour's near half: staying
expect(at(190)).toBeNull();
expect(at(250)).toBeNull();
expect(at(290)).toBeNull();
expect(at(350)).toBeNull();
// Past the right neighbour's middle, and past the end
expect(at(380)).toBe(4);
expect(at(900)).toBe(4);
// The last column dragged to the end stays
expect(dropBoundary(900, cells, 3)).toBeNull();
});
test("the columns a drag passes slide by its width to make room", () => {
// Five columns; the second, 80px wide, is dragged
expect(columnShifts(5, 1, 80, null)).toEqual([0, 0, 0, 0, 0]);
// Right, to before the fifth: the third and fourth slide left
expect(columnShifts(5, 1, 80, 4)).toEqual([0, 0, -80, -80, 0]);
// Right, to the end: all after it slide left
expect(columnShifts(5, 1, 80, 5)).toEqual([0, 0, -80, -80, -80]);
// Left, to the start: the first slides right
expect(columnShifts(5, 1, 80, 0)).toEqual([80, 0, 0, 0, 0]);
// The fourth, dragged left to before the second: the second and third slide right
expect(columnShifts(5, 3, 60, 1)).toEqual([0, 60, 60, 0, 0]);
});
test("Reset Column Order puts every column back", () => {
let prefs = moveColumn(defaultColumnPrefs, "time", 0);
prefs = toggleColumn(prefs, "latency");
prefs = resetOrder(prefs);
expect(prefs.order).toEqual([]);
expect(ids(prefs)).toEqual([...defaults.slice(0, -1), "time", "latency"]);
});
});
describe("sorting", () => {
const one = (column: FlowSortKey["column"], descending: boolean) => [{ column, descending }];
+140 -11
View File
@@ -145,26 +145,45 @@ export const CELL_PADDING = "0.75rem";
/**
* What the person chose: which columns show, the widths they dragged, by id, the header and
* query parameter columns they added, in order, and the list's sort
* query parameter columns they added, in order, the order they dragged the columns into, and the
* list's sort
*/
export interface ColumnPrefs {
visible: Partial<Record<ColumnId, boolean>>;
widths: Partial<Record<ColumnId, number>>;
custom: CustomColumnId[];
/**
* The columns' order, by id. Columns not listed follow the listed ones, in their default order;
* empty for the default order.
*/
order: ColumnId[];
/** The keys in order; empty for the order flows arrive in */
sort: FlowSortKey[];
}
export const defaultColumnPrefs: ColumnPrefs = { visible: {}, widths: {}, custom: [], sort: [] };
export const defaultColumnPrefs: ColumnPrefs = {
visible: {},
widths: {},
custom: [],
order: [],
sort: [],
};
export interface Column extends ColumnDef {
/** Its width now, in pixels of content */
current: number;
}
/** Every column the picker lists: the built-in ones, then those the person added */
/**
* Every column, in the order the person left them: those they placed, then the rest in their
* default order, the built-in ones before those added. The picker lists them so.
*/
export function allColumns(prefs: ColumnPrefs): ColumnDef[] {
return [...COLUMNS, ...prefs.custom.map(customDef)];
const defaults = [...COLUMNS, ...prefs.custom.map(customDef)];
if (prefs.order.length === 0) return defaults;
const byId = new Map(defaults.map((c) => [c.id, c]));
const placed = prefs.order.flatMap((id) => byId.get(id) ?? []);
return [...placed, ...defaults.filter((c) => !prefs.order.includes(c.id))];
}
function isVisible(prefs: ColumnPrefs, def: ColumnDef): boolean {
@@ -217,7 +236,104 @@ export function toggleColumn(
): ColumnPrefs {
const shown = isVisible(prefs, columnDef(id));
if (shown && !canHide(prefs, id, unavailable)) return prefs;
return { ...prefs, visible: { ...prefs.visible, [id]: !shown } };
const next = { ...prefs, visible: { ...prefs.visible, [id]: !shown } };
return shown ? next : placeAtEnd(next, id);
}
/**
* Puts a column just shown after the others shown, unless the person placed it before, so it
* goes back to that place. The columns shown before it are placed too, where they are, so the
* ones not listed don't jump ahead of it. Until the person has ordered the columns, each keeps
* its default place: a built-in one among the others, an added one at the end.
*/
function placeAtEnd(prefs: ColumnPrefs, id: ColumnId): ColumnPrefs {
if (prefs.order.length === 0 || prefs.order.includes(id)) return prefs;
const all = allColumns(prefs);
if (all.filter((c) => isVisible(prefs, c)).at(-1)?.id === id) return prefs;
const placed = all.filter(
(c) => c.id !== id && (prefs.order.includes(c.id) || isVisible(prefs, c)),
);
return { ...prefs, order: [...placed.map((c) => c.id), id] };
}
/**
* Moves a shown column to the boundary `to` between the columns shown: 0 before the first, their
* count after the last. Hidden columns keep their places among the others.
*/
export function moveColumn(
prefs: ColumnPrefs,
id: ColumnId,
to: number,
unavailable: ColumnId[] = [],
): ColumnPrefs {
const shown = shownColumns(prefs, unavailable).map((c) => c.id);
const from = shown.indexOf(id);
if (from === -1 || to < 0 || to > shown.length || to === from || to === from + 1) return prefs;
// Every column shown, and those hidden that were placed, so they keep their places
const order = allColumns(prefs)
.filter((c) => c.id !== id && (prefs.order.includes(c.id) || shown.includes(c.id)))
.map((c) => c.id);
// Before the column at the boundary, or after the last one shown
const before = shown[to];
const last = shown.at(-1);
const at = before != null ? order.indexOf(before) : last == null ? -1 : order.indexOf(last) + 1;
if (at === -1) return prefs;
order.splice(at, 0, id);
return { ...prefs, order };
}
/** One place left, or with `1`, right, among the columns shown */
export function moveColumnBy(
prefs: ColumnPrefs,
id: ColumnId,
by: -1 | 1,
unavailable: ColumnId[] = [],
): ColumnPrefs {
const from = shownColumns(prefs, unavailable).findIndex((c) => c.id === id);
if (from === -1) return prefs;
return moveColumn(prefs, id, by === -1 ? from - 1 : from + 2, unavailable);
}
/** Every column back to its default place */
export function resetOrder(prefs: ColumnPrefs): ColumnPrefs {
return { ...prefs, order: [] };
}
/**
* Where a dragged column would drop, from the pointer and the shown columns' edges, left to
* right, as they are laid out before the drag: the boundary before the first column whose middle
* is right of the pointer. Over its own column or the near half of a neighbour, that is an edge
* of its own slot, where it would stay, so null. Left of the first column, over the fixed mark
* column, it is the first boundary.
*/
export function dropBoundary(
x: number,
cells: { left: number; right: number }[],
from: number,
): number | null {
let at = cells.findIndex((r) => x < (r.left + r.right) / 2);
if (at === -1) at = cells.length;
const stays = from === -1 || at === from || at === from + 1;
return stays ? null : at;
}
/**
* How far, in pixels, each of `count` shown columns slides while the one at `from`, `width`
* wide, is dragged to the boundary `to`: those it would pass make room for it, leftwards when it
* goes right and rightwards when it goes left. The dragged column itself, and the rest, stay.
*/
export function columnShifts(
count: number,
from: number,
width: number,
to: number | null,
): number[] {
return Array.from({ length: count }, (_, i) => {
if (to == null || i === from) return 0;
if (to > from && i > from && i < to) return -width;
if (to < from && i >= to && i < from) return width;
return 0;
});
}
/** Shows `id` alone */
@@ -227,20 +343,24 @@ export function hideOtherColumns(prefs: ColumnPrefs, id: ColumnId): ColumnPrefs
return { ...prefs, visible };
}
/** Adds a column for a header or query parameter's path, shown at the end; already there, shown */
/**
* Adds a column for a header or query parameter's path, shown at the end; already there, shown,
* in its place
*/
export function addCustomColumn(prefs: ColumnPrefs, path: string): ColumnPrefs {
const id = customColumnId(path);
const custom = prefs.custom.includes(id) ? prefs.custom : [...prefs.custom, id];
return { ...prefs, custom, visible: { ...prefs.visible, [id]: true } };
return placeAtEnd({ ...prefs, custom, visible: { ...prefs.visible, [id]: true } }, id);
}
/** Takes the column out, and out of the sort */
/** Takes the column out, and out of the order and the sort */
export function removeCustomColumn(prefs: ColumnPrefs, id: CustomColumnId): ColumnPrefs {
const { [id]: _v, ...visible } = prefs.visible;
const { [id]: _w, ...widths } = prefs.widths;
const custom = prefs.custom.filter((c) => c !== id);
const order = prefs.order.filter((c) => c !== id);
const sort = prefs.sort.filter((k) => k.column !== id);
return { ...prefs, custom, visible, widths, sort };
return { ...prefs, custom, visible, widths, order, sort };
}
export function resizeColumn(prefs: ColumnPrefs, id: ColumnId, width: number): ColumnPrefs {
@@ -270,9 +390,9 @@ export function showOperationFor(prefs: ColumnPrefs, flows: Flow[]): ColumnPrefs
* wrong type, so a change to the columns never breaks the list.
*/
export function parseColumnPrefs(value: unknown): ColumnPrefs {
const prefs: ColumnPrefs = { visible: {}, widths: {}, custom: [], sort: [] };
const prefs: ColumnPrefs = { visible: {}, widths: {}, custom: [], order: [], sort: [] };
if (value == null || typeof value !== "object") return prefs;
const { visible, widths, custom, sort } = value as Record<string, unknown>;
const { visible, widths, custom, order, sort } = value as Record<string, unknown>;
if (Array.isArray(custom)) {
for (const id of custom) {
if (typeof id !== "string" || !id.startsWith("field:")) continue;
@@ -288,6 +408,15 @@ export function parseColumnPrefs(value: unknown): ColumnPrefs {
const w = (widths as Record<string, unknown> | undefined)?.[def.id];
if (typeof w === "number" && Number.isFinite(w) && w > 0) prefs.widths[def.id] = w;
}
// Columns no longer there are dropped; those not listed follow the rest in their default order
if (Array.isArray(order)) {
for (const column of order) {
if (typeof column !== "string") continue;
const id = column as ColumnId;
const known = isCustomColumn(id) ? prefs.custom.includes(id) : DEFS.has(id);
if (known && !prefs.order.includes(id)) prefs.order.push(id);
}
}
if (Array.isArray(sort)) {
for (const key of sort) {
if (key == null || typeof key !== "object") continue;
+1
View File
@@ -9,6 +9,7 @@
"lint": "tsc --noEmit"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@tanstack/react-query": "^5.105.0",
"@tanstack/react-virtual": "^3.14.24",
"@tauri-apps/api": "^2.12.4",
+1
View File
@@ -246,6 +246,7 @@
"name": "@yaakapp/yaak-proxy",
"version": "1.0.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@tanstack/react-query": "^5.105.0",
"@tanstack/react-virtual": "^3.14.24",
"@tauri-apps/api": "^2.12.4",
+46
View File
@@ -0,0 +1,46 @@
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;
/**
* 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;
}
/**
* 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 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. 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 }: Props) {
return (
<div
className={classNames(
"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
"*:h-sm *:rounded-md",
"[&>: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);
}}
/>
);
}
@@ -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
+43
View File
@@ -0,0 +1,43 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, test } from "vite-plus/test";
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "./Table";
import { CsvViewerInner } from "./viewers/CsvViewer";
function table(density?: "default" | "compact") {
return renderToStaticMarkup(
<Table density={density}>
<TableHead>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
<TableRow>
<TableCell>Accept</TableCell>
</TableRow>
</TableBody>
</Table>,
);
}
describe("Table density", () => {
test("a compact table trims its cells' and header's padding", () => {
const markup = table("compact");
expect(markup).toContain('data-density="compact"');
expect(markup).toContain("[&amp;_td]:py-0.5");
expect(markup).toContain("[&amp;_th]:py-0.5");
});
test("other tables keep their spacing", () => {
const markup = table();
expect(markup).toContain('data-density="default"');
expect(markup).not.toContain("py-0.5");
expect(markup).toContain("py-2");
});
test("the CSV viewer's table is compact", () => {
expect(renderToStaticMarkup(<CsvViewerInner text={"a,b\n1,2"} />)).toContain(
'data-density="compact"',
);
});
});
+6
View File
@@ -6,11 +6,14 @@ export function Table({
children,
className,
scrollable,
density = "default",
style,
}: {
children: ReactNode;
className?: string;
scrollable?: boolean;
/** Compact rows, as dense as the proxy's flow list, for tables of data rather than settings */
density?: "default" | "compact";
style?: React.CSSProperties;
}) {
return (
@@ -21,7 +24,10 @@ export function Table({
"w-full text-sm mb-auto min-w-full max-w-full",
"border-separate border-spacing-0",
scrollable && "[&_thead]:sticky [&_thead]:top-0 [&_thead]:z-10",
// Overrides the cells' own padding, which outranks theirs as it names the table too
density === "compact" && "[&_td]:py-0.5 [&_th]:py-0.5",
)}
data-density={density}
>
{children}
</table>
@@ -0,0 +1,94 @@
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");
});
test("holds every action to one height and radius, centred on the row", () => {
const markup = renderToStaticMarkup(
<ActionsBar>
<IconButton icon="filter" title="Filter" />
</ActionsBar>,
);
expect(markup).toContain("items-center");
expect(markup).toContain("*:h-sm");
expect(markup).toContain("*:rounded-md");
});
test("the switch is sized to the bar's small icon buttons", () => {
const markup = renderToStaticMarkup(
<BodyViewModeSwitch pretty="formatted" value="pretty" onChange={() => {}} />,
);
expect(markup).toContain("h-sm!");
expect(markup).toContain("[&amp;_[role=radio]]:h-full!");
});
});
@@ -0,0 +1,82 @@
import classNames from "classnames";
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 h-full"
// As tall as the bar's `sm` icon buttons, its options filling the height inside the border
className={classNames(
"h-sm! m-0! p-0.5! gap-0.5! rounded-md! bg-surface! border border-border-subtle",
"[&_[role=radio]]:h-full!",
)}
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>
@@ -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}`}
/>
@@ -109,8 +109,6 @@ export interface HttpRequestPaneViewProps {
/** Replaces the URL bar, as the proxy's card shows a captured request's method and URL */
urlLine?: ReactNode;
/** Tabs with nothing in them read in the subtlest colour */
dimEmptyTabs?: boolean;
/**
* The tab to open on for a request with no tab of its own; with it, a tab chosen for one
* request doesn't carry to the next
@@ -168,7 +166,6 @@ export function HttpRequestPaneView({
authContent,
extraTabs,
urlLine,
dimEmptyTabs = false,
defaultTab,
activeTab,
onTabChange,
@@ -193,20 +190,17 @@ export function HttpRequestPaneView({
bodyTab ?? {
value: REQUEST_TAB_BODY,
label: bodyTypeLabel(request.bodyType, request.contentType),
empty: dimEmptyTabs && request.bodyType == null,
rightSlot: numParams > 0 ? <CountBadge count={numParams} /> : null,
},
{
value: REQUEST_TAB_PARAMS,
rightSlot: <CountBadge count={paramPairs.length} />,
label: "Params",
empty: dimEmptyTabs && paramPairs.length === 0,
},
...(headersTabs ?? [
{
value: REQUEST_TAB_HEADERS,
label: "Headers",
empty: dimEmptyTabs && headerCount === 0,
rightSlot: <CountBadge count={headerCount} />,
},
]),
@@ -224,7 +218,6 @@ export function HttpRequestPaneView({
[
authTabs,
bodyTab,
dimEmptyTabs,
extraTabs,
headerCount,
headersTabs,
@@ -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 */
@@ -76,8 +80,6 @@ export interface HttpResponsePaneViewProps {
requestShownApart?: boolean;
/** Drawn inside a card the app draws, without a card of its own */
bare?: boolean;
/** Tabs with nothing in them read in the subtlest colour */
dimEmptyTabs?: boolean;
/**
* The tab to open on for a key with no tab of its own; with it, a tab chosen under one key
* doesn't carry to the next
@@ -97,7 +99,7 @@ export function HttpResponsePaneView({
style,
className,
emptyState,
viewMode,
viewMode = "pretty",
onViewModeChange,
tabsStorageKey = "http_response_tabs",
activeTabKey,
@@ -115,7 +117,6 @@ export function HttpResponsePaneView({
renderCopyButton,
requestShownApart = false,
bare = false,
dimEmptyTabs = false,
defaultTab,
activeTab,
onTabChange,
@@ -162,27 +163,24 @@ export function HttpResponsePaneView({
: []),
];
const noBody = response == null || (response.contentLength ?? 0) === 0;
const headerCount = response?.headers.length ?? 0;
const cookieCount = requestShownApart
? 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" }
: {
value: RESPONSE_TAB_BODY,
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
? []
: [
@@ -196,7 +194,6 @@ export function HttpResponsePaneView({
{
value: RESPONSE_TAB_HEADERS,
label: "Headers",
empty: dimEmptyTabs && headerCount === 0,
rightSlot: requestShownApart ? (
<CountBadge count={headerCount} />
) : (
@@ -206,7 +203,6 @@ export function HttpResponsePaneView({
{
value: RESPONSE_TAB_COOKIES,
label: "Cookies",
empty: dimEmptyTabs && cookieCount === 0,
rightSlot: requestShownApart ? (
<CountBadge count={cookieCounts.received} />
) : cookieCounts.sent > 0 || cookieCounts.received > 0 ? (
@@ -217,7 +213,6 @@ export function HttpResponsePaneView({
];
}, [
cookieCounts,
dimEmptyTabs,
extraTabs,
mimeType,
onCopyAsCurl,
@@ -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>
+1 -14
View File
@@ -41,8 +41,6 @@ export type TabItem =
value: string;
label: string;
hidden?: boolean;
/** Nothing in it: the label reads in the subtlest colour */
empty?: boolean;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
}
@@ -59,8 +57,6 @@ export type TabItem =
export interface TabOptionsItem {
value: string;
options: Omit<RadioDropdownProps, "children">;
/** Nothing in it: the label reads in the subtlest colour */
empty?: boolean;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
}
@@ -526,15 +522,7 @@ function TabButton({
) : "options" in tab ? (
<TabOptionsButton tab={tab} buttonProps={btnProps} isActive={isActive} />
) : (
<Button
leftSlot={tab.leftSlot}
rightSlot={tab.rightSlot}
{...btnProps}
className={classNames(
btnProps.className,
!isActive && "empty" in tab && tab.empty && "text-text-subtlest!",
)}
>
<Button leftSlot={tab.leftSlot} rightSlot={tab.rightSlot} {...btnProps}>
{"label" in tab && tab.label ? tab.label : tab.value}
</Button>
);
@@ -607,7 +595,6 @@ function TabOptionsButton({
</div>
}
{...buttonProps}
className={classNames(buttonProps.className, !isActive && tab.empty && "text-text-subtlest!")}
>
{option && "shortLabel" in option && option.shortLabel
? option.shortLabel
@@ -31,7 +31,7 @@ export function CsvViewerInner({ text, className }: { text: string | null; class
return (
<div className="overflow-auto h-full">
<Table className={classNames(className, "text-sm")}>
<Table density="compact" className={classNames(className, "text-sm")}>
<TableHead>
<TableRow>
{columnIndexes.map((columnIndex) => (
@@ -275,7 +275,7 @@ function LeafBody({
}
if (mediaType?.match(/csv|tab-separated/i)) {
return <CsvViewer text={content} className="bg-primary h-10 w-10" />;
return <CsvViewer text={content} className="pb-2" />;
}
if (mediaType?.match(/^text\/html/i) || (!binary && detectedLanguage === "html")) {
@@ -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";
@@ -61,7 +62,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";
@@ -126,6 +133,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";
@@ -148,6 +157,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";