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..8e687c3ab 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, its formatted view, with the switch in the fields' own bar */ + form?: (actions: ReactNode) => ReactNode; + /** Leaves saying the body was cut short to the tab, which says it its own way */ + hideCutNote?: boolean; } export function useFlowBody(flow: Flow, which: FlowBody, enabled = true) { return useQuery({ ...flowBodyQueryOptions(flow, which), enabled }); } -export function FlowBodyViewer({ flow, which, pretty }: Props) { +/** A mode chosen for one large body, which holds for it alone */ +interface ChosenViewMode { + key: string; + mode: ResponseViewMode; +} + +/** The mode a body shows in: the preference, except raw for a large one until chosen for it */ +export function shownViewMode({ + key, + large, + preferred, + chosen, +}: { + key: string; + large: boolean; + preferred: ResponseViewMode; + chosen: ChosenViewMode | null; +}): ResponseViewMode { + if (chosen?.key === key) return chosen.mode; + return large ? "raw" : preferred; +} + +/** + * Formatted or raw, as last chosen, except that a body big enough to slow the card opens raw, + * and choosing for it holds for that body alone + */ +export function useBodyViewMode( + key: string, + large: boolean, +): [ResponseViewMode, (mode: ResponseViewMode) => void] { + const [preferred, setPreferred] = useAtom(bodyViewModeAtom); + const [chosen, setChosen] = useState(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 ownsBar = 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 && form != null) { + ownsBar = true; + viewer = form(modeSwitch); + } 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 = ( - - ); - } + ownsBar = true; + viewer = ( + + ); } - 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 = ( +
+ {viewer} + {modeSwitch} +
+ ); + } + + if ((!truncated || hideCutNote) && decodeError == null) return viewer; return (
@@ -112,7 +241,7 @@ export function FlowBodyViewer({ flow, which, pretty }: Props) { {decodeError}. Showing the body as it was on the wire. )} - {truncated && ( + {truncated && !hideCutNote && ( {wireTruncated || decodeError != null ? `Showing the first ${formatSize(storedSize)} of ${formatSize(wireSize)}, the capture limit. The app received all of it.` @@ -168,7 +297,16 @@ function FlowTextViewer({ text, language, pretty, -}: Props & { text: string; language: CodeEditorProps["language"] }) { + modeSwitch, +}: { + flow: Flow; + which: FlowBody; + text: string; + language: CodeEditorProps["language"]; + pretty: boolean; + /** Formatted or raw, in the viewer's bar after its filter; null for a body with one view */ + modeSwitch: ReactNode; +}) { const runFilter = useCallback( (expression: string) => filterText(flow.id, which, expression, language), [flow.id, which, language], @@ -183,6 +321,8 @@ function FlowTextViewer({ className="-mr-2 bg-surface" formatText={formatText} filterText={runFilter} + ownActionsBar + pinnedActions={modeSwitch} /> ); } diff --git a/apps/yaak-proxy/components/FlowCard.tsx b/apps/yaak-proxy/components/FlowCard.tsx index 6469b05d4..fbdc7340a 100644 --- a/apps/yaak-proxy/components/FlowCard.tsx +++ b/apps/yaak-proxy/components/FlowCard.tsx @@ -6,6 +6,7 @@ import { Dropdown, EmptyStateText, type ExtraPaneTab, + FormUrlencodedEditor, type HttpBodyResult, HttpRequestPaneView, HttpResponsePaneView, @@ -13,14 +14,13 @@ import { IconButton, parseCookieHeaders, ResponseCookies, - type ResponseViewMode, SplitLayout, Tooltip, } from "@yaakapp-internal/ui"; import classNames from "classnames"; import { useAtomValue, useSetAtom, useStore } from "jotai"; -import { useCallback, useMemo, useState } from "react"; -import { BODY_TYPE_BINARY } from "@yaakapp-internal/ui/httpTypes"; +import { useCallback, useMemo } from "react"; +import { BODY_TYPE_FORM_URLENCODED } from "@yaakapp-internal/ui/httpTypes"; import { placeTabsAtom } from "../lib/atoms"; import { findHeader } from "../lib/body"; import { @@ -143,7 +143,6 @@ function FlowRequestHalf({ flow }: { flow: Flow }) { tab: { value: REQUEST_TAB_COOKIES, label: "Cookies", - empty: sentCount === 0, rightSlot: , }, content: , @@ -160,13 +159,16 @@ function FlowRequestHalf({ flow }: { flow: Flow }) { ]; const { activeTab, defaultTab, onTabChange } = useShapeTab("request", flow, available); + const forceUpdateKey = `${flow.updatedAt}.${body.data == null ? "" : "loaded"}`; + return ( } - renderBody={(editor) => ( + renderBody={() => ( - ) : null + // The response's viewer choice, with the form's fields as a URL-encoded body's + // formatted view, the switch in one bar with their bulk-view toggle + viewer={ + ( + + ) + : undefined + } + hideCutNote + /> } requestSize={flow.requestSize} /> @@ -210,7 +227,6 @@ function UrlLine({ flow }: { flow: Flow }) { } function FlowResponseHalf({ flow, className }: { flow: Flow; className: string }) { - const [viewMode, setViewMode] = useState("pretty"); // A stream's body is its messages, read by the Messages tab; reading it whole again on every // message would only hold copies of it const loadBody = flow.streamKind == null; @@ -242,7 +258,6 @@ function FlowResponseHalf({ flow, className }: { flow: Flow; className: string } tab: { value: RESPONSE_TAB_MESSAGES, label: messagesLabel, - empty: flow.messageCount === 0, rightSlot: ( Waiting for a response} - viewMode={viewMode} - onViewModeChange={setViewMode} tabsStorageKey="proxy_response_tabs" activeTabKey={flow.id} defaultTab={defaultTab} activeTab={activeTab} onTabChange={onTabChange} extraTabs={extraTabs} - renderBodyViewer={() => ( - - )} + renderBodyViewer={() => } statusLineCenter={ flow.responseTruncated ? ( (null); + const getScrollElement = useCallback(() => scrollRef.current, []); + const onColumnDrag = useColumnDragView(rootRef, headerScrollRef, scrollRef); + // A list too short to scroll still loads older flows on scrolling up. const handleWheel = useCallback( (e: WheelEvent) => { @@ -439,10 +452,11 @@ export function FlowList({ style, className }: Props) { return (
{/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- scrolls the list sideways */} @@ -462,11 +476,14 @@ export function FlowList({ style, className }: Props) { onSort={handleSort} onSetSort={handleSetSort} onResetSort={follow} + scrollElement={getScrollElement} + onColumnDrag={onColumnDrag} />
{ }, [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, + headerRef: RefObject, + listRef: RefObject, +): (event: ColumnDragEvent) => void { + const drag = useRef(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("[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(`[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('[role="option"]')) { + const rowBox = row.getBoundingClientRect(); + if (rowBox.bottom <= listBox.top || rowBox.top >= listBox.top + listHeight) continue; + const source = row.querySelector(`[data-column="${id}"]`); + if (source == null) continue; + const copy = place( + rowBox.top - listBox.top, + rowBox.height, + classNames( + "whitespace-nowrap tabular-nums", + row.getAttribute("aria-selected") === "true" && "text-text bg-surface-highlight", + row.classList.contains("text-text-subtlest") && "text-text-subtlest", + ), + ); + for (const child of source.children) { + const clone = child.cloneNode(true) as HTMLElement; + clone.style.width = "100%"; + copy.append(clone); + } + rows.append(copy); + } + el.append(rows); + root.append(el); + + // Kept within the list's visible width + const min = listBox.left; + const max = Math.max(min, listBox.left + list.clientWidth - cellBox.width); + const move = (x: number) => { + const left = Math.min(max, Math.max(min, cellBox.left + x - startX)); + el.style.transform = `translateX(${left - cellBox.left}px)`; + }; + move(startX); + return { move, remove: () => el.remove() }; +} + +/** A line of the ghost, `top` pixels down its box */ +function place(top: number, height: number, className: string): HTMLElement { + const el = document.createElement("div"); + el.className = classNames("absolute left-0 right-0 flex items-center", className); + Object.assign(el.style, { top: `${top}px`, height: `${height}px` }); + return el; +} + interface RowProps { flow: Flow; index: number; diff --git a/apps/yaak-proxy/components/FlowListHeader.tsx b/apps/yaak-proxy/components/FlowListHeader.tsx index 19a483752..869cc7805 100644 --- a/apps/yaak-proxy/components/FlowListHeader.tsx +++ b/apps/yaak-proxy/components/FlowListHeader.tsx @@ -1,3 +1,11 @@ +import { + DndContext, + type DragStartEvent, + PointerSensor, + useDraggable, + useSensor, + useSensors, +} from "@dnd-kit/core"; import type { FlowSortKey } from "@yaakapp-internal/proxy-lib"; import { ContextMenu, @@ -9,7 +17,16 @@ import { } from "@yaakapp-internal/ui"; import classNames from "classnames"; import { useAtomValue, useSetAtom } from "jotai"; -import { memo, type MouseEvent, useCallback, useRef, useState } from "react"; +import { + memo, + type MouseEvent, + type PointerEvent as ReactPointerEvent, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; import { addSortKey, allColumns, @@ -19,9 +36,13 @@ import { type ColumnPrefs, type CustomColumnId, canHide, + dropBoundary, hideOtherColumns, isCustomColumn, + moveColumn, + moveColumnBy, removeCustomColumn, + resetOrder, resetWidth, resetWidths, resizeColumn, @@ -31,6 +52,13 @@ import { import { columnPrefsAtom, hasAppsAtom } from "../lib/store"; import { addColumnDialogAtom } from "./AddColumnDialog"; +/** Pixels a press on a label moves before it drags the column rather than sorting by it */ +const DRAG_DISTANCE = 4; +/** How near the list's side edge, in pixels, a dragged column scrolls the list that way */ +const EDGE = 40; +/** The most the list scrolls sideways a frame, at the very edge */ +const EDGE_SPEED = 18; + interface Props { columns: Column[]; template: string; @@ -43,13 +71,29 @@ interface Props { /** Sets the sort from the menus; empty goes back to arrival order */ onSetSort: (sort: FlowSortKey[]) => void; onResetSort: () => void; + /** The rows' scrolling element, which a column dragged to its edge scrolls sideways */ + scrollElement: () => HTMLElement | null; + /** A column's drag, for the list to show: its ghost, and the columns making room for it */ + onColumnDrag: (event: ColumnDragEvent) => void; } +/** + * A column's drag: its start, the pointer's moves (`x`, across the window), the boundary it + * would drop at when that changes (`to`, null where it would stay), and its end, dropped there + * or put back + */ +export type ColumnDragEvent = + | { type: "start"; id: ColumnId; x: number } + | { type: "move"; x: number } + | { type: "target"; to: number | null } + | { type: "end"; dropped: boolean }; + /** * The list's only control surface: a click on a label sorts by it alone, a second reverses it * and a third goes back to arrival order; Option-click adds it to the sort as a further key. - * Dragging the edge after a label resizes its column and a double-click on it resets it. A - * right-click on a label opens the column's menu; the picker sits in the empty corner on hover. + * Dragging a label moves its column, and Escape puts it back. Dragging the edge after a label + * resizes its column and a double-click on it resets it. A right-click on a label opens the + * column's menu; the picker sits in the empty corner on hover. */ export const FlowListHeader = memo(function FlowListHeader({ columns, @@ -60,6 +104,8 @@ export const FlowListHeader = memo(function FlowListHeader({ onSort, onSetSort: setSort, onResetSort, + scrollElement, + onColumnDrag, }: Props) { const setPrefs = useSetAtom(columnPrefsAtom); const unavailable = unavailableColumns(useAtomValue(hasAppsAtom)); @@ -67,6 +113,12 @@ export const FlowListHeader = memo(function FlowListHeader({ const [menu, setMenu] = useState<{ x: number; y: number; column: ColumnDef | null } | null>(null); // Widths when a drag began, so each move sets the width from where it started. const startWidth = useRef(0); + const { sensors, dndHandlers, gridRef, onPointerDownCapture } = useColumnDrag({ + columns, + scrollElement, + onColumnDrag, + onDrop: (id, to) => setPrefs((p) => moveColumn(p, id, to, unavailable)), + }); // Taking out the sort's only key goes back to arrival order, as Reset Sort does const removeColumn = (id: CustomColumnId) => { @@ -110,11 +162,17 @@ export const FlowListHeader = memo(function FlowListHeader({ { type: "separator" }, { label: "Reset Sort", disabled: sort.length === 0, onSelect: onResetSort }, { label: "Reset Column Widths", onSelect: () => setPrefs((p) => resetWidths(p)) }, + { + label: "Reset Column Order", + disabled: prefs.order.length === 0, + onSelect: () => setPrefs((p) => resetOrder(p)), + }, ]; const columnItems = (c: ColumnDef): DropdownItem[] => { const sortBy = (descending: boolean) => setSort([{ column: c.id, descending }]); const addTo = (descending: boolean) => setSort(addSortKey(sort, c.id, descending)); + const at = columns.findIndex((col) => col.id === c.id); return [ { label: "Sort Ascending", onSelect: () => sortBy(false) }, { label: "Sort Descending", onSelect: () => sortBy(true) }, @@ -122,6 +180,17 @@ export const FlowListHeader = memo(function FlowListHeader({ { label: "Add to Sort Descending", onSelect: () => addTo(true) }, { label: "Clear Sorting", disabled: sort.length === 0, onSelect: onResetSort }, { type: "separator" }, + { + label: "Move Left", + disabled: at <= 0, + onSelect: () => setPrefs((p) => moveColumnBy(p, c.id, -1, unavailable)), + }, + { + label: "Move Right", + disabled: at === -1 || at >= columns.length - 1, + onSelect: () => setPrefs((p) => moveColumnBy(p, c.id, 1, unavailable)), + }, + { type: "separator" }, { label: "Hide Column", disabled: !canHide(prefs, c.id, unavailable), @@ -163,6 +232,9 @@ export const FlowListHeader = memo(function FlowListHeader({
{/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- a right-click opens the column's menu */}
- {columns.map((c) => { - const at = sort.findIndex((k) => k.column === c.id); - const key = sort[at]; - const label = ( - <> - {key != null && ( - - )} - {key != null && sort.length > 1 && ( - {at + 1} - )} - {c.id === "app" ? "" : c.label} - - ); - 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 ( - + {columns.map((c) => ( + - - {/* On the column's right edge, the boundary with the next, resizing this one */} - { - 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))} - /> - - ); - })} + column={c} + sort={sort} + onSort={onSort} + onResizeStart={() => { + startWidth.current = c.current; + }} + onResizeMove={(dx) => setPrefs((p) => resizeColumn(p, c.id, startWidth.current + dx))} + onResetWidth={() => setPrefs((p) => resetWidth(p, c.id))} + /> + ))} + ); }); + +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 ( + + + {/* On the column's right edge, the boundary with the next, resizing this one */} + onResizeMove(e.x - e.xStart)} + onReset={onResetWidth} + /> + + ); +} + +/** 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 && ( + + )} + {key != null && sort.length > 1 && ( + {at + 1} + )} + {c.id === "app" ? "" : c.label} + + ); +} + +/** + * 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(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(":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) { + 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(" "); +} diff --git a/apps/yaak-proxy/components/FlowRequestBody.test.tsx b/apps/yaak-proxy/components/FlowRequestBody.test.tsx index b6d4f3a5d..cc4b54f44 100644 --- a/apps/yaak-proxy/components/FlowRequestBody.test.tsx +++ b/apps/yaak-proxy/components/FlowRequestBody.test.tsx @@ -21,17 +21,9 @@ const data = (truncated = false): FlowBodyData => ({ 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..c473e3dc7 100644 --- a/apps/yaak-proxy/harness/fixtures.ts +++ b/apps/yaak-proxy/harness/fixtures.ts @@ -75,6 +75,38 @@ const graphqlRequest = JSON.stringify({ operationName: "GetViewer", }); const formRequest = "grant_type=client_credentials&client_id=yaak-proxy&scope=read+write"; +const csvResponse = [ + "id,email,plan,created_at", + "1,ada@example.com,pro,2026-10-01", + "2,grace@example.com,team,2026-10-14", + "3,linus@example.com,free,2026-11-02", +].join("\n"); +const tsvResponse = [ + "sku\tname\tstock\tnote", + "A-100\tDesk lamp\t42\tWarm white, dimmable, ships with a long braided fabric cable", + "A-101\tMonitor arm\t7\t", + "A-102\tKeyboard tray\t0\tBack in stock next week", +].join("\n"); +const multipartBoundary = "yaakBoundary7MA4YWxk"; +const multipartRequest = [ + `--${multipartBoundary}`, + 'Content-Disposition: form-data; name="title"', + "", + "Quarterly report", + `--${multipartBoundary}`, + 'Content-Disposition: form-data; name="meta"; filename="meta.json"', + "Content-Type: application/json", + "", + '{"pages":12,"draft":false}', + `--${multipartBoundary}`, + 'Content-Disposition: form-data; name="rows"; filename="rows.csv"', + "Content-Type: text/csv", + "", + "region,revenue\nnorth,1200\nsouth,980", + `--${multipartBoundary}--`, + "", +].join("\r\n"); +const textResponse = "Uploaded 3 parts"; const htmlResponse = "

Signed in

You can close this window.

"; // A 24x24 PNG gradient @@ -198,6 +230,55 @@ export const fixtures: FlowFixture[] = [ requestBody: body(graphqlRequest), responseBody: body('{"data":{"viewer":{"login":"yaak"}}}'), }, + { + label: "CSV GET", + flow: { + ...base, + id: "fl_csv", + url: "https://api.example.com/users/export.csv", + contentType: "text/csv", + requestHeaders: [{ name: "Accept", value: "text/csv" }], + responseHeaders: [{ name: "Content-Type", value: "text/csv; charset=utf-8" }], + responseSize: csvResponse.length, + }, + requestBody: body(""), + responseBody: body(csvResponse), + }, + { + label: "TSV GET", + flow: { + ...base, + id: "fl_tsv", + url: "https://api.example.com/inventory.tsv", + contentType: "text/tab-separated-values", + requestHeaders: [{ name: "Accept", value: "text/tab-separated-values" }], + responseHeaders: [{ name: "Content-Type", value: "text/tab-separated-values" }], + responseSize: tsvResponse.length, + }, + requestBody: body(""), + responseBody: body(tsvResponse), + }, + { + label: "Multipart POST", + flow: { + ...base, + id: "fl_multipart", + method: "POST", + url: "https://api.example.com/reports", + contentType: "text/plain", + requestHeaders: [ + { + name: "Content-Type", + value: `multipart/form-data; boundary=${multipartBoundary}`, + }, + ], + responseHeaders: [{ name: "Content-Type", value: "text/plain; charset=utf-8" }], + requestSize: multipartRequest.length, + responseSize: textResponse.length, + }, + requestBody: body(multipartRequest), + responseBody: body(textResponse), + }, { label: "gRPC call", flow: { diff --git a/apps/yaak-proxy/lib/atoms.ts b/apps/yaak-proxy/lib/atoms.ts index 59446796d..d898e5052 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"; import { currentPlace, EMPTY_PLACE, type PlaceStack, stackOf, updatePlace } from "./places"; @@ -63,3 +63,11 @@ export const helperDialogOpenAtom = atom(false); /** Stacked by default; the header's toggle switches to side by side */ export const flowLayoutAtom = atomWithStorage("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/apps/yaak-proxy/lib/columns.test.ts b/apps/yaak-proxy/lib/columns.test.ts index 95b3dcce9..210c31a98 100644 --- a/apps/yaak-proxy/lib/columns.test.ts +++ b/apps/yaak-proxy/lib/columns.test.ts @@ -4,21 +4,27 @@ import customSortFixture from "../../../crates-proxy/yaak-proxy-lib/tests/fixtur import { addCustomColumn, addSortKey, + allColumns, cellFilterTerm, + columnShifts, type ColumnPrefs, customPath, customPathProblem, customValue, defaultColumnPrefs, + dropBoundary, fieldReference, fieldReferenceChanges, gridTemplate, gridWidth, hideOtherColumns, + moveColumn, + moveColumnBy, nextSort, operationOf, parseColumnPrefs, removeCustomColumn, + resetOrder, resetWidth, resetWidths, resizeColumn, @@ -121,6 +127,7 @@ describe("column preferences", () => { visible: { number: false, "field:header.x-cache": true }, widths: { host: 60 }, custom: ["field:header.x-cache"], + order: [], sort: [{ column: "status", descending: true }], }); expect( @@ -138,6 +145,7 @@ describe("column preferences", () => { visible: {}, widths: { size: 90 }, custom: ["field:query.page"], + order: [], sort: [{ column: "time", descending: false }], }); expect(parseColumnPrefs(null)).toEqual(defaultColumnPrefs); @@ -181,6 +189,196 @@ describe("column preferences", () => { }); }); +describe("column order", () => { + const defaults = ids(defaultColumnPrefs); + + test("a column moves to the boundary it was dropped at", () => { + expect(ids(moveColumn(defaultColumnPrefs, "status", 2))).toEqual([ + "number", + "app", + "status", + "method", + "host", + "path", + "query", + "type", + "size", + "time", + ]); + // Rightwards, before the column at the boundary + expect(ids(moveColumn(defaultColumnPrefs, "method", 5)).slice(2, 6)).toEqual([ + "host", + "path", + "method", + "query", + ]); + // To either end + expect(ids(moveColumn(defaultColumnPrefs, "time", 0))[0]).toBe("time"); + expect(ids(moveColumn(defaultColumnPrefs, "number", defaults.length)).at(-1)).toBe("number"); + // The picker lists them in the same order + const moved = moveColumn(defaultColumnPrefs, "time", 0); + expect(allColumns(moved)[0]?.id).toBe("time"); + }); + + test("a drop where the column already is changes nothing", () => { + expect(moveColumn(defaultColumnPrefs, "host", 3)).toBe(defaultColumnPrefs); + expect(moveColumn(defaultColumnPrefs, "host", 4)).toBe(defaultColumnPrefs); + expect(moveColumn(defaultColumnPrefs, "host", -1)).toBe(defaultColumnPrefs); + expect(moveColumn(defaultColumnPrefs, "host", defaults.length + 1)).toBe(defaultColumnPrefs); + // A hidden column has no place among those shown + expect(moveColumn(defaultColumnPrefs, "latency", 0)).toBe(defaultColumnPrefs); + }); + + test("Move Left and Move Right go one place, and not past the ends", () => { + expect(ids(moveColumnBy(defaultColumnPrefs, "host", -1)).slice(2, 5)).toEqual([ + "host", + "method", + "path", + ]); + expect(ids(moveColumnBy(defaultColumnPrefs, "host", 1)).slice(3, 6)).toEqual([ + "path", + "host", + "query", + ]); + expect(moveColumnBy(defaultColumnPrefs, "number", -1)).toBe(defaultColumnPrefs); + expect(moveColumnBy(defaultColumnPrefs, "time", 1)).toBe(defaultColumnPrefs); + // Where App has nothing to show, Number is next to Method + expect( + shownColumns(moveColumnBy(defaultColumnPrefs, "method", -1, ["app"]), ["app"]).map( + (c) => c.id, + )[0], + ).toBe("method"); + }); + + test("a hidden column keeps its place and comes back to it", () => { + let prefs = moveColumn(defaultColumnPrefs, "time", 0); + prefs = toggleColumn(prefs, "status"); + prefs = moveColumn(prefs, "host", 0); + expect(ids(prefs)).not.toContain("status"); + prefs = toggleColumn(prefs, "status"); + expect(ids(prefs)).toEqual([ + "host", + "time", + "number", + "app", + "method", + "path", + "query", + "status", + "type", + "size", + ]); + }); + + test("never reordered, a built-in column turned on takes its default place", () => { + // Request Size's default place is before Size + const prefs = toggleColumn(defaultColumnPrefs, "requestSize"); + expect(prefs.order).toEqual([]); + expect(ids(prefs).slice(-4)).toEqual(["type", "requestSize", "size", "time"]); + // Among the built-in ones, so a custom column stays at the end + const custom = addCustomColumn(defaultColumnPrefs, "header.x-cache"); + expect(ids(custom).at(-1)).toBe("field:header.x-cache"); + const both = toggleColumn(custom, "requestSize"); + expect(both.order).toEqual([]); + expect(ids(both).slice(-4)).toEqual(["requestSize", "size", "time", "field:header.x-cache"]); + }); + + test("once reordered, a column turned on goes at the end, unless it had a place", () => { + let prefs = moveColumn(defaultColumnPrefs, "time", 0); + prefs = toggleColumn(prefs, "requestSize"); + expect(ids(prefs).at(-1)).toBe("requestSize"); + // Moved, hidden and shown again, it goes back where it was moved to + const before = ids(prefs).indexOf("size"); + prefs = moveColumn(prefs, "requestSize", before); + prefs = toggleColumn(toggleColumn(prefs, "requestSize"), "requestSize"); + expect(ids(prefs).slice(-3)).toEqual(["type", "requestSize", "size"]); + // A custom column added goes at the end too + expect(ids(addCustomColumn(prefs, "query.page")).at(-1)).toBe("field:query.page"); + }); + + test("a custom column is added at the end and leaves the order when removed", () => { + const moved = moveColumn(defaultColumnPrefs, "size", 0); + let prefs = addCustomColumn(moved, "header.x-cache"); + expect(ids(prefs).at(-1)).toBe("field:header.x-cache"); + prefs = moveColumn(prefs, "field:header.x-cache", 1); + expect(ids(prefs).slice(0, 2)).toEqual(["size", "field:header.x-cache"]); + // Hidden and added again, it keeps its place + prefs = addCustomColumn(toggleColumn(prefs, "field:header.x-cache"), "header.x-cache"); + expect(ids(prefs)[1]).toBe("field:header.x-cache"); + prefs = removeCustomColumn(prefs, "field:header.x-cache"); + expect(prefs.order).not.toContain("field:header.x-cache"); + expect(ids(prefs)).toEqual(ids(moved)); + // Added again, it starts over at the end + expect(ids(addCustomColumn(prefs, "header.x-cache")).at(-1)).toBe("field:header.x-cache"); + }); + + test("the order persists, and columns it doesn't list follow it in their default order", () => { + let prefs = addCustomColumn(defaultColumnPrefs, "query.page"); + prefs = moveColumn(prefs, "field:query.page", 0); + expect(parseColumnPrefs(JSON.parse(JSON.stringify(prefs))).order).toEqual(prefs.order); + const parsed = parseColumnPrefs({ + custom: ["field:query.page"], + order: ["time", "gone", 3, "time", "field:header.x-gone", "field:query.page", "host"], + }); + expect(parsed.order).toEqual(["time", "field:query.page", "host"]); + expect(ids(parsed)).toEqual([ + "time", + "field:query.page", + "host", + "number", + "app", + "method", + "path", + "query", + "status", + "type", + "size", + ]); + expect(parseColumnPrefs({ order: "time" }).order).toEqual([]); + }); + + test("a drag's target is the boundary nearest the pointer, null in its own slot", () => { + // Four columns 100px wide from x 24, after the mark column; the third is dragged + const cells = [24, 124, 224, 324].map((left) => ({ left, right: left + 100 })); + const at = (x: number) => dropBoundary(x, cells, 2); + // Over the mark column or the first's left half: the first boundary + expect(at(5)).toBe(0); + expect(at(60)).toBe(0); + expect(at(80)).toBe(1); + // The left neighbour's near half, its own column, the right neighbour's near half: staying + expect(at(190)).toBeNull(); + expect(at(250)).toBeNull(); + expect(at(290)).toBeNull(); + expect(at(350)).toBeNull(); + // Past the right neighbour's middle, and past the end + expect(at(380)).toBe(4); + expect(at(900)).toBe(4); + // The last column dragged to the end stays + expect(dropBoundary(900, cells, 3)).toBeNull(); + }); + + test("the columns a drag passes slide by its width to make room", () => { + // Five columns; the second, 80px wide, is dragged + expect(columnShifts(5, 1, 80, null)).toEqual([0, 0, 0, 0, 0]); + // Right, to before the fifth: the third and fourth slide left + expect(columnShifts(5, 1, 80, 4)).toEqual([0, 0, -80, -80, 0]); + // Right, to the end: all after it slide left + expect(columnShifts(5, 1, 80, 5)).toEqual([0, 0, -80, -80, -80]); + // Left, to the start: the first slides right + expect(columnShifts(5, 1, 80, 0)).toEqual([80, 0, 0, 0, 0]); + // The fourth, dragged left to before the second: the second and third slide right + expect(columnShifts(5, 3, 60, 1)).toEqual([0, 60, 60, 0, 0]); + }); + + test("Reset Column Order puts every column back", () => { + let prefs = moveColumn(defaultColumnPrefs, "time", 0); + prefs = toggleColumn(prefs, "latency"); + prefs = resetOrder(prefs); + expect(prefs.order).toEqual([]); + expect(ids(prefs)).toEqual([...defaults.slice(0, -1), "time", "latency"]); + }); +}); + describe("sorting", () => { const one = (column: FlowSortKey["column"], descending: boolean) => [{ column, descending }]; diff --git a/apps/yaak-proxy/lib/columns.ts b/apps/yaak-proxy/lib/columns.ts index f94655389..ffb150a8a 100644 --- a/apps/yaak-proxy/lib/columns.ts +++ b/apps/yaak-proxy/lib/columns.ts @@ -145,26 +145,45 @@ export const CELL_PADDING = "0.75rem"; /** * What the person chose: which columns show, the widths they dragged, by id, the header and - * query parameter columns they added, in order, and the list's sort + * query parameter columns they added, in order, the order they dragged the columns into, and the + * list's sort */ export interface ColumnPrefs { visible: Partial>; widths: Partial>; custom: CustomColumnId[]; + /** + * The columns' order, by id. Columns not listed follow the listed ones, in their default order; + * empty for the default order. + */ + order: ColumnId[]; /** The keys in order; empty for the order flows arrive in */ sort: FlowSortKey[]; } -export const defaultColumnPrefs: ColumnPrefs = { visible: {}, widths: {}, custom: [], sort: [] }; +export const defaultColumnPrefs: ColumnPrefs = { + visible: {}, + widths: {}, + custom: [], + order: [], + sort: [], +}; export interface Column extends ColumnDef { /** Its width now, in pixels of content */ current: number; } -/** Every column the picker lists: the built-in ones, then those the person added */ +/** + * Every column, in the order the person left them: those they placed, then the rest in their + * default order, the built-in ones before those added. The picker lists them so. + */ export function allColumns(prefs: ColumnPrefs): ColumnDef[] { - return [...COLUMNS, ...prefs.custom.map(customDef)]; + const defaults = [...COLUMNS, ...prefs.custom.map(customDef)]; + if (prefs.order.length === 0) return defaults; + const byId = new Map(defaults.map((c) => [c.id, c])); + const placed = prefs.order.flatMap((id) => byId.get(id) ?? []); + return [...placed, ...defaults.filter((c) => !prefs.order.includes(c.id))]; } function isVisible(prefs: ColumnPrefs, def: ColumnDef): boolean { @@ -217,7 +236,104 @@ export function toggleColumn( ): ColumnPrefs { const shown = isVisible(prefs, columnDef(id)); if (shown && !canHide(prefs, id, unavailable)) return prefs; - return { ...prefs, visible: { ...prefs.visible, [id]: !shown } }; + const next = { ...prefs, visible: { ...prefs.visible, [id]: !shown } }; + return shown ? next : placeAtEnd(next, id); +} + +/** + * Puts a column just shown after the others shown, unless the person placed it before, so it + * goes back to that place. The columns shown before it are placed too, where they are, so the + * ones not listed don't jump ahead of it. Until the person has ordered the columns, each keeps + * its default place: a built-in one among the others, an added one at the end. + */ +function placeAtEnd(prefs: ColumnPrefs, id: ColumnId): ColumnPrefs { + if (prefs.order.length === 0 || prefs.order.includes(id)) return prefs; + const all = allColumns(prefs); + if (all.filter((c) => isVisible(prefs, c)).at(-1)?.id === id) return prefs; + const placed = all.filter( + (c) => c.id !== id && (prefs.order.includes(c.id) || isVisible(prefs, c)), + ); + return { ...prefs, order: [...placed.map((c) => c.id), id] }; +} + +/** + * Moves a shown column to the boundary `to` between the columns shown: 0 before the first, their + * count after the last. Hidden columns keep their places among the others. + */ +export function moveColumn( + prefs: ColumnPrefs, + id: ColumnId, + to: number, + unavailable: ColumnId[] = [], +): ColumnPrefs { + const shown = shownColumns(prefs, unavailable).map((c) => c.id); + const from = shown.indexOf(id); + if (from === -1 || to < 0 || to > shown.length || to === from || to === from + 1) return prefs; + // Every column shown, and those hidden that were placed, so they keep their places + const order = allColumns(prefs) + .filter((c) => c.id !== id && (prefs.order.includes(c.id) || shown.includes(c.id))) + .map((c) => c.id); + // Before the column at the boundary, or after the last one shown + const before = shown[to]; + const last = shown.at(-1); + const at = before != null ? order.indexOf(before) : last == null ? -1 : order.indexOf(last) + 1; + if (at === -1) return prefs; + order.splice(at, 0, id); + return { ...prefs, order }; +} + +/** One place left, or with `1`, right, among the columns shown */ +export function moveColumnBy( + prefs: ColumnPrefs, + id: ColumnId, + by: -1 | 1, + unavailable: ColumnId[] = [], +): ColumnPrefs { + const from = shownColumns(prefs, unavailable).findIndex((c) => c.id === id); + if (from === -1) return prefs; + return moveColumn(prefs, id, by === -1 ? from - 1 : from + 2, unavailable); +} + +/** Every column back to its default place */ +export function resetOrder(prefs: ColumnPrefs): ColumnPrefs { + return { ...prefs, order: [] }; +} + +/** + * Where a dragged column would drop, from the pointer and the shown columns' edges, left to + * right, as they are laid out before the drag: the boundary before the first column whose middle + * is right of the pointer. Over its own column or the near half of a neighbour, that is an edge + * of its own slot, where it would stay, so null. Left of the first column, over the fixed mark + * column, it is the first boundary. + */ +export function dropBoundary( + x: number, + cells: { left: number; right: number }[], + from: number, +): number | null { + let at = cells.findIndex((r) => x < (r.left + r.right) / 2); + if (at === -1) at = cells.length; + const stays = from === -1 || at === from || at === from + 1; + return stays ? null : at; +} + +/** + * How far, in pixels, each of `count` shown columns slides while the one at `from`, `width` + * wide, is dragged to the boundary `to`: those it would pass make room for it, leftwards when it + * goes right and rightwards when it goes left. The dragged column itself, and the rest, stay. + */ +export function columnShifts( + count: number, + from: number, + width: number, + to: number | null, +): number[] { + return Array.from({ length: count }, (_, i) => { + if (to == null || i === from) return 0; + if (to > from && i > from && i < to) return -width; + if (to < from && i >= to && i < from) return width; + return 0; + }); } /** Shows `id` alone */ @@ -227,20 +343,24 @@ export function hideOtherColumns(prefs: ColumnPrefs, id: ColumnId): ColumnPrefs return { ...prefs, visible }; } -/** Adds a column for a header or query parameter's path, shown at the end; already there, shown */ +/** + * Adds a column for a header or query parameter's path, shown at the end; already there, shown, + * in its place + */ export function addCustomColumn(prefs: ColumnPrefs, path: string): ColumnPrefs { const id = customColumnId(path); const custom = prefs.custom.includes(id) ? prefs.custom : [...prefs.custom, id]; - return { ...prefs, custom, visible: { ...prefs.visible, [id]: true } }; + return placeAtEnd({ ...prefs, custom, visible: { ...prefs.visible, [id]: true } }, id); } -/** Takes the column out, and out of the sort */ +/** Takes the column out, and out of the order and the sort */ export function removeCustomColumn(prefs: ColumnPrefs, id: CustomColumnId): ColumnPrefs { const { [id]: _v, ...visible } = prefs.visible; const { [id]: _w, ...widths } = prefs.widths; const custom = prefs.custom.filter((c) => c !== id); + const order = prefs.order.filter((c) => c !== id); const sort = prefs.sort.filter((k) => k.column !== id); - return { ...prefs, custom, visible, widths, sort }; + return { ...prefs, custom, visible, widths, order, sort }; } export function resizeColumn(prefs: ColumnPrefs, id: ColumnId, width: number): ColumnPrefs { @@ -270,9 +390,9 @@ export function showOperationFor(prefs: ColumnPrefs, flows: Flow[]): ColumnPrefs * wrong type, so a change to the columns never breaks the list. */ export function parseColumnPrefs(value: unknown): ColumnPrefs { - const prefs: ColumnPrefs = { visible: {}, widths: {}, custom: [], sort: [] }; + const prefs: ColumnPrefs = { visible: {}, widths: {}, custom: [], order: [], sort: [] }; if (value == null || typeof value !== "object") return prefs; - const { visible, widths, custom, sort } = value as Record; + const { visible, widths, custom, order, sort } = value as Record; if (Array.isArray(custom)) { for (const id of custom) { if (typeof id !== "string" || !id.startsWith("field:")) continue; @@ -288,6 +408,15 @@ export function parseColumnPrefs(value: unknown): ColumnPrefs { const w = (widths as Record | undefined)?.[def.id]; if (typeof w === "number" && Number.isFinite(w) && w > 0) prefs.widths[def.id] = w; } + // Columns no longer there are dropped; those not listed follow the rest in their default order + if (Array.isArray(order)) { + for (const column of order) { + if (typeof column !== "string") continue; + const id = column as ColumnId; + const known = isCustomColumn(id) ? prefs.custom.includes(id) : DEFS.has(id); + if (known && !prefs.order.includes(id)) prefs.order.push(id); + } + } if (Array.isArray(sort)) { for (const key of sort) { if (key == null || typeof key !== "object") continue; diff --git a/apps/yaak-proxy/package.json b/apps/yaak-proxy/package.json index 7e160c0b9..dc2baf4ba 100644 --- a/apps/yaak-proxy/package.json +++ b/apps/yaak-proxy/package.json @@ -9,6 +9,7 @@ "lint": "tsc --noEmit" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", "@tanstack/react-query": "^5.105.0", "@tanstack/react-virtual": "^3.14.24", "@tauri-apps/api": "^2.12.4", diff --git a/package-lock.json b/package-lock.json index 1238d6bc1..754dd06ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -246,6 +246,7 @@ "name": "@yaakapp/yaak-proxy", "version": "1.0.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", "@tanstack/react-query": "^5.105.0", "@tanstack/react-virtual": "^3.14.24", "@tauri-apps/api": "^2.12.4", diff --git a/packages/ui/src/components/ActionsBar.tsx b/packages/ui/src/components/ActionsBar.tsx new file mode 100644 index 000000000..ff1315d61 --- /dev/null +++ b/packages/ui/src/components/ActionsBar.tsx @@ -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 ( +
: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 ( -
+
} {...buttonProps} - className={classNames(buttonProps.className, !isActive && tab.empty && "text-text-subtlest!")} > {option && "shortLabel" in option && option.shortLabel ? option.shortLabel diff --git a/packages/ui/src/components/viewers/CsvViewer.tsx b/packages/ui/src/components/viewers/CsvViewer.tsx index 031c37315..b5a640589 100644 --- a/packages/ui/src/components/viewers/CsvViewer.tsx +++ b/packages/ui/src/components/viewers/CsvViewer.tsx @@ -31,7 +31,7 @@ export function CsvViewerInner({ text, className }: { text: string | null; class return (
- +
{columnIndexes.map((columnIndex) => ( diff --git a/packages/ui/src/components/viewers/MultipartViewer.tsx b/packages/ui/src/components/viewers/MultipartViewer.tsx index 0e5b5a42d..8b61a1e5b 100644 --- a/packages/ui/src/components/viewers/MultipartViewer.tsx +++ b/packages/ui/src/components/viewers/MultipartViewer.tsx @@ -275,7 +275,7 @@ function LeafBody({ } if (mediaType?.match(/csv|tab-separated/i)) { - return ; + return ; } if (mediaType?.match(/^text\/html/i) || (!binary && detectedLanguage === "html")) { diff --git a/packages/ui/src/components/viewers/TextViewer.tsx b/packages/ui/src/components/viewers/TextViewer.tsx index 854a15566..1f699ea1b 100644 --- a/packages/ui/src/components/viewers/TextViewer.tsx +++ b/packages/ui/src/components/viewers/TextViewer.tsx @@ -1,9 +1,11 @@ import type { Extension } from "@codemirror/state"; import { useQuery } from "@tanstack/react-query"; +import classNames from "classnames"; import type { ReactNode } from "react"; import { Children, useCallback, useMemo, useState } from "react"; import { type TextFilterApi, useTextFilter } from "../../hooks/useTextFilter"; import { docFingerprint } from "../../lib/docFingerprint"; +import { ACTIONS_BAR_CONTAINER, ActionsBar } from "../ActionsBar"; import type { CodeEditorProps } from "../editor/CodeEditor"; import { jsonBreadcrumbExtension } from "../editor/json/breadcrumbExtension"; import type { JsonPathSegment } from "../editor/json/jsonPath"; @@ -20,6 +22,13 @@ export interface TextViewerProps { pretty?: boolean; className?: string; footerActions?: ReactNode; + /** Last of the actions and kept while filtering, like a view-mode switch */ + pinnedActions?: ReactNode; + /** + * Draws the actions in an `ActionsBar` over the viewer rather than in the editor's own row. + * Only the proxy's body tabs set it for now; once every site has moved it goes away + */ + ownActionsBar?: boolean; extensions?: Extension[]; /** Pretty-prints the text when `pretty` is set */ formatText?: (text: string, language: CodeEditorProps["language"]) => Promise; @@ -42,6 +51,8 @@ export function TextViewer({ pretty, className, footerActions, + pinnedActions, + ownActionsBar = false, extensions, formatText, filterText, @@ -121,8 +132,9 @@ export function TextViewer({ const actions = useMemo(() => { const nodes: ReactNode[] = isSearching ? [] : Children.toArray(footerActions); + const pinned = Children.toArray(pinnedActions); - if (!canFilter) return nodes; + if (!canFilter) return [...nodes, ...pinned]; if (isSearching) { nodes.push( @@ -173,10 +185,11 @@ export function TextViewer({ ); } - return nodes; + return [...nodes, ...pinned]; }, [ canFilter, footerActions, + pinnedActions, filter, filterResult.isPending, filterResult.isFetching, @@ -221,16 +234,19 @@ export function TextViewer({ } return ( -
+
{renderEditor({ readOnly: true, className, defaultValue: body, language, - actions, + actions: ownActionsBar ? undefined : actions, extensions: extraExtensions, stateKey, })} + {ownActionsBar && actions.length > 0 && {actions}} {showBreadcrumbs && (