From 7ece5fa5dfe4f1130d15a37df2adb1c7bbee2c57 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Tue, 10 Nov 2026 01:14:45 -0700 Subject: [PATCH] Add a shared actions bar and a Pretty/Raw switch for Yaak Proxy's bodies The actions row moves out of the code editor into ActionsBar, a plain component a wrapper places over its content. FormatAction reformats an editor through its ref, and BodyViewModeSwitch names its first state for the body's format (Pretty, Table, Parts, Preview, Form) next to Raw. Yaak Proxy's request and response body tabs now share one viewer choice, with the switch in each tab's bar beside the text viewer's filter. The choice is remembered for both panes; bodies over the large size open raw. "Response (Raw)" leaves the proxy's tab menu, while Yaak's response pane keeps it until the remaining sites move. SegmentedControl moves from the client into packages/ui. Co-Authored-By: Claude Opus 5.5 --- .../components/HttpAuthenticationEditor.tsx | 3 +- .../components/ImportDataDialog.tsx | 11 +- .../yaak-client/components/MarkdownEditor.tsx | 2 +- .../components/Settings/SettingsAgents.tsx | 3 +- .../components/FlowBodyViewer.test.tsx | 80 +++++++++ apps/yaak-proxy/components/FlowBodyViewer.tsx | 170 ++++++++++++++++-- apps/yaak-proxy/components/FlowCard.tsx | 27 ++- .../components/FlowRequestBody.test.tsx | 15 +- .../yaak-proxy/components/FlowRequestBody.tsx | 15 +- apps/yaak-proxy/harness/fixtures.ts | 56 ++++++ apps/yaak-proxy/lib/atoms.ts | 10 +- packages/ui/src/components/ActionsBar.tsx | 36 ++++ .../ui/src/components/FormatAction.test.tsx | 62 +++++++ packages/ui/src/components/FormatAction.tsx | 49 +++++ .../ui/src/components}/SegmentedControl.tsx | 51 ++++-- .../http/BodyViewModeSwitch.test.tsx | 75 ++++++++ .../components/http/BodyViewModeSwitch.tsx | 77 ++++++++ .../components/http/ConfirmLargeResponse.tsx | 7 +- .../components/http/HttpResponsePaneView.tsx | 42 +++-- .../ui/src/components/viewers/TextViewer.tsx | 24 ++- packages/ui/src/index.ts | 13 +- 21 files changed, 729 insertions(+), 99 deletions(-) create mode 100644 apps/yaak-proxy/components/FlowBodyViewer.test.tsx create mode 100644 packages/ui/src/components/ActionsBar.tsx create mode 100644 packages/ui/src/components/FormatAction.test.tsx create mode 100644 packages/ui/src/components/FormatAction.tsx rename {apps/yaak-client/components/core => packages/ui/src/components}/SegmentedControl.tsx (73%) create mode 100644 packages/ui/src/components/http/BodyViewModeSwitch.test.tsx create mode 100644 packages/ui/src/components/http/BodyViewModeSwitch.tsx diff --git a/apps/yaak-client/components/HttpAuthenticationEditor.tsx b/apps/yaak-client/components/HttpAuthenticationEditor.tsx index 0da989bcc..159ceb16b 100644 --- a/apps/yaak-client/components/HttpAuthenticationEditor.tsx +++ b/apps/yaak-client/components/HttpAuthenticationEditor.tsx @@ -6,7 +6,7 @@ import type { Workspace, } from "@yaakapp-internal/models"; import { patchModel } from "@yaakapp-internal/models"; -import { HStack, Icon, InlineCode } from "@yaakapp-internal/ui"; +import { HStack, Icon, InlineCode, SegmentedControl } from "@yaakapp-internal/ui"; import { useCallback } from "react"; import { openFolderSettings } from "../commands/openFolderSettings"; import { openWorkspaceSettings } from "../commands/openWorkspaceSettings"; @@ -21,7 +21,6 @@ import { IconButton } from "./core/IconButton"; import { Input, type InputProps } from "./core/Input"; import { Link } from "./core/Link"; import { RadioDropdown } from "./core/RadioDropdown"; -import { SegmentedControl } from "./core/SegmentedControl"; import { DynamicForm } from "./DynamicForm"; import { EmptyStateText } from "./EmptyStateText"; diff --git a/apps/yaak-client/components/ImportDataDialog.tsx b/apps/yaak-client/components/ImportDataDialog.tsx index 43e698e06..50d1f6e9e 100644 --- a/apps/yaak-client/components/ImportDataDialog.tsx +++ b/apps/yaak-client/components/ImportDataDialog.tsx @@ -5,7 +5,15 @@ import { type ImportSource, type Workspace, } from "@yaakapp-internal/models"; -import { Banner, FormattedError, HStack, Icon, VStack, IconTooltip } from "@yaakapp-internal/ui"; +import { + Banner, + FormattedError, + HStack, + Icon, + VStack, + IconTooltip, + SegmentedControl, +} from "@yaakapp-internal/ui"; import classNames from "classnames"; import { type ComponentProps, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { errorMessage } from "../lib/errorMessage"; @@ -22,7 +30,6 @@ import { CheckboxTree } from "./core/CheckboxTree"; import { Chip } from "./core/Chip"; import { DialogFooter } from "./core/Dialog"; import { Select } from "./core/Select"; -import { SegmentedControl } from "./core/SegmentedControl"; import { pluralize } from "@yaakapp-internal/lib/pluralize"; interface Props { diff --git a/apps/yaak-client/components/MarkdownEditor.tsx b/apps/yaak-client/components/MarkdownEditor.tsx index be31570f2..80f540c55 100644 --- a/apps/yaak-client/components/MarkdownEditor.tsx +++ b/apps/yaak-client/components/MarkdownEditor.tsx @@ -1,8 +1,8 @@ +import { SegmentedControl } from "@yaakapp-internal/ui"; import classNames from "classnames"; import { useRef, useState } from "react"; import type { EditorProps } from "./core/Editor/Editor"; import { Editor } from "./core/Editor/LazyEditor"; -import { SegmentedControl } from "./core/SegmentedControl"; import { Markdown } from "./Markdown"; type ViewMode = "edit" | "preview"; diff --git a/apps/yaak-client/components/Settings/SettingsAgents.tsx b/apps/yaak-client/components/Settings/SettingsAgents.tsx index 4ba04091c..bdc0f629f 100644 --- a/apps/yaak-client/components/Settings/SettingsAgents.tsx +++ b/apps/yaak-client/components/Settings/SettingsAgents.tsx @@ -1,7 +1,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { platform } from "@yaakapp-internal/platform"; import type { AgentSetupStatus, AppMetaData } from "@yaakapp-internal/rpc-schema"; -import { Banner, Heading, Icon, InlineCode, VStack } from "@yaakapp-internal/ui"; +import { Banner, Heading, Icon, InlineCode, SegmentedControl, VStack } from "@yaakapp-internal/ui"; import { useEffect } from "react"; import { useKeyValue } from "../../hooks/useKeyValue"; import { usePluginInfos } from "../../hooks/usePluginInfo"; @@ -14,7 +14,6 @@ import { import { appInfo } from "../../lib/appInfo"; import { rpc } from "../../lib/rpc"; import { Link } from "../core/Link"; -import { SegmentedControl } from "../core/SegmentedControl"; import { SettingCommand, SettingSnippet, diff --git a/apps/yaak-proxy/components/FlowBodyViewer.test.tsx b/apps/yaak-proxy/components/FlowBodyViewer.test.tsx new file mode 100644 index 000000000..f7b9f63da --- /dev/null +++ b/apps/yaak-proxy/components/FlowBodyViewer.test.tsx @@ -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"); + }); +}); diff --git a/apps/yaak-proxy/components/FlowBodyViewer.tsx b/apps/yaak-proxy/components/FlowBodyViewer.tsx index e63850af9..656eb3673 100644 --- a/apps/yaak-proxy/components/FlowBodyViewer.tsx +++ b/apps/yaak-proxy/components/FlowBodyViewer.tsx @@ -2,22 +2,32 @@ import { useQuery } from "@tanstack/react-query"; import { formatSize } from "@yaakapp-internal/lib/formatSize"; import type { Flow, FlowBody } from "@yaakapp-internal/proxy-lib"; import { + ACTIONS_BAR_CONTAINER, + ActionsBar, Banner, + BodyViewModeSwitch, Button, CsvViewer, HexdumpViewer, Icon, ImageViewer, + LARGE_BODY_BYTES, languageFromContentType, LoadingIcon, MultipartViewer, + type PrettyView, + prettyViewFor, + type ResponseViewMode, SvgViewer, TextViewer, WebPageViewer, } from "@yaakapp-internal/ui"; import type { CodeEditorProps } from "@yaakapp-internal/ui/editor"; +import classNames from "classnames"; +import { useAtom } from "jotai"; import type { ReactNode } from "react"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { bodyViewModeAtom } from "../lib/atoms"; import { bodyAsText, bodyImageType, @@ -33,15 +43,97 @@ import { useSaveFlowBody } from "../lib/flowActions"; interface Props { flow: Flow; which: FlowBody; - pretty: boolean; + /** A URL-encoded body's fields, shown as its formatted view */ + form?: ReactNode; + /** Leaves saying the body was cut short to the tab, which says it its own way */ + hideCutNote?: boolean; } export function useFlowBody(flow: Flow, which: FlowBody, enabled = true) { return useQuery({ ...flowBodyQueryOptions(flow, which), enabled }); } -export function FlowBodyViewer({ flow, which, pretty }: Props) { +/** A mode chosen for one large body, which holds for it alone */ +interface ChosenViewMode { + key: string; + mode: ResponseViewMode; +} + +/** The mode a body shows in: the preference, except raw for a large one until chosen for it */ +export function shownViewMode({ + key, + large, + preferred, + chosen, +}: { + key: string; + large: boolean; + preferred: ResponseViewMode; + chosen: ChosenViewMode | null; +}): ResponseViewMode { + if (chosen?.key === key) return chosen.mode; + return large ? "raw" : preferred; +} + +/** + * Formatted or raw, as last chosen, except that a body big enough to slow the card opens raw, + * and choosing for it holds for that body alone + */ +export function useBodyViewMode( + key: string, + large: boolean, +): [ResponseViewMode, (mode: ResponseViewMode) => void] { + const [preferred, setPreferred] = useAtom(bodyViewModeAtom); + const [chosen, setChosen] = useState(null); + const mode = shownViewMode({ key, large, preferred, chosen }); + const setMode = useCallback( + (next: ResponseViewMode) => { + if (large) { + setChosen({ key, mode: next }); + } else { + setChosen(null); + setPreferred(next); + } + }, + [key, large, setPreferred], + ); + return [mode, setMode]; +} + +/** + * The formatted view a body has, or null when it has only the one: parts for multipart, the + * fields for a URL-encoded form, or else what the shared rule gives its text. Images and binary + * bodies have only their one view. + */ +export function flowPrettyView({ + which, + mime, + text, + binary, + language, + hasForm, +}: { + which: FlowBody; + mime: string; + text: string | null; + /** An image, or what failed to decode */ + binary: boolean; + language: CodeEditorProps["language"] | null; + hasForm: boolean; +}): PrettyView | null { + if (binary) return null; + if (mime.startsWith("multipart/")) return "parts"; + if (text == null) return null; + if (hasForm && mime === "application/x-www-form-urlencoded") return "form"; + return prettyViewFor({ mimeType: mime, language, previewHtml: which === "response" }); +} + +export function FlowBodyViewer({ flow, which, form, hideCutNote = false }: Props) { const body = useFlowBody(flow, which); + const [mode, setMode] = useBodyViewMode( + `${flow.id}.${which}`, + (body.data?.bytes.length ?? 0) > LARGE_BODY_BYTES, + ); if (body.isPending) { return ( @@ -69,12 +161,28 @@ export function FlowBodyViewer({ flow, which, pretty }: Props) { const imageType = decodeError == null ? bodyImageType(bytes, truncated, contentType) : null; const text = decodeError == null && imageType == null ? bodyAsText(bytes, truncated, contentType) : null; + const language = text == null ? null : languageFromContentType(contentType, text); + const prettyView = flowPrettyView({ + which, + mime, + text, + binary: decodeError != null || imageType != null, + language, + hasForm: form != null, + }); + // The one view a body without a formatted one has is its raw one + const pretty = prettyView != null && mode === "pretty"; + const modeSwitch = + prettyView == null ? null : ( + + ); let viewer: ReactNode; + let textViewer = false; if (decodeError != null) { // What failed to decode is as it was on the wire, compressed, so never text. viewer = ; - } else if (mime.startsWith("multipart/") && pretty) { + } else if (prettyView === "parts" && pretty) { const boundary = contentType?.match(/;\s*boundary="?([^";]+)"?/i)?.[1] ?? "unknown"; viewer = ( ; } else if (text == null) { viewer = ; - } else if (mime.startsWith("image/svg")) { - viewer = ; - } else if (/csv|tab-separated/.test(mime) && pretty) { + } else if (prettyView === "form" && pretty) { + viewer = form; + } else if (prettyView === "table" && pretty) { viewer = ; + } else if (prettyView === "preview" && pretty) { + viewer = mime.startsWith("image/svg") ? ( + + ) : ( + + ); } else { - const language = languageFromContentType(contentType, text); - if (language === "html" && pretty && which === "response") { - viewer = ; - } else { - viewer = ( - - ); - } + textViewer = true; + viewer = ( + + ); } - if (!truncated && decodeError == null) return viewer; + // The text viewer draws the switch in its own bar, beside its filter; the rest get a bar here + if (modeSwitch != null && !textViewer) { + viewer = ( +
+ {viewer} + {modeSwitch} +
+ ); + } + + if ((!truncated || hideCutNote) && decodeError == null) return viewer; return (
@@ -168,7 +295,16 @@ function FlowTextViewer({ text, language, pretty, -}: Props & { text: string; language: CodeEditorProps["language"] }) { + modeSwitch, +}: { + flow: Flow; + which: FlowBody; + text: string; + language: CodeEditorProps["language"]; + pretty: boolean; + /** Formatted or raw, in the viewer's bar after its filter; null for a body with one view */ + modeSwitch: ReactNode; +}) { const runFilter = useCallback( (expression: string) => filterText(flow.id, which, expression, language), [flow.id, which, language], @@ -183,6 +319,8 @@ function FlowTextViewer({ className="-mr-2 bg-surface" formatText={formatText} filterText={runFilter} + ownActionsBar + pinnedActions={modeSwitch} /> ); } diff --git a/apps/yaak-proxy/components/FlowCard.tsx b/apps/yaak-proxy/components/FlowCard.tsx index 6fd906c9c..78abde9bd 100644 --- a/apps/yaak-proxy/components/FlowCard.tsx +++ b/apps/yaak-proxy/components/FlowCard.tsx @@ -13,14 +13,13 @@ import { IconButton, parseCookieHeaders, ResponseCookies, - type ResponseViewMode, SplitLayout, Tooltip, } from "@yaakapp-internal/ui"; import classNames from "classnames"; import { useSetAtom } from "jotai"; -import { useCallback, useMemo, useState } from "react"; -import { BODY_TYPE_BINARY } from "@yaakapp-internal/ui/httpTypes"; +import { useCallback, useMemo } from "react"; +import { BODY_TYPE_FORM_URLENCODED } from "@yaakapp-internal/ui/httpTypes"; import { findHeader } from "../lib/body"; import { browserTabMemory, @@ -166,12 +165,15 @@ function FlowRequestHalf({ flow }: { flow: Flow }) { renderBody={(editor) => ( - ) : null + // The response's viewer choice, with the form's fields as a URL-encoded body's + // formatted view + viewer={ + } requestSize={flow.requestSize} /> @@ -199,7 +201,6 @@ function UrlLine({ flow }: { flow: Flow }) { } function FlowResponseHalf({ flow, className }: { flow: Flow; className: string }) { - const [viewMode, setViewMode] = useState("pretty"); // A stream's body is its messages, read by the Messages tab; reading it whole again on every // message would only hold copies of it const loadBody = flow.streamKind == null; @@ -268,16 +269,12 @@ function FlowResponseHalf({ flow, className }: { flow: Flow; className: string } body={loader} requestBody={null} emptyState={Waiting for a response} - viewMode={viewMode} - onViewModeChange={setViewMode} tabsStorageKey="proxy_response_tabs" activeTabKey={flow.id} defaultTab={defaultTab} onTabChange={onTabChange} extraTabs={extraTabs} - renderBodyViewer={() => ( - - )} + renderBodyViewer={() => } statusLineCenter={ flow.responseTruncated ? ( ({ storedSize: 7, }); -function render( - body: Parameters[0]["body"], - binaryViewer: string | null = null, -) { +function render(body: Parameters[0]["body"]) { return renderToStaticMarkup( - EDITOR
} - binaryViewer={binaryViewer == null ? null :
{binaryViewer}
} - requestSize={2048} - />, + EDITOR} requestSize={2048} />, ); } @@ -49,9 +41,8 @@ describe("FlowRequestBody", () => { expect(html).toContain("LOADING"); }); - test("the editor once loaded, the proxy's viewer for a binary body", () => { + test("the viewer once loaded", () => { expect(render({ data: data(), isLoading: false, error: null })).toContain("EDITOR"); - expect(render({ data: data(), isLoading: false, error: null }, "HEXDUMP")).toContain("HEXDUMP"); }); test("a cut body says how much was kept", () => { diff --git a/apps/yaak-proxy/components/FlowRequestBody.tsx b/apps/yaak-proxy/components/FlowRequestBody.tsx index 162a0adcd..11d6cf69d 100644 --- a/apps/yaak-proxy/components/FlowRequestBody.tsx +++ b/apps/yaak-proxy/components/FlowRequestBody.tsx @@ -13,19 +13,17 @@ import type { FlowBodyData } from "../lib/flowViews"; interface Props { /** The request body as the engine's body RPC returned it */ body: HttpBodyResult; - /** The shared editor for the body's type */ - editor: ReactNode; - /** The proxy's own viewer, for a body that isn't text; null for one that is */ - binaryViewer: ReactNode | null; + /** The body in the viewer for its type, formatted or raw */ + viewer: ReactNode; /** Bytes of the request body on the wire */ requestSize: number; } /** - * The request half's Body tab: the body in its editor, or why it isn't there yet. A body that + * The request half's Body tab: the body in its viewer, or why it isn't there yet. A body that * failed to load says why, as the response's viewer does, rather than reading as no body. */ -export function FlowRequestBody({ body, editor, binaryViewer, requestSize }: Props) { +export function FlowRequestBody({ body, viewer, requestSize }: Props) { if (body.error != null) { return ( @@ -40,7 +38,6 @@ export function FlowRequestBody({ body, editor, binaryViewer, requestSize }: Pro ); } - if (binaryViewer != null) return binaryViewer; if (body.data?.truncated) { return (
@@ -51,9 +48,9 @@ export function FlowRequestBody({ body, editor, binaryViewer, requestSize }: Pro -
{editor}
+
{viewer}
); } - return editor; + return viewer; } diff --git a/apps/yaak-proxy/harness/fixtures.ts b/apps/yaak-proxy/harness/fixtures.ts index 7368f528c..0bca85476 100644 --- a/apps/yaak-proxy/harness/fixtures.ts +++ b/apps/yaak-proxy/harness/fixtures.ts @@ -75,6 +75,27 @@ const graphqlRequest = JSON.stringify({ operationName: "GetViewer", }); const formRequest = "grant_type=client_credentials&client_id=yaak-proxy&scope=read+write"; +const csvResponse = [ + "id,email,plan,created_at", + "1,ada@example.com,pro,2026-10-01", + "2,grace@example.com,team,2026-10-14", + "3,linus@example.com,free,2026-11-02", +].join("\n"); +const multipartBoundary = "yaakBoundary7MA4YWxk"; +const multipartRequest = [ + `--${multipartBoundary}`, + 'Content-Disposition: form-data; name="title"', + "", + "Quarterly report", + `--${multipartBoundary}`, + 'Content-Disposition: form-data; name="meta"; filename="meta.json"', + "Content-Type: application/json", + "", + '{"pages":12,"draft":false}', + `--${multipartBoundary}--`, + "", +].join("\r\n"); +const textResponse = "Uploaded 2 parts"; const htmlResponse = "

Signed in

You can close this window.

"; // A 24x24 PNG gradient @@ -198,6 +219,41 @@ export const fixtures: FlowFixture[] = [ requestBody: body(graphqlRequest), responseBody: body('{"data":{"viewer":{"login":"yaak"}}}'), }, + { + label: "CSV GET", + flow: { + ...base, + id: "fl_csv", + url: "https://api.example.com/users/export.csv", + contentType: "text/csv", + requestHeaders: [{ name: "Accept", value: "text/csv" }], + responseHeaders: [{ name: "Content-Type", value: "text/csv; charset=utf-8" }], + responseSize: csvResponse.length, + }, + requestBody: body(""), + responseBody: body(csvResponse), + }, + { + label: "Multipart POST", + flow: { + ...base, + id: "fl_multipart", + method: "POST", + url: "https://api.example.com/reports", + contentType: "text/plain", + requestHeaders: [ + { + name: "Content-Type", + value: `multipart/form-data; boundary=${multipartBoundary}`, + }, + ], + responseHeaders: [{ name: "Content-Type", value: "text/plain; charset=utf-8" }], + requestSize: multipartRequest.length, + responseSize: textResponse.length, + }, + requestBody: body(multipartRequest), + responseBody: body(textResponse), + }, { label: "gRPC call", flow: { diff --git a/apps/yaak-proxy/lib/atoms.ts b/apps/yaak-proxy/lib/atoms.ts index 291ddef95..e68132615 100644 --- a/apps/yaak-proxy/lib/atoms.ts +++ b/apps/yaak-proxy/lib/atoms.ts @@ -1,5 +1,5 @@ import type { Flow } from "@yaakapp-internal/proxy-lib"; -import type { SplitLayoutLayout } from "@yaakapp-internal/ui"; +import type { ResponseViewMode, SplitLayoutLayout } from "@yaakapp-internal/ui"; import { atom } from "jotai"; import { atomWithStorage } from "jotai/utils"; @@ -22,3 +22,11 @@ export const helperDialogOpenAtom = atom(false); /** Stacked by default; the header's toggle switches to side by side */ export const flowLayoutAtom = atomWithStorage("flow_layout", "vertical"); + +/** Bodies show formatted or raw, one choice for both halves of every flow */ +export const bodyViewModeAtom = atomWithStorage( + "body_view_mode", + "pretty", + undefined, + { getOnInit: true }, +); diff --git a/packages/ui/src/components/ActionsBar.tsx b/packages/ui/src/components/ActionsBar.tsx new file mode 100644 index 000000000..891d18395 --- /dev/null +++ b/packages/ui/src/components/ActionsBar.tsx @@ -0,0 +1,36 @@ +import classNames from "classnames"; +import type { ReactNode } from "react"; + +/** For the positioned element an `ActionsBar` sits in: hovering it brings the bar's actions in */ +export const ACTIONS_BAR_CONTAINER = "relative group/actions"; + +/** Set on an action's outermost element to keep it in view, as a control that shows state does */ +export const PINNED_ACTION = { "data-pinned": true } as const; + +interface Props { + children: ReactNode; + className?: string; +} + +/** + * A row of actions over the bottom-right corner of the content in an `ACTIONS_BAR_CONTAINER`: + * small bordered buttons on the surface colour, so they read over text. Each fades in while the + * container is hovered or the action has focus, unless it carries `PINNED_ACTION`. The row lets + * clicks through to the content between its actions. + */ +export function ActionsBar({ children, className }: Props) { + return ( +
:not([data-pinned])]:opacity-0", + "group-hover/actions:*:opacity-100! *:focus-within:opacity-100! *:hover:opacity-100!", + )} + > + {children} +
+ ); +} diff --git a/packages/ui/src/components/FormatAction.test.tsx b/packages/ui/src/components/FormatAction.test.tsx new file mode 100644 index 000000000..c58ff4c11 --- /dev/null +++ b/packages/ui/src/components/FormatAction.test.tsx @@ -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()).toContain( + "Reformat contents", + ); + expect(renderToStaticMarkup()).toBe( + "", + ); + }); +}); diff --git a/packages/ui/src/components/FormatAction.tsx b/packages/ui/src/components/FormatAction.tsx new file mode 100644 index 000000000..b79f44498 --- /dev/null +++ b/packages/ui/src/components/FormatAction.tsx @@ -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; + +/** + * 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, +): Promise { + 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; + format: (text: string) => Promise; + /** 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 ( + { + if (editor.current == null) return; + await formatEditor(editor.current, format); + }} + /> + ); +} diff --git a/apps/yaak-client/components/core/SegmentedControl.tsx b/packages/ui/src/components/SegmentedControl.tsx similarity index 73% rename from apps/yaak-client/components/core/SegmentedControl.tsx rename to packages/ui/src/components/SegmentedControl.tsx index b2ab147f5..74092c9f2 100644 --- a/apps/yaak-client/components/core/SegmentedControl.tsx +++ b/packages/ui/src/components/SegmentedControl.tsx @@ -1,19 +1,33 @@ -import type { IconProps } from "@yaakapp-internal/ui"; -import { HStack, useStateWithDeps, IconTooltip, Label } from "@yaakapp-internal/ui"; +import { generateId } from "@yaakapp-internal/lib/generateId"; import classNames from "classnames"; import { type ReactNode, useRef } from "react"; -import { Button } from "./Button"; +import { useStateWithDeps } from "../hooks/useStateWithDeps"; +import { Button, CONTROL_TOOLTIP_DELAY } from "./Button"; +import type { IconProps } from "./Icon"; import { IconButton, type IconButtonProps } from "./IconButton"; -import { generateId } from "@yaakapp-internal/lib/generateId"; +import { IconTooltip } from "./IconTooltip"; +import { Label } from "./Label"; +import { HStack } from "./Stacks"; +import { Tooltip } from "./Tooltip"; -interface Props { - options: { value: T; label: string; icon?: IconProps["icon"] }[]; +export interface SegmentedControlOption { + 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 { + options: SegmentedControlOption[]; onChange: (value: T) => void; value: T; name: string; size?: IconButtonProps["size"]; label: string; className?: string; + /** Replaces the full width the control takes by default */ + containerClassName?: string; hideLabel?: boolean; labelClassName?: string; help?: ReactNode; @@ -29,7 +43,8 @@ export function SegmentedControl({ labelClassName, help, className, -}: Props) { + containerClassName = "w-full", +}: SegmentedControlProps) { const [selectedValue, setSelectedValue] = useStateWithDeps(value, [value]); const containerRef = useRef(null); const id = useRef(`input-${generateId()}`); @@ -38,8 +53,13 @@ export function SegmentedControl({ const inlineHelp = hideLabel && help ? : null; + const focusOption = (index: number) => { + const option = containerRef.current?.querySelectorAll('[role="radio"]')[index]; + option?.focus(); + }; + return ( -
+