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, Workspace,
} from "@yaakapp-internal/models"; } from "@yaakapp-internal/models";
import { patchModel } 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 { useCallback } from "react";
import { openFolderSettings } from "../commands/openFolderSettings"; import { openFolderSettings } from "../commands/openFolderSettings";
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings"; import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
@@ -21,7 +21,6 @@ import { IconButton } from "./core/IconButton";
import { Input, type InputProps } from "./core/Input"; import { Input, type InputProps } from "./core/Input";
import { Link } from "./core/Link"; import { Link } from "./core/Link";
import { RadioDropdown } from "./core/RadioDropdown"; import { RadioDropdown } from "./core/RadioDropdown";
import { SegmentedControl } from "./core/SegmentedControl";
import { DynamicForm } from "./DynamicForm"; import { DynamicForm } from "./DynamicForm";
import { EmptyStateText } from "./EmptyStateText"; import { EmptyStateText } from "./EmptyStateText";
@@ -5,7 +5,15 @@ import {
type ImportSource, type ImportSource,
type Workspace, type Workspace,
} from "@yaakapp-internal/models"; } 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 classNames from "classnames";
import { type ComponentProps, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { type ComponentProps, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { errorMessage } from "../lib/errorMessage"; import { errorMessage } from "../lib/errorMessage";
@@ -22,7 +30,6 @@ import { CheckboxTree } from "./core/CheckboxTree";
import { Chip } from "./core/Chip"; import { Chip } from "./core/Chip";
import { DialogFooter } from "./core/Dialog"; import { DialogFooter } from "./core/Dialog";
import { Select } from "./core/Select"; import { Select } from "./core/Select";
import { SegmentedControl } from "./core/SegmentedControl";
import { pluralize } from "@yaakapp-internal/lib/pluralize"; import { pluralize } from "@yaakapp-internal/lib/pluralize";
interface Props { interface Props {
@@ -1,8 +1,8 @@
import { SegmentedControl } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import type { EditorProps } from "./core/Editor/Editor"; import type { EditorProps } from "./core/Editor/Editor";
import { Editor } from "./core/Editor/LazyEditor"; import { Editor } from "./core/Editor/LazyEditor";
import { SegmentedControl } from "./core/SegmentedControl";
import { Markdown } from "./Markdown"; import { Markdown } from "./Markdown";
type ViewMode = "edit" | "preview"; type ViewMode = "edit" | "preview";
@@ -1,7 +1,7 @@
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { platform } from "@yaakapp-internal/platform"; import { platform } from "@yaakapp-internal/platform";
import type { AgentSetupStatus, AppMetaData } from "@yaakapp-internal/rpc-schema"; 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 { useEffect } from "react";
import { useKeyValue } from "../../hooks/useKeyValue"; import { useKeyValue } from "../../hooks/useKeyValue";
import { usePluginInfos } from "../../hooks/usePluginInfo"; import { usePluginInfos } from "../../hooks/usePluginInfo";
@@ -14,7 +14,6 @@ import {
import { appInfo } from "../../lib/appInfo"; import { appInfo } from "../../lib/appInfo";
import { rpc } from "../../lib/rpc"; import { rpc } from "../../lib/rpc";
import { Link } from "../core/Link"; import { Link } from "../core/Link";
import { SegmentedControl } from "../core/SegmentedControl";
import { import {
SettingCommand, SettingCommand,
SettingSnippet, 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 { formatSize } from "@yaakapp-internal/lib/formatSize";
import type { Flow, FlowBody } from "@yaakapp-internal/proxy-lib"; import type { Flow, FlowBody } from "@yaakapp-internal/proxy-lib";
import { import {
ACTIONS_BAR_CONTAINER,
ActionsBar,
Banner, Banner,
BodyViewModeSwitch,
Button, Button,
CsvViewer, CsvViewer,
HexdumpViewer, HexdumpViewer,
Icon, Icon,
ImageViewer, ImageViewer,
LARGE_BODY_BYTES,
languageFromContentType, languageFromContentType,
LoadingIcon, LoadingIcon,
MultipartViewer, MultipartViewer,
type PrettyView,
prettyViewFor,
type ResponseViewMode,
SvgViewer, SvgViewer,
TextViewer, TextViewer,
WebPageViewer, WebPageViewer,
} from "@yaakapp-internal/ui"; } from "@yaakapp-internal/ui";
import type { CodeEditorProps } from "@yaakapp-internal/ui/editor"; import type { CodeEditorProps } from "@yaakapp-internal/ui/editor";
import classNames from "classnames";
import { useAtom } from "jotai";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { bodyViewModeAtom } from "../lib/atoms";
import { import {
bodyAsText, bodyAsText,
bodyImageType, bodyImageType,
@@ -33,15 +43,97 @@ import { useSaveFlowBody } from "../lib/flowActions";
interface Props { interface Props {
flow: Flow; flow: Flow;
which: FlowBody; 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) { export function useFlowBody(flow: Flow, which: FlowBody, enabled = true) {
return useQuery({ ...flowBodyQueryOptions(flow, which), enabled }); 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 body = useFlowBody(flow, which);
const [mode, setMode] = useBodyViewMode(
`${flow.id}.${which}`,
(body.data?.bytes.length ?? 0) > LARGE_BODY_BYTES,
);
if (body.isPending) { if (body.isPending) {
return ( return (
@@ -69,12 +161,28 @@ export function FlowBodyViewer({ flow, which, pretty }: Props) {
const imageType = decodeError == null ? bodyImageType(bytes, truncated, contentType) : null; const imageType = decodeError == null ? bodyImageType(bytes, truncated, contentType) : null;
const text = const text =
decodeError == null && imageType == null ? bodyAsText(bytes, truncated, contentType) : null; 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 viewer: ReactNode;
let ownsBar = false;
if (decodeError != null) { if (decodeError != null) {
// What failed to decode is as it was on the wire, compressed, so never text. // What failed to decode is as it was on the wire, compressed, so never text.
viewer = <BinaryBody flow={flow} which={which} bytes={bytes} />; 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"; const boundary = contentType?.match(/;\s*boundary="?([^";]+)"?/i)?.[1] ?? "unknown";
viewer = ( viewer = (
<MultipartViewer <MultipartViewer
@@ -88,22 +196,43 @@ export function FlowBodyViewer({ flow, which, pretty }: Props) {
viewer = <ImageBody bytes={bytes} mime={imageType} />; viewer = <ImageBody bytes={bytes} mime={imageType} />;
} else if (text == null) { } else if (text == null) {
viewer = <BinaryBody flow={flow} which={which} bytes={bytes} />; viewer = <BinaryBody flow={flow} which={which} bytes={bytes} />;
} else if (mime.startsWith("image/svg")) { } else if (prettyView === "form" && pretty && form != null) {
viewer = <SvgViewer text={text} />; ownsBar = true;
} else if (/csv|tab-separated/.test(mime) && pretty) { viewer = form(modeSwitch);
} else if (prettyView === "table" && pretty) {
viewer = <CsvViewer text={text} className="pb-2" />; 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 { } else {
const language = languageFromContentType(contentType, text); ownsBar = true;
if (language === "html" && pretty && which === "response") { viewer = (
viewer = <WebPageViewer html={text} baseUrl={flow.url} />; <FlowTextViewer
} else { flow={flow}
viewer = ( which={which}
<FlowTextViewer flow={flow} which={which} text={text} language={language} pretty={pretty} /> 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 ( return (
<div className="h-full grid grid-rows-[auto_auto_minmax(0,1fr)]"> <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. {decodeError}. Showing the body as it was on the wire.
</Banner> </Banner>
)} )}
{truncated && ( {truncated && !hideCutNote && (
<Banner color="notice" className="text-sm mb-2"> <Banner color="notice" className="text-sm mb-2">
{wireTruncated || decodeError != null {wireTruncated || decodeError != null
? `Showing the first ${formatSize(storedSize)} of ${formatSize(wireSize)}, the capture limit. The app received all of it.` ? `Showing the first ${formatSize(storedSize)} of ${formatSize(wireSize)}, the capture limit. The app received all of it.`
@@ -168,7 +297,16 @@ function FlowTextViewer({
text, text,
language, language,
pretty, 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( const runFilter = useCallback(
(expression: string) => filterText(flow.id, which, expression, language), (expression: string) => filterText(flow.id, which, expression, language),
[flow.id, which, language], [flow.id, which, language],
@@ -183,6 +321,8 @@ function FlowTextViewer({
className="-mr-2 bg-surface" className="-mr-2 bg-surface"
formatText={formatText} formatText={formatText}
filterText={runFilter} filterText={runFilter}
ownActionsBar
pinnedActions={modeSwitch}
/> />
); );
} }
+32 -22
View File
@@ -6,6 +6,7 @@ import {
Dropdown, Dropdown,
EmptyStateText, EmptyStateText,
type ExtraPaneTab, type ExtraPaneTab,
FormUrlencodedEditor,
type HttpBodyResult, type HttpBodyResult,
HttpRequestPaneView, HttpRequestPaneView,
HttpResponsePaneView, HttpResponsePaneView,
@@ -13,14 +14,13 @@ import {
IconButton, IconButton,
parseCookieHeaders, parseCookieHeaders,
ResponseCookies, ResponseCookies,
type ResponseViewMode,
SplitLayout, SplitLayout,
Tooltip, Tooltip,
} from "@yaakapp-internal/ui"; } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import { useAtomValue, useSetAtom, useStore } from "jotai"; import { useAtomValue, useSetAtom, useStore } from "jotai";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo } from "react";
import { BODY_TYPE_BINARY } from "@yaakapp-internal/ui/httpTypes"; import { BODY_TYPE_FORM_URLENCODED } from "@yaakapp-internal/ui/httpTypes";
import { placeTabsAtom } from "../lib/atoms"; import { placeTabsAtom } from "../lib/atoms";
import { findHeader } from "../lib/body"; import { findHeader } from "../lib/body";
import { import {
@@ -143,7 +143,6 @@ function FlowRequestHalf({ flow }: { flow: Flow }) {
tab: { tab: {
value: REQUEST_TAB_COOKIES, value: REQUEST_TAB_COOKIES,
label: "Cookies", label: "Cookies",
empty: sentCount === 0,
rightSlot: <CountBadge count={sentCount} />, rightSlot: <CountBadge count={sentCount} />,
}, },
content: <ResponseCookies response={cookies} side="sent" />, content: <ResponseCookies response={cookies} side="sent" />,
@@ -160,13 +159,16 @@ function FlowRequestHalf({ flow }: { flow: Flow }) {
]; ];
const { activeTab, defaultTab, onTabChange } = useShapeTab("request", flow, available); const { activeTab, defaultTab, onTabChange } = useShapeTab("request", flow, available);
const forceUpdateKey = `${flow.updatedAt}.${body.data == null ? "" : "loaded"}`;
return ( return (
<HttpRequestPaneView <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} request={request}
fullHeight fullHeight
dimEmptyTabs forceUpdateKey={forceUpdateKey}
forceUpdateKey={`${flow.updatedAt}.${body.data == null ? "" : "loaded"}`}
tabsStorageKey="proxy_request_tabs" tabsStorageKey="proxy_request_tabs"
bodyLoader={loader} bodyLoader={loader}
extraTabs={extraTabs} extraTabs={extraTabs}
@@ -174,15 +176,30 @@ function FlowRequestHalf({ flow }: { flow: Flow }) {
activeTab={activeTab} activeTab={activeTab}
onTabChange={onTabChange} onTabChange={onTabChange}
urlLine={<UrlLine flow={flow} />} urlLine={<UrlLine flow={flow} />}
renderBody={(editor) => ( renderBody={() => (
<FlowRequestBody <FlowRequestBody
body={body} body={body}
editor={editor} // The response's viewer choice, with the form's fields as a URL-encoded body's
// The proxy's own viewer, with its hexdump and save, rather than a size alone // formatted view, the switch in one bar with their bulk-view toggle
binaryViewer={ viewer={
request.bodyType === BODY_TYPE_BINARY ? ( <FlowBodyViewer
<FlowBodyViewer flow={flow} which="request" pretty /> flow={flow}
) : null 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} requestSize={flow.requestSize}
/> />
@@ -210,7 +227,6 @@ function UrlLine({ flow }: { flow: Flow }) {
} }
function FlowResponseHalf({ flow, className }: { flow: Flow; className: string }) { 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 // 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 // message would only hold copies of it
const loadBody = flow.streamKind == null; const loadBody = flow.streamKind == null;
@@ -242,7 +258,6 @@ function FlowResponseHalf({ flow, className }: { flow: Flow; className: string }
tab: { tab: {
value: RESPONSE_TAB_MESSAGES, value: RESPONSE_TAB_MESSAGES,
label: messagesLabel, label: messagesLabel,
empty: flow.messageCount === 0,
rightSlot: ( rightSlot: (
<CountBadge <CountBadge
count={flow.messageCount} count={flow.messageCount}
@@ -273,23 +288,18 @@ function FlowResponseHalf({ flow, className }: { flow: Flow; className: string }
<HttpResponsePaneView <HttpResponsePaneView
bare bare
requestShownApart requestShownApart
dimEmptyTabs
className={classNames(className, "border-border-subtle")} className={classNames(className, "border-border-subtle")}
response={response} response={response}
body={loader} body={loader}
requestBody={null} requestBody={null}
emptyState={<EmptyStateText>Waiting for a response</EmptyStateText>} emptyState={<EmptyStateText>Waiting for a response</EmptyStateText>}
viewMode={viewMode}
onViewModeChange={setViewMode}
tabsStorageKey="proxy_response_tabs" tabsStorageKey="proxy_response_tabs"
activeTabKey={flow.id} activeTabKey={flow.id}
defaultTab={defaultTab} defaultTab={defaultTab}
activeTab={activeTab} activeTab={activeTab}
onTabChange={onTabChange} onTabChange={onTabChange}
extraTabs={extraTabs} extraTabs={extraTabs}
renderBodyViewer={() => ( renderBodyViewer={() => <FlowBodyViewer flow={flow} which="response" />}
<FlowBodyViewer flow={flow} which="response" pretty={viewMode === "pretty"} />
)}
statusLineCenter={ statusLineCenter={
flow.responseTruncated ? ( flow.responseTruncated ? (
<span <span
+247 -3
View File
@@ -14,7 +14,14 @@ import {
} from "@yaakapp-internal/ui"; } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai"; 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 { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { import {
appliedFilterAtom, appliedFilterAtom,
@@ -28,6 +35,7 @@ import {
type Column, type Column,
type ColumnId, type ColumnId,
cellFilterTerm, cellFilterTerm,
columnShifts,
customValue, customValue,
gridTemplate, gridTemplate,
gridWidth, gridWidth,
@@ -65,7 +73,7 @@ import {
import { useShowToast } from "../lib/toast"; import { useShowToast } from "../lib/toast";
import { AppIcon } from "./AppIcon"; import { AppIcon } from "./AppIcon";
import { FlowListEmpty, PausedRow } from "./FlowListEmpty"; import { FlowListEmpty, PausedRow } from "./FlowListEmpty";
import { FlowListHeader } from "./FlowListHeader"; import { type ColumnDragEvent, FlowListHeader } from "./FlowListHeader";
const BOTTOM_PADDING = 6; const BOTTOM_PADDING = 6;
/** Rows from the top at which older flows start loading, or from the bottom, sorted, more */ /** 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; 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. // A list too short to scroll still loads older flows on scrolling up.
const handleWheel = useCallback( const handleWheel = useCallback(
(e: WheelEvent<HTMLDivElement>) => { (e: WheelEvent<HTMLDivElement>) => {
@@ -439,10 +452,11 @@ export function FlowList({ style, className }: Props) {
return ( return (
<div <div
ref={rootRef}
style={style} style={style}
className={classNames( className={classNames(
className, 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 */} {/* 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} onSort={handleSort}
onSetSort={handleSetSort} onSetSort={handleSetSort}
onResetSort={follow} onResetSort={follow}
scrollElement={getScrollElement}
onColumnDrag={onColumnDrag}
/> />
</div> </div>
<div className="relative min-h-0"> <div className="relative min-h-0">
<div <div
ref={scrollRef} ref={scrollRef}
data-flow-rows
role="listbox" role="listbox"
aria-label="Flows" aria-label="Flows"
tabIndex={0} tabIndex={0}
@@ -576,6 +593,233 @@ function useArrivals(flows: Flow[], following: boolean): Map<string, number> {
}, [flows, following]); }, [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 { interface RowProps {
flow: Flow; flow: Flow;
index: number; 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 type { FlowSortKey } from "@yaakapp-internal/proxy-lib";
import { import {
ContextMenu, ContextMenu,
@@ -9,7 +17,16 @@ import {
} from "@yaakapp-internal/ui"; } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import { useAtomValue, useSetAtom } from "jotai"; 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 { import {
addSortKey, addSortKey,
allColumns, allColumns,
@@ -19,9 +36,13 @@ import {
type ColumnPrefs, type ColumnPrefs,
type CustomColumnId, type CustomColumnId,
canHide, canHide,
dropBoundary,
hideOtherColumns, hideOtherColumns,
isCustomColumn, isCustomColumn,
moveColumn,
moveColumnBy,
removeCustomColumn, removeCustomColumn,
resetOrder,
resetWidth, resetWidth,
resetWidths, resetWidths,
resizeColumn, resizeColumn,
@@ -31,6 +52,13 @@ import {
import { columnPrefsAtom, hasAppsAtom } from "../lib/store"; import { columnPrefsAtom, hasAppsAtom } from "../lib/store";
import { addColumnDialogAtom } from "./AddColumnDialog"; 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 { interface Props {
columns: Column[]; columns: Column[];
template: string; template: string;
@@ -43,13 +71,29 @@ interface Props {
/** Sets the sort from the menus; empty goes back to arrival order */ /** Sets the sort from the menus; empty goes back to arrival order */
onSetSort: (sort: FlowSortKey[]) => void; onSetSort: (sort: FlowSortKey[]) => void;
onResetSort: () => 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 * 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. * 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 * Dragging a label moves its column, and Escape puts it back. Dragging the edge after a label
* right-click on a label opens the column's menu; the picker sits in the empty corner on hover. * 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({ export const FlowListHeader = memo(function FlowListHeader({
columns, columns,
@@ -60,6 +104,8 @@ export const FlowListHeader = memo(function FlowListHeader({
onSort, onSort,
onSetSort: setSort, onSetSort: setSort,
onResetSort, onResetSort,
scrollElement,
onColumnDrag,
}: Props) { }: Props) {
const setPrefs = useSetAtom(columnPrefsAtom); const setPrefs = useSetAtom(columnPrefsAtom);
const unavailable = unavailableColumns(useAtomValue(hasAppsAtom)); 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); 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. // Widths when a drag began, so each move sets the width from where it started.
const startWidth = useRef(0); 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 // Taking out the sort's only key goes back to arrival order, as Reset Sort does
const removeColumn = (id: CustomColumnId) => { const removeColumn = (id: CustomColumnId) => {
@@ -110,11 +162,17 @@ export const FlowListHeader = memo(function FlowListHeader({
{ type: "separator" }, { type: "separator" },
{ label: "Reset Sort", disabled: sort.length === 0, onSelect: onResetSort }, { label: "Reset Sort", disabled: sort.length === 0, onSelect: onResetSort },
{ label: "Reset Column Widths", onSelect: () => setPrefs((p) => resetWidths(p)) }, { 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 columnItems = (c: ColumnDef): DropdownItem[] => {
const sortBy = (descending: boolean) => setSort([{ column: c.id, descending }]); const sortBy = (descending: boolean) => setSort([{ column: c.id, descending }]);
const addTo = (descending: boolean) => setSort(addSortKey(sort, c.id, descending)); const addTo = (descending: boolean) => setSort(addSortKey(sort, c.id, descending));
const at = columns.findIndex((col) => col.id === c.id);
return [ return [
{ label: "Sort Ascending", onSelect: () => sortBy(false) }, { label: "Sort Ascending", onSelect: () => sortBy(false) },
{ label: "Sort Descending", onSelect: () => sortBy(true) }, { 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: "Add to Sort Descending", onSelect: () => addTo(true) },
{ label: "Clear Sorting", disabled: sort.length === 0, onSelect: onResetSort }, { label: "Clear Sorting", disabled: sort.length === 0, onSelect: onResetSort },
{ type: "separator" }, { 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", label: "Hide Column",
disabled: !canHide(prefs, c.id, unavailable), 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"> <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 */} {/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- a right-click opens the column's menu */}
<div <div
ref={gridRef}
data-flow-header
onPointerDownCapture={onPointerDownCapture}
onContextMenu={openMenu} onContextMenu={openMenu}
style={{ gridTemplateColumns: template }} style={{ gridTemplateColumns: template }}
className={classNames( className={classNames(
@@ -182,60 +254,21 @@ export const FlowListHeader = memo(function FlowListHeader({
/> />
</Dropdown> </Dropdown>
</span> </span>
{columns.map((c) => { <DndContext sensors={sensors} autoScroll={false} {...dndHandlers}>
const at = sort.findIndex((k) => k.column === c.id); {columns.map((c) => (
const key = sort[at]; <HeaderCell
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
key={c.id} key={c.id}
data-column={c.id} column={c}
className="relative h-full flex items-center min-w-0 px-1.5" sort={sort}
> onSort={onSort}
<button onResizeStart={() => {
type="button" startWidth.current = c.current;
onClick={(e) => onSort(c.id, e.altKey)} }}
// No tooltip: the header sits under the search field, and a tooltip here covered it onResizeMove={(dx) => setPrefs((p) => resizeColumn(p, c.id, startWidth.current + dx))}
aria-label={`Sort by ${c.label}`} onResetWidth={() => setPrefs((p) => resetWidth(p, c.id))}
className={classNames(labelClass, "hover:text-text-subtle")} />
> ))}
{label} </DndContext>
</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>
);
})}
<ContextMenu <ContextMenu
triggerPosition={menu} triggerPosition={menu}
items={menu == null ? [] : menu.column == null ? pickerItems : columnItems(menu.column)} items={menu == null ? [] : menu.column == null ? pickerItems : columnItems(menu.column)}
@@ -245,3 +278,264 @@ export const FlowListHeader = memo(function FlowListHeader({
</div> </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, storedSize: 7,
}); });
function render( function render(body: Parameters<typeof FlowRequestBody>[0]["body"]) {
body: Parameters<typeof FlowRequestBody>[0]["body"],
binaryViewer: string | null = null,
) {
return renderToStaticMarkup( return renderToStaticMarkup(
<FlowRequestBody <FlowRequestBody body={body} viewer={<div>EDITOR</div>} requestSize={2048} />,
body={body}
editor={<div>EDITOR</div>}
binaryViewer={binaryViewer == null ? null : <div>{binaryViewer}</div>}
requestSize={2048}
/>,
); );
} }
@@ -49,9 +41,8 @@ describe("FlowRequestBody", () => {
expect(html).toContain("LOADING"); 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 })).toContain("EDITOR");
expect(render({ data: data(), isLoading: false, error: null }, "HEXDUMP")).toContain("HEXDUMP");
}); });
test("a cut body says how much was kept", () => { test("a cut body says how much was kept", () => {
@@ -13,19 +13,17 @@ import type { FlowBodyData } from "../lib/flowViews";
interface Props { interface Props {
/** The request body as the engine's body RPC returned it */ /** The request body as the engine's body RPC returned it */
body: HttpBodyResult<FlowBodyData | null>; body: HttpBodyResult<FlowBodyData | null>;
/** The shared editor for the body's type */ /** The body in the viewer for its type, formatted or raw */
editor: ReactNode; viewer: ReactNode;
/** The proxy's own viewer, for a body that isn't text; null for one that is */
binaryViewer: ReactNode | null;
/** Bytes of the request body on the wire */ /** Bytes of the request body on the wire */
requestSize: number; 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. * 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) { if (body.error != null) {
return ( return (
<Banner color="danger" className="text-sm"> <Banner color="danger" className="text-sm">
@@ -40,7 +38,6 @@ export function FlowRequestBody({ body, editor, binaryViewer, requestSize }: Pro
</EmptyStateText> </EmptyStateText>
); );
} }
if (binaryViewer != null) return binaryViewer;
if (body.data?.truncated) { if (body.data?.truncated) {
return ( return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]"> <div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
@@ -51,9 +48,9 @@ export function FlowRequestBody({ body, editor, binaryViewer, requestSize }: Pro
</span> </span>
</Tooltip> </Tooltip>
</Banner> </Banner>
<div className="min-h-0">{editor}</div> <div className="min-h-0">{viewer}</div>
</div> </div>
); );
} }
return editor; return viewer;
} }
+81
View File
@@ -75,6 +75,38 @@ const graphqlRequest = JSON.stringify({
operationName: "GetViewer", operationName: "GetViewer",
}); });
const formRequest = "grant_type=client_credentials&client_id=yaak-proxy&scope=read+write"; 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 = const htmlResponse =
"<!doctype html><html><body style='font-family:sans-serif'><h1>Signed in</h1><p>You can close this window.</p></body></html>"; "<!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 // A 24x24 PNG gradient
@@ -198,6 +230,55 @@ export const fixtures: FlowFixture[] = [
requestBody: body(graphqlRequest), requestBody: body(graphqlRequest),
responseBody: body('{"data":{"viewer":{"login":"yaak"}}}'), 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", label: "gRPC call",
flow: { flow: {
+9 -1
View File
@@ -1,5 +1,5 @@
import type { Flow } from "@yaakapp-internal/proxy-lib"; 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 { atom } from "jotai";
import { atomWithStorage } from "jotai/utils"; import { atomWithStorage } from "jotai/utils";
import { currentPlace, EMPTY_PLACE, type PlaceStack, stackOf, updatePlace } from "./places"; 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 */ /** Stacked by default; the header's toggle switches to side by side */
export const flowLayoutAtom = atomWithStorage<SplitLayoutLayout>("flow_layout", "vertical"); 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 { import {
addCustomColumn, addCustomColumn,
addSortKey, addSortKey,
allColumns,
cellFilterTerm, cellFilterTerm,
columnShifts,
type ColumnPrefs, type ColumnPrefs,
customPath, customPath,
customPathProblem, customPathProblem,
customValue, customValue,
defaultColumnPrefs, defaultColumnPrefs,
dropBoundary,
fieldReference, fieldReference,
fieldReferenceChanges, fieldReferenceChanges,
gridTemplate, gridTemplate,
gridWidth, gridWidth,
hideOtherColumns, hideOtherColumns,
moveColumn,
moveColumnBy,
nextSort, nextSort,
operationOf, operationOf,
parseColumnPrefs, parseColumnPrefs,
removeCustomColumn, removeCustomColumn,
resetOrder,
resetWidth, resetWidth,
resetWidths, resetWidths,
resizeColumn, resizeColumn,
@@ -121,6 +127,7 @@ describe("column preferences", () => {
visible: { number: false, "field:header.x-cache": true }, visible: { number: false, "field:header.x-cache": true },
widths: { host: 60 }, widths: { host: 60 },
custom: ["field:header.x-cache"], custom: ["field:header.x-cache"],
order: [],
sort: [{ column: "status", descending: true }], sort: [{ column: "status", descending: true }],
}); });
expect( expect(
@@ -138,6 +145,7 @@ describe("column preferences", () => {
visible: {}, visible: {},
widths: { size: 90 }, widths: { size: 90 },
custom: ["field:query.page"], custom: ["field:query.page"],
order: [],
sort: [{ column: "time", descending: false }], sort: [{ column: "time", descending: false }],
}); });
expect(parseColumnPrefs(null)).toEqual(defaultColumnPrefs); 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", () => { describe("sorting", () => {
const one = (column: FlowSortKey["column"], descending: boolean) => [{ column, descending }]; 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 * 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 { export interface ColumnPrefs {
visible: Partial<Record<ColumnId, boolean>>; visible: Partial<Record<ColumnId, boolean>>;
widths: Partial<Record<ColumnId, number>>; widths: Partial<Record<ColumnId, number>>;
custom: CustomColumnId[]; 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 */ /** The keys in order; empty for the order flows arrive in */
sort: FlowSortKey[]; sort: FlowSortKey[];
} }
export const defaultColumnPrefs: ColumnPrefs = { visible: {}, widths: {}, custom: [], sort: [] }; export const defaultColumnPrefs: ColumnPrefs = {
visible: {},
widths: {},
custom: [],
order: [],
sort: [],
};
export interface Column extends ColumnDef { export interface Column extends ColumnDef {
/** Its width now, in pixels of content */ /** Its width now, in pixels of content */
current: number; 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[] { 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 { function isVisible(prefs: ColumnPrefs, def: ColumnDef): boolean {
@@ -217,7 +236,104 @@ export function toggleColumn(
): ColumnPrefs { ): ColumnPrefs {
const shown = isVisible(prefs, columnDef(id)); const shown = isVisible(prefs, columnDef(id));
if (shown && !canHide(prefs, id, unavailable)) return prefs; 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 */ /** Shows `id` alone */
@@ -227,20 +343,24 @@ export function hideOtherColumns(prefs: ColumnPrefs, id: ColumnId): ColumnPrefs
return { ...prefs, visible }; 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 { export function addCustomColumn(prefs: ColumnPrefs, path: string): ColumnPrefs {
const id = customColumnId(path); const id = customColumnId(path);
const custom = prefs.custom.includes(id) ? prefs.custom : [...prefs.custom, id]; 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 { export function removeCustomColumn(prefs: ColumnPrefs, id: CustomColumnId): ColumnPrefs {
const { [id]: _v, ...visible } = prefs.visible; const { [id]: _v, ...visible } = prefs.visible;
const { [id]: _w, ...widths } = prefs.widths; const { [id]: _w, ...widths } = prefs.widths;
const custom = prefs.custom.filter((c) => c !== id); 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); 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 { 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. * wrong type, so a change to the columns never breaks the list.
*/ */
export function parseColumnPrefs(value: unknown): ColumnPrefs { 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; 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)) { if (Array.isArray(custom)) {
for (const id of custom) { for (const id of custom) {
if (typeof id !== "string" || !id.startsWith("field:")) continue; 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]; const w = (widths as Record<string, unknown> | undefined)?.[def.id];
if (typeof w === "number" && Number.isFinite(w) && w > 0) prefs.widths[def.id] = w; 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)) { if (Array.isArray(sort)) {
for (const key of sort) { for (const key of sort) {
if (key == null || typeof key !== "object") continue; if (key == null || typeof key !== "object") continue;
+1
View File
@@ -9,6 +9,7 @@
"lint": "tsc --noEmit" "lint": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@tanstack/react-query": "^5.105.0", "@tanstack/react-query": "^5.105.0",
"@tanstack/react-virtual": "^3.14.24", "@tanstack/react-virtual": "^3.14.24",
"@tauri-apps/api": "^2.12.4", "@tauri-apps/api": "^2.12.4",
+1
View File
@@ -246,6 +246,7 @@
"name": "@yaakapp/yaak-proxy", "name": "@yaakapp/yaak-proxy",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@tanstack/react-query": "^5.105.0", "@tanstack/react-query": "^5.105.0",
"@tanstack/react-virtual": "^3.14.24", "@tanstack/react-virtual": "^3.14.24",
"@tauri-apps/api": "^2.12.4", "@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 { generateId } from "@yaakapp-internal/lib/generateId";
import { HStack, useStateWithDeps, IconTooltip, Label } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import { type ReactNode, useRef } from "react"; 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 { 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> { export interface SegmentedControlOption<T extends string> {
options: { value: T; label: string; icon?: IconProps["icon"] }[]; 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; onChange: (value: T) => void;
value: T; value: T;
name: string; name: string;
size?: IconButtonProps["size"]; size?: IconButtonProps["size"];
label: string; label: string;
className?: string; className?: string;
/** Replaces the full width the control takes by default */
containerClassName?: string;
hideLabel?: boolean; hideLabel?: boolean;
labelClassName?: string; labelClassName?: string;
help?: ReactNode; help?: ReactNode;
@@ -29,7 +43,8 @@ export function SegmentedControl<T extends string>({
labelClassName, labelClassName,
help, help,
className, className,
}: Props<T>) { containerClassName = "w-full",
}: SegmentedControlProps<T>) {
const [selectedValue, setSelectedValue] = useStateWithDeps<T>(value, [value]); const [selectedValue, setSelectedValue] = useStateWithDeps<T>(value, [value]);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const id = useRef(`input-${generateId()}`); const id = useRef(`input-${generateId()}`);
@@ -38,8 +53,13 @@ export function SegmentedControl<T extends string>({
const inlineHelp = const inlineHelp =
hideLabel && help ? <IconTooltip tabIndex={-1} content={help} iconSize="xs" /> : null; 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 ( return (
<div className="w-full grid"> <div className={classNames(containerClassName, "grid")}>
<Label <Label
htmlFor={id.current} htmlFor={id.current}
help={hideLabel ? undefined : help} help={hideLabel ? undefined : help}
@@ -67,16 +87,14 @@ export function SegmentedControl<T extends string>({
if (options[newIndex]) { if (options[newIndex]) {
setSelectedValue(options[newIndex].value); setSelectedValue(options[newIndex].value);
} }
const child = containerRef.current?.children[newIndex] as HTMLButtonElement; focusOption(newIndex);
child.focus();
} else if (e.key === "ArrowLeft") { } else if (e.key === "ArrowLeft") {
e.preventDefault(); e.preventDefault();
const newIndex = Math.abs((selectedIndex - 1) % options.length); const newIndex = Math.abs((selectedIndex - 1) % options.length);
if (options[newIndex]) { if (options[newIndex]) {
setSelectedValue(options[newIndex].value); setSelectedValue(options[newIndex].value);
} }
const child = containerRef.current?.children[newIndex] as HTMLButtonElement; focusOption(newIndex);
child.focus();
} }
}} }}
> >
@@ -85,7 +103,7 @@ export function SegmentedControl<T extends string>({
const isActive = value === o.value; const isActive = value === o.value;
const rightSlot = i === options.length - 1 ? inlineHelp : null; const rightSlot = i === options.length - 1 ? inlineHelp : null;
if (o.icon == null) { if (o.icon == null) {
return ( const button = (
<Button <Button
key={o.label} key={o.label}
aria-checked={isActive} aria-checked={isActive}
@@ -104,6 +122,13 @@ export function SegmentedControl<T extends string>({
{o.label} {o.label}
</Button> </Button>
); );
return o.tooltip == null ? (
button
) : (
<Tooltip key={o.label} wrap content={o.tooltip} delay={CONTROL_TOOLTIP_DELAY}>
{button}
</Tooltip>
);
} else { } else {
return ( return (
<IconButton <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, children,
className, className,
scrollable, scrollable,
density = "default",
style, style,
}: { }: {
children: ReactNode; children: ReactNode;
className?: string; className?: string;
scrollable?: boolean; 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; style?: React.CSSProperties;
}) { }) {
return ( return (
@@ -21,7 +24,10 @@ export function Table({
"w-full text-sm mb-auto min-w-full max-w-full", "w-full text-sm mb-auto min-w-full max-w-full",
"border-separate border-spacing-0", "border-separate border-spacing-0",
scrollable && "[&_thead]:sticky [&_thead]:top-0 [&_thead]:z-10", 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} {children}
</table> </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; 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 */ /** Holds back a body big enough to slow the window down, until asked to show it */
export function ConfirmLargeResponse({ export function ConfirmLargeResponse({
@@ -40,14 +41,14 @@ export function ConfirmLargeResponse({
const [showLargeResponse, toggleShowLargeResponse] = useToggle(); const [showLargeResponse, toggleShowLargeResponse] = useToggle();
const isProbablyText = useMemo(() => isProbablyTextContentType(contentType), [contentType]); const isProbablyText = useMemo(() => isProbablyTextContentType(contentType), [contentType]);
const isLarge = (contentLength ?? 0) > LARGE_BYTES; const isLarge = (contentLength ?? 0) > LARGE_BODY_BYTES;
if (!showLargeResponse && isLarge) { if (!showLargeResponse && isLarge) {
return ( return (
<Banner color="primary" className="flex flex-col gap-3"> <Banner color="primary" className="flex flex-col gap-3">
<p> <p>
Showing {noun} over{" "} Showing {noun} over{" "}
<InlineCode> <InlineCode>
<SizeTag contentLength={LARGE_BYTES} /> <SizeTag contentLength={LARGE_BODY_BYTES} />
</InlineCode>{" "} </InlineCode>{" "}
may impact performance may impact performance
</p> </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 type { Pair, PairEditorProps } from "../pairs/PairEditor";
import { PairOrBulkEditor } from "../pairs/PairOrBulkEditor"; import { PairOrBulkEditor } from "../pairs/PairOrBulkEditor";
import type { HttpFormFieldView, HttpRequestBodyView } from "./types"; import type { HttpFormFieldView, HttpRequestBodyView } from "./types";
@@ -11,6 +11,8 @@ type Props = {
onChange?: (body: HttpRequestBodyView) => void; onChange?: (body: HttpRequestBodyView) => void;
/** Show the fields without a way to change them */ /** Show the fields without a way to change them */
readOnly?: boolean; readOnly?: boolean;
/** Drawn in one bar with the bulk-edit toggle, like a view-mode switch */
actions?: ReactNode;
}; };
export function FormUrlencodedEditor({ export function FormUrlencodedEditor({
@@ -19,6 +21,7 @@ export function FormUrlencodedEditor({
forceUpdateKey, forceUpdateKey,
onChange, onChange,
readOnly, readOnly,
actions,
}: Props) { }: Props) {
const pairs = useMemo<Pair[]>( const pairs = useMemo<Pair[]>(
() => () =>
@@ -52,6 +55,7 @@ export function FormUrlencodedEditor({
pairs={pairs} pairs={pairs}
onChange={handleChange} onChange={handleChange}
readOnly={readOnly} readOnly={readOnly}
actions={actions}
forceUpdateKey={forceUpdateKey} forceUpdateKey={forceUpdateKey}
stateKey={`urlencoded.${requestId}`} 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 */ /** Replaces the URL bar, as the proxy's card shows a captured request's method and URL */
urlLine?: ReactNode; 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 * 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 * request doesn't carry to the next
@@ -168,7 +166,6 @@ export function HttpRequestPaneView({
authContent, authContent,
extraTabs, extraTabs,
urlLine, urlLine,
dimEmptyTabs = false,
defaultTab, defaultTab,
activeTab, activeTab,
onTabChange, onTabChange,
@@ -193,20 +190,17 @@ export function HttpRequestPaneView({
bodyTab ?? { bodyTab ?? {
value: REQUEST_TAB_BODY, value: REQUEST_TAB_BODY,
label: bodyTypeLabel(request.bodyType, request.contentType), label: bodyTypeLabel(request.bodyType, request.contentType),
empty: dimEmptyTabs && request.bodyType == null,
rightSlot: numParams > 0 ? <CountBadge count={numParams} /> : null, rightSlot: numParams > 0 ? <CountBadge count={numParams} /> : null,
}, },
{ {
value: REQUEST_TAB_PARAMS, value: REQUEST_TAB_PARAMS,
rightSlot: <CountBadge count={paramPairs.length} />, rightSlot: <CountBadge count={paramPairs.length} />,
label: "Params", label: "Params",
empty: dimEmptyTabs && paramPairs.length === 0,
}, },
...(headersTabs ?? [ ...(headersTabs ?? [
{ {
value: REQUEST_TAB_HEADERS, value: REQUEST_TAB_HEADERS,
label: "Headers", label: "Headers",
empty: dimEmptyTabs && headerCount === 0,
rightSlot: <CountBadge count={headerCount} />, rightSlot: <CountBadge count={headerCount} />,
}, },
]), ]),
@@ -224,7 +218,6 @@ export function HttpRequestPaneView({
[ [
authTabs, authTabs,
bodyTab, bodyTab,
dimEmptyTabs,
extraTabs, extraTabs,
headerCount, headerCount,
headersTabs, headersTabs,
@@ -43,8 +43,12 @@ export interface HttpResponsePaneViewProps {
className?: string; className?: string;
/** Shown before there's a response */ /** Shown before there's a response */
emptyState: ReactNode; emptyState: ReactNode;
viewMode: ResponseViewMode; viewMode?: ResponseViewMode;
onViewModeChange: (viewMode: ResponseViewMode) => void; /**
* 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 */ /** Where the tab order and the active tab are remembered */
tabsStorageKey?: string; tabsStorageKey?: string;
/** Remembers the active tab per request or flow */ /** Remembers the active tab per request or flow */
@@ -76,8 +80,6 @@ export interface HttpResponsePaneViewProps {
requestShownApart?: boolean; requestShownApart?: boolean;
/** Drawn inside a card the app draws, without a card of its own */ /** Drawn inside a card the app draws, without a card of its own */
bare?: boolean; 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 * 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 * doesn't carry to the next
@@ -97,7 +99,7 @@ export function HttpResponsePaneView({
style, style,
className, className,
emptyState, emptyState,
viewMode, viewMode = "pretty",
onViewModeChange, onViewModeChange,
tabsStorageKey = "http_response_tabs", tabsStorageKey = "http_response_tabs",
activeTabKey, activeTabKey,
@@ -115,7 +117,6 @@ export function HttpResponsePaneView({
renderCopyButton, renderCopyButton,
requestShownApart = false, requestShownApart = false,
bare = false, bare = false,
dimEmptyTabs = false,
defaultTab, defaultTab,
activeTab, activeTab,
onTabChange, onTabChange,
@@ -162,27 +163,24 @@ export function HttpResponsePaneView({
: []), : []),
]; ];
const noBody = response == null || (response.contentLength ?? 0) === 0;
const headerCount = response?.headers.length ?? 0; const headerCount = response?.headers.length ?? 0;
const cookieCount = requestShownApart
? cookieCounts.received
: cookieCounts.sent + cookieCounts.received;
return [ return [
{ onViewModeChange == null && itemsAfter.length === 0
value: RESPONSE_TAB_BODY, ? { value: RESPONSE_TAB_BODY, label: "Response" }
empty: dimEmptyTabs && noBody, : {
options: { value: RESPONSE_TAB_BODY,
value: viewMode, options: {
onChange: (v) => onViewModeChange(v === "raw" ? "raw" : "pretty"), value: viewMode,
items: [ onChange: (v: string | null) => onViewModeChange?.(v === "raw" ? "raw" : "pretty"),
{ label: "Response", value: "pretty" }, items: [
...(mimeType?.startsWith("image") { label: "Response", value: "pretty" },
? [] ...(onViewModeChange == null || mimeType?.startsWith("image")
: [{ label: "Response (Raw)", shortLabel: "Raw", value: "raw" }]), ? []
], : [{ label: "Response (Raw)", shortLabel: "Raw", value: "raw" }]),
itemsAfter: itemsAfter.length > 0 ? itemsAfter : undefined, ],
}, itemsAfter: itemsAfter.length > 0 ? itemsAfter : undefined,
}, },
},
...(requestShownApart ...(requestShownApart
? [] ? []
: [ : [
@@ -196,7 +194,6 @@ export function HttpResponsePaneView({
{ {
value: RESPONSE_TAB_HEADERS, value: RESPONSE_TAB_HEADERS,
label: "Headers", label: "Headers",
empty: dimEmptyTabs && headerCount === 0,
rightSlot: requestShownApart ? ( rightSlot: requestShownApart ? (
<CountBadge count={headerCount} /> <CountBadge count={headerCount} />
) : ( ) : (
@@ -206,7 +203,6 @@ export function HttpResponsePaneView({
{ {
value: RESPONSE_TAB_COOKIES, value: RESPONSE_TAB_COOKIES,
label: "Cookies", label: "Cookies",
empty: dimEmptyTabs && cookieCount === 0,
rightSlot: requestShownApart ? ( rightSlot: requestShownApart ? (
<CountBadge count={cookieCounts.received} /> <CountBadge count={cookieCounts.received} />
) : cookieCounts.sent > 0 || cookieCounts.received > 0 ? ( ) : cookieCounts.sent > 0 || cookieCounts.received > 0 ? (
@@ -217,7 +213,6 @@ export function HttpResponsePaneView({
]; ];
}, [ }, [
cookieCounts, cookieCounts,
dimEmptyTabs,
extraTabs, extraTabs,
mimeType, mimeType,
onCopyAsCurl, 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 classNames from "classnames";
import type { ReactNode } from "react";
import { ACTIONS_BAR_CONTAINER, ActionsBar } from "../ActionsBar";
import { useEditorHost } from "../EditorHost"; import { useEditorHost } from "../EditorHost";
import { IconButton } from "../IconButton"; import { IconButton } from "../IconButton";
import { BulkPairEditor } from "./BulkPairEditor"; import { BulkPairEditor } from "./BulkPairEditor";
@@ -8,9 +10,14 @@ import { PairEditor } from "./PairEditor";
interface Props extends PairEditorProps { interface Props extends PairEditorProps {
preferenceName: string; preferenceName: string;
forcedEnvironmentId?: 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 { useStoredValue } = useEditorHost();
const [useBulk, setUseBulk] = useStoredValue<boolean>( const [useBulk, setUseBulk] = useStoredValue<boolean>(
["bulk_edit", preferenceName], ["bulk_edit", preferenceName],
@@ -18,20 +25,37 @@ export function PairOrBulkEditor({ preferenceName, ...props }: Props) {
"global", "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 ( return (
<div className="relative h-full w-full group/wrapper"> <div className="relative h-full w-full group/wrapper">
{useBulk ? <BulkPairEditor {...props} /> : <PairEditor {...props} />} {editor}
<div className="absolute right-0 bottom-0"> <div className="absolute right-0 bottom-0">
<IconButton <IconButton
size="sm" {...toggleProps}
variant="border"
title={useBulk ? "Enable form edit" : "Enable bulk edit"}
className={classNames( className={classNames(
"transition-opacity opacity-0 group-hover:opacity-80 hover:opacity-100! shadow", "transition-opacity opacity-0 group-hover:opacity-80 hover:opacity-100! shadow",
"bg-surface hover:text group-hover/wrapper:opacity-100", "bg-surface hover:text group-hover/wrapper:opacity-100",
)} )}
onClick={() => setUseBulk((b) => !b)}
icon={useBulk ? "table" : "file_code"}
/> />
</div> </div>
</div> </div>
+1 -14
View File
@@ -41,8 +41,6 @@ export type TabItem =
value: string; value: string;
label: string; label: string;
hidden?: boolean; hidden?: boolean;
/** Nothing in it: the label reads in the subtlest colour */
empty?: boolean;
leftSlot?: ReactNode; leftSlot?: ReactNode;
rightSlot?: ReactNode; rightSlot?: ReactNode;
} }
@@ -59,8 +57,6 @@ export type TabItem =
export interface TabOptionsItem { export interface TabOptionsItem {
value: string; value: string;
options: Omit<RadioDropdownProps, "children">; options: Omit<RadioDropdownProps, "children">;
/** Nothing in it: the label reads in the subtlest colour */
empty?: boolean;
leftSlot?: ReactNode; leftSlot?: ReactNode;
rightSlot?: ReactNode; rightSlot?: ReactNode;
} }
@@ -526,15 +522,7 @@ function TabButton({
) : "options" in tab ? ( ) : "options" in tab ? (
<TabOptionsButton tab={tab} buttonProps={btnProps} isActive={isActive} /> <TabOptionsButton tab={tab} buttonProps={btnProps} isActive={isActive} />
) : ( ) : (
<Button <Button leftSlot={tab.leftSlot} rightSlot={tab.rightSlot} {...btnProps}>
leftSlot={tab.leftSlot}
rightSlot={tab.rightSlot}
{...btnProps}
className={classNames(
btnProps.className,
!isActive && "empty" in tab && tab.empty && "text-text-subtlest!",
)}
>
{"label" in tab && tab.label ? tab.label : tab.value} {"label" in tab && tab.label ? tab.label : tab.value}
</Button> </Button>
); );
@@ -607,7 +595,6 @@ function TabOptionsButton({
</div> </div>
} }
{...buttonProps} {...buttonProps}
className={classNames(buttonProps.className, !isActive && tab.empty && "text-text-subtlest!")}
> >
{option && "shortLabel" in option && option.shortLabel {option && "shortLabel" in option && option.shortLabel
? option.shortLabel ? option.shortLabel
@@ -31,7 +31,7 @@ export function CsvViewerInner({ text, className }: { text: string | null; class
return ( return (
<div className="overflow-auto h-full"> <div className="overflow-auto h-full">
<Table className={classNames(className, "text-sm")}> <Table density="compact" className={classNames(className, "text-sm")}>
<TableHead> <TableHead>
<TableRow> <TableRow>
{columnIndexes.map((columnIndex) => ( {columnIndexes.map((columnIndex) => (
@@ -275,7 +275,7 @@ function LeafBody({
} }
if (mediaType?.match(/csv|tab-separated/i)) { 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")) { if (mediaType?.match(/^text\/html/i) || (!binary && detectedLanguage === "html")) {
@@ -1,9 +1,11 @@
import type { Extension } from "@codemirror/state"; import type { Extension } from "@codemirror/state";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import classNames from "classnames";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Children, useCallback, useMemo, useState } from "react"; import { Children, useCallback, useMemo, useState } from "react";
import { type TextFilterApi, useTextFilter } from "../../hooks/useTextFilter"; import { type TextFilterApi, useTextFilter } from "../../hooks/useTextFilter";
import { docFingerprint } from "../../lib/docFingerprint"; import { docFingerprint } from "../../lib/docFingerprint";
import { ACTIONS_BAR_CONTAINER, ActionsBar } from "../ActionsBar";
import type { CodeEditorProps } from "../editor/CodeEditor"; import type { CodeEditorProps } from "../editor/CodeEditor";
import { jsonBreadcrumbExtension } from "../editor/json/breadcrumbExtension"; import { jsonBreadcrumbExtension } from "../editor/json/breadcrumbExtension";
import type { JsonPathSegment } from "../editor/json/jsonPath"; import type { JsonPathSegment } from "../editor/json/jsonPath";
@@ -20,6 +22,13 @@ export interface TextViewerProps {
pretty?: boolean; pretty?: boolean;
className?: string; className?: string;
footerActions?: ReactNode; 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[]; extensions?: Extension[];
/** Pretty-prints the text when `pretty` is set */ /** Pretty-prints the text when `pretty` is set */
formatText?: (text: string, language: CodeEditorProps["language"]) => Promise<string>; formatText?: (text: string, language: CodeEditorProps["language"]) => Promise<string>;
@@ -42,6 +51,8 @@ export function TextViewer({
pretty, pretty,
className, className,
footerActions, footerActions,
pinnedActions,
ownActionsBar = false,
extensions, extensions,
formatText, formatText,
filterText, filterText,
@@ -121,8 +132,9 @@ export function TextViewer({
const actions = useMemo<ReactNode[]>(() => { const actions = useMemo<ReactNode[]>(() => {
const nodes: ReactNode[] = isSearching ? [] : Children.toArray(footerActions); const nodes: ReactNode[] = isSearching ? [] : Children.toArray(footerActions);
const pinned = Children.toArray(pinnedActions);
if (!canFilter) return nodes; if (!canFilter) return [...nodes, ...pinned];
if (isSearching) { if (isSearching) {
nodes.push( nodes.push(
@@ -173,10 +185,11 @@ export function TextViewer({
); );
} }
return nodes; return [...nodes, ...pinned];
}, [ }, [
canFilter, canFilter,
footerActions, footerActions,
pinnedActions,
filter, filter,
filterResult.isPending, filterResult.isPending,
filterResult.isFetching, filterResult.isFetching,
@@ -221,16 +234,19 @@ export function TextViewer({
} }
return ( return (
<div className="relative h-full w-full"> <div
className={classNames("h-full w-full", ownActionsBar ? ACTIONS_BAR_CONTAINER : "relative")}
>
{renderEditor({ {renderEditor({
readOnly: true, readOnly: true,
className, className,
defaultValue: body, defaultValue: body,
language, language,
actions, actions: ownActionsBar ? undefined : actions,
extensions: extraExtensions, extensions: extraExtensions,
stateKey, stateKey,
})} })}
{ownActionsBar && actions.length > 0 && <ActionsBar>{actions}</ActionsBar>}
{showBreadcrumbs && ( {showBreadcrumbs && (
<div className="absolute top-0 right-3 max-w-[70%] pointer-events-none"> <div className="absolute top-0 right-3 max-w-[70%] pointer-events-none">
<ResponseBreadcrumbBar <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 type { BannerProps } from "./components/Banner";
export { Banner } from "./components/Banner"; export { Banner } from "./components/Banner";
export type { ButtonProps } from "./components/Button"; export type { ButtonProps } from "./components/Button";
@@ -61,7 +62,13 @@ export type {
export { eventDeltas, formatEventDelta, formatEventTime } from "./components/events/streamEvents"; export { eventDeltas, formatEventDelta, formatEventTime } from "./components/events/streamEvents";
export type { ExternalLinkProps } from "./components/ExternalLink"; export type { ExternalLinkProps } from "./components/ExternalLink";
export { ExternalLink } 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 { FormMultipartEditor } from "./components/http/FormMultipartEditor";
export { FormUrlencodedEditor } from "./components/http/FormUrlencodedEditor"; export { FormUrlencodedEditor } from "./components/http/FormUrlencodedEditor";
export { HeadersEditor } from "./components/http/HeadersEditor"; export { HeadersEditor } from "./components/http/HeadersEditor";
@@ -126,6 +133,8 @@ export type { SelectProps } from "./components/Select";
export { Select } from "./components/Select"; export { Select } from "./components/Select";
export type { ErrorBoundaryProps } from "./components/ErrorBoundary"; export type { ErrorBoundaryProps } from "./components/ErrorBoundary";
export { ErrorBoundary } 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 { FormattedError } from "./components/FormattedError";
export { HeaderSize } from "./components/HeaderSize"; export { HeaderSize } from "./components/HeaderSize";
export type { HeadersTableProps } from "./components/HeadersTable"; export type { HeadersTableProps } from "./components/HeadersTable";
@@ -148,6 +157,8 @@ export { Overlay } from "./components/Overlay";
export type { PlainInputProps } from "./components/PlainInput"; export type { PlainInputProps } from "./components/PlainInput";
export { PlainInput } from "./components/PlainInput"; export { PlainInput } from "./components/PlainInput";
export { Portal } from "./components/Portal"; export { Portal } from "./components/Portal";
export type { SegmentedControlOption, SegmentedControlProps } from "./components/SegmentedControl";
export { SegmentedControl } from "./components/SegmentedControl";
export type { SeparatorAction } from "./components/Separator"; export type { SeparatorAction } from "./components/Separator";
export { Separator } from "./components/Separator"; export { Separator } from "./components/Separator";
export type { SettingRowBaseProps, SettingSnippetLanguage } from "./components/settings/SettingRow"; export type { SettingRowBaseProps, SettingSnippetLanguage } from "./components/settings/SettingRow";