From 08a98d5f82d88ada63d1f03e2ad717eb898f4d77 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Thu, 5 Nov 2026 09:23:38 -0700 Subject: [PATCH] Share Yaak's request and response panes (#815) Co-authored-by: Claude Opus 5.5 --- .../components/ClientEditorHost.tsx | 99 ++ .../components/ConfirmLargeResponse.tsx | 60 -- .../ConfirmLargeResponseRequest.tsx | 55 - .../yaak-client/components/EmptyStateText.tsx | 25 +- .../components/FormUrlencodedEditor.tsx | 46 - apps/yaak-client/components/HeadersEditor.tsx | 181 +--- .../components/HttpRequestPane.tsx | 359 +++---- .../components/HttpResponsePane.tsx | 537 ++++------ .../yaak-client/components/JsonBodyEditor.tsx | 121 --- .../components/RequestBodyViewer.tsx | 105 -- .../components/RequestMethodDropdown.tsx | 67 +- .../components/ResponseHeaders.tsx | 63 -- apps/yaak-client/components/UrlBar.tsx | 111 +- .../components/UrlParameterEditor.tsx | 34 +- .../core/Editor/genericCompletion.ts | 13 +- .../components/core/HotkeyList.tsx | 32 +- .../components/core/PairEditor.tsx | 976 +---------------- .../components/core/PairEditor.util.tsx | 11 +- .../components/core/PairOrBulkEditor.tsx | 39 +- .../components/core/PillButton.tsx | 15 +- .../components/core/RadioDropdown.tsx | 71 +- .../yaak-client/components/core/Tabs/Tabs.tsx | 91 +- .../responseViewers/HTMLOrTextViewer.tsx | 118 --- apps/yaak-client/hooks/useHotKey.ts | 1 + apps/yaak-client/hooks/useHttpBodyLoaders.ts | 64 ++ apps/yaak-client/hooks/useToggle.ts | 9 +- apps/yaak-client/lib/contentType.ts | 45 +- apps/yaak-client/lib/httpViews.test.ts | 83 ++ apps/yaak-client/lib/httpViews.ts | 64 ++ apps/yaak-client/lib/model_util.ts | 18 +- apps/yaak-client/routes/__root.tsx | 25 +- .../yaak-proxy/components/ProxyEditorHost.tsx | 35 + apps/yaak-proxy/harness.html | 33 + apps/yaak-proxy/harness/FlowPanesHarness.tsx | 113 ++ apps/yaak-proxy/harness/fixtures.ts | 141 +++ apps/yaak-proxy/harness/main.tsx | 20 + apps/yaak-proxy/lib/flowViews.test.ts | 222 ++++ apps/yaak-proxy/lib/flowViews.ts | 235 +++++ packages/ui/package.json | 11 +- packages/ui/src/components/EditorHost.tsx | 194 ++++ packages/ui/src/components/EmptyStateText.tsx | 24 + .../ui/src/components}/HotkeyLabel.tsx | 4 +- .../ui/src/components}/HotkeyList.test.tsx | 2 +- packages/ui/src/components/HotkeyList.tsx | 31 + packages/ui/src/components/PillButton.tsx | 14 + packages/ui/src/components/RadioDropdown.tsx | 69 ++ .../components/http/ConfirmLargeResponse.tsx | 70 ++ .../components/http}/FormMultipartEditor.tsx | 33 +- .../components/http/FormUrlencodedEditor.tsx | 59 ++ .../ui/src/components/http/HeadersEditor.tsx | 189 ++++ .../components/http/HttpRequestPaneView.tsx | 458 ++++++++ .../components/http/HttpResponsePaneView.tsx | 350 ++++++ .../ui/src/components/http/JsonBodyEditor.tsx | 141 +++ .../src/components/http/RequestBodyViewer.tsx | 102 ++ .../components/http/RequestMethodDropdown.tsx | 87 ++ .../components/http/ResponseBodyViewer.tsx | 224 ++++ .../src/components/http}/ResponseCookies.tsx | 73 +- .../src/components/http/ResponseHeaders.tsx | 83 ++ packages/ui/src/components/http/UrlBar.tsx | 146 +++ .../components/http/UrlParametersEditor.tsx | 45 + .../ui/src/components/http/readOnly.test.tsx | 247 +++++ packages/ui/src/components/http/types.ts | 127 +++ .../components/pairs}/BulkPairEditor.test.ts | 0 .../src/components/pairs}/BulkPairEditor.tsx | 9 +- .../ui/src/components/pairs/PairEditor.tsx | 994 ++++++++++++++++++ .../src/components/pairs/PairEditor.util.tsx | 10 + .../src/components/pairs/PairOrBulkEditor.tsx | 39 + packages/ui/src/components/tabs/Tabs.tsx | 72 +- .../tags/HttpResponseDurationTag.tsx | 40 +- packages/ui/src/hooks/useHotKey.ts | 7 + packages/ui/src/hooks/useToggle.ts | 7 + packages/ui/src/index.ts | 97 +- .../ui/src}/lib/data/acceptLanguages.ts | 0 .../ui/src}/lib/data/cacheControl.ts | 0 .../ui/src}/lib/data/charsets.ts | 0 .../ui/src}/lib/data/connections.ts | 0 .../ui/src}/lib/data/encodings.ts | 0 .../ui/src}/lib/data/headerNames.ts | 0 .../ui/src}/lib/data/headerValuePresets.ts | 0 .../ui/src}/lib/data/mimetypes.ts | 0 .../ui/src}/lib/data/userAgents.ts | 0 .../ui/src}/lib/jsonComments.ts | 0 packages/ui/src/lib/mimeType.ts | 44 + .../ui/src}/lib/validateHttpHeader.test.ts | 0 .../ui/src}/lib/validateHttpHeader.ts | 0 85 files changed, 5567 insertions(+), 2872 deletions(-) create mode 100644 apps/yaak-client/components/ClientEditorHost.tsx delete mode 100644 apps/yaak-client/components/ConfirmLargeResponse.tsx delete mode 100644 apps/yaak-client/components/ConfirmLargeResponseRequest.tsx delete mode 100644 apps/yaak-client/components/FormUrlencodedEditor.tsx delete mode 100644 apps/yaak-client/components/JsonBodyEditor.tsx delete mode 100644 apps/yaak-client/components/RequestBodyViewer.tsx delete mode 100644 apps/yaak-client/components/ResponseHeaders.tsx delete mode 100644 apps/yaak-client/components/responseViewers/HTMLOrTextViewer.tsx create mode 100644 apps/yaak-client/hooks/useHttpBodyLoaders.ts create mode 100644 apps/yaak-client/lib/httpViews.test.ts create mode 100644 apps/yaak-client/lib/httpViews.ts create mode 100644 apps/yaak-proxy/components/ProxyEditorHost.tsx create mode 100644 apps/yaak-proxy/harness.html create mode 100644 apps/yaak-proxy/harness/FlowPanesHarness.tsx create mode 100644 apps/yaak-proxy/harness/fixtures.ts create mode 100644 apps/yaak-proxy/harness/main.tsx create mode 100644 apps/yaak-proxy/lib/flowViews.test.ts create mode 100644 apps/yaak-proxy/lib/flowViews.ts create mode 100644 packages/ui/src/components/EditorHost.tsx create mode 100644 packages/ui/src/components/EmptyStateText.tsx rename {apps/yaak-client/components/core => packages/ui/src/components}/HotkeyLabel.tsx (73%) rename {apps/yaak-client/components/core => packages/ui/src/components}/HotkeyList.test.tsx (93%) create mode 100644 packages/ui/src/components/HotkeyList.tsx create mode 100644 packages/ui/src/components/PillButton.tsx create mode 100644 packages/ui/src/components/RadioDropdown.tsx create mode 100644 packages/ui/src/components/http/ConfirmLargeResponse.tsx rename {apps/yaak-client/components => packages/ui/src/components/http}/FormMultipartEditor.tsx (57%) create mode 100644 packages/ui/src/components/http/FormUrlencodedEditor.tsx create mode 100644 packages/ui/src/components/http/HeadersEditor.tsx create mode 100644 packages/ui/src/components/http/HttpRequestPaneView.tsx create mode 100644 packages/ui/src/components/http/HttpResponsePaneView.tsx create mode 100644 packages/ui/src/components/http/JsonBodyEditor.tsx create mode 100644 packages/ui/src/components/http/RequestBodyViewer.tsx create mode 100644 packages/ui/src/components/http/RequestMethodDropdown.tsx create mode 100644 packages/ui/src/components/http/ResponseBodyViewer.tsx rename {apps/yaak-client/components => packages/ui/src/components/http}/ResponseCookies.tsx (79%) create mode 100644 packages/ui/src/components/http/ResponseHeaders.tsx create mode 100644 packages/ui/src/components/http/UrlBar.tsx create mode 100644 packages/ui/src/components/http/UrlParametersEditor.tsx create mode 100644 packages/ui/src/components/http/readOnly.test.tsx create mode 100644 packages/ui/src/components/http/types.ts rename {apps/yaak-client/components/core => packages/ui/src/components/pairs}/BulkPairEditor.test.ts (100%) rename {apps/yaak-client/components/core => packages/ui/src/components/pairs}/BulkPairEditor.tsx (96%) create mode 100644 packages/ui/src/components/pairs/PairEditor.tsx create mode 100644 packages/ui/src/components/pairs/PairEditor.util.tsx create mode 100644 packages/ui/src/components/pairs/PairOrBulkEditor.tsx create mode 100644 packages/ui/src/hooks/useToggle.ts rename {apps/yaak-client => packages/ui/src}/lib/data/acceptLanguages.ts (100%) rename {apps/yaak-client => packages/ui/src}/lib/data/cacheControl.ts (100%) rename {apps/yaak-client => packages/ui/src}/lib/data/charsets.ts (100%) rename {apps/yaak-client => packages/ui/src}/lib/data/connections.ts (100%) rename {apps/yaak-client => packages/ui/src}/lib/data/encodings.ts (100%) rename {apps/yaak-client => packages/ui/src}/lib/data/headerNames.ts (100%) rename {apps/yaak-client => packages/ui/src}/lib/data/headerValuePresets.ts (100%) rename {apps/yaak-client => packages/ui/src}/lib/data/mimetypes.ts (100%) rename {apps/yaak-client => packages/ui/src}/lib/data/userAgents.ts (100%) rename {apps/yaak-client => packages/ui/src}/lib/jsonComments.ts (100%) create mode 100644 packages/ui/src/lib/mimeType.ts rename {apps/yaak-client => packages/ui/src}/lib/validateHttpHeader.test.ts (100%) rename {apps/yaak-client => packages/ui/src}/lib/validateHttpHeader.ts (100%) diff --git a/apps/yaak-client/components/ClientEditorHost.tsx b/apps/yaak-client/components/ClientEditorHost.tsx new file mode 100644 index 000000000..48151927e --- /dev/null +++ b/apps/yaak-client/components/ClientEditorHost.tsx @@ -0,0 +1,99 @@ +import { platform } from "@yaakapp-internal/platform"; +import { + Banner, + type EditorHost, + EditorHostProvider, + EmptyStateText, + type HostBodyViewerArgs, + type HttpBodyLoader, + LoadingIcon, + type StoredValue, + type StoredValueNamespace, +} from "@yaakapp-internal/ui"; +import { lazy, type ReactNode, Suspense } from "react"; +import { useKeyValue } from "../hooks/useKeyValue"; +import { copyToClipboard } from "../lib/copy"; +import { showDialog } from "../lib/dialog"; +import { showPrompt } from "../lib/prompt"; +import { useDetailsBannerStoredValue } from "./core/DetailsBanner"; +import { Editor } from "./core/Editor/LazyEditor"; +import { Input } from "./core/Input"; +import { ErrorBoundary } from "./ErrorBoundary"; +import { MultipartViewer } from "./responseViewers/MultipartViewer"; +import { TextViewer } from "./responseViewers/TextViewer"; +import { SelectFile } from "./SelectFile"; + +const PdfViewer = lazy(() => + import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })), +); + +function useClientStoredValue( + key: string | string[] | null, + fallback: T, + namespace: StoredValueNamespace = "global", +) { + // A null key is never written by the shared components, so any stable key will do + const { value, set } = useKeyValue({ namespace, key: key ?? ["tabs", "default"], fallback }); + return [value ?? fallback, set] as const; +} + +function renderBodyViewer({ source, mimeType, body, complete }: HostBodyViewerArgs) { + if (!mimeType?.match(/pdf/i)) return null; + return source === "response" ? ( + + ) : ( + + ); +} + +/** Waits for the whole response, then loads the PDF from its file */ +function PdfResponseBody({ body, complete }: { body: HttpBodyLoader; complete: boolean }) { + const bodyUrl = body.useUrl(complete); + + if (!complete || bodyUrl.isLoading) { + return ( + + + + ); + } + if (bodyUrl.error) { + return {String(bodyUrl.error)}; + } + if (bodyUrl.data == null) { + return
Empty response body
; + } + return ; +} + +function PdfRequestBody({ body }: { body: HttpBodyLoader }) { + const bytes = body.useBytes(); + if (bytes.data == null) return null; + return ( + }> + + + ); +} + +const clientEditorHost: EditorHost = { + Editor, + Input, + SelectFile, + showDialog, + showPrompt, + basename: (path) => platform.files.basename(path), + useStoredValue: useClientStoredValue, + useDetailsBannerStoredValue, + ErrorBoundary, + TextViewer, + MultipartViewer, + renderBodyViewer, + openUrl: (url) => platform.openUrl(url), + copy: (text) => copyToClipboard(text, { disableToast: true }), +}; + +/** Gives the shared editing components the client's templated editor, dialogs and storage */ +export function ClientEditorHost({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/apps/yaak-client/components/ConfirmLargeResponse.tsx b/apps/yaak-client/components/ConfirmLargeResponse.tsx deleted file mode 100644 index f7b99072f..000000000 --- a/apps/yaak-client/components/ConfirmLargeResponse.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import type { HttpResponse } from "@yaakapp-internal/models"; -import { Banner, HStack, InlineCode, SizeTag } from "@yaakapp-internal/ui"; -import { type ReactNode, useMemo } from "react"; -import { useSaveResponse } from "../hooks/useSaveResponse"; -import { useToggle } from "../hooks/useToggle"; -import { isProbablyTextContentType } from "../lib/contentType"; -import { getContentTypeFromHeaders } from "../lib/model_util"; -import { getResponseBodyText } from "../lib/responseBody"; -import { CopyButton } from "./CopyButton"; -import { Button } from "./core/Button"; - -interface Props { - children: ReactNode; - response: HttpResponse; -} - -const LARGE_BYTES = 2 * 1000 * 1000; - -export function ConfirmLargeResponse({ children, response }: Props) { - const { mutate: saveResponse } = useSaveResponse(response); - const [showLargeResponse, toggleShowLargeResponse] = useToggle(); - const isProbablyText = useMemo(() => { - const contentType = getContentTypeFromHeaders(response.headers); - return isProbablyTextContentType(contentType); - }, [response.headers]); - - const contentLength = response.contentLength ?? 0; - const isLarge = contentLength > LARGE_BYTES; - if (!showLargeResponse && isLarge) { - return ( - -

- Showing responses over{" "} - - - {" "} - may impact performance -

- - - - {isProbablyText && ( - getResponseBodyText({ response, filter: null })} - /> - )} - -
- ); - } - - return <>{children}; -} diff --git a/apps/yaak-client/components/ConfirmLargeResponseRequest.tsx b/apps/yaak-client/components/ConfirmLargeResponseRequest.tsx deleted file mode 100644 index 639139724..000000000 --- a/apps/yaak-client/components/ConfirmLargeResponseRequest.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import type { HttpResponse } from "@yaakapp-internal/models"; -import { Banner, HStack, InlineCode, SizeTag } from "@yaakapp-internal/ui"; -import { type ReactNode, useMemo } from "react"; -import { getRequestBodyText as getHttpResponseRequestBodyText } from "../hooks/useHttpRequestBody"; -import { useToggle } from "../hooks/useToggle"; -import { isProbablyTextContentType } from "../lib/contentType"; -import { getContentTypeFromHeaders } from "../lib/model_util"; -import { CopyButton } from "./CopyButton"; -import { Button } from "./core/Button"; - -interface Props { - children: ReactNode; - response: HttpResponse; -} - -const LARGE_BYTES = 2 * 1000 * 1000; - -export function ConfirmLargeResponseRequest({ children, response }: Props) { - const [showLargeResponse, toggleShowLargeResponse] = useToggle(); - const isProbablyText = useMemo(() => { - const contentType = getContentTypeFromHeaders(response.headers); - return isProbablyTextContentType(contentType); - }, [response.headers]); - - const contentLength = response.requestContentLength ?? 0; - const isLarge = contentLength > LARGE_BYTES; - if (!showLargeResponse && isLarge) { - return ( - -

- Showing content over{" "} - - - {" "} - may impact performance -

- - - {isProbablyText && ( - getHttpResponseRequestBodyText(response).then((d) => d?.bodyText ?? "")} - /> - )} - -
- ); - } - - return <>{children}; -} diff --git a/apps/yaak-client/components/EmptyStateText.tsx b/apps/yaak-client/components/EmptyStateText.tsx index 30f75bb40..4a557c4d2 100644 --- a/apps/yaak-client/components/EmptyStateText.tsx +++ b/apps/yaak-client/components/EmptyStateText.tsx @@ -1,24 +1 @@ -import classNames from "classnames"; -import type { ReactNode } from "react"; - -interface Props { - children: ReactNode; - className?: string; - wrapperClassName?: string; -} - -export function EmptyStateText({ children, className, wrapperClassName }: Props) { - return ( -
-
- {children} -
-
- ); -} +export { EmptyStateText } from "@yaakapp-internal/ui"; diff --git a/apps/yaak-client/components/FormUrlencodedEditor.tsx b/apps/yaak-client/components/FormUrlencodedEditor.tsx deleted file mode 100644 index 94a0aa03b..000000000 --- a/apps/yaak-client/components/FormUrlencodedEditor.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import type { HttpRequest } from "@yaakapp-internal/models"; -import { useCallback, useMemo } from "react"; -import type { Pair, PairEditorProps } from "./core/PairEditor"; -import { PairOrBulkEditor } from "./core/PairOrBulkEditor"; - -type Props = { - forceUpdateKey: string; - request: HttpRequest; - onChange: (headers: HttpRequest["body"]) => void; -}; - -export function FormUrlencodedEditor({ request, forceUpdateKey, onChange }: Props) { - const pairs = useMemo( - () => - (Array.isArray(request.body.form) ? request.body.form : []).map((p) => ({ - enabled: !!p.enabled, - name: p.name || "", - value: p.value || "", - id: p.id, - })), - [request.body.form], - ); - - const handleChange = useCallback( - (pairs) => - onChange({ form: pairs.map((p) => ({ enabled: p.enabled, name: p.name, value: p.value })) }), - [onChange], - ); - - return ( - - ); -} diff --git a/apps/yaak-client/components/HeadersEditor.tsx b/apps/yaak-client/components/HeadersEditor.tsx index d3755f78d..77c9cf9d3 100644 --- a/apps/yaak-client/components/HeadersEditor.tsx +++ b/apps/yaak-client/components/HeadersEditor.tsx @@ -1,180 +1 @@ -import type { HttpRequestHeader } from "@yaakapp-internal/models"; -import { HStack, CountBadge } from "@yaakapp-internal/ui"; -import { acceptLanguages } from "../lib/data/acceptLanguages"; -import { cacheControlDirectives } from "../lib/data/cacheControl"; -import { charsets } from "../lib/data/charsets"; -import { connections } from "../lib/data/connections"; -import { encodings } from "../lib/data/encodings"; -import type { HeaderValuePreset } from "../lib/data/headerValuePresets"; -import { headerNames } from "../lib/data/headerNames"; -import { mimeTypes } from "../lib/data/mimetypes"; -import { userAgents } from "../lib/data/userAgents"; -import { validateHttpHeader } from "../lib/validateHttpHeader"; -import { DetailsBanner } from "./core/DetailsBanner"; -import type { GenericCompletion, GenericCompletionConfig } from "./core/Editor/genericCompletion"; -import type { InputProps } from "./core/Input"; -import type { Pair, PairEditorProps } from "./core/PairEditor"; -import { PairEditorRow } from "./core/PairEditor"; -import { ensurePairId } from "./core/PairEditor.util"; -import { PairOrBulkEditor } from "./core/PairOrBulkEditor"; - -type Props = { - forceUpdateKey: string; - headers: HttpRequestHeader[]; - inheritedHeaders?: HttpRequestHeader[]; - inheritedHeadersLabel?: string; - stateKey: string; - onChange: (headers: HttpRequestHeader[]) => void; - label?: string; -}; - -export function HeadersEditor({ - stateKey, - headers, - inheritedHeaders, - inheritedHeadersLabel = "Inherited", - onChange, - forceUpdateKey, -}: Props) { - // Get header names defined at current level (case-insensitive) - const currentHeaderNames = new Set( - headers.filter((h) => h.name).map((h) => h.name.toLowerCase()), - ); - // Filter inherited headers: must be enabled, have content, and not be overridden by current level - const validInheritedHeaders = - inheritedHeaders?.filter( - (pair) => - pair.enabled && - (pair.name || pair.value) && - !currentHeaderNames.has(pair.name.toLowerCase()), - ) ?? []; - const hasInheritedHeaders = validInheritedHeaders.length > 0; - return ( -
- {hasInheritedHeaders && ( - - {inheritedHeadersLabel} - - } - > -
- {validInheritedHeaders?.map((pair, i) => ( - - ))} -
-
- )} - -
- ); -} - -const MIN_MATCH = 3; - -const headerOptionsMap: Record = { - "content-type": mimeTypes, - accept: ["*/*", ...mimeTypes], - "accept-encoding": encodings, - "content-encoding": encodings, - connection: connections, - "accept-charset": charsets, - "accept-language": acceptLanguages, - "cache-control": cacheControlDirectives, - "user-agent": userAgents, - pragma: ["no-cache"], - te: ["trailers", "compress", "deflate", "gzip"], - dnt: ["1", "0"], - "upgrade-insecure-requests": ["1"], - "x-requested-with": ["XMLHttpRequest"], -}; - -const valueType = (pair: Pair): InputProps["type"] => { - const name = pair.name.toLowerCase().trim(); - if ( - name.includes("authorization") || - name.includes("api-key") || - name.includes("access-token") || - name.includes("auth") || - name.includes("secret") || - name.includes("token") || - name === "cookie" || - name === "set-cookie" - ) { - return "password"; - } - return "text"; -}; - -const valueAutocomplete = (headerName: string): GenericCompletionConfig | undefined => { - const name = headerName.toLowerCase().trim(); - const options: GenericCompletion[] = - headerOptionsMap[name]?.map((o) => - typeof o === "string" - ? { - label: o, - type: "constant", - boost: 1, // Put above other completions - } - : { - // Show the short label but insert the full value (e.g. User-Agent) - label: o.label, - apply: o.value, - type: "constant", - boost: 1, - }, - ) ?? []; - return { minMatch: MIN_MATCH, options }; -}; - -const nameAutocomplete: PairEditorProps["nameAutocomplete"] = { - minMatch: MIN_MATCH, - options: headerNames.map((t) => - typeof t === "string" - ? { - label: t, - type: "constant", - boost: 1, // Put above other completions - } - : { - ...t, - boost: 1, // Put above other completions - }, - ), -}; +export { HeadersEditor } from "@yaakapp-internal/ui"; diff --git a/apps/yaak-client/components/HttpRequestPane.tsx b/apps/yaak-client/components/HttpRequestPane.tsx index cf081f0a8..579c26622 100644 --- a/apps/yaak-client/components/HttpRequestPane.tsx +++ b/apps/yaak-client/components/HttpRequestPane.tsx @@ -1,9 +1,16 @@ import type { HttpRequest } from "@yaakapp-internal/models"; import { getModel, patchModel, patchModelDebounced } from "@yaakapp-internal/models"; import type { GenericCompletionOption } from "@yaakapp-internal/plugins"; -import classNames from "classnames"; +import { + CountBadge, + type EditablePair, + type ExtraPaneTab, + HttpRequestPaneView, + InlineCode, + languageFromContentType, +} from "@yaakapp-internal/ui"; import { atom, useAtomValue } from "jotai"; -import type { CSSProperties } from "react"; +import type { CSSProperties, ReactNode } from "react"; import { lazy, Suspense, useCallback, useMemo, useRef, useState } from "react"; import { importCurl, looksLikeCurl } from "../commands/importCurl"; import { allRequestUrlsAtom } from "../hooks/useAllRequests"; @@ -15,8 +22,8 @@ import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse"; import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor"; import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey"; import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest"; -import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders"; -import { convertRequestBody } from "../lib/requestBodyConversion"; +import { fireAndForget } from "../lib/fireAndForget"; +import { httpRequestToView } from "../lib/httpViews"; import { BODY_TYPE_BINARY, BODY_TYPE_FORM_MULTIPART, @@ -28,28 +35,22 @@ import { BODY_TYPE_XML, getContentTypeFromHeaders, } from "../lib/model_util"; +import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders"; import { prepareImportQuerystring } from "../lib/prepareImportQuerystring"; +import { convertRequestBody } from "../lib/requestBodyConversion"; import { resolvedModelName } from "../lib/resolvedModelName"; import { showToast } from "../lib/toast"; import { BinaryFileEditor } from "./BinaryFileEditor"; import { ConfirmLargeRequestBody } from "./ConfirmLargeRequestBody"; import type { GenericCompletionConfig } from "./core/Editor/genericCompletion"; import { getUrlCompletionConfig } from "./core/Editor/url/completion"; -import { Editor } from "./core/Editor/LazyEditor"; -import { InlineCode, languageFromContentType, CountBadge } from "@yaakapp-internal/ui"; import { PlainInput } from "./core/PlainInput"; import type { TabItem, TabsRef } from "./core/Tabs/Tabs"; -import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs"; -import { EmptyStateText } from "./EmptyStateText"; -import { FormMultipartEditor } from "./FormMultipartEditor"; -import { FormUrlencodedEditor } from "./FormUrlencodedEditor"; -import { HeadersEditor } from "./HeadersEditor"; +import { setActiveTab } from "./core/Tabs/Tabs"; import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor"; -import { JsonBodyEditor } from "./JsonBodyEditor"; import { MarkdownEditor } from "./MarkdownEditor"; -import { RequestMethodDropdown } from "./RequestMethodDropdown"; import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor"; -import { UrlBar } from "./UrlBar"; +import { RequestMethodDropdown } from "./RequestMethodDropdown"; import { UrlParametersEditor } from "./UrlParameterEditor"; import { generateId } from "@yaakapp-internal/lib/generateId"; @@ -165,9 +166,9 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }: const hasDescription = activeRequest.description.trim().length > 0; - const tabs = useMemo( - () => [ - { + const bodyTab = useMemo( + () => + ({ value: TAB_BODY, rightSlot: numParams > 0 ? : null, options: { @@ -244,36 +245,8 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }: } }, }, - }, - { - value: TAB_PARAMS, - rightSlot: , - label: "Params", - }, - ...headersTab, - ...authTab, - { - value: TAB_SETTINGS, - label: "Settings", - rightSlot: , - }, - { - value: TAB_DESCRIPTION, - label: "Info", - rightSlot: hasDescription && , - }, - ], - [ - activeRequest, - authTab, - contentType, - handleContentTypeChange, - hasDescription, - headersTab, - numParams, - numSettingsOverrides, - urlParameterPairs.length, - ], + }) satisfies TabItem, + [activeRequest, contentType, handleContentTypeChange, numParams], ); const { mutate: sendRequest } = useSendAnyHttpRequest(); @@ -335,154 +308,152 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }: [activeRequest], ); - return ( -
- {activeRequest && ( - <> - - -
- } - forceUpdateKey={updateKey} - isLoading={activeResponse != null && activeResponse.state !== "closed"} + const handleJsonOrBodyTextChange = useCallback( + (text: string, bodyType: string | null) => { + // JSON writes at once, as its editor always has; other text debounces + if (bodyType === BODY_TYPE_JSON) { + return patchModel(activeRequest, { body: { ...activeRequest.body, text } }); + } + return handleBodyTextChange(text); + }, + [activeRequest, handleBodyTextChange], + ); + + const handleSendJsonCommentsChange = useCallback( + (sendJsonComments: boolean) => { + const newBody = { ...activeRequest.body }; + if (sendJsonComments) { + newBody.sendJsonComments = true; + } else { + delete newBody.sendJsonComments; + } + fireAndForget(patchModel(activeRequest, { body: newBody })); + }, + [activeRequest], + ); + + const requestView = useMemo(() => httpRequestToView(activeRequest), [activeRequest]); + + const extraTabs = useMemo( + () => [ + { + tab: { + value: TAB_SETTINGS, + label: "Settings", + rightSlot: , + }, + content: , + }, + { + tab: { + value: TAB_DESCRIPTION, + label: "Info", + rightSlot: hasDescription && , + }, + content: ( +
+ patchModel(activeRequest, { name })} + /> + patchModel(activeRequest, { description })} + /> +
+ ), + }, + ], + [activeRequest, hasDescription, numSettingsOverrides, updateKey], + ); + + const renderBody = useCallback( + (sharedEditor: ReactNode) => ( + + {activeRequest.bodyType === BODY_TYPE_GRAPHQL ? ( + + + + ) : activeRequest.bodyType === BODY_TYPE_BINARY ? ( + patchModelDebounced(activeRequest, { body })} + onChangeContentType={handleContentTypeChange} /> - - - - - - patchModelDebounced(activeRequest, { headers })} - /> - - - patchModelDebounced(activeRequest, { urlParameters })} - /> - - - - - - - {activeRequest.bodyType === BODY_TYPE_JSON ? ( - - ) : activeRequest.bodyType === BODY_TYPE_XML ? ( - - ) : activeRequest.bodyType === BODY_TYPE_GRAPHQL ? ( - - - - ) : activeRequest.bodyType === BODY_TYPE_FORM_URLENCODED ? ( - - ) : activeRequest.bodyType === BODY_TYPE_FORM_MULTIPART ? ( - - ) : activeRequest.bodyType === BODY_TYPE_BINARY ? ( - patchModelDebounced(activeRequest, { body })} - onChangeContentType={handleContentTypeChange} - /> - ) : typeof activeRequest.bodyType === "string" ? ( - - ) : ( - No Body - )} - - - -
- patchModel(activeRequest, { name })} - /> - patchModel(activeRequest, { description })} - /> -
-
-
- - )} - + ) : ( + sharedEditor + )} +
+ ), + [activeRequest, contentType, forceUpdateKey, handleBodyChange, handleContentTypeChange], + ); + + const renderUrlParameters = useCallback( + (pairs: EditablePair[]) => ( + patchModelDebounced(activeRequest, { urlParameters })} + /> + ), + [activeRequest, forceUpdateKey, urlParametersKey], + ); + + return ( + } + bodyTab={bodyTab} + renderBody={renderBody} + onBodyChange={handleBodyChange} + onBodyTextChange={handleJsonOrBodyTextChange} + onSendJsonCommentsChange={handleSendJsonCommentsChange} + jsonNoticeStorageKey={activeRequest.workspaceId} + urlParameterPairs={urlParameterPairs} + renderUrlParameters={renderUrlParameters} + headersTabs={headersTab} + inheritedHeaders={inheritedHeaders} + headersForceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`} + onHeadersChange={(headers) => patchModelDebounced(activeRequest, { headers })} + authTabs={authTab} + authContent={} + extraTabs={extraTabs} + /> ); } diff --git a/apps/yaak-client/components/HttpResponsePane.tsx b/apps/yaak-client/components/HttpResponsePane.tsx index 13f864eb8..03267f837 100644 --- a/apps/yaak-client/components/HttpResponsePane.tsx +++ b/apps/yaak-client/components/HttpResponsePane.tsx @@ -1,56 +1,36 @@ import type { HttpResponse, HttpResponseEvent } from "@yaakapp-internal/models"; import { - Banner, - HStack, - Icon, - LoadingIcon, - VStack, CountBadge, - HttpResponseDurationTag, - HttpStatusTag, - SizeTag, + type ExtraPaneTab, + type HostBodyViewerArgs, + HttpResponsePaneView, + Icon, + type ResponseTextViewerExtras, + type TextFilterApi, Tooltip, - AudioViewer, - CsvViewer, - ImageViewer, - SvgViewer, - VideoViewer, + VStack, } from "@yaakapp-internal/ui"; -import classNames from "classnames"; -import type { ComponentType, CSSProperties } from "react"; -import { lazy, Suspense, useMemo } from "react"; +import type { CSSProperties } from "react"; +import { useCallback, useMemo } from "react"; import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse"; import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse"; +import { useRequestBodyLoader, useResponseBodyLoader } from "../hooks/useHttpBodyLoaders"; import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents"; import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse"; -import { useResponseBodyBytes, useResponseBodyText } from "../hooks/useResponseBodyText"; -import { useResponseBodyUrl } from "../hooks/useResponseBodyUrl"; +import { useRecentFilters } from "../hooks/useRecentFilters"; import { useResponseViewMode } from "../hooks/useResponseViewMode"; import { useSaveResponse } from "../hooks/useSaveResponse"; import { useTimelineViewMode } from "../hooks/useTimelineViewMode"; -import { getMimeTypeFromContentType } from "../lib/contentType"; -import { getContentTypeFromHeaders, getCookieCounts } from "../lib/model_util"; -import { ConfirmLargeResponse } from "./ConfirmLargeResponse"; -import { ConfirmLargeResponseRequest } from "./ConfirmLargeResponseRequest"; -import { Button } from "./core/Button"; +import { httpResponseToView } from "../lib/httpViews"; +import { getCookieCounts } from "../lib/model_util"; +import { CopyButton } from "./CopyButton"; import { HotkeyList } from "./core/HotkeyList"; +import { IconButton } from "./core/IconButton"; import { PillButton } from "./core/PillButton"; -import type { TabItem } from "./core/Tabs/Tabs"; -import { TabContent, Tabs } from "./core/Tabs/Tabs"; -import { EmptyStateText } from "./EmptyStateText"; -import { ErrorBoundary } from "./ErrorBoundary"; import { HttpResponseTimeline } from "./HttpResponseTimeline"; import { RecentHttpResponsesDropdown } from "./RecentHttpResponsesDropdown"; -import { RequestBodyViewer } from "./RequestBodyViewer"; -import { ResponseCookies } from "./ResponseCookies"; -import { ResponseHeaders } from "./ResponseHeaders"; import { EventStreamViewer } from "./responseViewers/EventStreamViewer"; -import { HTMLOrTextViewer } from "./responseViewers/HTMLOrTextViewer"; -import { MultipartViewer } from "./responseViewers/MultipartViewer"; - -const PdfViewer = lazy(() => - import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })), -); +import { RecentFiltersDropdown } from "./responseViewers/RecentFiltersDropdown"; interface Props { style?: CSSProperties; @@ -58,10 +38,6 @@ interface Props { activeRequestId: string; } -const TAB_BODY = "body"; -const TAB_REQUEST = "request"; -const TAB_HEADERS = "headers"; -const TAB_COOKIES = "cookies"; const TAB_TIMELINE = "timeline"; export type TimelineViewMode = "timeline" | "text"; @@ -71,12 +47,11 @@ interface RedirectDropWarning { droppedHeaders: string[]; } +/** The shared response pane, bound to the pinned response of the active request */ export function HttpResponsePane({ style, className, activeRequestId }: Props) { const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId); const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId); const [timelineViewMode, setTimelineViewMode] = useTimelineViewMode(); - const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null); - const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence; const responseEvents = useHttpResponseEvents(activeResponse); const redirectDropWarning = useMemo( @@ -89,280 +64,181 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]); const saveResponse = useSaveResponse(activeResponse ?? null); const copyResponse = useCopyHttpResponse(activeResponse ?? null); + const cancel = useCancelHttpResponse(activeResponse?.id ?? null); + const body = useResponseBodyLoader(activeResponse ?? null); + const requestBody = useRequestBodyLoader(activeResponse ?? null); + const textViewer = useResponseTextViewerExtras(activeResponse ?? null); - const tabs = useMemo( - () => [ - { - value: TAB_BODY, - label: "Response", - options: { - value: viewMode, - onChange: setViewMode, - items: [ - { label: "Response", value: "pretty" }, - ...(mimeType?.startsWith("image") - ? [] - : [{ label: "Response (Raw)", shortLabel: "Raw", value: "raw" }]), - ], - itemsAfter: [ - { - label: "Save to File", - onSelect: saveResponse.mutate, - leftSlot: , - hidden: activeResponse == null || !!activeResponse.error, - disabled: activeResponse?.state !== "closed" && (activeResponse?.status ?? 0) >= 100, - }, - { - label: "Copy Body", - onSelect: copyResponse.mutate, - leftSlot: , - hidden: activeResponse == null || !!activeResponse.error, - disabled: activeResponse?.state !== "closed" && (activeResponse?.status ?? 0) >= 100, - }, - ], - }, - }, - { - value: TAB_REQUEST, - label: "Request", - rightSlot: - (activeResponse?.requestContentLength ?? 0) > 0 ? : null, - }, - { - value: TAB_HEADERS, - label: "Headers", - rightSlot: ( - - ), - }, - { - value: TAB_COOKIES, - label: "Cookies", - rightSlot: - cookieCounts.sent > 0 || cookieCounts.received > 0 ? ( - - ) : null, - }, - { - value: TAB_TIMELINE, - rightSlot: , - options: { - value: timelineViewMode, - onChange: (v) => setTimelineViewMode((v as TimelineViewMode) ?? "timeline"), - items: [ - { label: "Timeline", value: "timeline" }, - { label: "Timeline (Text)", shortLabel: "Timeline", value: "text" }, - ], - }, - }, - ], - [ - activeResponse?.headers, - activeResponse, - activeResponse?.error, - activeResponse?.requestContentLength, - activeResponse?.requestHeaders.length, - activeResponse?.state, - activeResponse?.status, - cookieCounts.sent, - cookieCounts.received, - copyResponse.mutate, - mimeType, - responseEvents.data?.length, - saveResponse.mutate, - setViewMode, - viewMode, - timelineViewMode, - setTimelineViewMode, - ], + const responseView = useMemo( + () => (activeResponse == null ? null : httpResponseToView(activeResponse, responseEvents.data)), + [activeResponse, responseEvents.data], ); - const cancel = useCancelHttpResponse(activeResponse?.id ?? null); + const extraTabs = useMemo( + () => + activeResponse == null + ? [] + : [ + { + tab: { + value: TAB_TIMELINE, + rightSlot: , + options: { + value: timelineViewMode, + onChange: (v) => setTimelineViewMode((v as TimelineViewMode) ?? "timeline"), + items: [ + { label: "Timeline", value: "timeline" }, + { label: "Timeline (Text)", shortLabel: "Timeline", value: "text" }, + ], + }, + }, + content: ( + + ), + }, + ], + [activeResponse, responseEvents.data?.length, setTimelineViewMode, timelineViewMode], + ); + + const renderBodyViewer = useCallback( + ({ mimeType, viewMode }: HostBodyViewerArgs) => + activeResponse != null && mimeType?.match(/^text\/event-stream/i) && viewMode === "pretty" ? ( + + ) : null, + [activeResponse], + ); return ( -
- {activeResponse == null ? ( + className={className} + response={responseView} + body={body} + requestBody={requestBody} + emptyState={ - ) : ( -
- - {activeResponse && ( -
+ Redirect changed this request + {redirectDropWarning.droppedBodyCount > 0 && ( + + Body dropped on {redirectDropWarning.droppedBodyCount}{" "} + {redirectDropWarning.droppedBodyCount === 1 ? "redirect hop" : "redirect hops"} + )} - > - - {activeResponse.state !== "closed" && } - - • - - • - - - {shouldShowRedirectDropWarning ? ( - - - Redirect changed this request - - {redirectDropWarning.droppedBodyCount > 0 && ( - - Body dropped on {redirectDropWarning.droppedBodyCount}{" "} - {redirectDropWarning.droppedBodyCount === 1 - ? "redirect hop" - : "redirect hops"} - - )} - {redirectDropWarning.droppedHeaders.length > 0 && ( - - Headers dropped:{" "} - - {redirectDropWarning.droppedHeaders.join(", ")} - - - )} - See Timeline for details. - - } - > - - } - > - - {getRedirectWarningLabel(redirectDropWarning)} - - + {redirectDropWarning.droppedHeaders.length > 0 && ( + + Headers dropped:{" "} + + {redirectDropWarning.droppedHeaders.join(", ")} - - ) : ( - + )} -
- -
-
- )} -
- -
- {activeResponse?.error && ( - - {activeResponse.error} - - )} - {/* Show tabs if we have any data (headers, body, etc.) even if there's an error */} - - - - - - {activeResponse.state === "initialized" ? ( - - - - - Sending Request - - - - - ) : activeResponse.state === "closed" && - (activeResponse.contentLength ?? 0) === 0 ? ( - Empty - ) : mimeType?.match(/^text\/event-stream/i) && viewMode === "pretty" ? ( - - ) : mimeType?.match(/^image\/svg/) ? ( - - ) : mimeType?.match(/^image/i) ? ( - - ) : mimeType?.match(/^audio/i) ? ( - - ) : mimeType?.match(/^video/i) ? ( - - ) : mimeType?.match(/^multipart/i) && viewMode === "pretty" ? ( - - ) : mimeType?.match(/pdf/i) ? ( - - ) : mimeType?.match(/csv|tab-separated/i) && viewMode === "pretty" ? ( - - ) : ( - - )} - - - - - - - - - - - - - - - - - - - -
-
- )} -
+ See Timeline for details. + + } + > + + } + > + {getRedirectWarningLabel(redirectDropWarning)} + + + + ) : null + } + statusLineRight={ + activeResponse != null && ( + + ) + } + /> ); } +/** The client's additions to the response text viewer: filter history, save and copy */ +function useResponseTextViewerExtras( + response: HttpResponse | null, +): ResponseTextViewerExtras | undefined { + const filterStateKey = `response.body.${response?.requestId ?? ""}`; + const { recentFilters, addFilter, removeFilter, togglePin, clearFilters } = + useRecentFilters(filterStateKey); + const saveResponse = useSaveResponse(response); + const copyResponse = useCopyHttpResponse(response); + const filterLeftSlot = useCallback( + (filter: TextFilterApi) => ( +
+ +
+ ), + [recentFilters, removeFilter, togglePin, clearFilters], + ); + + if (response == null) return undefined; + const actionsDisabled = response.state !== "closed" && response.status >= 100; + return { + filterStateKey, + onFilterApplied: addFilter, + filterLeftSlot, + footerActions: [ + saveResponse.mutate()} + className="border !border-border-subtle" + />, + copyResponse.mutate()} + className="border !border-border-subtle" + />, + ], + }; +} + +function renderCopyButton(getText: () => Promise) { + return ; +} + function getRedirectDropWarning( events: HttpResponseEvent[] | undefined, ): RedirectDropWarning | null { @@ -410,58 +286,3 @@ function getRedirectWarningLabel(warning: RedirectDropWarning): string { } return "Dropped headers"; } - -function EnsureCompleteResponse({ - response, - Component, -}: { - response: HttpResponse; - Component: ComponentType<{ bodyUrl: string }>; -}) { - // Wait until the response has been fully-downloaded before asking for it - const complete = response.state === "closed"; - const bodyUrl = useResponseBodyUrl(complete ? response : null); - - if (!complete || bodyUrl.isPending) { - return ( - - - - ); - } - - if (bodyUrl.error) { - return {String(bodyUrl.error)}; - } - - if (bodyUrl.data == null) { - return
Empty response body
; - } - - return ; -} - -function HttpSvgViewer({ response }: { response: HttpResponse }) { - const body = useResponseBodyText({ response, filter: null }); - - if (!body.data) return null; - - return ; -} - -function HttpCsvViewer({ response, className }: { response: HttpResponse; className?: string }) { - const body = useResponseBodyText({ response, filter: null }); - - return ; -} - -function HttpMultipartViewer({ response }: { response: HttpResponse }) { - const body = useResponseBodyBytes({ response }); - - if (body.data == null) return null; - - const contentTypeHeader = getContentTypeFromHeaders(response.headers); - const boundary = contentTypeHeader?.split("boundary=")[1] ?? "unknown"; - - return ; -} diff --git a/apps/yaak-client/components/JsonBodyEditor.tsx b/apps/yaak-client/components/JsonBodyEditor.tsx deleted file mode 100644 index 7f171c154..000000000 --- a/apps/yaak-client/components/JsonBodyEditor.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { linter } from "@codemirror/lint"; -import type { HttpRequest } from "@yaakapp-internal/models"; -import { patchModel } from "@yaakapp-internal/models"; -import { Banner, Icon, IconTooltip } from "@yaakapp-internal/ui"; -import { useCallback, useMemo } from "react"; -import { useKeyValue } from "../hooks/useKeyValue"; -import { fireAndForget } from "../lib/fireAndForget"; -import { textLikelyContainsJsonComments } from "../lib/jsonComments"; -import type { DropdownItem } from "./core/Dropdown"; -import { Dropdown } from "./core/Dropdown"; -import type { EditorProps } from "./core/Editor/Editor"; -import { jsonParseLinter } from "./core/Editor/json-lint"; -import { Editor } from "./core/Editor/LazyEditor"; -import { IconButton } from "./core/IconButton"; - -interface Props { - forceUpdateKey: string; - heightMode: EditorProps["heightMode"]; - request: HttpRequest; -} - -export function JsonBodyEditor({ forceUpdateKey, heightMode, request }: Props) { - const handleChange = useCallback( - (text: string) => patchModel(request, { body: { ...request.body, text } }), - [request], - ); - - const autoFix = request.body?.sendJsonComments !== true; - - const lintExtension = useMemo( - () => - linter( - jsonParseLinter( - autoFix - ? { allowComments: true, allowTrailingCommas: true } - : { allowComments: false, allowTrailingCommas: false }, - ), - ), - [autoFix], - ); - - const hasComments = useMemo( - () => textLikelyContainsJsonComments(request.body?.text ?? ""), - [request.body?.text], - ); - - const { value: bannerDismissed, set: setBannerDismissed } = useKeyValue({ - namespace: "no_sync", - key: ["json-fix-3", request.workspaceId], - fallback: false, - }); - - const handleToggleAutoFix = useCallback(() => { - const newBody = { ...request.body }; - if (autoFix) { - newBody.sendJsonComments = true; - } else { - delete newBody.sendJsonComments; - } - fireAndForget(patchModel(request, { body: newBody })); - }, [request, autoFix]); - - const handleDropdownOpen = useCallback(() => { - if (!bannerDismissed) { - fireAndForget(setBannerDismissed(true)); - } - }, [bannerDismissed, setBannerDismissed]); - - const showBanner = hasComments && autoFix && !bannerDismissed; - - const stripMessage = "Automatically strip comments and trailing commas before sending"; - const actions = useMemo( - () => [ - showBanner && ( - -

- Auto-fix enabled - -

-
- ), -
- , - leftSlot: ( - - ), - }, - ] satisfies DropdownItem[] - } - > - - -
, - ], - [handleDropdownOpen, handleToggleAutoFix, autoFix, showBanner], - ); - - return ( - - ); -} diff --git a/apps/yaak-client/components/RequestBodyViewer.tsx b/apps/yaak-client/components/RequestBodyViewer.tsx deleted file mode 100644 index f4e63ed66..000000000 --- a/apps/yaak-client/components/RequestBodyViewer.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import type { HttpResponse } from "@yaakapp-internal/models"; -import { lazy, Suspense } from "react"; -import { useHttpRequestBody } from "../hooks/useHttpRequestBody"; -import { getMimeTypeFromContentType } from "../lib/contentType"; -import { - LoadingIcon, - languageFromContentType, - AudioViewer, - CsvViewer, - ImageViewer, - SvgViewer, - VideoViewer, - WebPageViewer, -} from "@yaakapp-internal/ui"; -import { EmptyStateText } from "./EmptyStateText"; -import { MultipartViewer } from "./responseViewers/MultipartViewer"; -import { TextViewer } from "./responseViewers/TextViewer"; - -const PdfViewer = lazy(() => - import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })), -); - -interface Props { - response: HttpResponse; -} - -export function RequestBodyViewer({ response }: Props) { - return ; -} - -function RequestBodyViewerInner({ response }: Props) { - const { data, isLoading, error } = useHttpRequestBody(response); - - if (isLoading) { - return ( - - - - ); - } - - if (error) { - return Error loading request body: {error.message}; - } - - if (data?.bodyText == null || data.bodyText.length === 0) { - return No request body; - } - - const { bodyText, body } = data; - - // Try to detect language from content-type header that was sent - const contentTypeHeader = response.requestHeaders.find( - (h) => h.name.toLowerCase() === "content-type", - ); - const contentType = contentTypeHeader?.value ?? null; - const mimeType = contentType ? getMimeTypeFromContentType(contentType).essence : null; - const language = languageFromContentType(contentType, bodyText); - - // Route to appropriate viewer based on content type - if (mimeType?.match(/^multipart/i)) { - const boundary = contentType?.split("boundary=")[1] ?? "unknown"; - // Create a copy because parseMultipart may detach the buffer - const bodyCopy = new Uint8Array(body); - return ( - - ); - } - - if (mimeType?.match(/^image\/svg/i)) { - return ; - } - - if (mimeType?.match(/^image/i)) { - return ; - } - - if (mimeType?.match(/^audio/i)) { - return ; - } - - if (mimeType?.match(/^video/i)) { - return ; - } - - if (mimeType?.match(/csv|tab-separated/i)) { - return ; - } - - if (mimeType?.match(/^text\/html/i)) { - return ; - } - - if (mimeType?.match(/pdf/i)) { - return ( - }> - - - ); - } - - return ( - - ); -} diff --git a/apps/yaak-client/components/RequestMethodDropdown.tsx b/apps/yaak-client/components/RequestMethodDropdown.tsx index 8754750e0..746117ad2 100644 --- a/apps/yaak-client/components/RequestMethodDropdown.tsx +++ b/apps/yaak-client/components/RequestMethodDropdown.tsx @@ -1,38 +1,20 @@ import type { HttpRequest } from "@yaakapp-internal/models"; -import { patchModel } from "@yaakapp-internal/models"; -import classNames from "classnames"; -import { memo, useCallback, useMemo } from "react"; -import { showPrompt } from "../lib/prompt"; -import { Button } from "./core/Button"; -import type { DropdownItem } from "./core/Dropdown"; -import { HttpMethodTag, HttpMethodTagRaw } from "./core/HttpMethodTag"; -import { Icon } from "@yaakapp-internal/ui"; -import type { RadioDropdownItem } from "./core/RadioDropdown"; -import { RadioDropdown } from "./core/RadioDropdown"; +import { patchModel, settingsAtom } from "@yaakapp-internal/models"; +import { RequestMethodDropdown as BaseRequestMethodDropdown } from "@yaakapp-internal/ui"; +import { useAtomValue } from "jotai"; +import { memo, useCallback } from "react"; type Props = { request: HttpRequest; className?: string; }; -const radioItems: RadioDropdownItem[] = [ - "GET", - "PUT", - "POST", - "PATCH", - "DELETE", - "OPTIONS", - "QUERY", - "HEAD", -].map((m) => ({ - value: m, - label: , -})); - +/** The shared method menu, writing to the request and coloured by the user's setting */ export const RequestMethodDropdown = memo(function RequestMethodDropdown({ request, className, }: Props) { + const settings = useAtomValue(settingsAtom); const handleChange = useCallback( async (method: string) => { await patchModel(request, { method }); @@ -40,39 +22,12 @@ export const RequestMethodDropdown = memo(function RequestMethodDropdown({ [request], ); - const itemsAfter = useMemo( - () => [ - { - key: "custom", - label: "CUSTOM", - leftSlot: , - onSelect: async () => { - const newMethod = await showPrompt({ - id: "custom-method", - label: "Http Method", - title: "Custom Method", - confirmText: "Save", - description: "Enter a custom method name", - placeholder: "CUSTOM", - }); - if (newMethod == null) return; - await handleChange(newMethod); - }, - }, - ], - [handleChange], - ); - return ( - - - + className={className} + /> ); }); diff --git a/apps/yaak-client/components/ResponseHeaders.tsx b/apps/yaak-client/components/ResponseHeaders.tsx deleted file mode 100644 index 2e933e633..000000000 --- a/apps/yaak-client/components/ResponseHeaders.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import type { HttpResponse } from "@yaakapp-internal/models"; -import { platform } from "@yaakapp-internal/platform"; -import { HeadersTable, KeyValueRows } from "@yaakapp-internal/ui"; -import { format, formatDistanceToNowStrict } from "date-fns"; -import { DetailsBanner, useDetailsBannerStoredValue } from "./core/DetailsBanner"; -import { IconButton } from "./core/IconButton"; -import { KeyValueRow } from "./core/KeyValueRow"; - -interface Props { - response: HttpResponse; -} - -export function ResponseHeaders({ response }: Props) { - return ( -
- Info}> - - - - - -
- {response.url} - platform.openUrl(response.url)} - title="Open in browser" - /> -
-
- - {response.remoteAddr ?? --} - - - {response.version ?? --} - -
-
- - -
- ); -} diff --git a/apps/yaak-client/components/UrlBar.tsx b/apps/yaak-client/components/UrlBar.tsx index b729ad3b1..bdec9c87f 100644 --- a/apps/yaak-client/components/UrlBar.tsx +++ b/apps/yaak-client/components/UrlBar.tsx @@ -1,113 +1,14 @@ -import type { HttpRequest } from "@yaakapp-internal/models"; -import type { IconProps } from "@yaakapp-internal/ui"; -import { HStack } from "@yaakapp-internal/ui"; -import classNames from "classnames"; -import type { FormEvent, ReactNode } from "react"; -import { memo, useCallback, useRef, useState } from "react"; -import { useHotKey } from "../hooks/useHotKey"; -import { IconButton } from "./core/IconButton"; -import type { InputHandle, InputProps } from "./core/Input"; -import { Input } from "./core/Input"; +import { UrlBar as BaseUrlBar, type UrlBarProps } from "@yaakapp-internal/ui"; +import { memo } from "react"; -type Props = Pick & { - className?: string; - placeholder: string; +type Props = Omit & { onSend: () => void; onUrlChange: (url: string) => void; - onPaste?: (v: string) => void; - onPasteOverwrite?: InputProps["onPasteOverwrite"]; onCancel: () => void; - submitIcon?: IconProps["icon"] | null; isLoading: boolean; - forceUpdateKey: string; - rightSlot?: ReactNode; - leftSlot?: ReactNode; - autocomplete?: InputProps["autocomplete"]; - stateKey: InputProps["stateKey"]; }; -export const UrlBar = memo(function UrlBar({ - forceUpdateKey, - onUrlChange, - url, - placeholder, - className, - onSend, - onCancel, - onPaste, - onPasteOverwrite, - submitIcon = "send_horizontal", - autocomplete, - leftSlot, - rightSlot, - isLoading, - stateKey, -}: Props) { - const inputRef = useRef(null); - const [isFocused, setIsFocused] = useState(false); - - const handleInitInputRef = useCallback((h: InputHandle | null) => { - inputRef.current = h; - }, []); - - useHotKey("url_bar.focus", () => { - inputRef.current?.selectAll(); - }); - - const handleSubmit = (e: FormEvent) => { - e.preventDefault(); - if (isLoading) onCancel(); - else onSend(); - }; - - return ( -
- setIsFocused(true)} - onBlur={() => setIsFocused(false)} - onPaste={onPaste} - onPasteOverwrite={onPasteOverwrite} - onChange={onUrlChange} - defaultValue={url} - placeholder={placeholder} - leftSlot={leftSlot} - rightSlot={ - - {rightSlot &&
{rightSlot}
} - {submitIcon !== null && ( -
- { - // Prevent the button from taking focus - e.preventDefault(); - }} - /> -
- )} -
- } - /> -
- ); +/** The shared URL bar, sending and focusing on the client's hotkeys */ +export const UrlBar = memo(function UrlBar(props: Props) { + return ; }); diff --git a/apps/yaak-client/components/UrlParameterEditor.tsx b/apps/yaak-client/components/UrlParameterEditor.tsx index aab2afad5..321993c19 100644 --- a/apps/yaak-client/components/UrlParameterEditor.tsx +++ b/apps/yaak-client/components/UrlParameterEditor.tsx @@ -1,8 +1,11 @@ -import { VStack } from "@yaakapp-internal/ui"; +import { + type EditablePair, + type PairEditorHandle, + type PairEditorProps, + UrlParametersEditor as BaseUrlParametersEditor, +} from "@yaakapp-internal/ui"; import { useCallback, useRef } from "react"; import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor"; -import type { EditablePair, PairEditorHandle, PairEditorProps } from "./core/PairEditor"; -import { PairOrBulkEditor } from "./core/PairOrBulkEditor"; type Props = { forceUpdateKey: string; @@ -11,6 +14,7 @@ type Props = { onChange: PairEditorProps["onChange"]; }; +/** The shared parameters editor, wired to the request editor's focus events */ export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey }: Props) { const pairEditorRef = useRef(null); const handleInitPairEditorRef = useCallback((ref: PairEditorHandle) => { @@ -33,22 +37,12 @@ export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey ); return ( - - - + ); } diff --git a/apps/yaak-client/components/core/Editor/genericCompletion.ts b/apps/yaak-client/components/core/Editor/genericCompletion.ts index 630da4cde..239a4896b 100644 --- a/apps/yaak-client/components/core/Editor/genericCompletion.ts +++ b/apps/yaak-client/components/core/Editor/genericCompletion.ts @@ -1,15 +1,8 @@ -import type { Completion, CompletionContext } from "@codemirror/autocomplete"; -import type { GenericCompletionOption } from "@yaakapp-internal/plugins"; +import type { CompletionContext } from "@codemirror/autocomplete"; +import type { GenericCompletionConfig } from "@yaakapp-internal/ui"; import { defaultBoost } from "./twig/completion"; -export type GenericCompletion = GenericCompletionOption & { - apply?: Completion["apply"]; -}; - -export interface GenericCompletionConfig { - minMatch?: number; - options: GenericCompletion[]; -} +export type { GenericCompletion, GenericCompletionConfig } from "@yaakapp-internal/ui"; /** * Complete options, always matching until the start of the line diff --git a/apps/yaak-client/components/core/HotkeyList.tsx b/apps/yaak-client/components/core/HotkeyList.tsx index 91f902547..751d3d3a3 100644 --- a/apps/yaak-client/components/core/HotkeyList.tsx +++ b/apps/yaak-client/components/core/HotkeyList.tsx @@ -1,31 +1 @@ -import classNames from "classnames"; -import type { ReactNode } from "react"; -import { Fragment } from "react"; -import type { HotkeyAction } from "../../hooks/useHotKey"; -import { Hotkey } from "./Hotkey"; -import { HotkeyLabel } from "./HotkeyLabel"; - -interface Props { - hotkeys: HotkeyAction[]; - bottomSlot?: ReactNode; - className?: string; -} - -export const HotkeyList = ({ hotkeys, bottomSlot, className }: Props) => { - return ( -
-
- {hotkeys.map((hotkey) => ( - - - {/* Keep this grid cell when Hotkey renders nothing so later rows stay aligned. */} -
- -
-
- ))} - {bottomSlot} -
-
- ); -}; +export { HotkeyList } from "@yaakapp-internal/ui"; diff --git a/apps/yaak-client/components/core/PairEditor.tsx b/apps/yaak-client/components/core/PairEditor.tsx index e3dbe7f86..c4e699526 100644 --- a/apps/yaak-client/components/core/PairEditor.tsx +++ b/apps/yaak-client/components/core/PairEditor.tsx @@ -1,970 +1,8 @@ -import type { DragEndEvent, DragMoveEvent, DragStartEvent } from "@dnd-kit/core"; -import { - DndContext, - DragOverlay, - PointerSensor, - pointerWithin, - useDraggable, - useDroppable, - useSensor, - useSensors, -} from "@dnd-kit/core"; -import classNames from "classnames"; -import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { WrappedEnvironmentVariable } from "../../hooks/useEnvironmentVariables"; -import { useRandomKey } from "../../hooks/useRandomKey"; -import { useToggle } from "../../hooks/useToggle"; -import { showDialog } from "../../lib/dialog"; -import { computeSideForDragMove, DropMarker, languageFromContentType } from "@yaakapp-internal/ui"; -import { showPrompt } from "../../lib/prompt"; -import { SelectFile } from "../SelectFile"; -import { Button } from "./Button"; -import { Checkbox } from "./Checkbox"; -import type { DropdownItem } from "./Dropdown"; -import { Dropdown } from "./Dropdown"; -import type { EditorProps } from "./Editor/Editor"; -import type { GenericCompletionConfig } from "./Editor/genericCompletion"; -import { Editor } from "./Editor/LazyEditor"; -import { Icon } from "@yaakapp-internal/ui"; -import { IconButton } from "./IconButton"; -import type { InputHandle, InputProps } from "./Input"; -import { Input } from "./Input"; -import { ensurePairId } from "./PairEditor.util"; -import type { RadioDropdownItem } from "./RadioDropdown"; -import { RadioDropdown } from "./RadioDropdown"; -import { platform } from "@yaakapp-internal/platform"; - -export interface PairEditorHandle { - /** - * Focus a row's name field once it's able to take focus. Focus can't land immediately when the - * row isn't mounted yet or the editor is hidden — eg. sitting in a tab that's still becoming - * active — so this retries for up to ~1s. A newer focus request cancels a pending one. - */ - focusName(id: string): void; - /** Focus a row's value field. See {@link PairEditorHandle.focusName} for timing. */ - focusValue(id: string): void; -} - -/** ~1s at 60fps, plenty for a tab switch to land without spinning forever if it never does */ -const MAX_FOCUS_ATTEMPTS = 60; - -export type PairEditorProps = { - allowFileValues?: boolean; - allowMultilineValues?: boolean; - className?: string; - forcedEnvironmentId?: string; - forceUpdateKey?: string; - nameAutocomplete?: GenericCompletionConfig; - nameAutocompleteFunctions?: boolean; - nameAutocompleteVariables?: boolean; - namePlaceholder?: string; - nameValidate?: InputProps["validate"]; - noScroll?: boolean; - onChange: (pairs: PairWithId[]) => void; - pairs: EditablePair[]; - stateKey: InputProps["stateKey"]; - setRef?: (n: PairEditorHandle) => void; - valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined; - valueAutocompleteFunctions?: boolean; - valueAutocompleteVariables?: boolean | "environment"; - valuePlaceholder?: string; - valueType?: InputProps["type"] | ((pair: Pair) => InputProps["type"]); - valueValidate?: InputProps["validate"]; -}; - -export type Pair = { - id?: string; - enabled?: boolean; - name: string; - value: string; - contentType?: string; - filename?: string; - isFile?: boolean; -}; - -export type PairWithId = Pair & { - id: string; -}; - -/** - * A pair as handed to the editor. Adds behaviour that only the editor cares about, so the plain - * `Pair` stays the shape that gets written to models. - */ -export type EditablePair = Pair & { - /** - * When set, name edits are held until the field blurs and then committed through this, instead - * of calling `onChange` on every keystroke. Return false to reject the new name, which reverts - * the field. For names that can't be written directly, like a URL path placeholder that lives - * in the URL itself. - */ - commitName?: (name: string) => boolean; -}; - -type EditablePairWithId = EditablePair & { - id: string; -}; - -/** Strip the editor-only fields, so they can never reach a model write */ -function toPairData({ commitName: _commitName, ...pair }: EditablePairWithId): PairWithId { - return pair; -} - -/** Max number of pairs to show before prompting the user to reveal the rest */ -const MAX_INITIAL_PAIRS = 30; - -// Keyed on `stateKey` so no state survives a change of owner. Row ids alone can't tell owners -// apart — two pair sets can share ids (eg. one duplicated from the other), and the same-rows -// fast path below would swap in the new data without rebuilding the row editors, leaving any -// still-focused input showing the old owner's text. -export function PairEditor(props: PairEditorProps) { - return ; -} - -function PairEditorInner({ - allowFileValues, - allowMultilineValues, - className, - forcedEnvironmentId, - forceUpdateKey, - nameAutocomplete, - nameAutocompleteFunctions, - nameAutocompleteVariables, - namePlaceholder, - nameValidate, - noScroll, - onChange, - pairs: originalPairs, - stateKey, - valueAutocomplete, - valueAutocompleteFunctions, - valueAutocompleteVariables, - valuePlaceholder, - valueType, - valueValidate, - setRef, -}: PairEditorProps) { - const [hoveredIndex, setHoveredIndex] = useState(null); - const [isDragging, setIsDragging] = useState(null); - const [pairs, setPairs] = useState([]); - const [showAll, toggleShowAll] = useToggle(false); - // NOTE: Use local force update key because we trigger an effect on forceUpdateKey change. If - // we simply pass forceUpdateKey to the editor, the data set by useEffect will be stale. - const [localForceUpdateKey, regenerateLocalForceUpdateKey] = useRandomKey(); - - const rowsRef = useRef>({}); - - const pendingFocusFrame = useRef(null); - useEffect( - () => () => { - if (pendingFocusFrame.current != null) cancelAnimationFrame(pendingFocusFrame.current); - }, - [], - ); - - const focusWhenReady = useCallback((id: string, field: "name" | "value") => { - if (pendingFocusFrame.current != null) cancelAnimationFrame(pendingFocusFrame.current); - - let attemptsLeft = MAX_FOCUS_ATTEMPTS; - const attempt = () => { - pendingFocusFrame.current = null; - const row = rowsRef.current[id]; - const landed = field === "name" ? row?.focusName() : row?.focusValue(); - if (landed || --attemptsLeft <= 0) return; - pendingFocusFrame.current = requestAnimationFrame(attempt); - }; - attempt(); - }, []); - - const handle = useMemo( - () => ({ - focusName(id: string) { - focusWhenReady(id, "name"); - }, - focusValue(id: string) { - focusWhenReady(id, "value"); - }, - }), - [focusWhenReady], - ); - - const initPairEditorRow = useCallback( - (id: string, n: RowHandle | null) => { - const isLast = id === pairs[pairs.length - 1]?.id; - if (isLast) return; // Never add the last pair - - rowsRef.current[id] = n; - const validHandles = Object.values(rowsRef.current).filter((v) => v != null); - - // Use >= because more might be added if an ID of one changes (eg. editing placeholder in URL regenerates fresh pairs every keystroke) - const ready = validHandles.length >= pairs.length - 1; - if (ready) { - setRef?.(handle); - } - }, - [handle, pairs, setRef], - ); - - // oxlint-disable-next-line react-hooks/exhaustive-deps -- Only care about forceUpdateKey - useEffect(() => { - // Remove empty headers on initial render and ensure they all have valid ids (pairs didn't use to have IDs) - const newPairs: EditablePairWithId[] = []; - for (let i = 0; i < originalPairs.length; i++) { - const p = originalPairs[i]; - if (!p) continue; // Make TS happy - if (isPairEmpty(p)) continue; - newPairs.push(ensurePairId(p)); - } - - // When the reset holds the exact same rows (eg. renaming a URL path placeholder, which keeps - // every row id), swap the data in without rebuilding the row editors. Unfocused inputs re-seed - // themselves when `defaultValue` changes, and a rebuild would drop the user's focus and - // selection — like tabbing from a placeholder's name into its value. - const trailingPair = pairs[pairs.length - 1]; - const sameRows = - trailingPair != null && - isPairEmpty(trailingPair) && - pairs.length === newPairs.length + 1 && - newPairs.every((p, i) => p.id === pairs[i]?.id); - if (sameRows) { - setPairs([...newPairs, trailingPair]); - return; - } - - // Add empty last pair if there is none - const lastPair = newPairs[newPairs.length - 1]; - if (lastPair == null || !isPairEmpty(lastPair)) { - newPairs.push(emptyPair()); - } - - setPairs(newPairs); - regenerateLocalForceUpdateKey(); - }, [forceUpdateKey]); - - const setPairsAndSave = useCallback( - (fn: (pairs: EditablePairWithId[]) => EditablePairWithId[]) => { - setPairs((oldPairs) => { - const pairs = fn(oldPairs); - onChange(pairs.map(toPairData)); - return pairs; - }); - }, - [onChange], - ); - - const handleChange = useCallback( - (pair: EditablePairWithId) => - setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair))), - [setPairsAndSave], - ); - - const handleDelete = useCallback( - (pair: Pair, focusPrevious: boolean) => { - if (focusPrevious) { - const index = pairs.findIndex((p) => p.id === pair.id); - const id = pairs[index - 1]?.id ?? null; - rowsRef.current[id ?? "n/a"]?.focusName(); - } - return setPairsAndSave((oldPairs) => oldPairs.filter((p) => p.id !== pair.id)); - }, - [setPairsAndSave, pairs], - ); - - const handleFocusName = useCallback( - (pair: Pair) => { - const isLast = pair.id === pairs[pairs.length - 1]?.id; - if (isLast) setPairs([...pairs, emptyPair()]); - }, - [pairs], - ); - - const handleFocusValue = useCallback( - (pair: Pair) => { - const isLast = pair.id === pairs[pairs.length - 1]?.id; - if (isLast) setPairs([...pairs, emptyPair()]); - }, - [pairs], - ); - - const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } })); - - // dnd-kit: show the “between rows” marker while hovering - const onDragMove = useCallback( - (e: DragMoveEvent) => { - const overId = e.over?.id as string | undefined; - if (!overId) return setHoveredIndex(null); - - const overPair = pairs.find((p) => p.id === overId); - if (overPair == null) return setHoveredIndex(null); - - const side = computeSideForDragMove(overPair.id, e); - const overIndex = pairs.findIndex((p) => p.id === overId); - const hoveredIndex = overIndex + (side === "before" ? 0 : 1); - - setHoveredIndex(hoveredIndex); - }, - [pairs], - ); - - const onDragStart = useCallback( - (e: DragStartEvent) => { - const pair = pairs.find((p) => p.id === e.active.id); - setIsDragging(pair ?? null); - }, - [pairs], - ); - - const onDragCancel = useCallback(() => setIsDragging(null), []); - - const onDragEnd = useCallback( - (e: DragEndEvent) => { - setIsDragging(null); - setHoveredIndex(null); - const activeId = e.active.id as string | undefined; - const overId = e.over?.id as string | undefined; - if (!activeId || !overId) return; - - const from = pairs.findIndex((p) => p.id === activeId); - const baseTo = pairs.findIndex((p) => p.id === overId); - const to = hoveredIndex ?? (baseTo === -1 ? from : baseTo); - - if (from !== -1 && to !== -1 && from !== to) { - setPairsAndSave((ps) => { - const next = [...ps]; - const [moved] = next.splice(from, 1); - if (moved === undefined) return ps; // Make TS happy - next.splice(to > from ? to - 1 : to, 0, moved); - return next; - }); - } - }, - [pairs, hoveredIndex, setPairsAndSave], - ); - - return ( -
-
- - {pairs.map((p, i) => { - if (!showAll && i > MAX_INITIAL_PAIRS) return null; - - const isLast = i === pairs.length - 1; - return ( - - {hoveredIndex === i && } - - - ); - })} - {!showAll && pairs.length > MAX_INITIAL_PAIRS && ( - - )} - - {isDragging && ( - - )} - - -
- -
{ - e.preventDefault(); - e.stopPropagation(); - }} - /> -
- ); -} - -type PairEditorRowProps = { - className?: string; - pair: EditablePairWithId; - forceFocusNamePairId?: string | null; - forceFocusValuePairId?: string | null; - onChange?: (pair: EditablePairWithId) => void; - onDelete?: (pair: EditablePairWithId, focusPrevious: boolean) => void; - onFocusName?: (pair: EditablePairWithId) => void; - onFocusValue?: (pair: EditablePairWithId) => void; - onSubmit?: (pair: EditablePairWithId) => void; - isLast?: boolean; - disabled?: boolean; - disableDrag?: boolean; - index: number; - isDraggingGlobal?: boolean; - setRef?: (id: string, n: RowHandle | null) => void; -} & Pick< +export type { + EditablePair, + Pair, + PairEditorHandle, PairEditorProps, - | "allowFileValues" - | "allowMultilineValues" - | "forcedEnvironmentId" - | "forceUpdateKey" - | "nameAutocomplete" - | "nameAutocompleteVariables" - | "namePlaceholder" - | "nameValidate" - | "nameAutocompleteFunctions" - | "stateKey" - | "valueAutocomplete" - | "valueAutocompleteFunctions" - | "valueAutocompleteVariables" - | "valuePlaceholder" - | "valueType" - | "valueValidate" ->; - -interface RowHandle { - focusName(): boolean; - focusValue(): boolean; -} - -export function PairEditorRow({ - allowFileValues, - allowMultilineValues, - className, - disableDrag, - disabled, - forceUpdateKey, - forcedEnvironmentId, - index, - isLast, - nameAutocomplete, - nameAutocompleteFunctions, - nameAutocompleteVariables, - namePlaceholder, - nameValidate, - isDraggingGlobal, - onChange, - onDelete, - onFocusName, - onFocusValue, - pair, - stateKey, - valueAutocomplete, - valueAutocompleteFunctions, - valueAutocompleteVariables, - valuePlaceholder, - valueType, - valueValidate, - setRef, -}: PairEditorRowProps) { - const nameInputRef = useRef(null); - const valueInputRef = useRef(null); - const handle = useRef({ - focusName() { - nameInputRef.current?.focus(); - return nameInputRef.current?.isFocused() ?? false; - }, - focusValue() { - valueInputRef.current?.focus(); - return valueInputRef.current?.isFocused() ?? false; - }, - }); - - const initNameInputRef = useCallback( - (n: InputHandle | null) => { - nameInputRef.current = n; - if (nameInputRef.current && valueInputRef.current) { - setRef?.(pair.id, handle.current); - } - }, - [pair.id, setRef], - ); - - const initValueInputRef = useCallback( - (n: InputHandle | null) => { - valueInputRef.current = n; - if (nameInputRef.current && valueInputRef.current) { - setRef?.(pair.id, handle.current); - } - }, - [pair.id, setRef], - ); - - const handleFocusName = useCallback(() => onFocusName?.(pair), [onFocusName, pair]); - const handleFocusValue = useCallback(() => onFocusValue?.(pair), [onFocusValue, pair]); - const handleDelete = useCallback(() => onDelete?.(pair, false), [onDelete, pair]); - - const handleChangeEnabled = useMemo( - () => (enabled: boolean) => onChange?.({ ...pair, enabled }), - [onChange, pair], - ); - - // The name being typed into a deferred-commit field, before it's committed or reverted - const pendingName = useRef(null); - - const handleChangeName = useMemo( - () => (name: string) => { - // Keep the edit local until commit. Writing on every keystroke would reset the editor from - // beneath the cursor, since the pairs are derived from the name being edited. - if (pair.commitName != null) pendingName.current = name; - else onChange?.({ ...pair, name }); - }, - [onChange, pair], - ); - - const revertName = useCallback(() => { - const nameInput = nameInputRef.current; - if (nameInput != null) { - const changes = { from: 0, to: nameInput.value().length, insert: pair.name }; - nameInput.dispatch({ changes }); - } - pendingName.current = null; - }, [pair.name]); - - const handleBlurName = useCallback(() => { - if (pair.commitName == null) return; - - const name = pendingName.current; - pendingName.current = null; - if (name == null || name === pair.name) return; - if (!pair.commitName(name)) revertName(); - }, [pair, revertName]); - - const handleChangeValueText = useMemo( - () => (value: string) => onChange?.({ ...pair, value, isFile: false }), - [onChange, pair], - ); - - const handleChangeValueFile = useMemo( - () => - ({ filePath }: { filePath: string | null }) => - onChange?.({ ...pair, value: filePath ?? "", isFile: true }), - [onChange, pair], - ); - - const handleChangeValueContentType = useMemo( - () => (contentType: string) => onChange?.({ ...pair, contentType }), - [onChange, pair], - ); - - const handleChangeValueFilename = useMemo( - () => (filename: string) => onChange?.({ ...pair, filename }), - [onChange, pair], - ); - - const handleEditMultiLineValue = useCallback( - () => - showDialog({ - id: "pair-edit-multiline", - size: "dynamic", - title: <>Edit {pair.name}, - render: ({ hide }) => ( - - ), - }), - [handleChangeValueText, pair.contentType, pair.name, pair.value], - ); - - const defaultItems = useMemo( - (): DropdownItem[] => [ - { - label: "Edit Multi-line", - onSelect: handleEditMultiLineValue, - hidden: !allowMultilineValues, - }, - { - label: "Delete", - onSelect: handleDelete, - color: "danger", - }, - ], - [allowMultilineValues, handleDelete, handleEditMultiLineValue], - ); - - const { attributes, listeners, setNodeRef: setDraggableRef } = useDraggable({ id: pair.id }); - const { setNodeRef: setDroppableRef } = useDroppable({ id: pair.id }); - - // Filter out the current pair name - const valueAutocompleteVariablesFiltered = useMemo(() => { - if (valueAutocompleteVariables === "environment") { - return (v: WrappedEnvironmentVariable): boolean => v.variable.name !== pair.name; - } - return valueAutocompleteVariables; - }, [pair.name, valueAutocompleteVariables]); - - const handleSetRef = useCallback( - (n: HTMLDivElement | null) => { - setDraggableRef(n); - setDroppableRef(n); - }, - [setDraggableRef, setDroppableRef], - ); - - // Skip the trailing placeholder row. It exists to start a new pair, so a picker there would - // imply it's already a real row. `Input` handles the rest, including having no options to show. - const showOptionsPicker = !isLast; - - return ( -
- - {!isLast && !disableDrag ? ( -
- -
- ) : ( - - )} -
- -
- {pair.isFile ? ( - - ) : pair.value.includes("\n") ? ( - - ) : ( - - )} -
-
- {allowFileValues ? ( - - ) : ( - - - - )} -
- ); -} - -const fileItems: RadioDropdownItem[] = [ - { label: "Text", value: "text" }, - { label: "File", value: "file" }, -]; - -function FileActionsDropdown({ - pair, - onChangeFile, - onChangeText, - onChangeContentType, - onChangeFilename, - onDelete, - editMultiLine, -}: { - pair: Pair; - onChangeFile: ({ filePath }: { filePath: string | null }) => void; - onChangeText: (text: string) => void; - onChangeContentType: (contentType: string) => void; - onChangeFilename: (filename: string) => void; - onDelete: () => void; - editMultiLine: () => void; -}) { - const onChange = useCallback( - (v: string) => { - if (v === "file") onChangeFile({ filePath: "" }); - else onChangeText(""); - }, - [onChangeFile, onChangeText], - ); - - const itemsAfter = useMemo( - () => [ - { - label: "Edit Multi-Line", - leftSlot: , - hidden: pair.isFile, - onSelect: editMultiLine, - }, - { - label: "Set Content-Type", - leftSlot: , - onSelect: async () => { - const contentType = await showPrompt({ - id: "content-type", - title: "Override Content-Type", - label: "Content-Type", - required: false, - placeholder: "text/plain", - defaultValue: pair.contentType ?? "", - confirmText: "Set", - description: "Leave blank to auto-detect", - }); - if (contentType == null) return; - onChangeContentType(contentType); - }, - }, - { - label: "Set File Name", - leftSlot: , - onSelect: async () => { - console.log("PAIR", pair); - const defaultFilename = await platform.files.basename(pair.value ?? ""); - const filename = await showPrompt({ - id: "filename", - title: "Override Filename", - label: "Filename", - required: false, - placeholder: defaultFilename ?? "myfile.png", - defaultValue: pair.filename, - confirmText: "Set", - description: "Leave blank to use the name of the selected file", - }); - if (filename == null) return; - onChangeFilename(filename); - }, - }, - { - label: "Unset File", - leftSlot: , - hidden: pair.isFile, - onSelect: async () => { - onChangeFile({ filePath: null }); - }, - }, - { - label: "Delete", - onSelect: onDelete, - variant: "danger", - leftSlot: , - color: "danger", - }, - ], - [ - editMultiLine, - onChangeContentType, - onChangeFile, - onDelete, - pair.contentType, - pair.isFile, - onChangeFilename, - pair.filename, - pair, - ], - ); - - return ( - - - - ); -} - -function emptyPair(): EditablePairWithId { - return ensurePairId({ enabled: true, name: "", value: "" }); -} - -function isPairEmpty(pair: Pair): boolean { - return !pair.name && !pair.value; -} - -function MultilineEditDialog({ - defaultValue, - contentType, - onChange, - hide, -}: { - defaultValue: string; - contentType: string | null; - onChange: (value: string) => void; - hide: () => void; -}) { - const [value, setValue] = useState(defaultValue); - const language = languageFromContentType(contentType, value); - return ( -
- -
- -
-
- ); -} + PairWithId, +} from "@yaakapp-internal/ui"; +export { PairEditor, PairEditorRow } from "@yaakapp-internal/ui"; diff --git a/apps/yaak-client/components/core/PairEditor.util.tsx b/apps/yaak-client/components/core/PairEditor.util.tsx index b43428292..f2db8c728 100644 --- a/apps/yaak-client/components/core/PairEditor.util.tsx +++ b/apps/yaak-client/components/core/PairEditor.util.tsx @@ -1,10 +1 @@ -import type { Pair, PairWithId } from "./PairEditor"; -import { generateId } from "@yaakapp-internal/lib/generateId"; - -// NOTE: Generic so callers keep whatever they passed in (eg. an EditablePair stays editable) -export function ensurePairId(p: T): T & PairWithId { - if (typeof p.id === "string") { - return p as T & PairWithId; - } - return { ...p, id: p.id ?? generateId() }; -} +export { ensurePairId } from "@yaakapp-internal/ui"; diff --git a/apps/yaak-client/components/core/PairOrBulkEditor.tsx b/apps/yaak-client/components/core/PairOrBulkEditor.tsx index 977c7dabd..861fd0e92 100644 --- a/apps/yaak-client/components/core/PairOrBulkEditor.tsx +++ b/apps/yaak-client/components/core/PairOrBulkEditor.tsx @@ -1,38 +1 @@ -import classNames from "classnames"; -import { useKeyValue } from "../../hooks/useKeyValue"; -import { BulkPairEditor } from "./BulkPairEditor"; -import { IconButton } from "./IconButton"; -import type { PairEditorProps } from "./PairEditor"; -import { PairEditor } from "./PairEditor"; - -interface Props extends PairEditorProps { - preferenceName: string; - forcedEnvironmentId?: string; -} - -export function PairOrBulkEditor({ preferenceName, ...props }: Props) { - const { value: useBulk, set: setUseBulk } = useKeyValue({ - namespace: "global", - key: ["bulk_edit", preferenceName], - fallback: false, - }); - - return ( -
- {useBulk ? : } -
- setUseBulk((b) => !b)} - icon={useBulk ? "table" : "file_code"} - /> -
-
- ); -} +export { PairOrBulkEditor } from "@yaakapp-internal/ui"; diff --git a/apps/yaak-client/components/core/PillButton.tsx b/apps/yaak-client/components/core/PillButton.tsx index ba2b64251..9b1bcf06a 100644 --- a/apps/yaak-client/components/core/PillButton.tsx +++ b/apps/yaak-client/components/core/PillButton.tsx @@ -1,14 +1 @@ -import classNames from "classnames"; -import type { ButtonProps } from "./Button"; -import { Button } from "./Button"; - -export function PillButton({ className, ...props }: ButtonProps) { - return ( -
- } - {...buttonProps} - > - {option && "shortLabel" in option && option.shortLabel - ? option.shortLabel - : (option?.label ?? "Unknown")} - - - ); - }, - }; -} - -function useTabsStoredValue(key: string | string[] | null, fallback: T) { - const { value, set } = useKeyValue({ - namespace: "no_sync", - key: key ?? ["tabs", "default"], - fallback, - }); - return [value ?? fallback, set] as const; -} - /** * Programmatically set the active tab for a Tabs component that uses storageKey + activeTabKey. * This is useful when you need to change the tab from outside the component (e.g., in response to an event). diff --git a/apps/yaak-client/components/responseViewers/HTMLOrTextViewer.tsx b/apps/yaak-client/components/responseViewers/HTMLOrTextViewer.tsx deleted file mode 100644 index 7e1becb7c..000000000 --- a/apps/yaak-client/components/responseViewers/HTMLOrTextViewer.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import type { HttpResponse } from "@yaakapp-internal/models"; -import { useQueryClient } from "@tanstack/react-query"; -import { languageFromContentType, type TextFilterApi, WebPageViewer } from "@yaakapp-internal/ui"; -import type { CodeEditorProps } from "@yaakapp-internal/ui/editor"; -import { useCallback } from "react"; -import { useCopyHttpResponse } from "../../hooks/useCopyHttpResponse"; -import { useRecentFilters } from "../../hooks/useRecentFilters"; -import { responseBodyTextQuery, useResponseBodyText } from "../../hooks/useResponseBodyText"; -import { useSaveResponse } from "../../hooks/useSaveResponse"; -import { getContentTypeFromHeaders } from "../../lib/model_util"; -import { IconButton } from "../core/IconButton"; -import { EmptyStateText } from "../EmptyStateText"; -import { RecentFiltersDropdown } from "./RecentFiltersDropdown"; -import { TextViewer } from "./TextViewer"; - -interface Props { - response: HttpResponse; - pretty: boolean; - textViewerClassName?: string; -} - -export function HTMLOrTextViewer({ response, pretty, textViewerClassName }: Props) { - const rawTextBody = useResponseBodyText({ response, filter: null }); - const contentType = getContentTypeFromHeaders(response.headers); - const language = languageFromContentType(contentType, rawTextBody.data ?? ""); - - if (rawTextBody.isLoading || response.state === "initialized") { - return null; - } - - if (language === "html" && pretty) { - return ; - } - if (rawTextBody.data == null) { - return Empty response; - } - return ( - - ); -} - -interface HttpTextViewerProps { - response: HttpResponse; - text: string; - language: CodeEditorProps["language"]; - pretty: boolean; - className?: string; -} - -function HttpTextViewer({ response, text, language, pretty, className }: HttpTextViewerProps) { - const queryClient = useQueryClient(); - const filterStateKey = `response.body.${response.requestId}`; - // Shares the response body query's cache, so applying and showing a filter cost one RPC - const filterText = useCallback( - (f: string) => queryClient.fetchQuery(responseBodyTextQuery({ response, filter: f })), - [queryClient, response], - ); - const { recentFilters, addFilter, removeFilter, togglePin, clearFilters } = - useRecentFilters(filterStateKey); - const filterLeftSlot = useCallback( - (filter: TextFilterApi) => ( -
- -
- ), - [recentFilters, removeFilter, togglePin, clearFilters], - ); - const saveResponse = useSaveResponse(response); - const copyResponse = useCopyHttpResponse(response); - const actionsDisabled = response.state !== "closed" && response.status >= 100; - - return ( - saveResponse.mutate()} - className="border !border-border-subtle" - />, - copyResponse.mutate()} - className="border !border-border-subtle" - />, - ]} - filterText={filterText} - filterStateKey={filterStateKey} - onFilterApplied={addFilter} - filterLeftSlot={filterLeftSlot} - /> - ); -} diff --git a/apps/yaak-client/hooks/useHotKey.ts b/apps/yaak-client/hooks/useHotKey.ts index 42f3a52f5..151624ba7 100644 --- a/apps/yaak-client/hooks/useHotKey.ts +++ b/apps/yaak-client/hooks/useHotKey.ts @@ -243,6 +243,7 @@ export function useHotkeyContextValue(): HotkeyContextValue { () => ({ os: platform.osType(), bindingFor: (action: HotkeyAction) => hotkeys[action] ?? null, + labelFor: (action: HotkeyAction) => hotkeyLabels[action], register: registerHotkey, }), [hotkeys], diff --git a/apps/yaak-client/hooks/useHttpBodyLoaders.ts b/apps/yaak-client/hooks/useHttpBodyLoaders.ts new file mode 100644 index 000000000..bb5d6a5d2 --- /dev/null +++ b/apps/yaak-client/hooks/useHttpBodyLoaders.ts @@ -0,0 +1,64 @@ +import { useQueryClient } from "@tanstack/react-query"; +import type { HttpResponse } from "@yaakapp-internal/models"; +import type { HttpBodyLoader, HttpBodyResult } from "@yaakapp-internal/ui"; +import { useMemo } from "react"; +import { getResponseBodyText } from "../lib/responseBody"; +import { getRequestBodyText, useHttpRequestBody } from "./useHttpRequestBody"; +import { + responseBodyTextQuery, + useResponseBodyBytes, + useResponseBodyText, +} from "./useResponseBodyText"; +import { useResponseBodyUrl } from "./useResponseBodyUrl"; + +interface QueryLike { + data: T | undefined; + isLoading: boolean; + error: Error | null; +} + +function result({ data, isLoading, error }: QueryLike): HttpBodyResult { + return { data, isLoading, error }; +} + +const noUrl: HttpBodyResult = { data: null, isLoading: false, error: null }; + +/** Reads a response's stored body the ways the shared viewers ask for it */ +export function useResponseBodyLoader(response: HttpResponse | null): HttpBodyLoader | null { + const queryClient = useQueryClient(); + return useMemo(() => { + if (response == null) return null; + return { + useText: () => result(useResponseBodyText({ response, filter: null })), + useBytes: () => result(useResponseBodyBytes({ response })), + useUrl: (enabled: boolean) => { + // Waiting on a URL that isn't asked for yet still counts as loading + const url = useResponseBodyUrl(enabled ? response : null); + return { data: url.data, isLoading: url.isPending, error: url.error }; + }, + // Shares the response body query's cache, so applying and showing a filter cost one RPC + filterText: (filter: string) => + queryClient.fetchQuery(responseBodyTextQuery({ response, filter })), + getText: () => getResponseBodyText({ response, filter: null }), + }; + }, [queryClient, response]); +} + +/** Reads the body a response's request was sent with */ +export function useRequestBodyLoader(response: HttpResponse | null): HttpBodyLoader | null { + return useMemo(() => { + if (response == null) return null; + return { + useText: () => { + const body = useHttpRequestBody(response); + return { ...result(body), data: body.data?.bodyText ?? null }; + }, + useBytes: () => { + const body = useHttpRequestBody(response); + return { ...result(body), data: body.data?.body ?? null }; + }, + useUrl: () => noUrl, + getText: () => getRequestBodyText(response).then((d) => d?.bodyText ?? ""), + }; + }, [response]); +} diff --git a/apps/yaak-client/hooks/useToggle.ts b/apps/yaak-client/hooks/useToggle.ts index e611d3826..a0d5f172d 100644 --- a/apps/yaak-client/hooks/useToggle.ts +++ b/apps/yaak-client/hooks/useToggle.ts @@ -1,7 +1,2 @@ -import { useCallback, useState } from "react"; - -export function useToggle(initialValue = false) { - const [value, setValue] = useState(initialValue); - const toggle = useCallback(() => setValue((v) => !v), []); - return [value, toggle, setValue] as const; -} +// From its own entry rather than the package index, so tests that mock the index keep it +export { useToggle } from "@yaakapp-internal/ui/useToggle"; diff --git a/apps/yaak-client/lib/contentType.ts b/apps/yaak-client/lib/contentType.ts index 262d7fc98..c8d42ca3a 100644 --- a/apps/yaak-client/lib/contentType.ts +++ b/apps/yaak-client/lib/contentType.ts @@ -1,44 +1 @@ -import { MIMEType } from "whatwg-mimetype"; - -export function isJSON(content: string | null | undefined): boolean { - if (typeof content !== "string") return false; - - try { - JSON.parse(content); - return true; - } catch { - return false; - } -} - -export function isProbablyTextContentType(contentType: string | null): boolean { - if (contentType == null) return false; - - const mimeType = getMimeTypeFromContentType(contentType).essence; - const normalized = mimeType.toLowerCase(); - - // Check if it starts with "text/" - if (normalized.startsWith("text/")) { - return true; - } - - // Common text mimetypes and suffixes - return [ - "application/json", - "application/xml", - "application/javascript", - "application/yaml", - "+json", - "+xml", - "+yaml", - "+text", - ].some((textType) => normalized === textType || normalized.endsWith(textType)); -} - -export function getMimeTypeFromContentType(contentType: string): MIMEType { - try { - return new MIMEType(contentType); - } catch { - return new MIMEType("text/plain"); - } -} +export { getMimeTypeFromContentType, isJSON, isProbablyTextContentType } from "@yaakapp-internal/ui/mimeType"; diff --git a/apps/yaak-client/lib/httpViews.test.ts b/apps/yaak-client/lib/httpViews.test.ts new file mode 100644 index 000000000..a9fdb1c9d --- /dev/null +++ b/apps/yaak-client/lib/httpViews.test.ts @@ -0,0 +1,83 @@ +import type { HttpRequest, HttpResponse, HttpResponseEvent } from "@yaakapp-internal/models"; +import { describe, expect, test } from "vite-plus/test"; +import { cookieHeadersFromEvents, httpRequestToView, httpResponseToView } from "./httpViews"; + +function event(e: HttpResponseEvent["event"]): HttpResponseEvent { + return { event: e } as HttpResponseEvent; +} + +describe("httpRequestToView", () => { + test("maps the request and reads its content type from the headers", () => { + const request = { + id: "rq_1", + method: "POST", + url: "https://yaak.app", + headers: [{ name: "content-type", value: "application/json", enabled: true }], + urlParameters: [{ name: "a", value: "1", enabled: true }], + bodyType: "application/json", + body: { text: "{}" }, + } as unknown as HttpRequest; + + expect(httpRequestToView(request)).toEqual({ + id: "rq_1", + method: "POST", + url: "https://yaak.app", + headers: request.headers, + urlParameters: request.urlParameters, + bodyType: "application/json", + body: { text: "{}" }, + contentType: "application/json", + auth: null, + }); + }); +}); + +describe("httpResponseToView", () => { + test("keys state to the request and puts the stored time in UTC", () => { + const response = { + id: "rs_1", + requestId: "rq_1", + state: "closed", + status: 200, + statusReason: "OK", + url: "https://yaak.app", + headers: [{ name: "Content-Type", value: "text/plain" }], + requestHeaders: [], + contentLength: 10, + contentLengthCompressed: 4, + requestContentLength: 0, + createdAt: "2026-11-04T22:10:00.123", + elapsed: 30, + elapsedHeaders: 20, + elapsedDns: 5, + error: null, + remoteAddr: null, + version: "HTTP/1.1", + } as unknown as HttpResponse; + + expect(httpResponseToView(response, undefined)).toMatchObject({ + id: "rs_1", + ownerId: "rq_1", + contentType: "text/plain", + startedAt: "2026-11-04T22:10:00.123Z", + elapsedMs: 30, + elapsedHeadersMs: 20, + elapsedDnsMs: 5, + contentLengthCompressed: 4, + cookieHeaders: { sent: [], received: [] }, + }); + }); +}); + +describe("cookieHeadersFromEvents", () => { + test("collects Cookie headers sent and Set-Cookie headers received", () => { + const events = [ + event({ type: "header_up", name: "Cookie", value: "a=1" }), + event({ type: "header_up", name: "Accept", value: "*/*" }), + event({ type: "header_down", name: "set-cookie", value: "b=2" }), + event({ type: "header_down", name: "Content-Type", value: "text/plain" }), + ]; + expect(cookieHeadersFromEvents(events)).toEqual({ sent: ["a=1"], received: ["b=2"] }); + expect(cookieHeadersFromEvents(undefined)).toEqual({ sent: [], received: [] }); + }); +}); diff --git a/apps/yaak-client/lib/httpViews.ts b/apps/yaak-client/lib/httpViews.ts new file mode 100644 index 000000000..fe7748f47 --- /dev/null +++ b/apps/yaak-client/lib/httpViews.ts @@ -0,0 +1,64 @@ +import type { HttpRequest, HttpResponse, HttpResponseEvent } from "@yaakapp-internal/models"; +import type { HttpRequestView, HttpResponseView } from "@yaakapp-internal/ui"; +import { getContentTypeFromHeaders } from "./model_util"; + +/** A request as the shared request pane shows it. Auth stays with the client's own editor */ +export function httpRequestToView(request: HttpRequest): HttpRequestView { + return { + id: request.id, + method: request.method, + url: request.url, + headers: request.headers, + urlParameters: request.urlParameters, + bodyType: request.bodyType, + body: request.body, + contentType: getContentTypeFromHeaders(request.headers), + auth: null, + }; +} + +/** A response as the shared response pane shows it, with its cookies from the send's events */ +export function httpResponseToView( + response: HttpResponse, + events: HttpResponseEvent[] | undefined, +): HttpResponseView { + return { + id: response.id, + ownerId: response.requestId, + state: response.state, + status: response.status, + statusReason: response.statusReason, + url: response.url, + headers: response.headers, + requestHeaders: response.requestHeaders, + contentType: getContentTypeFromHeaders(response.headers), + contentLength: response.contentLength, + contentLengthCompressed: response.contentLengthCompressed, + requestContentLength: response.requestContentLength, + // Stored as UTC without a zone + startedAt: `${response.createdAt}Z`, + elapsedMs: response.elapsed, + elapsedHeadersMs: response.elapsedHeaders, + elapsedDnsMs: response.elapsedDns, + error: response.error, + remoteAddr: response.remoteAddr, + version: response.version, + cookieHeaders: cookieHeadersFromEvents(events), + }; +} + +/** The `Cookie` headers sent and `Set-Cookie` headers received, from a send's events */ +export function cookieHeadersFromEvents( + events: HttpResponseEvent[] | undefined, +): HttpResponseView["cookieHeaders"] { + const sent: string[] = []; + const received: string[] = []; + for (const { event } of events ?? []) { + if (event.type === "header_up" && event.name.toLowerCase() === "cookie") { + sent.push(event.value); + } else if (event.type === "header_down" && event.name.toLowerCase() === "set-cookie") { + received.push(event.value); + } + } + return { sent, received }; +} diff --git a/apps/yaak-client/lib/model_util.ts b/apps/yaak-client/lib/model_util.ts index 069dd2fd7..95537278a 100644 --- a/apps/yaak-client/lib/model_util.ts +++ b/apps/yaak-client/lib/model_util.ts @@ -6,14 +6,16 @@ import type { HttpResponseHeader, } from "@yaakapp-internal/models"; -export const BODY_TYPE_NONE = null; -export const BODY_TYPE_GRAPHQL = "graphql"; -export const BODY_TYPE_JSON = "application/json"; -export const BODY_TYPE_BINARY = "binary"; -export const BODY_TYPE_OTHER = "other"; -export const BODY_TYPE_FORM_URLENCODED = "application/x-www-form-urlencoded"; -export const BODY_TYPE_FORM_MULTIPART = "multipart/form-data"; -export const BODY_TYPE_XML = "text/xml"; +export { + BODY_TYPE_BINARY, + BODY_TYPE_FORM_MULTIPART, + BODY_TYPE_FORM_URLENCODED, + BODY_TYPE_GRAPHQL, + BODY_TYPE_JSON, + BODY_TYPE_NONE, + BODY_TYPE_OTHER, + BODY_TYPE_XML, +} from "@yaakapp-internal/ui/httpTypes"; export function cookieDomain(cookie: Cookie): string { if (cookie.domain === "NotPresent" || cookie.domain === "Empty") { diff --git a/apps/yaak-client/routes/__root.tsx b/apps/yaak-client/routes/__root.tsx index 1c0380008..1c547e23a 100644 --- a/apps/yaak-client/routes/__root.tsx +++ b/apps/yaak-client/routes/__root.tsx @@ -6,6 +6,7 @@ import classNames from "classnames"; import { Provider as JotaiProvider } from "jotai"; import { LazyMotion, MotionConfig } from "motion/react"; import { lazy, type ReactNode, Suspense } from "react"; +import { ClientEditorHost } from "../components/ClientEditorHost"; import { GlobalHooks } from "../components/GlobalHooks"; import RouteError from "../components/RouteError"; import { useHotkeyContextValue } from "../hooks/useHotKey"; @@ -30,17 +31,19 @@ function RouteComponent() { - - - - - - - - - - - + + + + + + + + + + + + + diff --git a/apps/yaak-proxy/components/ProxyEditorHost.tsx b/apps/yaak-proxy/components/ProxyEditorHost.tsx new file mode 100644 index 000000000..f02423ce4 --- /dev/null +++ b/apps/yaak-proxy/components/ProxyEditorHost.tsx @@ -0,0 +1,35 @@ +import { + type EditorHost, + EditorHostProvider, + MultipartViewer, + type MultipartViewerProps, + TextViewer, + type TextViewerProps, +} from "@yaakapp-internal/ui"; +import { type ReactNode, useMemo } from "react"; +import { formatText as engineFormatText } from "../lib/body"; + +/** + * Gives the shared panes the proxy's formatting. Everything else keeps the shared defaults: + * plain editors with no templating, state kept for the session, no dialogs. + */ +export function ProxyEditorHost({ + children, + formatText = engineFormatText, +}: { + children: ReactNode; + /** Pretty-prints a body; the engine's formatter unless replaced */ + formatText?: TextViewerProps["formatText"]; +}) { + // Components made once per formatter, so viewers aren't remounted on every render + const host = useMemo>( + () => ({ + TextViewer: (props: TextViewerProps) => , + MultipartViewer: (props: MultipartViewerProps) => ( + + ), + }), + [formatText], + ); + return {children}; +} diff --git a/apps/yaak-proxy/harness.html b/apps/yaak-proxy/harness.html new file mode 100644 index 000000000..58c4cf6df --- /dev/null +++ b/apps/yaak-proxy/harness.html @@ -0,0 +1,33 @@ + + + + + + Yaak Proxy, shared panes harness + + + + +
+ + + + + diff --git a/apps/yaak-proxy/harness/FlowPanesHarness.tsx b/apps/yaak-proxy/harness/FlowPanesHarness.tsx new file mode 100644 index 000000000..ff15d61ba --- /dev/null +++ b/apps/yaak-proxy/harness/FlowPanesHarness.tsx @@ -0,0 +1,113 @@ +import { + HttpRequestPaneView, + HttpResponsePaneView, + type HttpBodyResult, + type ResponseViewMode, +} from "@yaakapp-internal/ui"; +import { useMemo, useState } from "react"; +import { ProxyEditorHost } from "../components/ProxyEditorHost"; +import { findHeader } from "../lib/body"; +import { + type FlowBodyData, + flowBodyFromRpc, + flowBodyLoader, + flowRequestToView, + flowResponseToView, +} from "../lib/flowViews"; +import { type FlowFixture, fixtures } from "./fixtures"; + +/** + * Dev only: renders captured flows through the shared request and response panes, read-only, + * side by side, the way the proxy's detail view will. Open `/harness.html?flow=0&theme=dark`. + */ +export function FlowPanesHarness() { + const params = new URLSearchParams(location.search); + const [index, setIndex] = useState(Number(params.get("flow") ?? 0)); + const fixture = fixtures[index] ?? fixtures[0]; + if (fixture == null) return null; + + return ( + +
+
+ {fixtures.map((f, i) => ( + + ))} +
+ +
+
+ ); +} + +function FlowPanes({ fixture }: { fixture: FlowFixture }) { + const { flow } = fixture; + const [viewMode, setViewMode] = useState("pretty"); + const requestBody = useMemo(() => flowBodyFromRpc(fixture.requestBody), [fixture]); + const responseBody = useMemo(() => flowBodyFromRpc(fixture.responseBody), [fixture]); + const request = useMemo(() => flowRequestToView(flow, requestBody), [flow, requestBody]); + const response = useMemo(() => flowResponseToView(flow, responseBody), [flow, responseBody]); + + const requestLoader = useMemo( + () => + flowBodyLoader({ + useBody: () => loaded(requestBody), + contentType: findHeader(flow.requestHeaders, "content-type"), + }), + [requestBody, flow], + ); + const responseLoader = useMemo( + () => + flowBodyLoader({ + useBody: () => loaded(responseBody), + contentType: findHeader(flow.responseHeaders, "content-type"), + }), + [responseBody, flow], + ); + + return ( +
+
+ +
+ Waiting for a response
} + viewMode={viewMode} + onViewModeChange={setViewMode} + tabsStorageKey="flow_response_tabs" + activeTabKey={flow.id} + onCopyAsCurl={() => undefined} + /> + + ); +} + +function loaded(data: FlowBodyData): HttpBodyResult { + return { data, isLoading: false, error: null }; +} + +/** The engine formats JSON over RPC, which a page outside the app can't reach */ +async function formatLocally(text: string, language: unknown): Promise { + if (language !== "json") return text; + try { + return JSON.stringify(JSON.parse(text), null, 2); + } catch { + return text; + } +} diff --git a/apps/yaak-proxy/harness/fixtures.ts b/apps/yaak-proxy/harness/fixtures.ts new file mode 100644 index 000000000..bc6966b45 --- /dev/null +++ b/apps/yaak-proxy/harness/fixtures.ts @@ -0,0 +1,141 @@ +import type { Flow, GetFlowBodyResponse } from "@yaakapp-internal/proxy-lib"; + +/** A flow fixture with its two bodies, as the engine's `get_flow_body` RPC returns them */ +export interface FlowFixture { + label: string; + flow: Flow; + requestBody: GetFlowBodyResponse; + responseBody: GetFlowBodyResponse; +} + +function body(data: string | Uint8Array): GetFlowBodyResponse { + const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data; + let binary = ""; + for (const b of bytes) binary += String.fromCharCode(b); + return { data: btoa(binary), truncated: false, storedSize: bytes.length }; +} + +const base: Flow = { + id: "fl_harness", + createdAt: "2026-11-04T22:10:00", + updatedAt: "2026-11-04T22:10:01", + startedAt: "2026-11-04T22:10:00.000", + method: "GET", + url: "https://api.example.com/", + clientHttpVersion: "HTTP/2", + upstreamHttpVersion: "HTTP/2", + remoteAddr: "93.184.216.34:443", + status: 200, + contentType: null, + requestHeaders: [], + responseHeaders: [], + requestSize: 0, + responseSize: 0, + requestTruncated: false, + responseTruncated: false, + elapsedHeadersMs: 84, + elapsedMs: 112, + error: null, + cancelled: false, + savedRequestId: null, + clientPid: 4211, + clientName: "curl", + clientBundleId: null, + clientPath: "/usr/bin/curl", +}; + +const jsonRequest = JSON.stringify({ amount: 4200, currency: "usd", capture: true }); +const jsonResponse = JSON.stringify({ + id: "pi_3Q8x", + object: "payment_intent", + amount: 4200, + currency: "usd", + status: "succeeded", + charges: { data: [{ id: "ch_1", paid: true }] }, +}); +const formRequest = "grant_type=client_credentials&client_id=yaak-proxy&scope=read+write"; +const htmlResponse = + "

Signed in

You can close this window.

"; +// A 24x24 PNG gradient +const png = Uint8Array.from( + atob( + "iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAIAAABvFaqvAAADnUlEQVR4nA3MEQJEIQBAwc95nOdxHj2J8zjP4zyP8zjP47wD7M4B5vv4BJ/kU3yaz/BZPsfn+QJf5Et8ma/wVb7G1/kG3+RbfJvv8F2+x/d9CIGQCIXQCIOwCIfwiICIiITIiIKoiIboiIGYiIXYiIO4iIf4R1IgJVIhNdIgLdIhPTIgIzIhM7IgK7IhO3IgJ3IhN/IgL/Ih/5ESKIlSKI0yKItyKI8KqIhKqIwqqIpqqI4aqIlaqI06qIt6qH+kBVqiFVqjDdqiHdqjAzqiEzqjC7qiG7qjB3qiF3qjD/qiH/ofGYGRGIXRGIOxGIfxmICJmITJmIKpmIbpmIGZmIXZmIO5mIf5R1ZgJVZhNdZgLdZhPTZgIzZhM7ZgK7ZhO3ZgJ3ZhN/ZgL/Zh/5ETOIlTOI0zOItzOI8LuIhLuIwruIpruI4buIlbuI07uIt7uH/kBV7iFV7jDd7iHd7jAz7iEz7jC77iG77jB37iF37jD/7iH/4fBUGQBEXQBEOwBEfwhECIhETIhEKohEbohEGYhEXYhEO4hEf4R1EQJVERNdEQLdERPTEQIzERM7EQK7ERO3EQJ3ERN/EQL/ER/1ESJElSJE0yJEtyJE8KpEhKpEwqpEpqpE4apElapE06pEt6pH+UBVmSFVmTDdmSHdmTAzmSEzmTC7mSG7mTB3mSF3mTD/mSH/kfFUGRFEXRFEOxFEfxlECJlETJlEKplEbplEGZlEXZlEO5lEf5R1VQJVVRNdVQLdVRPTVQIzVRM7VQK7VRO3VQJ3VRN/VQL/VR/1ETNElTNE0zNEtzNE8LtEhLtEwrtEprtE4btElbtE07tEt7tH/UBV3SFV3TDd3SHd3TAz3SEz3TC73SG73TB33SF33TD/3SH/0fDcGQDMXQDMOwDMfwjMCIjMTIjMKojMbojMGYjMXYjMO4jMf4R1MwJVMxNdMwLdMxPTMwIzMxM7MwK7MxO3MwJ3MxN/MwL/Mx/9ESLMlSLM0yLMtyLM8KrMhKrMwqrMpqrM4arMlarM06rMt6rH+0BVuyFVuzDduyHduzAzuyEzuzC7uyG7uzB3uyF3uzD/uyH/sfHcGRHMXRHMOxHMfxnMCJnMTJnMKpnMbpnMGZnMXZnMO5nMf5R1dwJVdxNddwLddxPTdwIzdxM7dwK7dxO3dwJ3dxN/dwL/dx/9ETPMlTPM0zPMtzPM8LvMhLvMwrvMprvM4bvMlbvM07vMt7vB8/gdf/dWsIbgAAAABJRU5ErkJggg==", + ), + (c) => c.charCodeAt(0), +); + +export const fixtures: FlowFixture[] = [ + { + label: "JSON POST", + flow: { + ...base, + id: "fl_json", + method: "POST", + url: "https://api.stripe.com/v1/payment_intents?expand=charges&limit=1", + contentType: "application/json", + requestHeaders: [ + { name: "Content-Type", value: "application/json" }, + { name: "Authorization", value: "Bearer sk_test_51Hx" }, + { name: "Cookie", value: "session=abc123; theme=dark" }, + { name: "User-Agent", value: "curl/8.7.1" }, + ], + responseHeaders: [ + { name: "Content-Type", value: "application/json; charset=utf-8" }, + { name: "Set-Cookie", value: "session=def456; Path=/; HttpOnly; Secure" }, + { name: "Request-Id", value: "req_9Z" }, + ], + requestSize: jsonRequest.length, + responseSize: jsonResponse.length, + }, + requestBody: body(jsonRequest), + responseBody: body(jsonResponse), + }, + { + label: "Form POST, HTML response", + flow: { + ...base, + id: "fl_form", + method: "POST", + url: "https://auth.example.com/oauth/token", + contentType: "text/html", + requestHeaders: [ + { name: "Content-Type", value: "application/x-www-form-urlencoded" }, + { name: "Authorization", value: "Basic eWFhazpwcm94eQ==" }, + ], + responseHeaders: [{ name: "Content-Type", value: "text/html; charset=utf-8" }], + requestSize: formRequest.length, + responseSize: htmlResponse.length, + }, + requestBody: body(formRequest), + responseBody: body(htmlResponse), + }, + { + label: "Image GET", + flow: { + ...base, + id: "fl_image", + url: "https://cdn.example.com/avatars/42.png?size=2", + contentType: "image/png", + requestHeaders: [{ name: "Accept", value: "image/*" }], + responseHeaders: [{ name: "Content-Type", value: "image/png" }], + responseSize: png.length, + }, + requestBody: body(""), + responseBody: body(png), + }, + { + label: "Failed", + flow: { + ...base, + id: "fl_failed", + url: "https://unreachable.example.com/health", + status: null, + elapsedHeadersMs: null, + elapsedMs: null, + error: "dns error: failed to lookup address information: nodename nor servname provided", + requestHeaders: [{ name: "Accept", value: "*/*" }], + }, + requestBody: body(""), + responseBody: body(""), + }, +]; diff --git a/apps/yaak-proxy/harness/main.tsx b/apps/yaak-proxy/harness/main.tsx new file mode 100644 index 000000000..f9e2d5c9c --- /dev/null +++ b/apps/yaak-proxy/harness/main.tsx @@ -0,0 +1,20 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { applyThemeToDocument, defaultDarkTheme, defaultLightTheme } from "@yaakapp-internal/theme"; +import { createStore, Provider } from "jotai"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { FlowPanesHarness } from "./FlowPanesHarness"; +import "../main.css"; + +const dark = new URLSearchParams(location.search).get("theme") !== "light"; +applyThemeToDocument(dark ? defaultDarkTheme : defaultLightTheme); + +createRoot(document.getElementById("root") as HTMLElement).render( + + + + + + + , +); diff --git a/apps/yaak-proxy/lib/flowViews.test.ts b/apps/yaak-proxy/lib/flowViews.test.ts new file mode 100644 index 000000000..0902ca2bf --- /dev/null +++ b/apps/yaak-proxy/lib/flowViews.test.ts @@ -0,0 +1,222 @@ +import type { Flow } from "@yaakapp-internal/proxy-lib"; +import { + BODY_TYPE_BINARY, + BODY_TYPE_FORM_MULTIPART, + BODY_TYPE_FORM_URLENCODED, + BODY_TYPE_JSON, + BODY_TYPE_OTHER, +} from "@yaakapp-internal/ui/httpTypes"; +import { describe, expect, test } from "vite-plus/test"; +import { + authFromHeaders, + type FlowBodyData, + flowBodyFromRpc, + flowRequestToView, + flowResponseToView, + queryToPairs, + utcTimestamp, +} from "./flowViews"; + +const flow: Flow = { + id: "fl_1", + createdAt: "2026-11-04T22:10:00Z", + updatedAt: "2026-11-04T22:10:01Z", + startedAt: "2026-11-04T22:10:00Z", + method: "POST", + url: "https://api.example.com/v1/items?limit=1&q=a+b#top", + clientHttpVersion: "HTTP/1.1", + upstreamHttpVersion: "HTTP/2", + remoteAddr: "1.2.3.4:443", + status: 201, + contentType: "application/json", + requestHeaders: [ + { name: "Content-Type", value: "application/json" }, + { name: "Cookie", value: "a=1" }, + ], + responseHeaders: [ + { name: "Content-Type", value: "application/json" }, + { name: "Set-Cookie", value: "b=2; Path=/" }, + ], + requestSize: 7, + responseSize: 12, + requestTruncated: false, + responseTruncated: false, + elapsedHeadersMs: 40, + elapsedMs: 55, + error: null, + cancelled: false, + savedRequestId: null, + clientPid: null, + clientName: null, + clientBundleId: null, + clientPath: null, +}; + +function bytes(text: string): FlowBodyData { + return { bytes: new TextEncoder().encode(text), truncated: false, storedSize: text.length }; +} + +function withContentType(contentType: string): Flow { + return { ...flow, requestHeaders: [{ name: "Content-Type", value: contentType }] }; +} + +describe("flowRequestToView", () => { + test("maps the request line, headers and query", () => { + const view = flowRequestToView(flow, bytes('{"a":1}')); + expect(view.method).toBe("POST"); + expect(view.url).toBe(flow.url); + expect(view.headers.map((h) => h.name)).toEqual(["Content-Type", "Cookie"]); + expect(view.urlParameters.map(({ name, value }) => ({ name, value }))).toEqual([ + { name: "limit", value: "1" }, + { name: "q", value: "a b" }, + ]); + expect(view.contentType).toBe("application/json"); + }); + + test("reads a JSON body as JSON, sent as written", () => { + const view = flowRequestToView(flow, bytes('{"a":1}')); + expect(view.bodyType).toBe(BODY_TYPE_JSON); + expect(view.body).toEqual({ text: '{"a":1}', sendJsonComments: true }); + }); + + test("reads a url-encoded body as form fields", () => { + const view = flowRequestToView( + withContentType("application/x-www-form-urlencoded; charset=utf-8"), + bytes("a=1&b=two+words&c=%26"), + ); + expect(view.bodyType).toBe(BODY_TYPE_FORM_URLENCODED); + expect(view.body.form?.map(({ name, value }) => [name, value])).toEqual([ + ["a", "1"], + ["b", "two words"], + ["c", "&"], + ]); + }); + + test("leaves multipart and binary bodies to the viewers", () => { + expect( + flowRequestToView(withContentType("multipart/form-data; boundary=x"), bytes("--x--")) + .bodyType, + ).toBe(BODY_TYPE_FORM_MULTIPART); + const binary: FlowBodyData = { + bytes: new Uint8Array([0xff, 0xfe, 0x00]), + truncated: false, + storedSize: 3, + }; + expect(flowRequestToView(withContentType("application/octet-stream"), binary).bodyType).toBe( + BODY_TYPE_BINARY, + ); + }); + + test("reads other text as text, and no body as none", () => { + expect(flowRequestToView(withContentType("text/plain"), bytes("hi")).bodyType).toBe( + BODY_TYPE_OTHER, + ); + expect(flowRequestToView(flow, bytes("")).bodyType).toBeNull(); + expect(flowRequestToView(flow, null).bodyType).toBeNull(); + }); + + test("shows authentication from the Authorization header", () => { + expect(authFromHeaders([{ name: "authorization", value: "Bearer abc" }])).toEqual({ + label: "Bearer", + values: [{ name: "Authorization", value: "Bearer abc" }], + }); + expect(authFromHeaders([{ name: "Authorization", value: "Custom x" }])?.label).toBe("Auth"); + expect(authFromHeaders([])).toBeNull(); + }); +}); + +describe("flowResponseToView", () => { + test("maps status, timing, sizes and cookies", () => { + const view = flowResponseToView(flow); + expect(view).toMatchObject({ + id: "fl_1", + ownerId: "fl_1", + state: "closed", + status: 201, + statusReason: "Created", + contentType: "application/json", + contentLength: 12, + requestContentLength: 7, + startedAt: "2026-11-04T22:10:00Z", + elapsedMs: 55, + elapsedHeadersMs: 40, + version: "HTTP/2", + remoteAddr: "1.2.3.4:443", + cookieHeaders: { sent: ["a=1"], received: ["b=2; Path=/"] }, + }); + }); + + test("shows a gzip response's decoded size, with its wire size as compressed", () => { + const gzipped: Flow = { + ...flow, + responseHeaders: [...flow.responseHeaders, { name: "Content-Encoding", value: "gzip" }], + responseSize: 40, + }; + const decoded: FlowBodyData = { + bytes: new Uint8Array(120), + truncated: false, + storedSize: 40, + }; + expect(flowResponseToView(gzipped, decoded)).toMatchObject({ + contentLength: 120, + contentLengthCompressed: 40, + }); + // Before the body loads, the wire size stands in, still marked compressed + expect(flowResponseToView(gzipped)).toMatchObject({ + contentLength: 40, + contentLengthCompressed: 40, + }); + }); + + test("keeps the wire size for an unencoded body cut at the capture limit", () => { + const cut: FlowBodyData = { bytes: new Uint8Array(5), truncated: true, storedSize: 5 }; + expect(flowResponseToView(flow, cut)).toMatchObject({ + contentLength: 12, + contentLengthCompressed: null, + }); + expect(flowResponseToView(flow, bytes("hello world!"))).toMatchObject({ + contentLength: 12, + contentLengthCompressed: null, + }); + }); + + test("reads the engine's zone-less timestamps as UTC", () => { + expect(flowResponseToView({ ...flow, startedAt: "2026-11-04T22:10:00.123" })?.startedAt).toBe( + "2026-11-04T22:10:00.123Z", + ); + expect(utcTimestamp("2026-11-04T22:10:00+02:00")).toBe("2026-11-04T22:10:00+02:00"); + }); + + test("is still connected while the body streams", () => { + expect(flowResponseToView({ ...flow, elapsedMs: null })?.state).toBe("connected"); + }); + + test("has no response while waiting on one", () => { + expect(flowResponseToView({ ...flow, status: null, elapsedMs: null })).toBeNull(); + }); + + test("carries a failure as the response's error", () => { + const view = flowResponseToView({ ...flow, status: null, elapsedMs: null, error: "refused" }); + expect(view).toMatchObject({ state: "closed", status: 0, error: "refused" }); + }); + + test("says when the client gave up before a response", () => { + const view = flowResponseToView({ ...flow, status: null, elapsedMs: null, cancelled: true }); + expect(view?.error).toBe("Cancelled by the client"); + }); +}); + +describe("flowBodyFromRpc", () => { + test("decodes the RPC's base64", () => { + const body = flowBodyFromRpc({ data: btoa("hello"), truncated: true, storedSize: 5 }); + expect(new TextDecoder().decode(body.bytes)).toBe("hello"); + expect(body.truncated).toBe(true); + }); +}); + +describe("queryToPairs", () => { + test("ignores the fragment and keeps repeats", () => { + expect(queryToPairs("https://x.test/?a=1&a=2#b=3").map((p) => p.value)).toEqual(["1", "2"]); + expect(queryToPairs("https://x.test/")).toEqual([]); + }); +}); diff --git a/apps/yaak-proxy/lib/flowViews.ts b/apps/yaak-proxy/lib/flowViews.ts new file mode 100644 index 000000000..f4945e04a --- /dev/null +++ b/apps/yaak-proxy/lib/flowViews.ts @@ -0,0 +1,235 @@ +import type { Flow, GetFlowBodyResponse, ProxyHeader } from "@yaakapp-internal/proxy-lib"; +import { + BODY_TYPE_BINARY, + BODY_TYPE_FORM_MULTIPART, + BODY_TYPE_FORM_URLENCODED, + BODY_TYPE_JSON, + BODY_TYPE_OTHER, + BODY_TYPE_XML, + type HttpAuthView, + type HttpBodyLoader, + type HttpBodyResult, + type HttpPairView, + type HttpRequestBodyView, + type HttpRequestView, + type HttpResponseView, +} from "@yaakapp-internal/ui/httpTypes"; +import { useEffect, useState } from "react"; +import { base64ToBytes, bytesToText, findHeader, mimeEssence } from "./body"; +import { statusReason } from "./statusReason"; + +/** A captured body as the engine's body RPC returns it, decoded */ +export interface FlowBodyData { + bytes: Uint8Array; + truncated: boolean; + storedSize: number; +} + +export function flowBodyFromRpc(res: GetFlowBodyResponse): FlowBodyData { + return { bytes: base64ToBytes(res.data), truncated: res.truncated, storedSize: res.storedSize }; +} + +/** A captured request as the shared request pane shows it, read-only */ +export function flowRequestToView(flow: Flow, body: FlowBodyData | null): HttpRequestView { + const contentType = findHeader(flow.requestHeaders, "content-type"); + const text = body == null ? null : bytesToText(body.bytes, body.truncated); + const { bodyType, body: bodyView } = requestBodyView(contentType, body, text); + + return { + id: flow.id, + method: flow.method, + url: flow.url, + headers: headersToPairs(flow.requestHeaders), + urlParameters: queryToPairs(flow.url), + bodyType, + body: bodyView, + contentType, + auth: authFromHeaders(flow.requestHeaders), + }; +} + +/** The body type a captured body reads as, in the shape that body type's editor shows */ +function requestBodyView( + contentType: string | null, + body: FlowBodyData | null, + text: string | null, +): { bodyType: string | null; body: HttpRequestBodyView } { + if (body == null || body.bytes.length === 0) return { bodyType: null, body: {} }; + + const mime = mimeEssence(contentType) ?? ""; + if (mime.startsWith("multipart/")) return { bodyType: BODY_TYPE_FORM_MULTIPART, body: {} }; + if (text == null) return { bodyType: BODY_TYPE_BINARY, body: {} }; + if (mime === BODY_TYPE_FORM_URLENCODED) { + return { bodyType: BODY_TYPE_FORM_URLENCODED, body: { form: formToPairs(text) } }; + } + if (mime === "application/json" || mime.endsWith("+json")) { + // Captured as sent, so nothing was cleaned up before it went out + return { bodyType: BODY_TYPE_JSON, body: { text, sendJsonComments: true } }; + } + if (mime === "text/xml" || mime === "application/xml" || mime.endsWith("+xml")) { + return { bodyType: BODY_TYPE_XML, body: { text } }; + } + return { bodyType: BODY_TYPE_OTHER, body: { text } }; +} + +/** + * A captured response as the shared response pane shows it, or null when the request never + * got one and didn't fail either (it's still waiting). `body` is the response body from the + * engine, decoded; its size is the one the pane shows, and the wire size counts as compressed + * when the response had a Content-Encoding. Until it loads, the wire size stands in. + */ +export function flowResponseToView( + flow: Flow, + body: FlowBodyData | null = null, +): HttpResponseView | null { + const ended = flow.elapsedMs != null || flow.error != null || flow.cancelled; + if (flow.status == null && !ended) return null; + + const encoding = findHeader(flow.responseHeaders, "content-encoding")?.trim().toLowerCase(); + const encoded = encoding != null && encoding !== "" && encoding !== "identity"; + + return { + id: flow.id, + ownerId: flow.id, + state: ended ? "closed" : "connected", + status: flow.status ?? 0, + statusReason: flow.status == null ? null : statusReason(flow.status), + url: flow.url, + headers: headersToPairs(flow.responseHeaders), + requestHeaders: headersToPairs(flow.requestHeaders), + contentType: findHeader(flow.responseHeaders, "content-type"), + // A body cut at the capture limit is short; unencoded, the wire size is the exact one + contentLength: + body == null || (body.truncated && !encoded) ? flow.responseSize : body.bytes.length, + contentLengthCompressed: encoded ? flow.responseSize : null, + requestContentLength: flow.requestSize, + startedAt: utcTimestamp(flow.startedAt), + elapsedMs: flow.elapsedMs ?? 0, + elapsedHeadersMs: flow.elapsedHeadersMs ?? 0, + elapsedDnsMs: 0, + error: flow.error ?? (flow.cancelled && flow.status == null ? "Cancelled by the client" : null), + remoteAddr: flow.remoteAddr, + version: flow.upstreamHttpVersion ?? flow.clientHttpVersion, + cookieHeaders: { + sent: headerValues(flow.requestHeaders, "cookie"), + received: headerValues(flow.responseHeaders, "set-cookie"), + }, + }; +} + +/** + * Reads a captured body the ways the shared viewers ask for it. `useBody` is the hook that + * loads it, the engine's body RPC in the app or a fixture in tests. + */ +export function flowBodyLoader({ + useBody, + contentType, + filterText, +}: { + useBody: () => HttpBodyResult; + contentType: string | null; + filterText?: (expression: string) => Promise; +}): HttpBodyLoader { + return { + useText: () => { + const body = useBody(); + return { ...body, data: mapData(body, (b) => bytesToText(b.bytes, b.truncated)) }; + }, + useBytes: () => { + const body = useBody(); + return { ...body, data: mapData(body, (b) => b.bytes) }; + }, + useUrl: (enabled: boolean) => { + const body = useBody(); + const bytes = enabled ? (body.data?.bytes ?? null) : null; + const url = useObjectUrl(bytes, contentType); + // The URL follows the bytes by a render + const waiting = bytes != null && bytes.length > 0 && url == null; + return { ...body, isLoading: !enabled || body.isLoading || waiting, data: url }; + }, + filterText, + }; +} + +function mapData( + body: HttpBodyResult, + fn: (b: FlowBodyData) => T, +): T | null | undefined { + if (body.data === undefined) return undefined; + if (body.data == null || body.data.bytes.length === 0) return null; + return fn(body.data); +} + +/** A URL for bytes held in memory, revoked when they change */ +function useObjectUrl(bytes: Uint8Array | null, contentType: string | null) { + const [url, setUrl] = useState(null); + // Made and revoked by the same effect, so a remount can't revoke a URL still in use + useEffect(() => { + if (bytes == null || bytes.length === 0) { + setUrl(null); + return; + } + const next = URL.createObjectURL(new Blob([bytes], { type: mimeEssence(contentType) ?? "" })); + setUrl(next); + return () => URL.revokeObjectURL(next); + }, [bytes, contentType]); + return url; +} + +/** The engine stores UTC without a zone; JavaScript would read that as local time */ +export function utcTimestamp(timestamp: string): string { + return /(Z|[+-]\d\d:?\d\d)$/i.test(timestamp) ? timestamp : `${timestamp}Z`; +} + +function headersToPairs(headers: ProxyHeader[]): HttpPairView[] { + return headers.map((h, i) => ({ id: `${i}`, name: h.name, value: h.value, enabled: true })); +} + +function headerValues(headers: ProxyHeader[], name: string): string[] { + return headers.filter((h) => h.name.toLowerCase() === name).map((h) => h.value); +} + +/** A URL's query string as parameters, in order, repeats kept */ +export function queryToPairs(url: string): HttpPairView[] { + const query = url.split("#")[0]?.split("?").slice(1).join("?") ?? ""; + return formToPairs(query); +} + +/** `a=1&b=2` as pairs, decoded the way a form or query string is */ +export function formToPairs(text: string): HttpPairView[] { + if (text === "") return []; + return text.split("&").map((part, i) => { + const [name = "", ...rest] = part.split("="); + return { + id: `${i}`, + name: decodeFormPart(name), + value: decodeFormPart(rest.join("=")), + enabled: true, + }; + }); +} + +function decodeFormPart(s: string): string { + try { + return decodeURIComponent(s.replaceAll("+", " ")); + } catch { + return s; + } +} + +/** What the request authenticated with, named by its scheme, for display */ +export function authFromHeaders(headers: ProxyHeader[]): HttpAuthView | null { + const value = findHeader(headers, "authorization"); + if (value == null) return null; + const scheme = value.split(" ")[0]?.toLowerCase() ?? ""; + return { label: AUTH_SCHEMES[scheme] ?? "Auth", values: [{ name: "Authorization", value }] }; +} + +const AUTH_SCHEMES: Record = { + basic: "Basic", + bearer: "Bearer", + digest: "Digest", + ntlm: "NTLM", + negotiate: "Negotiate", + "aws4-hmac-sha256": "AWS", +}; diff --git a/packages/ui/package.json b/packages/ui/package.json index 849778086..92ae0f847 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -3,18 +3,21 @@ "version": "1.0.0", "private": true, "type": "module", + "sideEffects": [ + "*.css" + ], "main": "src/index.ts", "types": "src/index.ts", "exports": { ".": "./src/index.ts", "./editor": "./src/editor.ts", + "./httpTypes": "./src/components/http/types.ts", + "./mimeType": "./src/lib/mimeType.ts", "./resolvedModelName": "./src/lib/resolvedModelName.ts", "./templateTags": "./src/lib/templateTags.ts", - "./toast": "./src/lib/toast.ts" + "./toast": "./src/lib/toast.ts", + "./useToggle": "./src/hooks/useToggle.ts" }, - "sideEffects": [ - "*.css" - ], "dependencies": { "hexy": "^0.4.0", "jotai-family": "^1.0.1" diff --git a/packages/ui/src/components/EditorHost.tsx b/packages/ui/src/components/EditorHost.tsx new file mode 100644 index 000000000..16ee8f30e --- /dev/null +++ b/packages/ui/src/components/EditorHost.tsx @@ -0,0 +1,194 @@ +import type { Completion } from "@codemirror/autocomplete"; +import type { Extension } from "@codemirror/state"; +import type { GenericCompletionOption } from "@yaakapp-internal/plugins"; +import type { ComponentType, ReactNode } from "react"; +import { createContext, useContext, useMemo } from "react"; +import type { SetStoredValue, StoredValue, UseStoredValue } from "../hooks/useStoredValue"; +import { useInMemoryStoredValue } from "../hooks/useStoredValue"; +import type { HttpBodyLoader } from "./http/types"; +import type { MultipartViewerProps } from "./viewers/MultipartViewer"; +import { MultipartViewer } from "./viewers/MultipartViewer"; +import type { TextViewerProps } from "./viewers/TextViewer"; +import { TextViewer } from "./viewers/TextViewer"; +import type { ButtonProps } from "./Button"; +import type { DialogProps } from "./Dialog"; +import type { CodeEditorProps } from "./editor/CodeEditor"; +import { ErrorBoundary } from "./ErrorBoundary"; +import { LazyCodeEditor } from "./editor/LazyCodeEditor"; +import type { InputProps } from "./Input"; +import { Input } from "./Input"; + +export type GenericCompletion = GenericCompletionOption & { + apply?: Completion["apply"]; +}; + +export interface GenericCompletionConfig { + minMatch?: number; + options: GenericCompletion[]; +} + +/** A template variable, as far as shared components need to know one */ +export interface TemplateVariableRef { + variable: { name: string }; +} + +/** + * What a host's editor adds on top of `CodeEditor`, like the client's template tags and + * environment variables. Shared components pass these through; a host without templating + * ignores them. + */ +export interface TemplatingProps { + autocomplete?: GenericCompletionConfig; + autocompleteFunctions?: boolean; + autocompleteVariables?: boolean | ((v: TemplateVariableRef) => boolean); + forcedEnvironmentId?: string; + extraExtensions?: Extension[] | Extension; +} + +/** `CodeEditor`'s languages, plus the host-only ones it may know (the client's URL and pairs) */ +export type HostEditorLanguage = CodeEditorProps["language"] | "pairs" | "url"; + +export type HostEditorProps = Omit & + TemplatingProps & { language?: HostEditorLanguage }; + +export type HostInputProps = Omit< + InputProps, + "language" | "extensions" | "renderEditor" | "renderOptionsPicker" +> & + TemplatingProps & { + language?: HostEditorLanguage; + /** Show a chevron that opens the `autocomplete` options as a menu */ + showOptionsPicker?: boolean; + }; + +export type SelectFileProps = Omit & { + onChange: (value: { filePath: string | null; contentType: string | null }) => void; + filePath: string | null; + nameOverride?: string | null; + inline?: boolean; +}; + +export interface HostPromptArgs { + id: string; + title: ReactNode; + label: string; + description?: ReactNode; + placeholder?: string; + defaultValue?: string; + confirmText?: string; + required?: boolean; +} + +export type HostDialogArgs = { + id: string; + render: ComponentType<{ hide: () => void }>; +} & Omit; + +export type StoredValueNamespace = "global" | "no_sync"; + +/** Like `UseStoredValue`, but told whether the value is a synced preference or local state */ +export type UseHostStoredValue = ( + key: string | string[] | null, + fallback: T, + namespace?: StoredValueNamespace, +) => readonly [T, SetStoredValue]; + +/** + * What shared editing components get from the app they run in. The client provides its + * templated editor, file picker, dialogs and key-value store; anything else (the proxy) gets + * plain `CodeEditor`s, in-memory state, and no dialogs. + */ +export interface EditorHost { + Editor: ComponentType; + Input: ComponentType; + SelectFile: ComponentType | null; + showDialog: ((args: HostDialogArgs) => void) | null; + showPrompt: ((args: HostPromptArgs) => Promise) | null; + basename: (path: string) => Promise; + useStoredValue: UseHostStoredValue; + /** Where a `DetailsBanner` with a `storageKey` keeps whether it's open */ + useDetailsBannerStoredValue: UseStoredValue; + ErrorBoundary: ComponentType<{ name: string; children: ReactNode }>; + /** The text viewer, with whatever the app adds to it (formatting, links) */ + TextViewer: ComponentType; + /** The multipart viewer, with whatever the app adds to it (a PDF viewer) */ + MultipartViewer: ComponentType; + /** Viewers only the app has, like the client's PDF viewer; null to use the shared ones */ + renderBodyViewer?: (args: HostBodyViewerArgs) => ReactNode | null; + /** Open a URL outside the app */ + openUrl: ((url: string) => void) | null; + /** Copy text to the clipboard, quietly, as rows of values do */ + copy: (text: string) => void; +} + +export interface HostBodyViewerArgs { + /** Which body: a response's, or the request body it was sent with */ + source: "response" | "request"; + /** The media type, without parameters */ + mimeType: string | null; + viewMode: "pretty" | "raw"; + body: HttpBodyLoader; + /** Whether the whole body has arrived */ + complete: boolean; +} + +const defaultEditorHost: EditorHost = { + ErrorBoundary, + useDetailsBannerStoredValue: useInMemoryStoredValue, + TextViewer, + MultipartViewer, + openUrl: null, + copy: (text) => { + navigator.clipboard.writeText(text).catch(console.error); + }, + Editor: PlainEditor, + Input: PlainInputEditor, + SelectFile: null, + showDialog: null, + showPrompt: null, + basename: async (path) => path.split(/[\\/]/).pop() ?? null, + useStoredValue: useInMemoryStoredValue, +}; + +const EditorHostContext = createContext(defaultEditorHost); + +export function EditorHostProvider({ + value, + children, +}: { + value: Partial; + children: ReactNode; +}) { + const parent = useContext(EditorHostContext); + const merged = useMemo(() => ({ ...parent, ...value }), [parent, value]); + return {children}; +} + +export function useEditorHost(): EditorHost { + return useContext(EditorHostContext); +} + +/** Drops what only a host's editor understands, leaving what `CodeEditor` does */ +function stripTemplating({ + autocomplete: _autocomplete, + autocompleteFunctions: _autocompleteFunctions, + autocompleteVariables: _autocompleteVariables, + forcedEnvironmentId: _forcedEnvironmentId, + extraExtensions, + language, + ...props +}: T) { + return { + ...props, + extensions: extraExtensions, + language: language === "pairs" || language === "url" ? null : language, + }; +} + +function PlainEditor(props: HostEditorProps) { + return ; +} + +function PlainInputEditor({ showOptionsPicker: _showOptionsPicker, ...props }: HostInputProps) { + return ; +} diff --git a/packages/ui/src/components/EmptyStateText.tsx b/packages/ui/src/components/EmptyStateText.tsx new file mode 100644 index 000000000..30f75bb40 --- /dev/null +++ b/packages/ui/src/components/EmptyStateText.tsx @@ -0,0 +1,24 @@ +import classNames from "classnames"; +import type { ReactNode } from "react"; + +interface Props { + children: ReactNode; + className?: string; + wrapperClassName?: string; +} + +export function EmptyStateText({ children, className, wrapperClassName }: Props) { + return ( +
+
+ {children} +
+
+ ); +} diff --git a/apps/yaak-client/components/core/HotkeyLabel.tsx b/packages/ui/src/components/HotkeyLabel.tsx similarity index 73% rename from apps/yaak-client/components/core/HotkeyLabel.tsx rename to packages/ui/src/components/HotkeyLabel.tsx index ab2e7360d..5e7902b65 100644 --- a/apps/yaak-client/components/core/HotkeyLabel.tsx +++ b/packages/ui/src/components/HotkeyLabel.tsx @@ -1,6 +1,6 @@ import classNames from "classnames"; -import type { HotkeyAction } from "../../hooks/useHotKey"; -import { useHotkeyLabel } from "../../hooks/useHotKey"; +import type { HotkeyAction } from "../hooks/useHotKey"; +import { useHotkeyLabel } from "../hooks/useHotKey"; interface Props { action: HotkeyAction; diff --git a/apps/yaak-client/components/core/HotkeyList.test.tsx b/packages/ui/src/components/HotkeyList.test.tsx similarity index 93% rename from apps/yaak-client/components/core/HotkeyList.test.tsx rename to packages/ui/src/components/HotkeyList.test.tsx index 47d99f4bb..b4018bf41 100644 --- a/apps/yaak-client/components/core/HotkeyList.test.tsx +++ b/packages/ui/src/components/HotkeyList.test.tsx @@ -1,6 +1,6 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, test, vi } from "vite-plus/test"; -import type { HotkeyAction } from "../../hooks/useHotKey"; +import type { HotkeyAction } from "../hooks/useHotKey"; import { HotkeyList } from "./HotkeyList"; vi.mock("./Hotkey", () => ({ diff --git a/packages/ui/src/components/HotkeyList.tsx b/packages/ui/src/components/HotkeyList.tsx new file mode 100644 index 000000000..0bcea69f5 --- /dev/null +++ b/packages/ui/src/components/HotkeyList.tsx @@ -0,0 +1,31 @@ +import classNames from "classnames"; +import type { ReactNode } from "react"; +import { Fragment } from "react"; +import type { HotkeyAction } from "../hooks/useHotKey"; +import { Hotkey } from "./Hotkey"; +import { HotkeyLabel } from "./HotkeyLabel"; + +interface Props { + hotkeys: HotkeyAction[]; + bottomSlot?: ReactNode; + className?: string; +} + +export const HotkeyList = ({ hotkeys, bottomSlot, className }: Props) => { + return ( +
+
+ {hotkeys.map((hotkey) => ( + + + {/* Keep this grid cell when Hotkey renders nothing so later rows stay aligned. */} +
+ +
+
+ ))} + {bottomSlot} +
+
+ ); +}; diff --git a/packages/ui/src/components/PillButton.tsx b/packages/ui/src/components/PillButton.tsx new file mode 100644 index 000000000..ba2b64251 --- /dev/null +++ b/packages/ui/src/components/PillButton.tsx @@ -0,0 +1,14 @@ +import classNames from "classnames"; +import type { ButtonProps } from "./Button"; +import { Button } from "./Button"; + +export function PillButton({ className, ...props }: ButtonProps) { + return ( + + {onSave != null && ( + + )} + {isProbablyText && getText != null && renderCopyButton?.(getText)} + + + ); + } + + return <>{children}; +} diff --git a/apps/yaak-client/components/FormMultipartEditor.tsx b/packages/ui/src/components/http/FormMultipartEditor.tsx similarity index 57% rename from apps/yaak-client/components/FormMultipartEditor.tsx rename to packages/ui/src/components/http/FormMultipartEditor.tsx index 4a93f9f25..e69b0e970 100644 --- a/apps/yaak-client/components/FormMultipartEditor.tsx +++ b/packages/ui/src/components/http/FormMultipartEditor.tsx @@ -1,32 +1,42 @@ -import type { HttpRequest } from "@yaakapp-internal/models"; import { useCallback, useMemo } from "react"; -import type { Pair, PairEditorProps } from "./core/PairEditor"; -import { PairEditor } from "./core/PairEditor"; +import type { Pair, PairEditorProps } from "../pairs/PairEditor"; +import { PairEditor } from "../pairs/PairEditor"; +import type { HttpFormFieldView, HttpRequestBodyView } from "./types"; type Props = { forceUpdateKey: string; - request: HttpRequest; - onChange: (body: HttpRequest["body"]) => void; + /** The request's id, keeping each request's editor state apart */ + requestId: string; + form: HttpFormFieldView[] | undefined; + onChange?: (body: HttpRequestBodyView) => void; + /** Show the fields without a way to change them */ + readOnly?: boolean; }; -export function FormMultipartEditor({ request, forceUpdateKey, onChange }: Props) { +export function FormMultipartEditor({ + requestId, + form, + forceUpdateKey, + onChange, + readOnly, +}: Props) { const pairs = useMemo( () => - (Array.isArray(request.body.form) ? request.body.form : []).map((p) => ({ + (Array.isArray(form) ? form : []).map((p) => ({ enabled: p.enabled, name: p.name, - value: p.file ?? p.value, + value: (p.file ?? p.value) as string, contentType: p.contentType, filename: p.filename, isFile: !!p.file, id: p.id, })), - [request.body.form], + [form], ); const handleChange = useCallback( (pairs) => - onChange({ + onChange?.({ form: pairs.map((p) => ({ enabled: p.enabled, name: p.name, @@ -50,8 +60,9 @@ export function FormMultipartEditor({ request, forceUpdateKey, onChange }: Props allowMultilineValues pairs={pairs} onChange={handleChange} + readOnly={readOnly} forceUpdateKey={forceUpdateKey} - stateKey={`multipart.${request.id}`} + stateKey={`multipart.${requestId}`} /> ); } diff --git a/packages/ui/src/components/http/FormUrlencodedEditor.tsx b/packages/ui/src/components/http/FormUrlencodedEditor.tsx new file mode 100644 index 000000000..6b5aef48b --- /dev/null +++ b/packages/ui/src/components/http/FormUrlencodedEditor.tsx @@ -0,0 +1,59 @@ +import { useCallback, useMemo } from "react"; +import type { Pair, PairEditorProps } from "../pairs/PairEditor"; +import { PairOrBulkEditor } from "../pairs/PairOrBulkEditor"; +import type { HttpFormFieldView, HttpRequestBodyView } from "./types"; + +type Props = { + forceUpdateKey: string; + /** The request's id, keeping each request's editor state apart */ + requestId: string; + form: HttpFormFieldView[] | undefined; + onChange?: (body: HttpRequestBodyView) => void; + /** Show the fields without a way to change them */ + readOnly?: boolean; +}; + +export function FormUrlencodedEditor({ + requestId, + form, + forceUpdateKey, + onChange, + readOnly, +}: Props) { + const pairs = useMemo( + () => + (Array.isArray(form) ? form : []).map((p) => ({ + enabled: !!p.enabled, + name: p.name || "", + value: p.value || "", + id: p.id, + })), + [form], + ); + + const handleChange = useCallback( + (pairs) => + onChange?.({ + form: pairs.map((p) => ({ enabled: p.enabled, name: p.name, value: p.value })), + }), + [onChange], + ); + + return ( + + ); +} diff --git a/packages/ui/src/components/http/HeadersEditor.tsx b/packages/ui/src/components/http/HeadersEditor.tsx new file mode 100644 index 000000000..88e7bf1ca --- /dev/null +++ b/packages/ui/src/components/http/HeadersEditor.tsx @@ -0,0 +1,189 @@ +import { acceptLanguages } from "../../lib/data/acceptLanguages"; +import { cacheControlDirectives } from "../../lib/data/cacheControl"; +import { charsets } from "../../lib/data/charsets"; +import { connections } from "../../lib/data/connections"; +import { encodings } from "../../lib/data/encodings"; +import type { HeaderValuePreset } from "../../lib/data/headerValuePresets"; +import { headerNames } from "../../lib/data/headerNames"; +import { mimeTypes } from "../../lib/data/mimetypes"; +import { userAgents } from "../../lib/data/userAgents"; +import { validateHttpHeader } from "../../lib/validateHttpHeader"; +import { CountBadge } from "../CountBadge"; +import { DetailsBanner } from "../DetailsBanner"; +import type { GenericCompletion, GenericCompletionConfig, HostInputProps } from "../EditorHost"; +import { useEditorHost } from "../EditorHost"; +import type { Pair, PairEditorProps } from "../pairs/PairEditor"; +import { PairEditorRow } from "../pairs/PairEditor"; +import { ensurePairId } from "../pairs/PairEditor.util"; +import { PairOrBulkEditor } from "../pairs/PairOrBulkEditor"; +import { HStack } from "../Stacks"; +import type { HttpPairView } from "./types"; + +type Props = { + forceUpdateKey: string; + headers: HttpPairView[]; + inheritedHeaders?: HttpPairView[]; + inheritedHeadersLabel?: string; + stateKey: string; + onChange?: PairEditorProps["onChange"]; + /** Show the headers without a way to change them */ + readOnly?: boolean; + label?: string; +}; + +export function HeadersEditor({ + stateKey, + headers, + inheritedHeaders, + inheritedHeadersLabel = "Inherited", + onChange = ignoreChange, + readOnly, + forceUpdateKey, +}: Props) { + const { useDetailsBannerStoredValue } = useEditorHost(); + // Get header names defined at current level (case-insensitive) + const currentHeaderNames = new Set( + headers.filter((h) => h.name).map((h) => h.name.toLowerCase()), + ); + // Filter inherited headers: must be enabled, have content, and not be overridden by current level + const validInheritedHeaders = + inheritedHeaders?.filter( + (pair) => + pair.enabled && + (pair.name || pair.value) && + !currentHeaderNames.has(pair.name.toLowerCase()), + ) ?? []; + const hasInheritedHeaders = validInheritedHeaders.length > 0; + return ( +
+ {hasInheritedHeaders && ( + + {inheritedHeadersLabel} + + } + > +
+ {validInheritedHeaders?.map((pair, i) => ( + + ))} +
+
+ )} + +
+ ); +} + +const MIN_MATCH = 3; + +const headerOptionsMap: Record = { + "content-type": mimeTypes, + accept: ["*/*", ...mimeTypes], + "accept-encoding": encodings, + "content-encoding": encodings, + connection: connections, + "accept-charset": charsets, + "accept-language": acceptLanguages, + "cache-control": cacheControlDirectives, + "user-agent": userAgents, + pragma: ["no-cache"], + te: ["trailers", "compress", "deflate", "gzip"], + dnt: ["1", "0"], + "upgrade-insecure-requests": ["1"], + "x-requested-with": ["XMLHttpRequest"], +}; + +function ignoreChange() {} + +const valueType = (pair: Pair): HostInputProps["type"] => { + const name = pair.name.toLowerCase().trim(); + if ( + name.includes("authorization") || + name.includes("api-key") || + name.includes("access-token") || + name.includes("auth") || + name.includes("secret") || + name.includes("token") || + name === "cookie" || + name === "set-cookie" + ) { + return "password"; + } + return "text"; +}; + +const valueAutocomplete = (headerName: string): GenericCompletionConfig | undefined => { + const name = headerName.toLowerCase().trim(); + const options: GenericCompletion[] = + headerOptionsMap[name]?.map((o) => + typeof o === "string" + ? { + label: o, + type: "constant", + boost: 1, // Put above other completions + } + : { + // Show the short label but insert the full value (e.g. User-Agent) + label: o.label, + apply: o.value, + type: "constant", + boost: 1, + }, + ) ?? []; + return { minMatch: MIN_MATCH, options }; +}; + +const nameAutocomplete: PairEditorProps["nameAutocomplete"] = { + minMatch: MIN_MATCH, + options: headerNames.map((t) => + typeof t === "string" + ? { + label: t, + type: "constant", + boost: 1, // Put above other completions + } + : { + ...t, + boost: 1, // Put above other completions + }, + ), +}; diff --git a/packages/ui/src/components/http/HttpRequestPaneView.tsx b/packages/ui/src/components/http/HttpRequestPaneView.tsx new file mode 100644 index 000000000..3402eea16 --- /dev/null +++ b/packages/ui/src/components/http/HttpRequestPaneView.tsx @@ -0,0 +1,458 @@ +import classNames from "classnames"; +import type { CSSProperties, ReactNode, Ref } from "react"; +import { useMemo } from "react"; +import { languageFromContentType } from "../../lib/contentType"; +import { CountBadge } from "../CountBadge"; +import { EmptyStateText } from "../EmptyStateText"; +import type { HostInputProps } from "../EditorHost"; +import { useEditorHost } from "../EditorHost"; +import { KeyValueRows } from "../KeyValueRow"; +import type { HotkeyAction } from "../../hooks/useHotKey"; +import type { EditablePair, PairEditorHandle, PairEditorProps } from "../pairs/PairEditor"; +import type { TabItem, TabsRef } from "../tabs/Tabs"; +import { TabContent, Tabs } from "../tabs/Tabs"; +import { FormMultipartEditor } from "./FormMultipartEditor"; +import { FormUrlencodedEditor } from "./FormUrlencodedEditor"; +import { HeadersEditor } from "./HeadersEditor"; +import type { ExtraPaneTab } from "./HttpResponsePaneView"; +import { JsonBodyEditor } from "./JsonBodyEditor"; +import { RequestBodyViewer } from "./RequestBodyViewer"; +import { RequestMethodDropdown } from "./RequestMethodDropdown"; +import { KeyValueRow } from "./ResponseHeaders"; +import { + BODY_TYPE_BINARY, + BODY_TYPE_FORM_MULTIPART, + BODY_TYPE_FORM_URLENCODED, + BODY_TYPE_GRAPHQL, + BODY_TYPE_JSON, + BODY_TYPE_OTHER, + BODY_TYPE_XML, + type HttpBodyLoader, + type HttpRequestBodyView, + type HttpRequestView, +} from "./types"; +import { UrlBar } from "./UrlBar"; +import { UrlParametersEditor } from "./UrlParametersEditor"; + +export const REQUEST_TAB_BODY = "body"; +export const REQUEST_TAB_PARAMS = "params"; +export const REQUEST_TAB_HEADERS = "headers"; +export const REQUEST_TAB_AUTH = "auth"; + +export interface HttpRequestPaneViewProps { + request: HttpRequestView; + /** Lets the request be changed and sent; without it the pane only shows it */ + editable?: boolean; + style?: CSSProperties; + className?: string; + fullHeight: boolean; + /** Where the tab order and the active tab are remembered */ + tabsStorageKey?: string; + tabsRef?: Ref; + /** Re-seeds the editors when the request changes from elsewhere */ + forceUpdateKey: string; + + /** The URL bar's own reset key, when the app resets it apart from the rest */ + urlBarKey?: string; + /** Re-seeds the URL bar's text without remounting it */ + urlForceUpdateKey?: string; + urlAutocomplete?: HostInputProps["autocomplete"]; + urlPlaceholder?: string; + onUrlChange?: (url: string) => void; + onUrlPasteOverwrite?: HostInputProps["onPasteOverwrite"]; + onSend?: () => void; + onCancel?: () => void; + isSending?: boolean; + /** The action whose keys send the request */ + sendHotkeyAction?: HotkeyAction; + /** The action whose keys select the URL */ + focusHotkeyAction?: HotkeyAction; + /** Colour the method by what it does */ + methodColored?: boolean; + onMethodChange?: (method: string) => void | Promise; + /** Replaces the method shown at the start of the URL bar */ + methodSlot?: ReactNode; + + /** The body tab, when the app offers a menu of body types; otherwise a label names it */ + bodyTab?: TabItem; + /** Renders the body tab's content, given the shared editor for the body type */ + renderBody?: (sharedEditor: ReactNode) => ReactNode; + /** Reads a body that isn't text, like a captured binary or multipart one */ + bodyLoader?: HttpBodyLoader; + onBodyChange?: (body: HttpRequestBodyView) => void; + /** A text body changed; JSON writes at once, the rest may debounce */ + onBodyTextChange?: (text: string, bodyType: string | null) => void; + onSendJsonCommentsChange?: (sendJsonComments: boolean) => void; + /** Where dismissing the JSON comments notice is remembered */ + jsonNoticeStorageKey?: string; + + /** Parameters to edit, when the app derives more than the request's own (path placeholders) */ + urlParameterPairs?: EditablePair[]; + urlParametersForceUpdateKey?: string; + onUrlParametersChange?: PairEditorProps["onChange"]; + onUrlParametersRef?: (handle: PairEditorHandle) => void; + /** Overrides the parameters editor, like the client's one wired to its focus events */ + renderUrlParameters?: (pairs: EditablePair[]) => ReactNode; + + /** The headers tab, when the app counts inherited headers into it */ + headersTabs?: TabItem[]; + inheritedHeaders?: HeadersEditorInherited; + headersForceUpdateKey?: string; + onHeadersChange?: PairEditorProps["onChange"]; + + /** The auth tab and what it shows, when the app edits authentication itself */ + authTabs?: TabItem[]; + authContent?: ReactNode; + + /** Tabs only the app has, after the shared ones */ + extraTabs?: ExtraPaneTab[]; +} + +type HeadersEditorInherited = { name: string; value: string; enabled?: boolean; id?: string }[]; + +/** Yaak's request pane: the URL bar over Body, Params, Headers and Auth tabs */ +export function HttpRequestPaneView({ + request, + editable = false, + style, + className, + fullHeight, + tabsStorageKey = "http_request_tabs", + tabsRef, + forceUpdateKey, + urlBarKey, + urlForceUpdateKey, + urlAutocomplete, + urlPlaceholder = "https://example.com", + onUrlChange, + onUrlPasteOverwrite, + onSend, + onCancel, + isSending, + sendHotkeyAction, + focusHotkeyAction, + methodColored, + onMethodChange, + methodSlot, + bodyTab, + renderBody, + bodyLoader, + onBodyChange, + onBodyTextChange, + onSendJsonCommentsChange, + jsonNoticeStorageKey, + urlParameterPairs, + urlParametersForceUpdateKey, + onUrlParametersChange, + onUrlParametersRef, + renderUrlParameters, + headersTabs, + inheritedHeaders, + headersForceUpdateKey, + onHeadersChange, + authTabs, + authContent, + extraTabs, +}: HttpRequestPaneViewProps) { + const { ErrorBoundary } = useEditorHost(); + const readOnly = !editable; + const paramPairs = urlParameterPairs ?? request.urlParameters; + + let numParams = 0; + if ( + request.bodyType === BODY_TYPE_FORM_URLENCODED || + request.bodyType === BODY_TYPE_FORM_MULTIPART + ) { + numParams = Array.isArray(request.body?.form) + ? request.body.form.filter((p) => p.name).length + : 0; + } + + const tabs = useMemo( + () => [ + bodyTab ?? { + value: REQUEST_TAB_BODY, + label: bodyTypeLabel(request.bodyType, request.contentType), + rightSlot: numParams > 0 ? : null, + }, + { + value: REQUEST_TAB_PARAMS, + rightSlot: , + label: "Params", + }, + ...(headersTabs ?? [ + { + value: REQUEST_TAB_HEADERS, + label: "Headers", + rightSlot: ( + h.name && h.enabled !== false).length} + /> + ), + }, + ]), + ...(authTabs ?? + (request.auth == null + ? [] + : [ + { + value: REQUEST_TAB_AUTH, + label: request.auth.label, + }, + ])), + ...(extraTabs?.map((t) => t.tab) ?? []), + ], + [ + authTabs, + bodyTab, + extraTabs, + headersTabs, + numParams, + paramPairs.length, + request.auth, + request.bodyType, + request.contentType, + request.headers, + ], + ); + + const sharedBody = ( + + ); + + return ( +
+ + {methodSlot ?? ( + + )} +
+ } + forceUpdateKey={urlForceUpdateKey ?? forceUpdateKey} + isLoading={isSending} + /> + + + {authContent ?? (request.auth != null && )} + + + + + + {renderUrlParameters?.(paramPairs) ?? ( + + )} + + + {renderBody ? renderBody(sharedBody) : sharedBody} + + {extraTabs?.map(({ tab, content }) => ( + + {content} + + ))} + + + ); +} + +interface RequestBodyEditorProps { + request: HttpRequestView; + readOnly: boolean; + fullHeight: boolean; + forceUpdateKey: string; + bodyLoader?: HttpBodyLoader; + onBodyChange?: (body: HttpRequestBodyView) => void; + onBodyTextChange?: (text: string, bodyType: string | null) => void; + onSendJsonCommentsChange?: (sendJsonComments: boolean) => void; + jsonNoticeStorageKey: string; +} + +/** The editor for a request body's type: JSON, XML, forms, or any other text */ +export function RequestBodyEditor({ + request, + readOnly, + fullHeight, + forceUpdateKey, + bodyLoader, + onBodyChange, + onBodyTextChange, + onSendJsonCommentsChange, + jsonNoticeStorageKey, +}: RequestBodyEditorProps) { + const { Editor } = useEditorHost(); + const { bodyType } = request; + const handleTextChange = (text: string) => onBodyTextChange?.(text, bodyType); + + // A captured body that isn't text shows in the viewer for its content type + if ( + bodyLoader != null && + (bodyType === BODY_TYPE_BINARY || bodyType === BODY_TYPE_FORM_MULTIPART) && + readOnly + ) { + return ( + + ); + } + + if (bodyType === BODY_TYPE_JSON) { + return ( + + ); + } + if (bodyType === BODY_TYPE_XML) { + return ( + + ); + } + if (bodyType === BODY_TYPE_FORM_URLENCODED) { + return ( + + ); + } + if (bodyType === BODY_TYPE_FORM_MULTIPART) { + return ( + + ); + } + if (typeof bodyType === "string" && bodyType !== BODY_TYPE_BINARY) { + return ( + + ); + } + return No Body; +} + +function AuthDisplay({ auth }: { auth: NonNullable }) { + return ( + + {auth.values.map((v) => ( + + {v.value} + + ))} + + ); +} + +/** What the body tab is called when there's no menu of body types to name it */ +export function bodyTypeLabel(bodyType: string | null, contentType: string | null): string { + switch (bodyType) { + case BODY_TYPE_JSON: + return "JSON"; + case BODY_TYPE_XML: + return "XML"; + case BODY_TYPE_GRAPHQL: + return "GraphQL"; + case BODY_TYPE_FORM_URLENCODED: + return "Url Encoded"; + case BODY_TYPE_FORM_MULTIPART: + return "Multi-Part"; + case BODY_TYPE_BINARY: + return "Binary"; + case BODY_TYPE_OTHER: + return languageFromContentType(contentType) === "markdown" ? "Markdown" : "Other"; + case null: + return "Body"; + default: + return "Body"; + } +} diff --git a/packages/ui/src/components/http/HttpResponsePaneView.tsx b/packages/ui/src/components/http/HttpResponsePaneView.tsx new file mode 100644 index 000000000..2549182df --- /dev/null +++ b/packages/ui/src/components/http/HttpResponsePaneView.tsx @@ -0,0 +1,350 @@ +import classNames from "classnames"; +import type { CSSProperties, ReactElement, ReactNode } from "react"; +import { Suspense, useMemo } from "react"; +import { getMimeTypeFromContentType } from "../../lib/mimeType"; +import { Banner } from "../Banner"; +import { CountBadge } from "../CountBadge"; +import type { DropdownItem } from "../Dropdown"; +import { type EditorHost, useEditorHost } from "../EditorHost"; +import { Icon } from "../Icon"; +import { LoadingIcon } from "../LoadingIcon"; +import { HStack } from "../Stacks"; +import type { TabItem } from "../tabs/Tabs"; +import { TabContent, Tabs } from "../tabs/Tabs"; +import { HttpResponseDurationTagRaw } from "../tags/HttpResponseDurationTag"; +import { HttpStatusTagRaw } from "../tags/HttpStatusTag"; +import { SizeTag } from "../tags/SizeTag"; +import { ConfirmLargeResponse } from "./ConfirmLargeResponse"; +import { RequestBodyViewer } from "./RequestBodyViewer"; +import type { ResponseTextViewerExtras, ResponseViewMode } from "./ResponseBodyViewer"; +import { ResponseBodyViewer } from "./ResponseBodyViewer"; +import { parseCookieHeaders, ResponseCookies } from "./ResponseCookies"; +import { ResponseHeaders } from "./ResponseHeaders"; +import type { HttpBodyLoader, HttpResponseView } from "./types"; + +export const RESPONSE_TAB_BODY = "body"; +export const RESPONSE_TAB_REQUEST = "request"; +export const RESPONSE_TAB_HEADERS = "headers"; +export const RESPONSE_TAB_COOKIES = "cookies"; + +/** A tab only one app has, like the client's timeline, with what it shows */ +export interface ExtraPaneTab { + tab: TabItem; + content: ReactNode; +} + +export interface HttpResponsePaneViewProps { + response: HttpResponseView | null; + /** Reads the response body */ + body: HttpBodyLoader | null; + /** Reads the request body the response answered */ + requestBody: HttpBodyLoader | null; + style?: CSSProperties; + className?: string; + /** Shown before there's a response */ + emptyState: ReactNode; + viewMode: ResponseViewMode; + onViewModeChange: (viewMode: ResponseViewMode) => void; + /** Where the tab order and the active tab are remembered */ + tabsStorageKey?: string; + /** Remembers the active tab per request or flow */ + activeTabKey: string; + /** Between the status and the right of the status line, like a warning */ + statusLineCenter?: ReactNode; + /** At the right of the status line, like a menu of earlier responses */ + statusLineRight?: ReactNode; + onSaveBody?: () => void; + onCopyBody?: () => void; + onCopyAsCurl?: () => void; + /** Stop a request that's still being sent */ + onCancel?: () => void; + /** Overrides the cookie counts the tab shows, when the app counts them its own way */ + cookieCounts?: { sent: number; received: number }; + /** Tabs only the app has, after the shared ones */ + extraTabs?: ExtraPaneTab[]; + textViewer?: ResponseTextViewerExtras; + /** Viewers only this app has for a response body, like the client's event stream viewer */ + renderBodyViewer?: EditorHost["renderBodyViewer"]; + /** More rows for the Headers tab's Info section */ + infoRows?: ReactElement[]; + /** The copy button for a body held back for its size */ + renderCopyButton?: (getText: () => Promise) => ReactNode; +} + +/** Yaak's response pane: a status line over Response, Request, Headers and Cookies tabs */ +export function HttpResponsePaneView({ + response, + body, + requestBody, + style, + className, + emptyState, + viewMode, + onViewModeChange, + tabsStorageKey = "http_response_tabs", + activeTabKey, + statusLineCenter, + statusLineRight, + onSaveBody, + onCopyBody, + onCopyAsCurl, + onCancel, + cookieCounts: cookieCountsOverride, + extraTabs, + textViewer, + renderBodyViewer, + infoRows, + renderCopyButton, +}: HttpResponsePaneViewProps) { + const { ErrorBoundary } = useEditorHost(); + const mimeType = + response?.contentType == null ? null : getMimeTypeFromContentType(response.contentType).essence; + + const cookieCounts = useMemo(() => { + if (cookieCountsOverride != null) return cookieCountsOverride; + if (response == null) return { sent: 0, received: 0 }; + const { sentCookies, receivedCookies } = parseCookieHeaders(response.cookieHeaders); + return { sent: sentCookies.length, received: receivedCookies.length }; + }, [cookieCountsOverride, response]); + + const tabs = useMemo(() => { + const bodyActionsDisabled = response?.state !== "closed" && (response?.status ?? 0) >= 100; + const bodyActionsHidden = response == null || !!response.error; + const itemsAfter: DropdownItem[] = [ + ...(onSaveBody != null + ? [ + { + label: "Save to File", + onSelect: onSaveBody, + leftSlot: , + hidden: bodyActionsHidden, + disabled: bodyActionsDisabled, + }, + ] + : []), + ...(onCopyBody != null + ? [ + { + label: "Copy Body", + onSelect: onCopyBody, + leftSlot: , + hidden: bodyActionsHidden, + disabled: bodyActionsDisabled, + }, + ] + : []), + ...(onCopyAsCurl != null + ? [{ label: "Copy as cURL", onSelect: onCopyAsCurl, leftSlot: }] + : []), + ]; + + return [ + { + value: RESPONSE_TAB_BODY, + options: { + value: viewMode, + onChange: (v) => onViewModeChange(v === "raw" ? "raw" : "pretty"), + items: [ + { label: "Response", value: "pretty" }, + ...(mimeType?.startsWith("image") + ? [] + : [{ label: "Response (Raw)", shortLabel: "Raw", value: "raw" }]), + ], + itemsAfter: itemsAfter.length > 0 ? itemsAfter : undefined, + }, + }, + { + value: RESPONSE_TAB_REQUEST, + label: "Request", + rightSlot: (response?.requestContentLength ?? 0) > 0 ? : null, + }, + { + value: RESPONSE_TAB_HEADERS, + label: "Headers", + rightSlot: ( + + ), + }, + { + value: RESPONSE_TAB_COOKIES, + label: "Cookies", + rightSlot: + cookieCounts.sent > 0 || cookieCounts.received > 0 ? ( + + ) : null, + }, + ...(extraTabs?.map((t) => t.tab) ?? []), + ]; + }, [ + cookieCounts, + extraTabs, + mimeType, + onCopyAsCurl, + onCopyBody, + onSaveBody, + onViewModeChange, + response, + viewMode, + ]); + + return ( +
+ {response == null ? ( + emptyState + ) : ( +
+ + + + +
+ {response.error && ( + + {response.error} + + )} + {/* Show tabs if we have any data (headers, body, etc.) even if there's an error */} + + + + + + {body != null && ( + + )} + + + + + + + {requestBody != null && ( + + )} + + + + + + + + + {extraTabs?.map(({ tab, content }) => ( + + {content} + + ))} + +
+
+ )} +
+ ); +} + +/** The status, time and size of a response, with room for the app's own pieces */ +export function ResponseStatusLine({ + response, + center, + right, +}: { + response: HttpResponseView; + center?: ReactNode; + right?: ReactNode; +}) { + return ( +
+ + {response.state !== "closed" && } + + • + + • + + + {center ?? } +
{right}
+
+ ); +} + +function findHeader(headers: { name: string; value: string }[], name: string): string | null { + return headers.find((h) => h.name.toLowerCase() === name)?.value ?? null; +} diff --git a/packages/ui/src/components/http/JsonBodyEditor.tsx b/packages/ui/src/components/http/JsonBodyEditor.tsx new file mode 100644 index 000000000..c369ae286 --- /dev/null +++ b/packages/ui/src/components/http/JsonBodyEditor.tsx @@ -0,0 +1,141 @@ +import { linter } from "@codemirror/lint"; +import { useCallback, useMemo } from "react"; +import { textLikelyContainsJsonComments } from "../../lib/jsonComments"; +import { replaceTemplateTags } from "../../lib/templateTags"; +import { Banner } from "../Banner"; +import type { DropdownItem } from "../Dropdown"; +import { Dropdown } from "../Dropdown"; +import type { HostEditorProps } from "../EditorHost"; +import { useEditorHost } from "../EditorHost"; +import { jsonParseLinter } from "../editor/json-lint"; +import { Icon } from "../Icon"; +import { IconButton } from "../IconButton"; +import { IconTooltip } from "../IconTooltip"; + +interface Props { + forceUpdateKey: string; + heightMode: HostEditorProps["heightMode"]; + /** The request's id, keeping each request's editor state apart */ + requestId: string; + text: string; + /** JSON is sent as written, comments and all, instead of cleaned up first */ + sendJsonComments: boolean; + onChange?: (text: string) => void; + onChangeSendJsonComments?: (sendJsonComments: boolean) => void; + /** Where dismissing the comments notice is remembered, like the workspace it applies to */ + noticeStorageKey: string; + /** Show the body without a way to change it */ + readOnly?: boolean; +} + +export function JsonBodyEditor({ + forceUpdateKey, + heightMode, + requestId, + text, + sendJsonComments, + onChange, + onChangeSendJsonComments, + noticeStorageKey, + readOnly, +}: Props) { + const { Editor, useStoredValue } = useEditorHost(); + const autoFix = sendJsonComments !== true; + + const lintExtension = useMemo( + () => + linter( + jsonParseLinter( + autoFix || readOnly + ? { allowComments: true, allowTrailingCommas: true, escape: escapeTemplateTags } + : { allowComments: false, allowTrailingCommas: false, escape: escapeTemplateTags }, + ), + ), + [autoFix, readOnly], + ); + + const hasComments = useMemo(() => textLikelyContainsJsonComments(text), [text]); + + const [bannerDismissed, setBannerDismissed] = useStoredValue( + ["json-fix-3", noticeStorageKey], + false, + "no_sync", + ); + + const handleToggleAutoFix = useCallback( + () => onChangeSendJsonComments?.(autoFix), + [autoFix, onChangeSendJsonComments], + ); + + const handleDropdownOpen = useCallback(() => { + if (!bannerDismissed) { + Promise.resolve(setBannerDismissed(true)).catch(console.error); + } + }, [bannerDismissed, setBannerDismissed]); + + const showBanner = hasComments && autoFix && !bannerDismissed; + + const stripMessage = "Automatically strip comments and trailing commas before sending"; + const actions = useMemo( + () => + readOnly + ? undefined + : [ + showBanner && ( + +

+ Auto-fix enabled + +

+
+ ), +
+ , + leftSlot: ( + + ), + }, + ] satisfies DropdownItem[] + } + > + + +
, + ], + [handleDropdownOpen, handleToggleAutoFix, autoFix, readOnly, showBanner], + ); + + return ( + + ); +} + +// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template +// syntax with repeating `1` characters, so it's valid JSON and the position is still correct. +function escapeTemplateTags(doc: string): string { + return replaceTemplateTags(doc, (m) => "1".repeat(m.end - m.start)); +} diff --git a/packages/ui/src/components/http/RequestBodyViewer.tsx b/packages/ui/src/components/http/RequestBodyViewer.tsx new file mode 100644 index 000000000..b058d5012 --- /dev/null +++ b/packages/ui/src/components/http/RequestBodyViewer.tsx @@ -0,0 +1,102 @@ +import { formatSize } from "@yaakapp-internal/lib/formatSize"; +import { languageFromContentType } from "../../lib/contentType"; +import { getMimeTypeFromContentType } from "../../lib/mimeType"; +import { EmptyStateText } from "../EmptyStateText"; +import { useEditorHost } from "../EditorHost"; +import { LoadingIcon } from "../LoadingIcon"; +import { AudioViewer } from "../viewers/AudioViewer"; +import { CsvViewer } from "../viewers/CsvViewer"; +import { ImageViewer } from "../viewers/ImageViewer"; +import { SvgViewer } from "../viewers/SvgViewer"; +import { VideoViewer } from "../viewers/VideoViewer"; +import { WebPageViewer } from "../viewers/WebPageViewer"; +import type { HttpBodyLoader } from "./types"; + +interface Props { + /** Identifies the body, for viewer state */ + id: string; + /** The Content-Type the request was sent with */ + contentType: string | null; + body: HttpBodyLoader; +} + +/** The body a request was sent with, in the viewer its content type calls for */ +export function RequestBodyViewer({ id, contentType, body }: Props) { + return ; +} + +function RequestBodyViewerInner({ id, contentType, body }: Props) { + const { MultipartViewer, TextViewer, renderBodyViewer } = useEditorHost(); + const text = body.useText(); + const bytes = body.useBytes(); + + if (text.isLoading || bytes.isLoading) { + return ( + + + + ); + } + + const error = text.error ?? bytes.error; + if (error) { + return Error loading request body: {error.message}; + } + + // Whether there's a body is the bytes' to say: a binary body has no text + const bodyBytes = bytes.data; + if (bodyBytes == null || bodyBytes.length === 0) { + return No request body; + } + + const mimeType = contentType ? getMimeTypeFromContentType(contentType).essence : null; + + // Route to appropriate viewer based on content type + if (mimeType?.match(/^multipart/i)) { + const boundary = contentType?.split("boundary=")[1] ?? "unknown"; + // Create a copy because parseMultipart may detach the buffer + const bodyCopy = new Uint8Array(bodyBytes); + return ; + } + + if (mimeType?.match(/^image/i) && !mimeType.match(/^image\/svg/i)) { + return ; + } + + if (mimeType?.match(/^audio/i)) { + return ; + } + + if (mimeType?.match(/^video/i)) { + return ; + } + + const appViewer = mimeType?.match(/pdf/i) + ? renderBodyViewer?.({ source: "request", mimeType, viewMode: "pretty", body, complete: true }) + : null; + + // The rest show text, which a body that isn't valid UTF-8 doesn't have + const bodyText = text.data; + if (bodyText == null || bodyText.length === 0) { + return ( + appViewer ?? Binary body, {formatSize(bodyBytes.length)} + ); + } + + if (mimeType?.match(/^image\/svg/i)) { + return ; + } + + if (mimeType?.match(/csv|tab-separated/i)) { + return ; + } + + if (mimeType?.match(/^text\/html/i)) { + return ; + } + + if (appViewer != null) return appViewer; + + const language = languageFromContentType(contentType, bodyText); + return ; +} diff --git a/packages/ui/src/components/http/RequestMethodDropdown.tsx b/packages/ui/src/components/http/RequestMethodDropdown.tsx new file mode 100644 index 000000000..cd0b4e89f --- /dev/null +++ b/packages/ui/src/components/http/RequestMethodDropdown.tsx @@ -0,0 +1,87 @@ +import classNames from "classnames"; +import { memo, useCallback, useMemo } from "react"; +import { Button } from "../Button"; +import type { DropdownItem } from "../Dropdown"; +import { useEditorHost } from "../EditorHost"; +import { Icon } from "../Icon"; +import type { RadioDropdownItem } from "../RadioDropdown"; +import { RadioDropdown } from "../RadioDropdown"; +import { HttpMethodTagRaw } from "../tags/HttpMethodTag"; + +type Props = { + method: string; + /** Colour the method by what it does, as the client's setting chooses */ + colored?: boolean; + onChange?: (method: string) => void | Promise; + /** Show the method without a menu to change it */ + readOnly?: boolean; + className?: string; +}; + +const METHODS = ["GET", "PUT", "POST", "PATCH", "DELETE", "OPTIONS", "QUERY", "HEAD"]; + +export const RequestMethodDropdown = memo(function RequestMethodDropdown({ + method, + colored, + onChange, + readOnly, + className, +}: Props) { + const { showPrompt } = useEditorHost(); + + const radioItems = useMemo[]>( + () => + METHODS.map((m) => ({ value: m, label: })), + [colored], + ); + + const handleChange = useCallback( + async (method: string) => { + await onChange?.(method); + }, + [onChange], + ); + + const itemsAfter = useMemo( + () => [ + { + key: "custom", + label: "CUSTOM", + leftSlot: , + hidden: showPrompt == null, + onSelect: async () => { + const newMethod = await showPrompt?.({ + id: "custom-method", + label: "Http Method", + title: "Custom Method", + confirmText: "Save", + description: "Enter a custom method name", + placeholder: "CUSTOM", + }); + if (newMethod == null) return; + await handleChange(newMethod); + }, + }, + ], + [handleChange, showPrompt], + ); + + const button = ( + + ); + + if (readOnly) return button; + + return ( + + {button} + + ); +}); diff --git a/packages/ui/src/components/http/ResponseBodyViewer.tsx b/packages/ui/src/components/http/ResponseBodyViewer.tsx new file mode 100644 index 000000000..a81f658fc --- /dev/null +++ b/packages/ui/src/components/http/ResponseBodyViewer.tsx @@ -0,0 +1,224 @@ +import { formatSize } from "@yaakapp-internal/lib/formatSize"; +import type { ComponentType } from "react"; +import { languageFromContentType } from "../../lib/contentType"; +import { getMimeTypeFromContentType } from "../../lib/mimeType"; +import { Banner } from "../Banner"; +import { Button } from "../Button"; +import { EmptyStateText } from "../EmptyStateText"; +import { type EditorHost, useEditorHost } from "../EditorHost"; +import { LoadingIcon } from "../LoadingIcon"; +import { HStack, VStack } from "../Stacks"; +import { AudioViewer } from "../viewers/AudioViewer"; +import { CsvViewer } from "../viewers/CsvViewer"; +import { ImageViewer } from "../viewers/ImageViewer"; +import { SvgViewer } from "../viewers/SvgViewer"; +import type { TextViewerProps } from "../viewers/TextViewer"; +import { VideoViewer } from "../viewers/VideoViewer"; +import { WebPageViewer } from "../viewers/WebPageViewer"; +import type { HttpBodyLoader, HttpResponseView } from "./types"; + +export type ResponseViewMode = "pretty" | "raw"; + +/** What the text viewer adds for the app, like its filter history and save and copy buttons */ +export type ResponseTextViewerExtras = Pick< + TextViewerProps, + "footerActions" | "filterStateKey" | "onFilterApplied" | "filterLeftSlot" +>; + +interface Props { + response: HttpResponseView; + body: HttpBodyLoader; + viewMode: ResponseViewMode; + textViewer?: ResponseTextViewerExtras; + /** Stop a request that's still being sent */ + onCancel?: () => void; + /** Viewers only this pane's app has, tried before the host's and the shared ones */ + renderBodyViewer?: EditorHost["renderBodyViewer"]; +} + +/** Picks the viewer for a response body from its content type, as the client's response pane does */ +export function ResponseBodyViewer({ + response, + body, + viewMode, + textViewer, + onCancel, + renderBodyViewer: renderPaneBodyViewer, +}: Props) { + const { renderBodyViewer: renderHostBodyViewer } = useEditorHost(); + const mimeType = + response.contentType == null ? null : getMimeTypeFromContentType(response.contentType).essence; + + if (response.state === "initialized") { + return ( + + + + + Sending Request + + {onCancel != null && ( + + )} + + + ); + } + + if (response.state === "closed" && (response.contentLength ?? 0) === 0) { + return Empty; + } + + const viewerArgs = { + source: "response" as const, + mimeType, + viewMode, + body, + complete: response.state === "closed", + }; + const appViewer = renderPaneBodyViewer?.(viewerArgs) ?? renderHostBodyViewer?.(viewerArgs); + if (appViewer != null) return appViewer; + + if (mimeType?.match(/^image\/svg/)) { + return ; + } + if (mimeType?.match(/^image/i)) { + return ; + } + if (mimeType?.match(/^audio/i)) { + return ; + } + if (mimeType?.match(/^video/i)) { + return ; + } + if (mimeType?.match(/^multipart/i) && viewMode === "pretty") { + return ; + } + if (mimeType?.match(/csv|tab-separated/i) && viewMode === "pretty") { + return ; + } + return ( + + ); +} + +/** A body with no text is either empty or not valid UTF-8, which only its bytes can tell apart */ +function BinaryOrEmptyBody({ body }: { body: HttpBodyLoader }) { + const bytes = body.useBytes(); + if (bytes.isLoading) return null; + const length = bytes.data?.length ?? 0; + return ( + + {length > 0 ? `Binary body, ${formatSize(length)}` : "Empty response"} + + ); +} + +function EnsureCompleteResponse({ + response, + body, + Component, +}: { + response: HttpResponseView; + body: HttpBodyLoader; + Component: ComponentType<{ bodyUrl: string }>; +}) { + // Wait until the response has been fully-downloaded before asking for it + const complete = response.state === "closed"; + const bodyUrl = body.useUrl(complete); + + if (!complete || bodyUrl.isLoading) { + return ( + + + + ); + } + + if (bodyUrl.error) { + return {String(bodyUrl.error)}; + } + + if (bodyUrl.data == null) { + return
Empty response body
; + } + + return ; +} + +function BodySvgViewer({ body }: { body: HttpBodyLoader }) { + const text = body.useText(); + if (!text.data) return null; + return ; +} + +function BodyCsvViewer({ body, className }: { body: HttpBodyLoader; className?: string }) { + const text = body.useText(); + return ; +} + +function BodyMultipartViewer({ + response, + body, +}: { + response: HttpResponseView; + body: HttpBodyLoader; +}) { + const { MultipartViewer } = useEditorHost(); + const bytes = body.useBytes(); + if (bytes.data == null) return null; + + const boundary = response.contentType?.split("boundary=")[1] ?? "unknown"; + return ; +} + +interface HTMLOrTextViewerProps { + response: HttpResponseView; + body: HttpBodyLoader; + pretty: boolean; + textViewerClassName?: string; + textViewer?: ResponseTextViewerExtras; +} + +export function HTMLOrTextViewer({ + response, + body, + pretty, + textViewerClassName, + textViewer, +}: HTMLOrTextViewerProps) { + const { TextViewer } = useEditorHost(); + const rawTextBody = body.useText(); + const language = languageFromContentType(response.contentType, rawTextBody.data ?? ""); + + if (rawTextBody.isLoading || response.state === "initialized") { + return null; + } + + if (language === "html" && pretty) { + return ; + } + if (rawTextBody.data == null) { + return ; + } + return ( + + ); +} diff --git a/apps/yaak-client/components/ResponseCookies.tsx b/packages/ui/src/components/http/ResponseCookies.tsx similarity index 79% rename from apps/yaak-client/components/ResponseCookies.tsx rename to packages/ui/src/components/http/ResponseCookies.tsx index 4a9eb29ad..61649b941 100644 --- a/apps/yaak-client/components/ResponseCookies.tsx +++ b/packages/ui/src/components/http/ResponseCookies.tsx @@ -1,14 +1,15 @@ -import type { HttpResponse } from "@yaakapp-internal/models"; import classNames from "classnames"; import { useMemo } from "react"; import type { JSX } from "react/jsx-runtime"; -import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents"; -import { DetailsBanner } from "./core/DetailsBanner"; -import { KeyValueRow } from "./core/KeyValueRow"; -import { CountBadge, KeyValueRows } from "@yaakapp-internal/ui"; +import { CountBadge } from "../CountBadge"; +import { DetailsBanner } from "../DetailsBanner"; +import { useEditorHost } from "../EditorHost"; +import { KeyValueRows } from "../KeyValueRow"; +import { KeyValueRow } from "./ResponseHeaders"; +import type { HttpResponseView } from "./types"; interface Props { - response: HttpResponse; + response: HttpResponseView; } interface ParsedCookie { @@ -80,45 +81,40 @@ function parseSetCookieHeader(setCookieHeader: string): ParsedCookie { return cookie; } -export function ResponseCookies({ response }: Props) { - const { data: events } = useHttpResponseEvents(response); +/** The cookies a response's headers sent and received, the latest value of each name winning */ +export function parseCookieHeaders({ sent, received }: HttpResponseView["cookieHeaders"]) { + const sentMap = new Map(); + const receivedMap = new Map(); - const { sentCookies, receivedCookies } = useMemo(() => { - if (!events) return { sentCookies: [], receivedCookies: [] }; - - // Use Maps to deduplicate by cookie name (latest value wins) - const sentMap = new Map(); - const receivedMap = new Map(); - - for (const event of events) { - const e = event.event; - - // Cookie headers sent (header_up with name=cookie) - if (e.type === "header_up" && e.name.toLowerCase() === "cookie") { - const cookies = parseCookieHeader(e.value); - for (const cookie of cookies) { - sentMap.set(cookie.name, cookie); - } - } - - // Set-Cookie headers received (header_down with name=set-cookie) - if (e.type === "header_down" && e.name.toLowerCase() === "set-cookie") { - const cookie = parseSetCookieHeader(e.value); - receivedMap.set(cookie.name, cookie); - } + for (const header of sent) { + for (const cookie of parseCookieHeader(header)) { + sentMap.set(cookie.name, cookie); } + } + for (const header of received) { + const cookie = parseSetCookieHeader(header); + receivedMap.set(cookie.name, cookie); + } - return { - sentCookies: Array.from(sentMap.values()), - receivedCookies: Array.from(receivedMap.values()), - }; - }, [events]); + return { + sentCookies: Array.from(sentMap.values()), + receivedCookies: Array.from(receivedMap.values()), + }; +} + +export function ResponseCookies({ response }: Props) { + const { useDetailsBannerStoredValue } = useEditorHost(); + const { sentCookies, receivedCookies } = useMemo( + () => parseCookieHeaders(response.cookieHeaders), + [response.cookieHeaders], + ); return (
Sent Cookies @@ -141,7 +137,8 @@ export function ResponseCookies({ response }: Props) { Received Cookies diff --git a/packages/ui/src/components/http/ResponseHeaders.tsx b/packages/ui/src/components/http/ResponseHeaders.tsx new file mode 100644 index 000000000..3206152de --- /dev/null +++ b/packages/ui/src/components/http/ResponseHeaders.tsx @@ -0,0 +1,83 @@ +import { format, formatDistanceToNowStrict } from "date-fns"; +import type { ReactElement } from "react"; +import { DetailsBanner } from "../DetailsBanner"; +import { useEditorHost } from "../EditorHost"; +import { HeadersTable } from "../HeadersTable"; +import { IconButton } from "../IconButton"; +import type { KeyValueRowProps } from "../KeyValueRow"; +import { KeyValueRow as BaseKeyValueRow, KeyValueRows } from "../KeyValueRow"; +import type { HttpResponseView } from "./types"; + +interface Props { + response: HttpResponseView; + /** More rows for the Info section, after the standard ones, each keyed */ + infoRows?: ReactElement[]; +} + +export function ResponseHeaders({ response, infoRows }: Props) { + const { openUrl, useDetailsBannerStoredValue } = useEditorHost(); + return ( +
+ Info} + useStoredValue={useDetailsBannerStoredValue} + > + + {[ + + + , + +
+ {response.url} + {openUrl != null && ( + openUrl(response.url)} + title="Open in browser" + /> + )} +
+
, + + {response.remoteAddr ?? --} + , + + {response.version ?? --} + , + ...(infoRows ?? []), + ]} +
+
+ + +
+ ); +} + +/** A row that copies its value with the host's clipboard */ +export function KeyValueRow(props: Omit) { + const { copy } = useEditorHost(); + return ; +} diff --git a/packages/ui/src/components/http/UrlBar.tsx b/packages/ui/src/components/http/UrlBar.tsx new file mode 100644 index 000000000..41c16e9a7 --- /dev/null +++ b/packages/ui/src/components/http/UrlBar.tsx @@ -0,0 +1,146 @@ +import classNames from "classnames"; +import type { FormEvent, ReactNode } from "react"; +import { memo, useCallback, useRef, useState } from "react"; +import type { HotkeyAction } from "../../hooks/useHotKey"; +import { useFormattedHotkey, useHotKey } from "../../hooks/useHotKey"; +import type { HostInputProps } from "../EditorHost"; +import { useEditorHost } from "../EditorHost"; +import type { IconProps } from "../Icon"; +import { IconButton } from "../IconButton"; +import type { InputHandle } from "../Input"; +import { HStack } from "../Stacks"; + +export type UrlBarProps = { + url: string; + className?: string; + placeholder: string; + onSend?: () => void; + onUrlChange?: (url: string) => void; + onPaste?: (v: string) => void; + onPasteOverwrite?: HostInputProps["onPasteOverwrite"]; + onCancel?: () => void; + submitIcon?: IconProps["icon"] | null; + isLoading?: boolean; + forceUpdateKey: string; + rightSlot?: ReactNode; + leftSlot?: ReactNode; + autocomplete?: HostInputProps["autocomplete"]; + stateKey: HostInputProps["stateKey"]; + /** Show the URL without a way to change or send it */ + readOnly?: boolean; + /** The action whose keys send, shown on the send button */ + sendHotkeyAction?: HotkeyAction; + /** The action whose keys select the whole URL */ + focusHotkeyAction?: HotkeyAction; +}; + +export const UrlBar = memo(function UrlBar({ + forceUpdateKey, + onUrlChange, + url, + placeholder, + className, + onSend, + onCancel, + onPaste, + onPasteOverwrite, + submitIcon = "send_horizontal", + autocomplete, + leftSlot, + rightSlot, + isLoading = false, + stateKey, + readOnly, + sendHotkeyAction, + focusHotkeyAction, +}: UrlBarProps) { + const { Input } = useEditorHost(); + const inputRef = useRef(null); + const [isFocused, setIsFocused] = useState(false); + + const handleInitInputRef = useCallback((h: InputHandle | null) => { + inputRef.current = h; + }, []); + + useHotKey(focusHotkeyAction ?? null, () => { + inputRef.current?.selectAll(); + }); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + if (readOnly) return; + if (isLoading) onCancel?.(); + else onSend?.(); + }; + + return ( +
+ setIsFocused(true)} + onBlur={() => setIsFocused(false)} + onPaste={onPaste} + onPasteOverwrite={onPasteOverwrite} + onChange={onUrlChange} + readOnly={readOnly} + defaultValue={url} + placeholder={placeholder} + leftSlot={leftSlot} + rightSlot={ + + {rightSlot &&
{rightSlot}
} + {submitIcon !== null && !readOnly && ( +
+ +
+ )} +
+ } + /> +
+ ); +}); + +function SendButton({ + icon, + hotkeyAction, +}: { + icon: IconProps["icon"]; + hotkeyAction: HotkeyAction | null; +}) { + const buttonRef = useRef(null); + const hotkeyTrigger = useFormattedHotkey(hotkeyAction)?.join(""); + useHotKey(hotkeyAction, () => buttonRef.current?.click()); + + return ( + { + // Prevent the button from taking focus + e.preventDefault(); + }} + /> + ); +} diff --git a/packages/ui/src/components/http/UrlParametersEditor.tsx b/packages/ui/src/components/http/UrlParametersEditor.tsx new file mode 100644 index 000000000..1093aa773 --- /dev/null +++ b/packages/ui/src/components/http/UrlParametersEditor.tsx @@ -0,0 +1,45 @@ +import type { EditablePair, PairEditorHandle, PairEditorProps } from "../pairs/PairEditor"; +import { PairOrBulkEditor } from "../pairs/PairOrBulkEditor"; +import { VStack } from "../Stacks"; + +type Props = { + forceUpdateKey: string; + pairs: EditablePair[]; + stateKey: PairEditorProps["stateKey"]; + onChange?: PairEditorProps["onChange"]; + /** Show the parameters without a way to change them */ + readOnly?: boolean; + setRef?: (handle: PairEditorHandle) => void; +}; + +export function UrlParametersEditor({ + pairs, + forceUpdateKey, + onChange = ignoreChange, + readOnly, + setRef, + stateKey, +}: Props) { + return ( + + + + ); +} + +function ignoreChange() {} diff --git a/packages/ui/src/components/http/readOnly.test.tsx b/packages/ui/src/components/http/readOnly.test.tsx new file mode 100644 index 000000000..22f188a2b --- /dev/null +++ b/packages/ui/src/components/http/readOnly.test.tsx @@ -0,0 +1,247 @@ +import type { ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, test } from "vite-plus/test"; +import type { HostEditorProps, HostInputProps, SelectFileProps } from "../EditorHost"; +import { EditorHostProvider } from "../EditorHost"; +import { PairEditorRow } from "../pairs/PairEditor"; +import { bodyTypeLabel } from "./HttpRequestPaneView"; +import { ResponseStatusLine } from "./HttpResponsePaneView"; +import { JsonBodyEditor } from "./JsonBodyEditor"; +import { RequestBodyViewer } from "./RequestBodyViewer"; +import { HTMLOrTextViewer } from "./ResponseBodyViewer"; +import { RequestMethodDropdown } from "./RequestMethodDropdown"; +import { parseCookieHeaders } from "./ResponseCookies"; +import { + BODY_TYPE_FORM_URLENCODED, + BODY_TYPE_JSON, + BODY_TYPE_OTHER, + type HttpBodyLoader, + type HttpResponseView, +} from "./types"; +import { UrlBar } from "./UrlBar"; + +// Stand-ins for the host's editors that show what they were given +function StubInput({ name, defaultValue, readOnly, rightSlot, leftSlot }: HostInputProps) { + return ( +
+ {leftSlot} + {defaultValue} + {rightSlot} +
+ ); +} + +function StubEditor({ defaultValue, readOnly, actions }: HostEditorProps) { + return ( +
+ {defaultValue} + {actions} +
+ ); +} + +function StubSelectFile({ filePath }: SelectFileProps) { + return ( + + ); +} + +function render(node: ReactNode) { + return renderToStaticMarkup( + + {node} + , + ); +} + +describe("read-only modes", () => { + test("a pair row shows its fields read-only, with no drag handle or row menu", () => { + const markup = render( + , + ); + expect(markup).toContain('data-input="name[0]" data-readonly="true"'); + expect(markup).toContain('data-input="value[0]" data-readonly="true"'); + expect(markup).not.toContain("More actions"); + expect(markup).toMatch(/]*disabled/); + }); + + test("a read-only file value shows as text, not a file picker", () => { + const pair = { + id: "p1", + enabled: true, + name: "avatar", + value: "/Users/me/avatar.png", + isFile: true, + }; + const host = { Input: StubInput, Editor: StubEditor, SelectFile: StubSelectFile }; + const readOnly = renderToStaticMarkup( + + + , + ); + expect(readOnly).not.toContain("data-picker"); + expect(readOnly).toContain("data-file-value"); + expect(readOnly).toContain("/Users/me/avatar.png"); + + const editable = renderToStaticMarkup( + + + , + ); + expect(editable).toContain("data-picker"); + }); + + test("an editable pair row keeps its row menu", () => { + const markup = render( + , + ); + expect(markup).toContain('data-input="name[0]" data-readonly="false"'); + expect(markup).toContain("More actions"); + }); + + test("the URL bar has no send button when read-only", () => { + const readOnly = render( + , + ); + expect(readOnly).toContain('data-input="url" data-readonly="true"'); + expect(readOnly).not.toContain("Send Request"); + + const editable = render( + , + ); + expect(editable).toContain("Send Request"); + }); + + test("the method shows without a menu when read-only", () => { + const readOnly = render(); + const editable = render( {}} />); + expect(readOnly).toContain("POST"); + expect(readOnly).not.toEqual(editable); + }); + + test("a read-only JSON body has no settings menu", () => { + const props = { + forceUpdateKey: "", + heightMode: "auto" as const, + requestId: "r1", + text: '{"a":1}', + sendJsonComments: false, + noticeStorageKey: "w1", + }; + const readOnly = render(); + expect(readOnly).toContain('data-readonly="true"'); + expect(readOnly).not.toContain("JSON Settings"); + + const editable = render( {}} />); + expect(editable).toContain("JSON Settings"); + }); +}); + +describe("RequestBodyViewer", () => { + // A captured body that isn't valid UTF-8 has bytes but no text + const binary: HttpBodyLoader = { + useText: () => ({ data: null, isLoading: false, error: null }), + useBytes: () => ({ data: new Uint8Array([0x89, 0x50, 0xff]), isLoading: false, error: null }), + useUrl: () => ({ data: null, isLoading: false, error: null }), + }; + + test("shows a binary body instead of calling it empty", () => { + const markup = render( + , + ); + expect(markup).not.toContain("No request body"); + expect(markup).toContain("Binary body, 3"); + }); + + test("hands a binary image to the image viewer", () => { + const markup = render(); + expect(markup).not.toContain("No request body"); + expect(markup).not.toContain("Binary body"); + }); +}); + +describe("HTMLOrTextViewer", () => { + const response = { + id: "rs_1", + ownerId: "rq_1", + state: "closed", + status: 200, + statusReason: "OK", + url: "https://yaak.app", + headers: [], + requestHeaders: [], + contentType: "application/octet-stream", + contentLength: 3, + contentLengthCompressed: null, + requestContentLength: 0, + startedAt: "2026-11-04T22:10:00Z", + elapsedMs: 1, + elapsedHeadersMs: 1, + elapsedDnsMs: 0, + error: null, + remoteAddr: null, + version: null, + cookieHeaders: { sent: [], received: [] }, + } satisfies HttpResponseView; + + function loader(bytes: Uint8Array): HttpBodyLoader { + return { + useText: () => ({ data: null, isLoading: false, error: null }), + useBytes: () => ({ data: bytes, isLoading: false, error: null }), + useUrl: () => ({ data: null, isLoading: false, error: null }), + }; + } + + test("tells a binary response from an empty one", () => { + const binary = render( + , + ); + expect(binary).toContain("Binary body, 3"); + + const empty = render( + , + ); + expect(empty).toContain("Empty response"); + }); + + test("the status line keeps its height without the client's history button", () => { + // The client's button sets the line's height; without it, the line would hug the card's top + const markup = render(); + expect(markup).toContain("min-h-[calc(var(--height-sm)+0.25rem)]"); + }); +}); + +describe("bodyTypeLabel", () => { + test("names a body by its type", () => { + expect(bodyTypeLabel(BODY_TYPE_JSON, "application/json")).toBe("JSON"); + expect(bodyTypeLabel(BODY_TYPE_FORM_URLENCODED, null)).toBe("Url Encoded"); + expect(bodyTypeLabel(BODY_TYPE_OTHER, "text/markdown")).toBe("Markdown"); + expect(bodyTypeLabel(null, null)).toBe("Body"); + }); +}); + +describe("parseCookieHeaders", () => { + test("splits sent cookies and parses received ones, the latest of a name winning", () => { + const { sentCookies, receivedCookies } = parseCookieHeaders({ + sent: ["a=1; b=2", "a=3"], + received: ["s=x; Path=/; HttpOnly", "gone=; Max-Age=0"], + }); + expect(sentCookies).toEqual([ + { name: "a", value: "3" }, + { name: "b", value: "2" }, + ]); + expect(receivedCookies[0]).toMatchObject({ name: "s", value: "x", path: "/", httpOnly: true }); + expect(receivedCookies[1]).toMatchObject({ name: "gone", isDeleted: true }); + }); +}); diff --git a/packages/ui/src/components/http/types.ts b/packages/ui/src/components/http/types.ts new file mode 100644 index 000000000..7b484f4e3 --- /dev/null +++ b/packages/ui/src/components/http/types.ts @@ -0,0 +1,127 @@ +/** + * What the shared request and response panes render. Each app maps its own models onto these + * (the client its `HttpRequest` and `HttpResponse`, the proxy a captured `Flow`), so the panes + * never import either app's model types. + */ + +export const BODY_TYPE_NONE = null; +export const BODY_TYPE_GRAPHQL = "graphql"; +export const BODY_TYPE_JSON = "application/json"; +export const BODY_TYPE_BINARY = "binary"; +export const BODY_TYPE_OTHER = "other"; +export const BODY_TYPE_FORM_URLENCODED = "application/x-www-form-urlencoded"; +export const BODY_TYPE_FORM_MULTIPART = "multipart/form-data"; +export const BODY_TYPE_XML = "text/xml"; + +/** A header, URL parameter or form field */ +export interface HttpPairView { + id?: string; + name: string; + value: string; + enabled?: boolean; +} + +/** A form field. A multipart file field has the file's path in `file` instead of a `value` */ +export interface HttpFormFieldView { + id?: string; + name: string; + value?: string; + enabled?: boolean; + file?: string; + contentType?: string; + filename?: string; +} + +/** A request body, in the shape its body type edits it */ +export interface HttpRequestBodyView { + /** Text bodies: JSON, XML, GraphQL's query, anything else typed */ + text?: string; + /** Form bodies, url-encoded or multipart */ + form?: HttpFormFieldView[]; + /** A binary body read from a file when sent */ + filePath?: string; + /** JSON sent as written, comments and all, instead of cleaned up first */ + sendJsonComments?: boolean; +} + +/** Authentication, for display only; editing it stays with the app that knows its plugins */ +export interface HttpAuthView { + /** The scheme's name, like `Bearer` or `Basic` */ + label: string; + /** What it sends, like an `Authorization` header */ + values: HttpPairView[]; +} + +export interface HttpRequestView { + /** Identifies the request across renders, for remembered tabs and editor state */ + id: string; + method: string; + url: string; + headers: HttpPairView[]; + urlParameters: HttpPairView[]; + /** One of the `BODY_TYPE_*` values, or a content type the body was typed as */ + bodyType: string | null; + body: HttpRequestBodyView; + /** The request's Content-Type header */ + contentType: string | null; + auth: HttpAuthView | null; +} + +export type HttpResponseViewState = "initialized" | "connected" | "closed"; + +export interface HttpResponseView { + /** Identifies this response, for viewer state */ + id: string; + /** Identifies what it answers (the request or flow), for state kept across responses */ + ownerId: string; + state: HttpResponseViewState; + /** 0 before a status arrives or when it never did */ + status: number; + statusReason: string | null; + url: string; + headers: HttpPairView[]; + requestHeaders: HttpPairView[]; + /** The response's Content-Type header */ + contentType: string | null; + /** Decoded body bytes */ + contentLength: number | null; + /** Body bytes on the wire, when it was compressed */ + contentLengthCompressed: number | null; + /** Request body bytes sent */ + requestContentLength: number | null; + /** When the request started, as an ISO 8601 string with its zone */ + startedAt: string; + elapsedMs: number; + elapsedHeadersMs: number; + elapsedDnsMs: number; + error: string | null; + remoteAddr: string | null; + version: string | null; + /** Raw `Cookie` header values sent and `Set-Cookie` values received */ + cookieHeaders: { sent: string[]; received: string[] }; +} + +/** What a viewer gets back from loading part of a body */ +export interface HttpBodyResult { + data: T | undefined; + isLoading: boolean; + error: Error | null; +} + +/** + * How a pane reads a body, which each app stores its own way: the client in files read + * through its host, the proxy through the engine's body RPC. Each `use*` is a hook, called + * unconditionally by the viewer that needs that form of the body. + */ +export interface HttpBodyLoader { + /** The body as text, null when there's none */ + useText: () => HttpBodyResult; + /** The body's bytes, null when there's none */ + useBytes: () => HttpBodyResult; + /** A URL a media element can load the body from, null when there's none or not `enabled` */ + useUrl: (enabled: boolean) => HttpBodyResult; + /** Run a JSONPath or XPath filter over the body */ + filterText?: (expression: string) => Promise; + /** The body's text, for copying, without rendering it */ + getText?: () => Promise; +} diff --git a/apps/yaak-client/components/core/BulkPairEditor.test.ts b/packages/ui/src/components/pairs/BulkPairEditor.test.ts similarity index 100% rename from apps/yaak-client/components/core/BulkPairEditor.test.ts rename to packages/ui/src/components/pairs/BulkPairEditor.test.ts diff --git a/apps/yaak-client/components/core/BulkPairEditor.tsx b/packages/ui/src/components/pairs/BulkPairEditor.tsx similarity index 96% rename from apps/yaak-client/components/core/BulkPairEditor.tsx rename to packages/ui/src/components/pairs/BulkPairEditor.tsx index f15945d46..5553ae225 100644 --- a/apps/yaak-client/components/core/BulkPairEditor.tsx +++ b/packages/ui/src/components/pairs/BulkPairEditor.tsx @@ -1,7 +1,7 @@ -import { useCallback, useMemo } from "react"; -import { Editor } from "./Editor/LazyEditor"; -import type { Pair, PairEditorProps, PairWithId } from "./PairEditor"; import { generateId } from "@yaakapp-internal/lib/generateId"; +import { useCallback, useMemo } from "react"; +import { useEditorHost } from "../EditorHost"; +import type { Pair, PairEditorProps, PairWithId } from "./PairEditor"; type Props = PairEditorProps; @@ -12,8 +12,10 @@ export function BulkPairEditor({ valuePlaceholder, forceUpdateKey, forcedEnvironmentId, + readOnly, stateKey, }: Props) { + const { Editor } = useEditorHost(); const pairsText = useMemo(() => formatBulkPairs(pairs), [pairs]); const handleChange = useCallback( @@ -33,6 +35,7 @@ export function BulkPairEditor({ placeholder={`${namePlaceholder ?? "name"}: ${valuePlaceholder ?? "value"}`} defaultValue={pairsText} language="pairs" + readOnly={readOnly} onChange={handleChange} /> ); diff --git a/packages/ui/src/components/pairs/PairEditor.tsx b/packages/ui/src/components/pairs/PairEditor.tsx new file mode 100644 index 000000000..f1444e4f7 --- /dev/null +++ b/packages/ui/src/components/pairs/PairEditor.tsx @@ -0,0 +1,994 @@ +import type { DragEndEvent, DragMoveEvent, DragStartEvent } from "@dnd-kit/core"; +import { + DndContext, + DragOverlay, + PointerSensor, + pointerWithin, + useDraggable, + useDroppable, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import classNames from "classnames"; +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useRandomKey } from "../../hooks/useRandomKey"; +import { useToggle } from "../../hooks/useToggle"; +import { computeSideForDragMove } from "../../lib/dnd"; +import { languageFromContentType } from "../../lib/contentType"; +import { Button } from "../Button"; +import { Checkbox } from "../Checkbox"; +import { DropMarker } from "../DropMarker"; +import type { DropdownItem } from "../Dropdown"; +import { Dropdown } from "../Dropdown"; +import type { GenericCompletionConfig, HostInputProps, TemplatingProps } from "../EditorHost"; +import { useEditorHost } from "../EditorHost"; +import { Icon } from "../Icon"; +import { IconButton } from "../IconButton"; +import type { InputHandle } from "../Input"; +import type { RadioDropdownItem } from "../RadioDropdown"; +import { RadioDropdown } from "../RadioDropdown"; +import { ensurePairId } from "./PairEditor.util"; + +export interface PairEditorHandle { + /** + * Focus a row's name field once it's able to take focus. Focus can't land immediately when the + * row isn't mounted yet or the editor is hidden — eg. sitting in a tab that's still becoming + * active — so this retries for up to ~1s. A newer focus request cancels a pending one. + */ + focusName(id: string): void; + /** Focus a row's value field. See {@link PairEditorHandle.focusName} for timing. */ + focusValue(id: string): void; +} + +/** ~1s at 60fps, plenty for a tab switch to land without spinning forever if it never does */ +const MAX_FOCUS_ATTEMPTS = 60; + +export type PairEditorProps = { + allowFileValues?: boolean; + allowMultilineValues?: boolean; + className?: string; + forcedEnvironmentId?: string; + forceUpdateKey?: string; + nameAutocomplete?: GenericCompletionConfig; + nameAutocompleteFunctions?: boolean; + nameAutocompleteVariables?: boolean; + namePlaceholder?: string; + nameValidate?: HostInputProps["validate"]; + noScroll?: boolean; + onChange: (pairs: PairWithId[]) => void; + pairs: EditablePair[]; + /** Show the pairs without a way to change them: no new row, no drag, no row menu */ + readOnly?: boolean; + stateKey: HostInputProps["stateKey"]; + setRef?: (n: PairEditorHandle) => void; + valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined; + valueAutocompleteFunctions?: boolean; + valueAutocompleteVariables?: boolean | "environment"; + valuePlaceholder?: string; + valueType?: HostInputProps["type"] | ((pair: Pair) => HostInputProps["type"]); + valueValidate?: HostInputProps["validate"]; +}; + +export type Pair = { + id?: string; + enabled?: boolean; + name: string; + value: string; + contentType?: string; + filename?: string; + isFile?: boolean; +}; + +export type PairWithId = Pair & { + id: string; +}; + +/** + * A pair as handed to the editor. Adds behaviour that only the editor cares about, so the plain + * `Pair` stays the shape that gets written to models. + */ +export type EditablePair = Pair & { + /** + * When set, name edits are held until the field blurs and then committed through this, instead + * of calling `onChange` on every keystroke. Return false to reject the new name, which reverts + * the field. For names that can't be written directly, like a URL path placeholder that lives + * in the URL itself. + */ + commitName?: (name: string) => boolean; +}; + +type EditablePairWithId = EditablePair & { + id: string; +}; + +/** Strip the editor-only fields, so they can never reach a model write */ +function toPairData({ commitName: _commitName, ...pair }: EditablePairWithId): PairWithId { + return pair; +} + +/** Max number of pairs to show before prompting the user to reveal the rest */ +const MAX_INITIAL_PAIRS = 30; + +// Keyed on `stateKey` so no state survives a change of owner. Row ids alone can't tell owners +// apart — two pair sets can share ids (eg. one duplicated from the other), and the same-rows +// fast path below would swap in the new data without rebuilding the row editors, leaving any +// still-focused input showing the old owner's text. +export function PairEditor(props: PairEditorProps) { + return ; +} + +function PairEditorInner({ + allowFileValues, + allowMultilineValues, + className, + forcedEnvironmentId, + forceUpdateKey, + nameAutocomplete, + nameAutocompleteFunctions, + nameAutocompleteVariables, + namePlaceholder, + nameValidate, + noScroll, + onChange, + pairs: originalPairs, + readOnly, + stateKey, + valueAutocomplete, + valueAutocompleteFunctions, + valueAutocompleteVariables, + valuePlaceholder, + valueType, + valueValidate, + setRef, +}: PairEditorProps) { + const [hoveredIndex, setHoveredIndex] = useState(null); + const [isDragging, setIsDragging] = useState(null); + const [pairs, setPairs] = useState([]); + const [showAll, toggleShowAll] = useToggle(false); + // NOTE: Use local force update key because we trigger an effect on forceUpdateKey change. If + // we simply pass forceUpdateKey to the editor, the data set by useEffect will be stale. + const [localForceUpdateKey, regenerateLocalForceUpdateKey] = useRandomKey(); + + const rowsRef = useRef>({}); + + const pendingFocusFrame = useRef(null); + useEffect( + () => () => { + if (pendingFocusFrame.current != null) cancelAnimationFrame(pendingFocusFrame.current); + }, + [], + ); + + const focusWhenReady = useCallback((id: string, field: "name" | "value") => { + if (pendingFocusFrame.current != null) cancelAnimationFrame(pendingFocusFrame.current); + + let attemptsLeft = MAX_FOCUS_ATTEMPTS; + const attempt = () => { + pendingFocusFrame.current = null; + const row = rowsRef.current[id]; + const landed = field === "name" ? row?.focusName() : row?.focusValue(); + if (landed || --attemptsLeft <= 0) return; + pendingFocusFrame.current = requestAnimationFrame(attempt); + }; + attempt(); + }, []); + + const handle = useMemo( + () => ({ + focusName(id: string) { + focusWhenReady(id, "name"); + }, + focusValue(id: string) { + focusWhenReady(id, "value"); + }, + }), + [focusWhenReady], + ); + + const initPairEditorRow = useCallback( + (id: string, n: RowHandle | null) => { + const isLast = id === pairs[pairs.length - 1]?.id; + if (isLast && !readOnly) return; // Never add the last pair + + rowsRef.current[id] = n; + const validHandles = Object.values(rowsRef.current).filter((v) => v != null); + + // Use >= because more might be added if an ID of one changes (eg. editing placeholder in URL regenerates fresh pairs every keystroke) + const ready = validHandles.length >= pairs.length - (readOnly ? 0 : 1); + if (ready) { + setRef?.(handle); + } + }, + [handle, pairs, readOnly, setRef], + ); + + // oxlint-disable-next-line react-hooks/exhaustive-deps -- Only care about forceUpdateKey + useEffect(() => { + // Remove empty headers on initial render and ensure they all have valid ids (pairs didn't use to have IDs) + const newPairs: EditablePairWithId[] = []; + for (let i = 0; i < originalPairs.length; i++) { + const p = originalPairs[i]; + if (!p) continue; // Make TS happy + if (isPairEmpty(p)) continue; + newPairs.push(ensurePairId(p)); + } + + // Nothing can be added, so there's no trailing row to start a new pair with + if (readOnly) { + setPairs(newPairs); + regenerateLocalForceUpdateKey(); + return; + } + + // When the reset holds the exact same rows (eg. renaming a URL path placeholder, which keeps + // every row id), swap the data in without rebuilding the row editors. Unfocused inputs re-seed + // themselves when `defaultValue` changes, and a rebuild would drop the user's focus and + // selection — like tabbing from a placeholder's name into its value. + const trailingPair = pairs[pairs.length - 1]; + const sameRows = + trailingPair != null && + isPairEmpty(trailingPair) && + pairs.length === newPairs.length + 1 && + newPairs.every((p, i) => p.id === pairs[i]?.id); + if (sameRows) { + setPairs([...newPairs, trailingPair]); + return; + } + + // Add empty last pair if there is none + const lastPair = newPairs[newPairs.length - 1]; + if (lastPair == null || !isPairEmpty(lastPair)) { + newPairs.push(emptyPair()); + } + + setPairs(newPairs); + regenerateLocalForceUpdateKey(); + }, [forceUpdateKey]); + + const setPairsAndSave = useCallback( + (fn: (pairs: EditablePairWithId[]) => EditablePairWithId[]) => { + setPairs((oldPairs) => { + const pairs = fn(oldPairs); + onChange(pairs.map(toPairData)); + return pairs; + }); + }, + [onChange], + ); + + const handleChange = useCallback( + (pair: EditablePairWithId) => + setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair))), + [setPairsAndSave], + ); + + const handleDelete = useCallback( + (pair: Pair, focusPrevious: boolean) => { + if (focusPrevious) { + const index = pairs.findIndex((p) => p.id === pair.id); + const id = pairs[index - 1]?.id ?? null; + rowsRef.current[id ?? "n/a"]?.focusName(); + } + return setPairsAndSave((oldPairs) => oldPairs.filter((p) => p.id !== pair.id)); + }, + [setPairsAndSave, pairs], + ); + + const handleFocusName = useCallback( + (pair: Pair) => { + const isLast = pair.id === pairs[pairs.length - 1]?.id; + if (isLast) setPairs([...pairs, emptyPair()]); + }, + [pairs], + ); + + const handleFocusValue = useCallback( + (pair: Pair) => { + const isLast = pair.id === pairs[pairs.length - 1]?.id; + if (isLast) setPairs([...pairs, emptyPair()]); + }, + [pairs], + ); + + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } })); + + // dnd-kit: show the “between rows” marker while hovering + const onDragMove = useCallback( + (e: DragMoveEvent) => { + const overId = e.over?.id as string | undefined; + if (!overId) return setHoveredIndex(null); + + const overPair = pairs.find((p) => p.id === overId); + if (overPair == null) return setHoveredIndex(null); + + const side = computeSideForDragMove(overPair.id, e); + const overIndex = pairs.findIndex((p) => p.id === overId); + const hoveredIndex = overIndex + (side === "before" ? 0 : 1); + + setHoveredIndex(hoveredIndex); + }, + [pairs], + ); + + const onDragStart = useCallback( + (e: DragStartEvent) => { + const pair = pairs.find((p) => p.id === e.active.id); + setIsDragging(pair ?? null); + }, + [pairs], + ); + + const onDragCancel = useCallback(() => setIsDragging(null), []); + + const onDragEnd = useCallback( + (e: DragEndEvent) => { + setIsDragging(null); + setHoveredIndex(null); + const activeId = e.active.id as string | undefined; + const overId = e.over?.id as string | undefined; + if (!activeId || !overId) return; + + const from = pairs.findIndex((p) => p.id === activeId); + const baseTo = pairs.findIndex((p) => p.id === overId); + const to = hoveredIndex ?? (baseTo === -1 ? from : baseTo); + + if (from !== -1 && to !== -1 && from !== to) { + setPairsAndSave((ps) => { + const next = [...ps]; + const [moved] = next.splice(from, 1); + if (moved === undefined) return ps; // Make TS happy + next.splice(to > from ? to - 1 : to, 0, moved); + return next; + }); + } + }, + [pairs, hoveredIndex, setPairsAndSave], + ); + + return ( +
+
+ + {pairs.map((p, i) => { + if (!showAll && i > MAX_INITIAL_PAIRS) return null; + + const isLast = !readOnly && i === pairs.length - 1; + return ( + + {hoveredIndex === i && } + + + ); + })} + {!showAll && pairs.length > MAX_INITIAL_PAIRS && ( + + )} + + {isDragging && ( + + )} + + +
+ +
{ + e.preventDefault(); + e.stopPropagation(); + }} + /> +
+ ); +} + +type PairEditorRowProps = { + className?: string; + pair: EditablePairWithId; + forceFocusNamePairId?: string | null; + forceFocusValuePairId?: string | null; + onChange?: (pair: EditablePairWithId) => void; + onDelete?: (pair: EditablePairWithId, focusPrevious: boolean) => void; + onFocusName?: (pair: EditablePairWithId) => void; + onFocusValue?: (pair: EditablePairWithId) => void; + onSubmit?: (pair: EditablePairWithId) => void; + isLast?: boolean; + disabled?: boolean; + disableDrag?: boolean; + readOnly?: boolean; + index: number; + isDraggingGlobal?: boolean; + setRef?: (id: string, n: RowHandle | null) => void; +} & Pick< + PairEditorProps, + | "allowFileValues" + | "allowMultilineValues" + | "forcedEnvironmentId" + | "forceUpdateKey" + | "nameAutocomplete" + | "nameAutocompleteVariables" + | "namePlaceholder" + | "nameValidate" + | "nameAutocompleteFunctions" + | "stateKey" + | "valueAutocomplete" + | "valueAutocompleteFunctions" + | "valueAutocompleteVariables" + | "valuePlaceholder" + | "valueType" + | "valueValidate" +>; + +interface RowHandle { + focusName(): boolean; + focusValue(): boolean; +} + +export function PairEditorRow({ + allowFileValues, + allowMultilineValues, + className, + disableDrag, + disabled, + forceUpdateKey, + forcedEnvironmentId, + index, + isLast, + nameAutocomplete, + nameAutocompleteFunctions, + nameAutocompleteVariables, + namePlaceholder, + nameValidate, + isDraggingGlobal, + onChange, + onDelete, + onFocusName, + onFocusValue, + pair, + readOnly, + stateKey, + valueAutocomplete, + valueAutocompleteFunctions, + valueAutocompleteVariables, + valuePlaceholder, + valueType, + valueValidate, + setRef, +}: PairEditorRowProps) { + const { Input, SelectFile, showDialog } = useEditorHost(); + const nameInputRef = useRef(null); + const valueInputRef = useRef(null); + const handle = useRef({ + focusName() { + nameInputRef.current?.focus(); + return nameInputRef.current?.isFocused() ?? false; + }, + focusValue() { + valueInputRef.current?.focus(); + return valueInputRef.current?.isFocused() ?? false; + }, + }); + + const initNameInputRef = useCallback( + (n: InputHandle | null) => { + nameInputRef.current = n; + if (nameInputRef.current && valueInputRef.current) { + setRef?.(pair.id, handle.current); + } + }, + [pair.id, setRef], + ); + + const initValueInputRef = useCallback( + (n: InputHandle | null) => { + valueInputRef.current = n; + if (nameInputRef.current && valueInputRef.current) { + setRef?.(pair.id, handle.current); + } + }, + [pair.id, setRef], + ); + + const handleFocusName = useCallback(() => onFocusName?.(pair), [onFocusName, pair]); + const handleFocusValue = useCallback(() => onFocusValue?.(pair), [onFocusValue, pair]); + const handleDelete = useCallback(() => onDelete?.(pair, false), [onDelete, pair]); + + const handleChangeEnabled = useMemo( + () => (enabled: boolean) => onChange?.({ ...pair, enabled }), + [onChange, pair], + ); + + // The name being typed into a deferred-commit field, before it's committed or reverted + const pendingName = useRef(null); + + const handleChangeName = useMemo( + () => (name: string) => { + // Keep the edit local until commit. Writing on every keystroke would reset the editor from + // beneath the cursor, since the pairs are derived from the name being edited. + if (pair.commitName != null) pendingName.current = name; + else onChange?.({ ...pair, name }); + }, + [onChange, pair], + ); + + const revertName = useCallback(() => { + const nameInput = nameInputRef.current; + if (nameInput != null) { + const changes = { from: 0, to: nameInput.value().length, insert: pair.name }; + nameInput.dispatch({ changes }); + } + pendingName.current = null; + }, [pair.name]); + + const handleBlurName = useCallback(() => { + if (pair.commitName == null) return; + + const name = pendingName.current; + pendingName.current = null; + if (name == null || name === pair.name) return; + if (!pair.commitName(name)) revertName(); + }, [pair, revertName]); + + const handleChangeValueText = useMemo( + () => (value: string) => onChange?.({ ...pair, value, isFile: false }), + [onChange, pair], + ); + + const handleChangeValueFile = useMemo( + () => + ({ filePath }: { filePath: string | null }) => + onChange?.({ ...pair, value: filePath ?? "", isFile: true }), + [onChange, pair], + ); + + const handleChangeValueContentType = useMemo( + () => (contentType: string) => onChange?.({ ...pair, contentType }), + [onChange, pair], + ); + + const handleChangeValueFilename = useMemo( + () => (filename: string) => onChange?.({ ...pair, filename }), + [onChange, pair], + ); + + const handleEditMultiLineValue = useCallback( + () => + showDialog?.({ + id: "pair-edit-multiline", + size: "dynamic", + title: <>Edit {pair.name}, + render: ({ hide }) => ( + + ), + }), + [handleChangeValueText, pair.contentType, pair.name, pair.value, showDialog], + ); + + const defaultItems = useMemo( + (): DropdownItem[] => [ + { + label: "Edit Multi-line", + onSelect: handleEditMultiLineValue, + hidden: !allowMultilineValues, + }, + { + label: "Delete", + onSelect: handleDelete, + color: "danger", + }, + ], + [allowMultilineValues, handleDelete, handleEditMultiLineValue], + ); + + const { attributes, listeners, setNodeRef: setDraggableRef } = useDraggable({ id: pair.id }); + const { setNodeRef: setDroppableRef } = useDroppable({ id: pair.id }); + + // Filter out the current pair name + const valueAutocompleteVariablesFiltered = useMemo< + TemplatingProps["autocompleteVariables"] + >(() => { + if (valueAutocompleteVariables === "environment") { + return (v): boolean => v.variable.name !== pair.name; + } + return valueAutocompleteVariables; + }, [pair.name, valueAutocompleteVariables]); + + const handleSetRef = useCallback( + (n: HTMLDivElement | null) => { + setDraggableRef(n); + setDroppableRef(n); + }, + [setDraggableRef, setDroppableRef], + ); + + // Skip the trailing placeholder row. It exists to start a new pair, so a picker there would + // imply it's already a real row. `Input` handles the rest, including having no options to show. + const showOptionsPicker = !isLast; + + return ( +
+ + {!isLast && !disableDrag && !readOnly ? ( +
+ +
+ ) : ( + + )} +
+ +
+ {pair.isFile && readOnly ? ( + // A file value to read, not to pick: no picker and nothing to drop a file on + + {pair.filename || pair.value || "No file"} + + ) : pair.isFile && SelectFile != null ? ( + + ) : pair.value.includes("\n") ? ( + + ) : ( + + )} +
+
+ {readOnly ? null : allowFileValues ? ( + + ) : ( + + + + )} +
+ ); +} + +const fileItems: RadioDropdownItem[] = [ + { label: "Text", value: "text" }, + { label: "File", value: "file" }, +]; + +function FileActionsDropdown({ + pair, + onChangeFile, + onChangeText, + onChangeContentType, + onChangeFilename, + onDelete, + editMultiLine, +}: { + pair: Pair; + onChangeFile: ({ filePath }: { filePath: string | null }) => void; + onChangeText: (text: string) => void; + onChangeContentType: (contentType: string) => void; + onChangeFilename: (filename: string) => void; + onDelete: () => void; + editMultiLine: () => void; +}) { + const { basename, showPrompt } = useEditorHost(); + const onChange = useCallback( + (v: string) => { + if (v === "file") onChangeFile({ filePath: "" }); + else onChangeText(""); + }, + [onChangeFile, onChangeText], + ); + + const itemsAfter = useMemo( + () => [ + { + label: "Edit Multi-Line", + leftSlot: , + hidden: pair.isFile, + onSelect: editMultiLine, + }, + { + label: "Set Content-Type", + leftSlot: , + onSelect: async () => { + const contentType = await showPrompt?.({ + id: "content-type", + title: "Override Content-Type", + label: "Content-Type", + required: false, + placeholder: "text/plain", + defaultValue: pair.contentType ?? "", + confirmText: "Set", + description: "Leave blank to auto-detect", + }); + if (contentType == null) return; + onChangeContentType(contentType); + }, + }, + { + label: "Set File Name", + leftSlot: , + onSelect: async () => { + console.log("PAIR", pair); + const defaultFilename = await basename(pair.value ?? ""); + const filename = await showPrompt?.({ + id: "filename", + title: "Override Filename", + label: "Filename", + required: false, + placeholder: defaultFilename ?? "myfile.png", + defaultValue: pair.filename, + confirmText: "Set", + description: "Leave blank to use the name of the selected file", + }); + if (filename == null) return; + onChangeFilename(filename); + }, + }, + { + label: "Unset File", + leftSlot: , + hidden: pair.isFile, + onSelect: async () => { + onChangeFile({ filePath: null }); + }, + }, + { + label: "Delete", + onSelect: onDelete, + variant: "danger", + leftSlot: , + color: "danger", + }, + ], + [ + editMultiLine, + onChangeContentType, + onChangeFile, + onDelete, + pair.contentType, + pair.isFile, + onChangeFilename, + pair.filename, + pair, + basename, + showPrompt, + ], + ); + + return ( + + + + ); +} + +function emptyPair(): EditablePairWithId { + return ensurePairId({ enabled: true, name: "", value: "" }); +} + +function isPairEmpty(pair: Pair): boolean { + return !pair.name && !pair.value; +} + +function MultilineEditDialog({ + defaultValue, + contentType, + onChange, + hide, +}: { + defaultValue: string; + contentType: string | null; + onChange: (value: string) => void; + hide: () => void; +}) { + const { Editor } = useEditorHost(); + const [value, setValue] = useState(defaultValue); + const language = languageFromContentType(contentType, value); + return ( +
+ +
+ +
+
+ ); +} diff --git a/packages/ui/src/components/pairs/PairEditor.util.tsx b/packages/ui/src/components/pairs/PairEditor.util.tsx new file mode 100644 index 000000000..b43428292 --- /dev/null +++ b/packages/ui/src/components/pairs/PairEditor.util.tsx @@ -0,0 +1,10 @@ +import type { Pair, PairWithId } from "./PairEditor"; +import { generateId } from "@yaakapp-internal/lib/generateId"; + +// NOTE: Generic so callers keep whatever they passed in (eg. an EditablePair stays editable) +export function ensurePairId(p: T): T & PairWithId { + if (typeof p.id === "string") { + return p as T & PairWithId; + } + return { ...p, id: p.id ?? generateId() }; +} diff --git a/packages/ui/src/components/pairs/PairOrBulkEditor.tsx b/packages/ui/src/components/pairs/PairOrBulkEditor.tsx new file mode 100644 index 000000000..22f4facc4 --- /dev/null +++ b/packages/ui/src/components/pairs/PairOrBulkEditor.tsx @@ -0,0 +1,39 @@ +import classNames from "classnames"; +import { useEditorHost } from "../EditorHost"; +import { IconButton } from "../IconButton"; +import { BulkPairEditor } from "./BulkPairEditor"; +import type { PairEditorProps } from "./PairEditor"; +import { PairEditor } from "./PairEditor"; + +interface Props extends PairEditorProps { + preferenceName: string; + forcedEnvironmentId?: string; +} + +export function PairOrBulkEditor({ preferenceName, ...props }: Props) { + const { useStoredValue } = useEditorHost(); + const [useBulk, setUseBulk] = useStoredValue( + ["bulk_edit", preferenceName], + false, + "global", + ); + + return ( +
+ {useBulk ? : } +
+ setUseBulk((b) => !b)} + icon={useBulk ? "table" : "file_code"} + /> +
+
+ ); +} diff --git a/packages/ui/src/components/tabs/Tabs.tsx b/packages/ui/src/components/tabs/Tabs.tsx index 046199855..8dd9a24bf 100644 --- a/packages/ui/src/components/tabs/Tabs.tsx +++ b/packages/ui/src/components/tabs/Tabs.tsx @@ -21,11 +21,15 @@ import { useRef, useState, } from "react"; -import { useInMemoryStoredValue, type UseStoredValue } from "../../hooks/useStoredValue"; +import type { UseStoredValue } from "../../hooks/useStoredValue"; +import { type UseHostStoredValue, useEditorHost } from "../EditorHost"; import { computeSideForDragMove } from "../../lib/dnd"; import type { ButtonProps } from "../Button"; import { Button } from "../Button"; import { DropMarker } from "../DropMarker"; +import { Icon } from "../Icon"; +import type { RadioDropdownProps } from "../RadioDropdown"; +import { RadioDropdown } from "../RadioDropdown"; export interface TabButtonRenderProps { buttonProps: Partial; @@ -46,7 +50,16 @@ export type TabItem = renderButton: (props: TabButtonRenderProps) => ReactNode; leftSlot?: ReactNode; rightSlot?: ReactNode; - }; + } + | TabOptionsItem; + +/** A tab whose button is also a menu of choices, labelled with the chosen one */ +export interface TabOptionsItem { + value: string; + options: Omit; + leftSlot?: ReactNode; + rightSlot?: ReactNode; +} export interface TabsStorage { order: string[]; @@ -91,19 +104,23 @@ export const Tabs = forwardRef(function Tabs( layout = "vertical", storageKey, activeTabKey, - useStoredValue = useInMemoryStoredValue, + useStoredValue: useStoredValueProp, }: TabsProps, forwardedRef: Ref, ) { const ref = useRef(null); const reorderable = !!storageKey; + const host = useEditorHost(); - // Use key-value storage for persistence if storageKey is provided + // Use key-value storage for persistence if storageKey is provided. Without one passed in, the + // host's local storage keeps it. // Handle migration from old format (string[]) to new format (TabsStorage) - const [rawStorage, setStorage] = useStoredValue(storageKey ?? null, { - order: [], - activeTabs: {}, - }); + const useStoredValue: UseHostStoredValue = useStoredValueProp ?? host.useStoredValue; + const [rawStorage, setStorage] = useStoredValue( + storageKey ?? null, + { order: [], activeTabs: {} }, + "no_sync", + ); // Migrate old format (string[]) to new format (TabsStorage) const storage: TabsStorage = Array.isArray(rawStorage) @@ -481,6 +498,8 @@ function TabButton({ const buttonContent = "renderButton" in tab ? ( tab.renderButton({ buttonProps: btnProps, isActive }) + ) : "options" in tab ? ( + ) : (
+ } + {...buttonProps} + > + {option && "shortLabel" in option && option.shortLabel + ? option.shortLabel + : (option?.label ?? "Unknown")} + + + ); +} diff --git a/packages/ui/src/components/tags/HttpResponseDurationTag.tsx b/packages/ui/src/components/tags/HttpResponseDurationTag.tsx index 93709aaeb..afd14f1be 100644 --- a/packages/ui/src/components/tags/HttpResponseDurationTag.tsx +++ b/packages/ui/src/components/tags/HttpResponseDurationTag.tsx @@ -6,23 +6,51 @@ interface Props { } export function HttpResponseDurationTag({ response }: Props) { + return ( + + ); +} + +interface RawProps { + /** Whether the response has ended; until then the time counts up from `startedAt` */ + closed: boolean; + /** When the request started, as an ISO 8601 string with its zone */ + startedAt: string; + elapsedMs: number; + elapsedHeadersMs: number; + elapsedDnsMs: number; +} + +export function HttpResponseDurationTagRaw({ + closed, + startedAt, + elapsedMs, + elapsedHeadersMs, + elapsedDnsMs, +}: RawProps) { const [fallbackElapsed, setFallbackElapsed] = useState(0); const timeout = useRef>(undefined); // Calculate the duration of the response for use when the response hasn't finished yet useEffect(() => { clearInterval(timeout.current); - if (response.state === "closed") return; + if (closed) return; timeout.current = setInterval(() => { - setFallbackElapsed(Date.now() - new Date(`${response.createdAt}Z`).getTime()); + setFallbackElapsed(Date.now() - new Date(startedAt).getTime()); }, 100); return () => clearInterval(timeout.current); - }, [response.createdAt, response.state]); + }, [startedAt, closed]); - const dnsValue = response.elapsedDns > 0 ? formatMillis(response.elapsedDns) : "--"; - const title = `DNS: ${dnsValue}\nHEADER: ${formatMillis(response.elapsedHeaders)}\nTOTAL: ${formatMillis(response.elapsed)}`; + const dnsValue = elapsedDnsMs > 0 ? formatMillis(elapsedDnsMs) : "--"; + const title = `DNS: ${dnsValue}\nHEADER: ${formatMillis(elapsedHeadersMs)}\nTOTAL: ${formatMillis(elapsedMs)}`; - const elapsed = response.state === "closed" ? response.elapsed : fallbackElapsed; + const elapsed = closed ? elapsedMs : fallbackElapsed; return ( diff --git a/packages/ui/src/hooks/useHotKey.ts b/packages/ui/src/hooks/useHotKey.ts index 09f2896ee..7bf9ff760 100644 --- a/packages/ui/src/hooks/useHotKey.ts +++ b/packages/ui/src/hooks/useHotKey.ts @@ -21,6 +21,8 @@ export interface HotkeyContextValue { os: string; /** The key combos bound to an action, like `["Meta+Shift+s"]`, or null when it has none */ bindingFor: (action: HotkeyAction) => string[] | null; + /** What an action does, in words, for lists of shortcuts. Falls back to the action's name */ + labelFor?: (action: HotkeyAction) => string; /** Bind a callback to an action's keys, returning the unbind */ register: ( action: HotkeyAction, @@ -49,6 +51,11 @@ export function useHotKey( }, [action, callback, options, register]); } +export function useHotkeyLabel(action: HotkeyAction): string { + const { labelFor } = useContext(HotkeyContext); + return labelFor?.(action) ?? action; +} + export function useFormattedHotkey(action: HotkeyAction | null): string[] | null { const { os, bindingFor } = useContext(HotkeyContext); const trigger = action == null ? null : (bindingFor(action)?.[0] ?? null); diff --git a/packages/ui/src/hooks/useToggle.ts b/packages/ui/src/hooks/useToggle.ts new file mode 100644 index 000000000..e611d3826 --- /dev/null +++ b/packages/ui/src/hooks/useToggle.ts @@ -0,0 +1,7 @@ +import { useCallback, useState } from "react"; + +export function useToggle(initialValue = false) { + const [value, setValue] = useState(initialValue); + const toggle = useCallback(() => setValue((v) => !v), []); + return [value, toggle, setValue] as const; +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 3c46e5edd..006b12733 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -23,6 +23,85 @@ export type { } from "./components/Dropdown"; export { ContextMenu, Dropdown } from "./components/Dropdown"; export { LazyCodeEditor } from "./components/editor/LazyCodeEditor"; +export type { + EditorHost, + GenericCompletion, + GenericCompletionConfig, + HostBodyViewerArgs, + HostDialogArgs, + HostEditorLanguage, + HostEditorProps, + HostInputProps, + HostPromptArgs, + SelectFileProps, + StoredValueNamespace, + TemplateVariableRef, + TemplatingProps, + UseHostStoredValue, +} from "./components/EditorHost"; +export { EditorHostProvider, useEditorHost } from "./components/EditorHost"; +export { EmptyStateText } from "./components/EmptyStateText"; +export { ConfirmLargeResponse } from "./components/http/ConfirmLargeResponse"; +export { FormMultipartEditor } from "./components/http/FormMultipartEditor"; +export { FormUrlencodedEditor } from "./components/http/FormUrlencodedEditor"; +export { HeadersEditor } from "./components/http/HeadersEditor"; +export type { HttpRequestPaneViewProps } from "./components/http/HttpRequestPaneView"; +export { + bodyTypeLabel, + HttpRequestPaneView, + REQUEST_TAB_AUTH, + REQUEST_TAB_BODY, + REQUEST_TAB_HEADERS, + REQUEST_TAB_PARAMS, + RequestBodyEditor, +} from "./components/http/HttpRequestPaneView"; +export type { ExtraPaneTab, HttpResponsePaneViewProps } from "./components/http/HttpResponsePaneView"; +export { + HttpResponsePaneView, + RESPONSE_TAB_BODY, + RESPONSE_TAB_COOKIES, + RESPONSE_TAB_HEADERS, + RESPONSE_TAB_REQUEST, + ResponseStatusLine, +} from "./components/http/HttpResponsePaneView"; +export { JsonBodyEditor } from "./components/http/JsonBodyEditor"; +export { RequestBodyViewer } from "./components/http/RequestBodyViewer"; +export { RequestMethodDropdown } from "./components/http/RequestMethodDropdown"; +export type { + ResponseTextViewerExtras, + ResponseViewMode, +} from "./components/http/ResponseBodyViewer"; +export { HTMLOrTextViewer, ResponseBodyViewer } from "./components/http/ResponseBodyViewer"; +export { parseCookieHeaders, ResponseCookies } from "./components/http/ResponseCookies"; +export { ResponseHeaders } from "./components/http/ResponseHeaders"; +export * from "./components/http/types"; +export type { UrlBarProps } from "./components/http/UrlBar"; +export { UrlBar } from "./components/http/UrlBar"; +export { UrlParametersEditor } from "./components/http/UrlParametersEditor"; +export { HotkeyLabel } from "./components/HotkeyLabel"; +export { HotkeyList } from "./components/HotkeyList"; +export { + formatBulkPairLine, + formatBulkPairs, + parseBulkPairLine, + parseBulkPairs, +} from "./components/pairs/BulkPairEditor"; +export type { + EditablePair, + Pair, + PairEditorHandle, + PairEditorProps, + PairWithId, +} from "./components/pairs/PairEditor"; +export { PairEditor, PairEditorRow } from "./components/pairs/PairEditor"; +export { ensurePairId } from "./components/pairs/PairEditor.util"; +export { PairOrBulkEditor } from "./components/pairs/PairOrBulkEditor"; +export { PillButton } from "./components/PillButton"; +export type { + RadioDropdownItem, + RadioDropdownProps, +} from "./components/RadioDropdown"; +export { RadioDropdown } from "./components/RadioDropdown"; export type { ErrorBoundaryProps } from "./components/ErrorBoundary"; export { ErrorBoundary } from "./components/ErrorBoundary"; export { FormattedError } from "./components/FormattedError"; @@ -78,7 +157,11 @@ export type { ToastProps } from "./components/Toast"; export { Toast } from "./components/Toast"; export { Toasts } from "./components/Toasts"; export { HttpMethodTag, HttpMethodTagRaw } from "./components/tags/HttpMethodTag"; -export { formatMillis, HttpResponseDurationTag } from "./components/tags/HttpResponseDurationTag"; +export { + formatMillis, + HttpResponseDurationTag, + HttpResponseDurationTagRaw, +} from "./components/tags/HttpResponseDurationTag"; export { HttpStatusTag, HttpStatusTagRaw } from "./components/tags/HttpStatusTag"; export { SizeTag } from "./components/tags/SizeTag"; export type { TooltipProps } from "./components/Tooltip"; @@ -113,7 +196,13 @@ export type { HotkeyActionRegistry, HotkeyContextValue, } from "./hooks/useHotKey"; -export { formatHotkey, HotkeyProvider, useFormattedHotkey, useHotKey } from "./hooks/useHotKey"; +export { + formatHotkey, + HotkeyProvider, + useFormattedHotkey, + useHotKey, + useHotkeyLabel, +} from "./hooks/useHotKey"; export { useIsFullscreen } from "./hooks/useIsFullscreen"; export { useRandomKey } from "./hooks/useRandomKey"; export { usePortal } from "./hooks/usePortal"; @@ -123,9 +212,13 @@ export { useInMemoryStoredValue } from "./hooks/useStoredValue"; export type { TextFilterApi } from "./hooks/useTextFilter"; export { useTextFilter } from "./hooks/useTextFilter"; export { useTimedBoolean } from "./hooks/useTimedBoolean"; +export { useToggle } from "./hooks/useToggle"; export { clamp } from "./lib/clamp"; export { HEADER_SIZE_LG, HEADER_SIZE_MD, WINDOW_CONTROLS_WIDTH } from "./lib/constants"; export { languageFromContent, languageFromContentType } from "./lib/contentType"; +export { getMimeTypeFromContentType, isJSON, isProbablyTextContentType } from "./lib/mimeType"; +export { textLikelyContainsJsonComments } from "./lib/jsonComments"; +export { validateHttpHeader } from "./lib/validateHttpHeader"; export { computeSideForDragMove } from "./lib/dnd"; export { docFingerprint } from "./lib/docFingerprint"; export { getNodeText } from "./lib/getNodeText"; diff --git a/apps/yaak-client/lib/data/acceptLanguages.ts b/packages/ui/src/lib/data/acceptLanguages.ts similarity index 100% rename from apps/yaak-client/lib/data/acceptLanguages.ts rename to packages/ui/src/lib/data/acceptLanguages.ts diff --git a/apps/yaak-client/lib/data/cacheControl.ts b/packages/ui/src/lib/data/cacheControl.ts similarity index 100% rename from apps/yaak-client/lib/data/cacheControl.ts rename to packages/ui/src/lib/data/cacheControl.ts diff --git a/apps/yaak-client/lib/data/charsets.ts b/packages/ui/src/lib/data/charsets.ts similarity index 100% rename from apps/yaak-client/lib/data/charsets.ts rename to packages/ui/src/lib/data/charsets.ts diff --git a/apps/yaak-client/lib/data/connections.ts b/packages/ui/src/lib/data/connections.ts similarity index 100% rename from apps/yaak-client/lib/data/connections.ts rename to packages/ui/src/lib/data/connections.ts diff --git a/apps/yaak-client/lib/data/encodings.ts b/packages/ui/src/lib/data/encodings.ts similarity index 100% rename from apps/yaak-client/lib/data/encodings.ts rename to packages/ui/src/lib/data/encodings.ts diff --git a/apps/yaak-client/lib/data/headerNames.ts b/packages/ui/src/lib/data/headerNames.ts similarity index 100% rename from apps/yaak-client/lib/data/headerNames.ts rename to packages/ui/src/lib/data/headerNames.ts diff --git a/apps/yaak-client/lib/data/headerValuePresets.ts b/packages/ui/src/lib/data/headerValuePresets.ts similarity index 100% rename from apps/yaak-client/lib/data/headerValuePresets.ts rename to packages/ui/src/lib/data/headerValuePresets.ts diff --git a/apps/yaak-client/lib/data/mimetypes.ts b/packages/ui/src/lib/data/mimetypes.ts similarity index 100% rename from apps/yaak-client/lib/data/mimetypes.ts rename to packages/ui/src/lib/data/mimetypes.ts diff --git a/apps/yaak-client/lib/data/userAgents.ts b/packages/ui/src/lib/data/userAgents.ts similarity index 100% rename from apps/yaak-client/lib/data/userAgents.ts rename to packages/ui/src/lib/data/userAgents.ts diff --git a/apps/yaak-client/lib/jsonComments.ts b/packages/ui/src/lib/jsonComments.ts similarity index 100% rename from apps/yaak-client/lib/jsonComments.ts rename to packages/ui/src/lib/jsonComments.ts diff --git a/packages/ui/src/lib/mimeType.ts b/packages/ui/src/lib/mimeType.ts new file mode 100644 index 000000000..262d7fc98 --- /dev/null +++ b/packages/ui/src/lib/mimeType.ts @@ -0,0 +1,44 @@ +import { MIMEType } from "whatwg-mimetype"; + +export function isJSON(content: string | null | undefined): boolean { + if (typeof content !== "string") return false; + + try { + JSON.parse(content); + return true; + } catch { + return false; + } +} + +export function isProbablyTextContentType(contentType: string | null): boolean { + if (contentType == null) return false; + + const mimeType = getMimeTypeFromContentType(contentType).essence; + const normalized = mimeType.toLowerCase(); + + // Check if it starts with "text/" + if (normalized.startsWith("text/")) { + return true; + } + + // Common text mimetypes and suffixes + return [ + "application/json", + "application/xml", + "application/javascript", + "application/yaml", + "+json", + "+xml", + "+yaml", + "+text", + ].some((textType) => normalized === textType || normalized.endsWith(textType)); +} + +export function getMimeTypeFromContentType(contentType: string): MIMEType { + try { + return new MIMEType(contentType); + } catch { + return new MIMEType("text/plain"); + } +} diff --git a/apps/yaak-client/lib/validateHttpHeader.test.ts b/packages/ui/src/lib/validateHttpHeader.test.ts similarity index 100% rename from apps/yaak-client/lib/validateHttpHeader.test.ts rename to packages/ui/src/lib/validateHttpHeader.test.ts diff --git a/apps/yaak-client/lib/validateHttpHeader.ts b/packages/ui/src/lib/validateHttpHeader.ts similarity index 100% rename from apps/yaak-client/lib/validateHttpHeader.ts rename to packages/ui/src/lib/validateHttpHeader.ts