mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Share Yaak's request and response panes (#815)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4ad0533901
commit
08a98d5f82
85 files changed
+5567
-2872
No files matched your search
@@ -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<T extends StoredValue>(
|
||||
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<T>({ 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" ? (
|
||||
<PdfResponseBody body={body} complete={complete} />
|
||||
) : (
|
||||
<PdfRequestBody body={body} />
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<EmptyStateText>
|
||||
<LoadingIcon />
|
||||
</EmptyStateText>
|
||||
);
|
||||
}
|
||||
if (bodyUrl.error) {
|
||||
return <Banner color="danger">{String(bodyUrl.error)}</Banner>;
|
||||
}
|
||||
if (bodyUrl.data == null) {
|
||||
return <div>Empty response body</div>;
|
||||
}
|
||||
return <PdfViewer bodyUrl={bodyUrl.data} />;
|
||||
}
|
||||
|
||||
function PdfRequestBody({ body }: { body: HttpBodyLoader }) {
|
||||
const bytes = body.useBytes();
|
||||
if (bytes.data == null) return null;
|
||||
return (
|
||||
<Suspense fallback={<LoadingIcon />}>
|
||||
<PdfViewer data={bytes.data} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
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 <EditorHostProvider value={clientEditorHost}>{children}</EditorHostProvider>;
|
||||
}
|
||||
@@ -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 (
|
||||
<Banner color="primary" className="flex flex-col gap-3">
|
||||
<p>
|
||||
Showing responses over{" "}
|
||||
<InlineCode>
|
||||
<SizeTag contentLength={LARGE_BYTES} />
|
||||
</InlineCode>{" "}
|
||||
may impact performance
|
||||
</p>
|
||||
<HStack wrap space={2}>
|
||||
<Button color="primary" size="xs" onClick={toggleShowLargeResponse}>
|
||||
Reveal Response
|
||||
</Button>
|
||||
<Button color="secondary" variant="border" size="xs" onClick={() => saveResponse()}>
|
||||
Save to File
|
||||
</Button>
|
||||
{isProbablyText && (
|
||||
<CopyButton
|
||||
color="secondary"
|
||||
variant="border"
|
||||
size="xs"
|
||||
text={() => getResponseBodyText({ response, filter: null })}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -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 (
|
||||
<Banner color="primary" className="flex flex-col gap-3">
|
||||
<p>
|
||||
Showing content over{" "}
|
||||
<InlineCode>
|
||||
<SizeTag contentLength={LARGE_BYTES} />
|
||||
</InlineCode>{" "}
|
||||
may impact performance
|
||||
</p>
|
||||
<HStack wrap space={2}>
|
||||
<Button color="primary" size="xs" onClick={toggleShowLargeResponse}>
|
||||
Reveal Request Body
|
||||
</Button>
|
||||
{isProbablyText && (
|
||||
<CopyButton
|
||||
color="secondary"
|
||||
variant="border"
|
||||
size="xs"
|
||||
text={() => getHttpResponseRequestBodyText(response).then((d) => d?.bodyText ?? "")}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className={classNames("w-full h-full pb-2", wrapperClassName)}>
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
"rounded-lg border border-dashed border-border-subtle",
|
||||
"h-full py-2 text-text-subtlest flex items-center justify-center italic",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export { EmptyStateText } from "@yaakapp-internal/ui";
|
||||
@@ -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<Pair[]>(
|
||||
() =>
|
||||
(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<PairEditorProps["onChange"]>(
|
||||
(pairs) =>
|
||||
onChange({ form: pairs.map((p) => ({ enabled: p.enabled, name: p.name, value: p.value })) }),
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<PairOrBulkEditor
|
||||
allowMultilineValues
|
||||
preferenceName="form_urlencoded"
|
||||
valueAutocompleteFunctions
|
||||
valueAutocompleteVariables
|
||||
nameAutocompleteFunctions
|
||||
nameAutocompleteVariables
|
||||
namePlaceholder="entry_name"
|
||||
valuePlaceholder="Value"
|
||||
pairs={pairs}
|
||||
onChange={handleChange}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
stateKey={`urlencoded.${request.id}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
className={
|
||||
hasInheritedHeaders
|
||||
? "@container w-full h-full grid grid-rows-[auto_minmax(0,1fr)] gap-y-1.5"
|
||||
: "@container w-full h-full"
|
||||
}
|
||||
>
|
||||
{hasInheritedHeaders && (
|
||||
<DetailsBanner
|
||||
color="secondary"
|
||||
className="text-sm"
|
||||
summary={
|
||||
<HStack>
|
||||
{inheritedHeadersLabel} <CountBadge count={validInheritedHeaders.length} />
|
||||
</HStack>
|
||||
}
|
||||
>
|
||||
<div className="pb-2">
|
||||
{validInheritedHeaders?.map((pair, i) => (
|
||||
<PairEditorRow
|
||||
key={`${pair.id}.${i}`}
|
||||
index={i}
|
||||
disabled
|
||||
disableDrag
|
||||
className="py-1"
|
||||
pair={ensurePairId(pair)}
|
||||
stateKey={null}
|
||||
nameAutocompleteFunctions
|
||||
nameAutocompleteVariables
|
||||
valueAutocompleteFunctions
|
||||
valueAutocompleteVariables
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</DetailsBanner>
|
||||
)}
|
||||
<PairOrBulkEditor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
nameAutocomplete={nameAutocomplete}
|
||||
nameAutocompleteFunctions
|
||||
nameAutocompleteVariables
|
||||
namePlaceholder="Header-Name"
|
||||
nameValidate={validateHttpHeader}
|
||||
onChange={onChange}
|
||||
pairs={headers}
|
||||
preferenceName="headers"
|
||||
stateKey={stateKey}
|
||||
valueType={valueType}
|
||||
valueAutocomplete={valueAutocomplete}
|
||||
valueAutocompleteFunctions
|
||||
valueAutocompleteVariables
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MIN_MATCH = 3;
|
||||
|
||||
const headerOptionsMap: Record<string, HeaderValuePreset[]> = {
|
||||
"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";
|
||||
@@ -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<TabItem[]>(
|
||||
() => [
|
||||
{
|
||||
const bodyTab = useMemo<TabItem>(
|
||||
() =>
|
||||
({
|
||||
value: TAB_BODY,
|
||||
rightSlot: numParams > 0 ? <CountBadge count={numParams} /> : null,
|
||||
options: {
|
||||
@@ -244,36 +245,8 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
value: TAB_PARAMS,
|
||||
rightSlot: <CountBadge count={urlParameterPairs.length} />,
|
||||
label: "Params",
|
||||
},
|
||||
...headersTab,
|
||||
...authTab,
|
||||
{
|
||||
value: TAB_SETTINGS,
|
||||
label: "Settings",
|
||||
rightSlot: <CountBadge count={numSettingsOverrides} />,
|
||||
},
|
||||
{
|
||||
value: TAB_DESCRIPTION,
|
||||
label: "Info",
|
||||
rightSlot: hasDescription && <CountBadge count={true} />,
|
||||
},
|
||||
],
|
||||
[
|
||||
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 (
|
||||
<div
|
||||
style={style}
|
||||
className={classNames(className, "h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1")}
|
||||
>
|
||||
{activeRequest && (
|
||||
<>
|
||||
<UrlBar
|
||||
stateKey={`url.${activeRequest.id}`}
|
||||
key={forceUpdateKey + urlKey}
|
||||
url={activeRequest.url}
|
||||
placeholder="https://example.com"
|
||||
onPasteOverwrite={handlePaste}
|
||||
autocomplete={autocomplete}
|
||||
onSend={handleSend}
|
||||
onCancel={cancelResponse}
|
||||
onUrlChange={handleUrlChange}
|
||||
leftSlot={
|
||||
<div className="py-0.5">
|
||||
<RequestMethodDropdown request={activeRequest} className="ml-0.5 h-full!" />
|
||||
</div>
|
||||
}
|
||||
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<ExtraPaneTab[]>(
|
||||
() => [
|
||||
{
|
||||
tab: {
|
||||
value: TAB_SETTINGS,
|
||||
label: "Settings",
|
||||
rightSlot: <CountBadge count={numSettingsOverrides} />,
|
||||
},
|
||||
content: <ModelSettingsEditor model={activeRequest} />,
|
||||
},
|
||||
{
|
||||
tab: {
|
||||
value: TAB_DESCRIPTION,
|
||||
label: "Info",
|
||||
rightSlot: hasDescription && <CountBadge count={true} />,
|
||||
},
|
||||
content: (
|
||||
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
|
||||
<PlainInput
|
||||
label="Request Name"
|
||||
hideLabel
|
||||
forceUpdateKey={updateKey}
|
||||
defaultValue={activeRequest.name}
|
||||
className="font-sans text-xl! px-0!"
|
||||
containerClassName="border-0"
|
||||
placeholder={resolvedModelName(activeRequest)}
|
||||
onChange={(name) => patchModel(activeRequest, { name })}
|
||||
/>
|
||||
<MarkdownEditor
|
||||
name="request-description"
|
||||
placeholder="Request description"
|
||||
defaultValue={activeRequest.description}
|
||||
stateKey={`description.${activeRequest.id}`}
|
||||
forceUpdateKey={updateKey}
|
||||
onChange={(description) => patchModel(activeRequest, { description })}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[activeRequest, hasDescription, numSettingsOverrides, updateKey],
|
||||
);
|
||||
|
||||
const renderBody = useCallback(
|
||||
(sharedEditor: ReactNode) => (
|
||||
<ConfirmLargeRequestBody request={activeRequest}>
|
||||
{activeRequest.bodyType === BODY_TYPE_GRAPHQL ? (
|
||||
<Suspense>
|
||||
<GraphQLEditor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
baseRequest={activeRequest}
|
||||
request={activeRequest}
|
||||
onChange={handleBodyChange}
|
||||
/>
|
||||
</Suspense>
|
||||
) : activeRequest.bodyType === BODY_TYPE_BINARY ? (
|
||||
<BinaryFileEditor
|
||||
requestId={activeRequest.id}
|
||||
contentType={contentType}
|
||||
body={activeRequest.body}
|
||||
onChange={(body) => patchModelDebounced(activeRequest, { body })}
|
||||
onChangeContentType={handleContentTypeChange}
|
||||
/>
|
||||
<Tabs
|
||||
ref={tabsRef}
|
||||
label="Request"
|
||||
tabs={tabs}
|
||||
tabListClassName="mt-1 -mb-1.5"
|
||||
storageKey={TABS_STORAGE_KEY}
|
||||
activeTabKey={activeRequestId}
|
||||
>
|
||||
<TabContent value={TAB_AUTH}>
|
||||
<HttpAuthenticationEditor model={activeRequest} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_HEADERS}>
|
||||
<HeadersEditor
|
||||
inheritedHeaders={inheritedHeaders}
|
||||
forceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
|
||||
headers={activeRequest.headers}
|
||||
stateKey={`headers.${activeRequest.id}`}
|
||||
onChange={(headers) => patchModelDebounced(activeRequest, { headers })}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_PARAMS}>
|
||||
<UrlParametersEditor
|
||||
stateKey={`params.${activeRequest.id}`}
|
||||
forceUpdateKey={forceUpdateKey + urlParametersKey}
|
||||
pairs={urlParameterPairs}
|
||||
onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_SETTINGS}>
|
||||
<ModelSettingsEditor model={activeRequest} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_BODY}>
|
||||
<ConfirmLargeRequestBody request={activeRequest}>
|
||||
{activeRequest.bodyType === BODY_TYPE_JSON ? (
|
||||
<JsonBodyEditor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
heightMode={fullHeight ? "full" : "auto"}
|
||||
request={activeRequest}
|
||||
/>
|
||||
) : activeRequest.bodyType === BODY_TYPE_XML ? (
|
||||
<Editor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
autocompleteFunctions
|
||||
autocompleteVariables
|
||||
placeholder="..."
|
||||
heightMode={fullHeight ? "full" : "auto"}
|
||||
defaultValue={`${activeRequest.body?.text ?? ""}`}
|
||||
language="xml"
|
||||
onChange={handleBodyTextChange}
|
||||
stateKey={`xml.${activeRequest.id}`}
|
||||
/>
|
||||
) : activeRequest.bodyType === BODY_TYPE_GRAPHQL ? (
|
||||
<Suspense>
|
||||
<GraphQLEditor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
baseRequest={activeRequest}
|
||||
request={activeRequest}
|
||||
onChange={handleBodyChange}
|
||||
/>
|
||||
</Suspense>
|
||||
) : activeRequest.bodyType === BODY_TYPE_FORM_URLENCODED ? (
|
||||
<FormUrlencodedEditor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
request={activeRequest}
|
||||
onChange={handleBodyChange}
|
||||
/>
|
||||
) : activeRequest.bodyType === BODY_TYPE_FORM_MULTIPART ? (
|
||||
<FormMultipartEditor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
request={activeRequest}
|
||||
onChange={handleBodyChange}
|
||||
/>
|
||||
) : activeRequest.bodyType === BODY_TYPE_BINARY ? (
|
||||
<BinaryFileEditor
|
||||
requestId={activeRequest.id}
|
||||
contentType={contentType}
|
||||
body={activeRequest.body}
|
||||
onChange={(body) => patchModelDebounced(activeRequest, { body })}
|
||||
onChangeContentType={handleContentTypeChange}
|
||||
/>
|
||||
) : typeof activeRequest.bodyType === "string" ? (
|
||||
<Editor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
autocompleteFunctions
|
||||
autocompleteVariables
|
||||
language={languageFromContentType(contentType)}
|
||||
placeholder="..."
|
||||
heightMode={fullHeight ? "full" : "auto"}
|
||||
defaultValue={`${activeRequest.body?.text ?? ""}`}
|
||||
onChange={handleBodyTextChange}
|
||||
stateKey={`other.${activeRequest.id}`}
|
||||
/>
|
||||
) : (
|
||||
<EmptyStateText>No Body</EmptyStateText>
|
||||
)}
|
||||
</ConfirmLargeRequestBody>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_DESCRIPTION}>
|
||||
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
|
||||
<PlainInput
|
||||
label="Request Name"
|
||||
hideLabel
|
||||
forceUpdateKey={updateKey}
|
||||
defaultValue={activeRequest.name}
|
||||
className="font-sans text-xl! px-0!"
|
||||
containerClassName="border-0"
|
||||
placeholder={resolvedModelName(activeRequest)}
|
||||
onChange={(name) => patchModel(activeRequest, { name })}
|
||||
/>
|
||||
<MarkdownEditor
|
||||
name="request-description"
|
||||
placeholder="Request description"
|
||||
defaultValue={activeRequest.description}
|
||||
stateKey={`description.${activeRequest.id}`}
|
||||
forceUpdateKey={updateKey}
|
||||
onChange={(description) => patchModel(activeRequest, { description })}
|
||||
/>
|
||||
</div>
|
||||
</TabContent>
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
sharedEditor
|
||||
)}
|
||||
</ConfirmLargeRequestBody>
|
||||
),
|
||||
[activeRequest, contentType, forceUpdateKey, handleBodyChange, handleContentTypeChange],
|
||||
);
|
||||
|
||||
const renderUrlParameters = useCallback(
|
||||
(pairs: EditablePair[]) => (
|
||||
<UrlParametersEditor
|
||||
stateKey={`params.${activeRequest.id}`}
|
||||
forceUpdateKey={forceUpdateKey + urlParametersKey}
|
||||
pairs={pairs}
|
||||
onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })}
|
||||
/>
|
||||
),
|
||||
[activeRequest, forceUpdateKey, urlParametersKey],
|
||||
);
|
||||
|
||||
return (
|
||||
<HttpRequestPaneView
|
||||
editable
|
||||
style={style}
|
||||
className={className}
|
||||
fullHeight={fullHeight}
|
||||
request={requestView}
|
||||
tabsRef={tabsRef}
|
||||
tabsStorageKey={TABS_STORAGE_KEY}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
urlBarKey={forceUpdateKey + urlKey}
|
||||
urlForceUpdateKey={updateKey}
|
||||
urlAutocomplete={autocomplete}
|
||||
onUrlChange={handleUrlChange}
|
||||
onUrlPasteOverwrite={handlePaste}
|
||||
onSend={handleSend}
|
||||
onCancel={cancelResponse}
|
||||
isSending={activeResponse != null && activeResponse.state !== "closed"}
|
||||
sendHotkeyAction="request.send"
|
||||
focusHotkeyAction="url_bar.focus"
|
||||
methodSlot={<RequestMethodDropdown request={activeRequest} className="ml-0.5 h-full!" />}
|
||||
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={<HttpAuthenticationEditor model={activeRequest} />}
|
||||
extraTabs={extraTabs}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<TabItem[]>(
|
||||
() => [
|
||||
{
|
||||
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: <Icon icon="save" />,
|
||||
hidden: activeResponse == null || !!activeResponse.error,
|
||||
disabled: activeResponse?.state !== "closed" && (activeResponse?.status ?? 0) >= 100,
|
||||
},
|
||||
{
|
||||
label: "Copy Body",
|
||||
onSelect: copyResponse.mutate,
|
||||
leftSlot: <Icon icon="copy" />,
|
||||
hidden: activeResponse == null || !!activeResponse.error,
|
||||
disabled: activeResponse?.state !== "closed" && (activeResponse?.status ?? 0) >= 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
value: TAB_REQUEST,
|
||||
label: "Request",
|
||||
rightSlot:
|
||||
(activeResponse?.requestContentLength ?? 0) > 0 ? <CountBadge count={true} /> : null,
|
||||
},
|
||||
{
|
||||
value: TAB_HEADERS,
|
||||
label: "Headers",
|
||||
rightSlot: (
|
||||
<CountBadge
|
||||
count={activeResponse?.requestHeaders.length ?? 0}
|
||||
count2={activeResponse?.headers.length ?? 0}
|
||||
showZero
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: TAB_COOKIES,
|
||||
label: "Cookies",
|
||||
rightSlot:
|
||||
cookieCounts.sent > 0 || cookieCounts.received > 0 ? (
|
||||
<CountBadge count={cookieCounts.sent} count2={cookieCounts.received} showZero />
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
value: TAB_TIMELINE,
|
||||
rightSlot: <CountBadge count={responseEvents.data?.length ?? 0} />,
|
||||
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<ExtraPaneTab[]>(
|
||||
() =>
|
||||
activeResponse == null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
tab: {
|
||||
value: TAB_TIMELINE,
|
||||
rightSlot: <CountBadge count={responseEvents.data?.length ?? 0} />,
|
||||
options: {
|
||||
value: timelineViewMode,
|
||||
onChange: (v) => setTimelineViewMode((v as TimelineViewMode) ?? "timeline"),
|
||||
items: [
|
||||
{ label: "Timeline", value: "timeline" },
|
||||
{ label: "Timeline (Text)", shortLabel: "Timeline", value: "text" },
|
||||
],
|
||||
},
|
||||
},
|
||||
content: (
|
||||
<HttpResponseTimeline response={activeResponse} viewMode={timelineViewMode} />
|
||||
),
|
||||
},
|
||||
],
|
||||
[activeResponse, responseEvents.data?.length, setTimelineViewMode, timelineViewMode],
|
||||
);
|
||||
|
||||
const renderBodyViewer = useCallback(
|
||||
({ mimeType, viewMode }: HostBodyViewerArgs) =>
|
||||
activeResponse != null && mimeType?.match(/^text\/event-stream/i) && viewMode === "pretty" ? (
|
||||
<EventStreamViewer response={activeResponse} />
|
||||
) : null,
|
||||
[activeResponse],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
<HttpResponsePaneView
|
||||
style={style}
|
||||
className={classNames(
|
||||
className,
|
||||
"x-theme-responsePane",
|
||||
"max-h-full h-full",
|
||||
"bg-surface rounded-md border border-border-subtle overflow-hidden",
|
||||
"relative",
|
||||
)}
|
||||
>
|
||||
{activeResponse == null ? (
|
||||
className={className}
|
||||
response={responseView}
|
||||
body={body}
|
||||
requestBody={requestBody}
|
||||
emptyState={
|
||||
<HotkeyList hotkeys={["request.send", "model.create", "sidebar.focus", "url_bar.focus"]} />
|
||||
) : (
|
||||
<div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1">
|
||||
<HStack
|
||||
className={classNames(
|
||||
"text-text-subtle w-full shrink-0",
|
||||
// Remove a bit of space because the tabs have lots too
|
||||
"-mb-1.5",
|
||||
)}
|
||||
>
|
||||
{activeResponse && (
|
||||
<div
|
||||
className={classNames(
|
||||
"grid grid-cols-[auto_minmax(4rem,1fr)_auto]",
|
||||
"cursor-default select-none",
|
||||
"whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars",
|
||||
}
|
||||
viewMode={viewMode === "raw" ? "raw" : "pretty"}
|
||||
onViewModeChange={setViewMode}
|
||||
activeTabKey={activeRequestId}
|
||||
onSaveBody={saveResponse.mutate}
|
||||
onCopyBody={copyResponse.mutate}
|
||||
onCancel={cancel.mutate}
|
||||
cookieCounts={cookieCounts}
|
||||
extraTabs={extraTabs}
|
||||
textViewer={textViewer}
|
||||
renderBodyViewer={renderBodyViewer}
|
||||
renderCopyButton={renderCopyButton}
|
||||
statusLineCenter={
|
||||
shouldShowRedirectDropWarning ? (
|
||||
<Tooltip
|
||||
tabIndex={0}
|
||||
className="my-auto pl-3 shrink-0 max-w-full justify-self-end overflow-hidden"
|
||||
content={
|
||||
<VStack alignItems="start" space={1} className="text-xs">
|
||||
<span className="font-medium text-warning">Redirect changed this request</span>
|
||||
{redirectDropWarning.droppedBodyCount > 0 && (
|
||||
<span>
|
||||
Body dropped on {redirectDropWarning.droppedBodyCount}{" "}
|
||||
{redirectDropWarning.droppedBodyCount === 1 ? "redirect hop" : "redirect hops"}
|
||||
</span>
|
||||
)}
|
||||
>
|
||||
<HStack space={2} className="w-full shrink-0">
|
||||
{activeResponse.state !== "closed" && <LoadingIcon size="sm" />}
|
||||
<HttpStatusTag showReason response={activeResponse} />
|
||||
<span>•</span>
|
||||
<HttpResponseDurationTag response={activeResponse} />
|
||||
<span>•</span>
|
||||
<SizeTag
|
||||
contentLength={activeResponse.contentLength ?? 0}
|
||||
contentLengthCompressed={activeResponse.contentLengthCompressed}
|
||||
/>
|
||||
</HStack>
|
||||
{shouldShowRedirectDropWarning ? (
|
||||
<Tooltip
|
||||
tabIndex={0}
|
||||
className="my-auto pl-3 shrink-0 max-w-full justify-self-end overflow-hidden"
|
||||
content={
|
||||
<VStack alignItems="start" space={1} className="text-xs">
|
||||
<span className="font-medium text-warning">
|
||||
Redirect changed this request
|
||||
</span>
|
||||
{redirectDropWarning.droppedBodyCount > 0 && (
|
||||
<span>
|
||||
Body dropped on {redirectDropWarning.droppedBodyCount}{" "}
|
||||
{redirectDropWarning.droppedBodyCount === 1
|
||||
? "redirect hop"
|
||||
: "redirect hops"}
|
||||
</span>
|
||||
)}
|
||||
{redirectDropWarning.droppedHeaders.length > 0 && (
|
||||
<span>
|
||||
Headers dropped:{" "}
|
||||
<span className="font-mono">
|
||||
{redirectDropWarning.droppedHeaders.join(", ")}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="text-text-subtle">See Timeline for details.</span>
|
||||
</VStack>
|
||||
}
|
||||
>
|
||||
<span className="inline-flex min-w-0">
|
||||
<PillButton
|
||||
color="warning"
|
||||
className="font-sans text-sm shrink! max-w-full"
|
||||
innerClassName="flex items-center"
|
||||
leftSlot={<Icon icon="alert_triangle" size="xs" color="warning" />}
|
||||
>
|
||||
<span className="truncate">
|
||||
{getRedirectWarningLabel(redirectDropWarning)}
|
||||
</span>
|
||||
</PillButton>
|
||||
{redirectDropWarning.droppedHeaders.length > 0 && (
|
||||
<span>
|
||||
Headers dropped:{" "}
|
||||
<span className="font-mono">
|
||||
{redirectDropWarning.droppedHeaders.join(", ")}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span />
|
||||
</span>
|
||||
)}
|
||||
<div className="justify-self-end shrink-0">
|
||||
<RecentHttpResponsesDropdown
|
||||
responses={responses}
|
||||
activeResponse={activeResponse}
|
||||
onPinnedResponseId={setPinnedResponseId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
<div className="overflow-hidden flex flex-col min-h-0">
|
||||
{activeResponse?.error && (
|
||||
<Banner color="danger" className="mx-3 mt-1 shrink-0">
|
||||
{activeResponse.error}
|
||||
</Banner>
|
||||
)}
|
||||
{/* Show tabs if we have any data (headers, body, etc.) even if there's an error */}
|
||||
<Tabs
|
||||
tabs={tabs}
|
||||
label="Response"
|
||||
className="ml-3 mr-3 mb-3 min-h-0 flex-1"
|
||||
tabListClassName="mt-0.5 -mb-1.5"
|
||||
storageKey="http_response_tabs"
|
||||
activeTabKey={activeRequestId}
|
||||
>
|
||||
<TabContent value={TAB_BODY}>
|
||||
<ErrorBoundary name="Http Response Viewer">
|
||||
<Suspense>
|
||||
<ConfirmLargeResponse response={activeResponse}>
|
||||
{activeResponse.state === "initialized" ? (
|
||||
<EmptyStateText>
|
||||
<VStack space={3}>
|
||||
<HStack space={3}>
|
||||
<LoadingIcon className="text-text-subtlest" />
|
||||
Sending Request
|
||||
</HStack>
|
||||
<Button size="sm" variant="border" onClick={() => cancel.mutate()}>
|
||||
Cancel
|
||||
</Button>
|
||||
</VStack>
|
||||
</EmptyStateText>
|
||||
) : activeResponse.state === "closed" &&
|
||||
(activeResponse.contentLength ?? 0) === 0 ? (
|
||||
<EmptyStateText>Empty</EmptyStateText>
|
||||
) : mimeType?.match(/^text\/event-stream/i) && viewMode === "pretty" ? (
|
||||
<EventStreamViewer response={activeResponse} />
|
||||
) : mimeType?.match(/^image\/svg/) ? (
|
||||
<HttpSvgViewer response={activeResponse} />
|
||||
) : mimeType?.match(/^image/i) ? (
|
||||
<EnsureCompleteResponse response={activeResponse} Component={ImageViewer} />
|
||||
) : mimeType?.match(/^audio/i) ? (
|
||||
<EnsureCompleteResponse response={activeResponse} Component={AudioViewer} />
|
||||
) : mimeType?.match(/^video/i) ? (
|
||||
<EnsureCompleteResponse response={activeResponse} Component={VideoViewer} />
|
||||
) : mimeType?.match(/^multipart/i) && viewMode === "pretty" ? (
|
||||
<HttpMultipartViewer response={activeResponse} />
|
||||
) : mimeType?.match(/pdf/i) ? (
|
||||
<EnsureCompleteResponse response={activeResponse} Component={PdfViewer} />
|
||||
) : mimeType?.match(/csv|tab-separated/i) && viewMode === "pretty" ? (
|
||||
<HttpCsvViewer className="pb-2" response={activeResponse} />
|
||||
) : (
|
||||
<HTMLOrTextViewer
|
||||
textViewerClassName="-mr-2 bg-surface" // Pull to the right
|
||||
response={activeResponse}
|
||||
pretty={viewMode === "pretty"}
|
||||
/>
|
||||
)}
|
||||
</ConfirmLargeResponse>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_REQUEST}>
|
||||
<ConfirmLargeResponseRequest response={activeResponse}>
|
||||
<RequestBodyViewer response={activeResponse} />
|
||||
</ConfirmLargeResponseRequest>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_HEADERS}>
|
||||
<ResponseHeaders response={activeResponse} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_COOKIES}>
|
||||
<ResponseCookies response={activeResponse} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_TIMELINE}>
|
||||
<HttpResponseTimeline response={activeResponse} viewMode={timelineViewMode} />
|
||||
</TabContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-text-subtle">See Timeline for details.</span>
|
||||
</VStack>
|
||||
}
|
||||
>
|
||||
<span className="inline-flex min-w-0">
|
||||
<PillButton
|
||||
color="warning"
|
||||
className="font-sans text-sm shrink! max-w-full"
|
||||
innerClassName="flex items-center"
|
||||
leftSlot={<Icon icon="alert_triangle" size="xs" color="warning" />}
|
||||
>
|
||||
<span className="truncate">{getRedirectWarningLabel(redirectDropWarning)}</span>
|
||||
</PillButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null
|
||||
}
|
||||
statusLineRight={
|
||||
activeResponse != null && (
|
||||
<RecentHttpResponsesDropdown
|
||||
responses={responses}
|
||||
activeResponse={activeResponse}
|
||||
onPinnedResponseId={setPinnedResponseId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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) => (
|
||||
<div className="py-0.5 flex">
|
||||
<RecentFiltersDropdown
|
||||
recentFilters={recentFilters}
|
||||
activeFilter={filter.appliedFilter}
|
||||
onSelect={filter.replaceFilter}
|
||||
onRemove={removeFilter}
|
||||
onTogglePin={togglePin}
|
||||
onClear={clearFilters}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
[recentFilters, removeFilter, togglePin, clearFilters],
|
||||
);
|
||||
|
||||
if (response == null) return undefined;
|
||||
const actionsDisabled = response.state !== "closed" && response.status >= 100;
|
||||
return {
|
||||
filterStateKey,
|
||||
onFilterApplied: addFilter,
|
||||
filterLeftSlot,
|
||||
footerActions: [
|
||||
<IconButton
|
||||
key="save"
|
||||
size="sm"
|
||||
icon="save"
|
||||
title="Save response to file"
|
||||
disabled={actionsDisabled}
|
||||
onClick={() => saveResponse.mutate()}
|
||||
className="border !border-border-subtle"
|
||||
/>,
|
||||
<IconButton
|
||||
key="copy"
|
||||
size="sm"
|
||||
icon="copy"
|
||||
title="Copy response body"
|
||||
disabled={actionsDisabled}
|
||||
onClick={() => copyResponse.mutate()}
|
||||
className="border !border-border-subtle"
|
||||
/>,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function renderCopyButton(getText: () => Promise<string | null>) {
|
||||
return <CopyButton color="secondary" variant="border" size="xs" text={getText} />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<EmptyStateText>
|
||||
<LoadingIcon />
|
||||
</EmptyStateText>
|
||||
);
|
||||
}
|
||||
|
||||
if (bodyUrl.error) {
|
||||
return <Banner color="danger">{String(bodyUrl.error)}</Banner>;
|
||||
}
|
||||
|
||||
if (bodyUrl.data == null) {
|
||||
return <div>Empty response body</div>;
|
||||
}
|
||||
|
||||
return <Component bodyUrl={bodyUrl.data} />;
|
||||
}
|
||||
|
||||
function HttpSvgViewer({ response }: { response: HttpResponse }) {
|
||||
const body = useResponseBodyText({ response, filter: null });
|
||||
|
||||
if (!body.data) return null;
|
||||
|
||||
return <SvgViewer text={body.data} />;
|
||||
}
|
||||
|
||||
function HttpCsvViewer({ response, className }: { response: HttpResponse; className?: string }) {
|
||||
const body = useResponseBodyText({ response, filter: null });
|
||||
|
||||
return <CsvViewer text={body.data ?? null} className={className} />;
|
||||
}
|
||||
|
||||
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 <MultipartViewer data={body.data} boundary={boundary} idPrefix={response.id} />;
|
||||
}
|
||||
@@ -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<boolean>({
|
||||
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<EditorProps["actions"]>(
|
||||
() => [
|
||||
showBanner && (
|
||||
<Banner color="notice" className="opacity-100! h-sm py-0! px-2! flex items-center text-xs">
|
||||
<p className="inline-flex items-center gap-1 min-w-0">
|
||||
<span className="truncate">Auto-fix enabled</span>
|
||||
<Icon icon="arrow_right" size="sm" className="opacity-disabled" />
|
||||
</p>
|
||||
</Banner>
|
||||
),
|
||||
<div key="settings" className="opacity-100! shadow!">
|
||||
<Dropdown
|
||||
onOpen={handleDropdownOpen}
|
||||
items={
|
||||
[
|
||||
{
|
||||
label: "Automatically Fix JSON",
|
||||
keepOpenOnSelect: true,
|
||||
onSelect: handleToggleAutoFix,
|
||||
rightSlot: <IconTooltip content={stripMessage} />,
|
||||
leftSlot: (
|
||||
<Icon icon={autoFix ? "check_square_checked" : "check_square_unchecked"} />
|
||||
),
|
||||
},
|
||||
] satisfies DropdownItem[]
|
||||
}
|
||||
>
|
||||
<IconButton size="sm" variant="border" icon="settings" title="JSON Settings" />
|
||||
</Dropdown>
|
||||
</div>,
|
||||
],
|
||||
[handleDropdownOpen, handleToggleAutoFix, autoFix, showBanner],
|
||||
);
|
||||
|
||||
return (
|
||||
<Editor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
autocompleteFunctions
|
||||
autocompleteVariables
|
||||
placeholder="..."
|
||||
heightMode={heightMode}
|
||||
defaultValue={`${request.body?.text ?? ""}`}
|
||||
language="json"
|
||||
onChange={handleChange}
|
||||
stateKey={`json.${request.id}`}
|
||||
actions={actions}
|
||||
lintExtension={lintExtension}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 <RequestBodyViewerInner key={response.id} response={response} />;
|
||||
}
|
||||
|
||||
function RequestBodyViewerInner({ response }: Props) {
|
||||
const { data, isLoading, error } = useHttpRequestBody(response);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<EmptyStateText>
|
||||
<LoadingIcon />
|
||||
</EmptyStateText>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <EmptyStateText>Error loading request body: {error.message}</EmptyStateText>;
|
||||
}
|
||||
|
||||
if (data?.bodyText == null || data.bodyText.length === 0) {
|
||||
return <EmptyStateText>No request body</EmptyStateText>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<MultipartViewer data={bodyCopy} boundary={boundary} idPrefix={`request.${response.id}`} />
|
||||
);
|
||||
}
|
||||
|
||||
if (mimeType?.match(/^image\/svg/i)) {
|
||||
return <SvgViewer text={bodyText} />;
|
||||
}
|
||||
|
||||
if (mimeType?.match(/^image/i)) {
|
||||
return <ImageViewer data={body.buffer} />;
|
||||
}
|
||||
|
||||
if (mimeType?.match(/^audio/i)) {
|
||||
return <AudioViewer data={body} />;
|
||||
}
|
||||
|
||||
if (mimeType?.match(/^video/i)) {
|
||||
return <VideoViewer data={body} />;
|
||||
}
|
||||
|
||||
if (mimeType?.match(/csv|tab-separated/i)) {
|
||||
return <CsvViewer text={bodyText} />;
|
||||
}
|
||||
|
||||
if (mimeType?.match(/^text\/html/i)) {
|
||||
return <WebPageViewer html={bodyText} />;
|
||||
}
|
||||
|
||||
if (mimeType?.match(/pdf/i)) {
|
||||
return (
|
||||
<Suspense fallback={<LoadingIcon />}>
|
||||
<PdfViewer data={body} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TextViewer text={bodyText} language={language} stateKey={`request.body.${response.id}`} />
|
||||
);
|
||||
}
|
||||
@@ -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<string>[] = [
|
||||
"GET",
|
||||
"PUT",
|
||||
"POST",
|
||||
"PATCH",
|
||||
"DELETE",
|
||||
"OPTIONS",
|
||||
"QUERY",
|
||||
"HEAD",
|
||||
].map((m) => ({
|
||||
value: m,
|
||||
label: <HttpMethodTagRaw method={m} />,
|
||||
}));
|
||||
|
||||
/** 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<DropdownItem[]>(
|
||||
() => [
|
||||
{
|
||||
key: "custom",
|
||||
label: "CUSTOM",
|
||||
leftSlot: <Icon icon="sparkles" />,
|
||||
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 (
|
||||
<RadioDropdown
|
||||
value={request.method}
|
||||
items={radioItems}
|
||||
itemsAfter={itemsAfter}
|
||||
<BaseRequestMethodDropdown
|
||||
method={request.method}
|
||||
colored={settings.coloredMethods}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<Button size="xs" className={classNames(className, "text-text-subtle hover:text")}>
|
||||
<HttpMethodTag request={request} noAlias />
|
||||
</Button>
|
||||
</RadioDropdown>
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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 (
|
||||
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
|
||||
<DetailsBanner storageKey={`${response.requestId}.general`} summary={<h2>Info</h2>}>
|
||||
<KeyValueRows>
|
||||
<KeyValueRow labelColor="secondary" label="Sent">
|
||||
<time
|
||||
dateTime={new Date(`${response.createdAt}Z`).toISOString()}
|
||||
title={formatDistanceToNowStrict(`${response.createdAt}Z`, { addSuffix: true })}
|
||||
>
|
||||
{format(`${response.createdAt}Z`, "MMM d, yyyy, h:mm:ss a O")}
|
||||
</time>
|
||||
</KeyValueRow>
|
||||
<KeyValueRow labelColor="secondary" label="Request URL">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="select-text cursor-text">{response.url}</span>
|
||||
<IconButton
|
||||
iconSize="sm"
|
||||
className="inline-block w-auto h-auto! opacity-50 hover:opacity-100"
|
||||
icon="external_link"
|
||||
onClick={() => platform.openUrl(response.url)}
|
||||
title="Open in browser"
|
||||
/>
|
||||
</div>
|
||||
</KeyValueRow>
|
||||
<KeyValueRow labelColor="secondary" label="Remote Address">
|
||||
{response.remoteAddr ?? <span className="text-text-subtlest">--</span>}
|
||||
</KeyValueRow>
|
||||
<KeyValueRow labelColor="secondary" label="Version">
|
||||
{response.version ?? <span className="text-text-subtlest">--</span>}
|
||||
</KeyValueRow>
|
||||
</KeyValueRows>
|
||||
</DetailsBanner>
|
||||
<HeadersTable
|
||||
title="Request Headers"
|
||||
headers={response.requestHeaders}
|
||||
labelColor="primary"
|
||||
storageKey={`${response.requestId}.request_headers`}
|
||||
useStoredValue={useDetailsBannerStoredValue}
|
||||
/>
|
||||
<HeadersTable
|
||||
defaultOpen
|
||||
title="Response Headers"
|
||||
headers={response.headers}
|
||||
labelColor="info"
|
||||
storageKey={`${response.requestId}.response_headers`}
|
||||
useStoredValue={useDetailsBannerStoredValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HttpRequest, "url"> & {
|
||||
className?: string;
|
||||
placeholder: string;
|
||||
type Props = Omit<UrlBarProps, "sendHotkeyAction" | "focusHotkeyAction" | "readOnly"> & {
|
||||
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<InputHandle>(null);
|
||||
const [isFocused, setIsFocused] = useState<boolean>(false);
|
||||
|
||||
const handleInitInputRef = useCallback((h: InputHandle | null) => {
|
||||
inputRef.current = h;
|
||||
}, []);
|
||||
|
||||
useHotKey("url_bar.focus", () => {
|
||||
inputRef.current?.selectAll();
|
||||
});
|
||||
|
||||
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (isLoading) onCancel();
|
||||
else onSend();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className={classNames("x-theme-urlBar", className)}>
|
||||
<Input
|
||||
setRef={handleInitInputRef}
|
||||
autocompleteFunctions
|
||||
autocompleteVariables
|
||||
stateKey={stateKey}
|
||||
size="sm"
|
||||
wrapLines={isFocused}
|
||||
hideLabel
|
||||
language="url"
|
||||
className="px-1.5 py-0.5"
|
||||
label="Enter URL"
|
||||
name="url"
|
||||
autocomplete={autocomplete}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
onPaste={onPaste}
|
||||
onPasteOverwrite={onPasteOverwrite}
|
||||
onChange={onUrlChange}
|
||||
defaultValue={url}
|
||||
placeholder={placeholder}
|
||||
leftSlot={leftSlot}
|
||||
rightSlot={
|
||||
<HStack space={0.5}>
|
||||
{rightSlot && <div className="py-0.5 h-full">{rightSlot}</div>}
|
||||
{submitIcon !== null && (
|
||||
<div className="py-0.5 h-full">
|
||||
<IconButton
|
||||
size="xs"
|
||||
iconSize="md"
|
||||
title="Send Request"
|
||||
type="submit"
|
||||
className="w-8 mr-0.5 h-full!"
|
||||
iconColor="secondary"
|
||||
icon={isLoading ? "x" : submitIcon}
|
||||
hotkeyAction="request.send"
|
||||
onMouseDown={(e) => {
|
||||
// Prevent the button from taking focus
|
||||
e.preventDefault();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
/** The shared URL bar, sending and focusing on the client's hotkeys */
|
||||
export const UrlBar = memo(function UrlBar(props: Props) {
|
||||
return <BaseUrlBar {...props} sendHotkeyAction="request.send" focusHotkeyAction="url_bar.focus" />;
|
||||
});
|
||||
@@ -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<PairEditorHandle>(null);
|
||||
const handleInitPairEditorRef = useCallback((ref: PairEditorHandle) => {
|
||||
@@ -33,22 +37,12 @@ export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey
|
||||
);
|
||||
|
||||
return (
|
||||
<VStack className="h-full">
|
||||
<PairOrBulkEditor
|
||||
setRef={handleInitPairEditorRef}
|
||||
allowMultilineValues
|
||||
forceUpdateKey={forceUpdateKey + urlParametersKey}
|
||||
nameAutocompleteFunctions
|
||||
nameAutocompleteVariables
|
||||
namePlaceholder="param_name"
|
||||
onChange={onChange}
|
||||
pairs={pairs}
|
||||
preferenceName="url_parameters"
|
||||
stateKey={stateKey}
|
||||
valueAutocompleteFunctions
|
||||
valueAutocompleteVariables
|
||||
valuePlaceholder="Value"
|
||||
/>
|
||||
</VStack>
|
||||
<BaseUrlParametersEditor
|
||||
setRef={handleInitPairEditorRef}
|
||||
forceUpdateKey={forceUpdateKey + urlParametersKey}
|
||||
onChange={onChange}
|
||||
pairs={pairs}
|
||||
stateKey={stateKey}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<div className={classNames(className, "h-full flex items-center justify-center")}>
|
||||
<div className="grid gap-2 grid-cols-[auto_auto]">
|
||||
{hotkeys.map((hotkey) => (
|
||||
<Fragment key={hotkey}>
|
||||
<HotkeyLabel className="truncate" action={hotkey} />
|
||||
{/* Keep this grid cell when Hotkey renders nothing so later rows stay aligned. */}
|
||||
<div className="ml-4">
|
||||
<Hotkey action={hotkey} />
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
{bottomSlot}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export { HotkeyList } from "@yaakapp-internal/ui";
|
||||
@@ -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 <PairEditorInner key={props.stateKey} {...props} />;
|
||||
}
|
||||
|
||||
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<number | null>(null);
|
||||
const [isDragging, setIsDragging] = useState<EditablePairWithId | null>(null);
|
||||
const [pairs, setPairs] = useState<EditablePairWithId[]>([]);
|
||||
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<Record<string, RowHandle | null>>({});
|
||||
|
||||
const pendingFocusFrame = useRef<number | null>(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<PairEditorHandle>(
|
||||
() => ({
|
||||
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 (
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
"@container relative",
|
||||
"pb-2 mb-auto h-full",
|
||||
!noScroll && "overflow-y-auto max-h-full",
|
||||
// Move over the width of the drag handle
|
||||
"-mr-2 pr-2",
|
||||
// Pad to make room for the drag divider
|
||||
"pt-0.5",
|
||||
"grid grid-rows-[auto_1fr]",
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<DndContext
|
||||
autoScroll
|
||||
sensors={sensors}
|
||||
onDragMove={onDragMove}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragStart={onDragStart}
|
||||
onDragCancel={onDragCancel}
|
||||
collisionDetection={pointerWithin}
|
||||
>
|
||||
{pairs.map((p, i) => {
|
||||
if (!showAll && i > MAX_INITIAL_PAIRS) return null;
|
||||
|
||||
const isLast = i === pairs.length - 1;
|
||||
return (
|
||||
<Fragment key={p.id}>
|
||||
{hoveredIndex === i && <DropMarker />}
|
||||
<PairEditorRow
|
||||
setRef={initPairEditorRow}
|
||||
allowFileValues={allowFileValues}
|
||||
allowMultilineValues={allowMultilineValues}
|
||||
className="py-1"
|
||||
forcedEnvironmentId={forcedEnvironmentId}
|
||||
forceUpdateKey={localForceUpdateKey}
|
||||
index={i}
|
||||
isLast={isLast}
|
||||
isDraggingGlobal={!!isDragging}
|
||||
nameAutocomplete={nameAutocomplete}
|
||||
nameAutocompleteFunctions={nameAutocompleteFunctions}
|
||||
nameAutocompleteVariables={nameAutocompleteVariables}
|
||||
namePlaceholder={namePlaceholder}
|
||||
nameValidate={nameValidate}
|
||||
onChange={handleChange}
|
||||
onDelete={handleDelete}
|
||||
onFocusName={handleFocusName}
|
||||
onFocusValue={handleFocusValue}
|
||||
pair={p}
|
||||
stateKey={stateKey}
|
||||
valueAutocomplete={valueAutocomplete}
|
||||
valueAutocompleteFunctions={valueAutocompleteFunctions}
|
||||
valueAutocompleteVariables={valueAutocompleteVariables}
|
||||
valuePlaceholder={valuePlaceholder}
|
||||
valueType={valueType}
|
||||
valueValidate={valueValidate}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{!showAll && pairs.length > MAX_INITIAL_PAIRS && (
|
||||
<Button onClick={toggleShowAll} variant="border" className="m-2" size="xs">
|
||||
Show {pairs.length - MAX_INITIAL_PAIRS} More
|
||||
</Button>
|
||||
)}
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{isDragging && (
|
||||
<PairEditorRow
|
||||
namePlaceholder={namePlaceholder}
|
||||
valuePlaceholder={valuePlaceholder}
|
||||
className="opacity-80"
|
||||
pair={isDragging}
|
||||
index={0}
|
||||
stateKey={null}
|
||||
/>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
|
||||
<div
|
||||
// There's a weird bug where clicking below one of the above Codemirror inputs will cause
|
||||
// it to focus. Putting this element here prevents that
|
||||
aria-hidden
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<InputHandle>(null);
|
||||
const valueInputRef = useRef<InputHandle>(null);
|
||||
const handle = useRef<RowHandle>({
|
||||
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<string | null>(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 }) => (
|
||||
<MultilineEditDialog
|
||||
hide={hide}
|
||||
onChange={handleChangeValueText}
|
||||
defaultValue={pair.value}
|
||||
contentType={pair.contentType ?? null}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
[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<EditorProps["autocompleteVariables"]>(() => {
|
||||
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 (
|
||||
<div
|
||||
ref={handleSetRef}
|
||||
className={classNames(
|
||||
className,
|
||||
"group/pair-row grid grid-cols-[auto_auto_minmax(0,1fr)_auto]",
|
||||
"grid-rows-1 items-center",
|
||||
!pair.enabled && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
hideLabel
|
||||
title={pair.enabled ? "Disable item" : "Enable item"}
|
||||
disabled={isLast || disabled}
|
||||
checked={isLast ? false : !!pair.enabled}
|
||||
className={classNames(isLast && "opacity-disabled!")}
|
||||
onChange={handleChangeEnabled}
|
||||
/>
|
||||
{!isLast && !disableDrag ? (
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={classNames(
|
||||
"py-2 h-7 w-4 flex items-center",
|
||||
"justify-center opacity-0 group-hover/pair-row:opacity-70",
|
||||
)}
|
||||
>
|
||||
<Icon size="sm" icon="grip_vertical" className="pointer-events-none" />
|
||||
</div>
|
||||
) : (
|
||||
<span className="w-4" />
|
||||
)}
|
||||
<div
|
||||
className={classNames(
|
||||
"grid items-center",
|
||||
"@xs:gap-2 @xs:grid-rows-1! @xs:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]!",
|
||||
"gap-0.5 grid-cols-1 grid-rows-2",
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
setRef={initNameInputRef}
|
||||
hideLabel
|
||||
stateKey={`name.${pair.id}.${stateKey}`}
|
||||
disabled={disabled}
|
||||
wrapLines={false}
|
||||
readOnly={isDraggingGlobal}
|
||||
size="sm"
|
||||
required={!isLast && !!pair.enabled && !!pair.value}
|
||||
validate={nameValidate}
|
||||
forcedEnvironmentId={forcedEnvironmentId}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
containerClassName={classNames("bg-surface", isLast && "border-dashed")}
|
||||
defaultValue={pair.name}
|
||||
label="Name"
|
||||
name={`name[${index}]`}
|
||||
onBlur={handleBlurName}
|
||||
onChange={handleChangeName}
|
||||
onFocus={handleFocusName}
|
||||
placeholder={namePlaceholder ?? "name"}
|
||||
autocomplete={nameAutocomplete}
|
||||
autocompleteVariables={nameAutocompleteVariables}
|
||||
autocompleteFunctions={nameAutocompleteFunctions}
|
||||
showOptionsPicker={showOptionsPicker}
|
||||
/>
|
||||
<div className="w-full grid grid-cols-[minmax(0,1fr)_auto] gap-1 items-center">
|
||||
{pair.isFile ? (
|
||||
<SelectFile
|
||||
disabled={disabled}
|
||||
inline
|
||||
size="xs"
|
||||
filePath={pair.value}
|
||||
nameOverride={pair.filename || null}
|
||||
onChange={handleChangeValueFile}
|
||||
/>
|
||||
) : pair.value.includes("\n") ? (
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={handleEditMultiLineValue}
|
||||
title={pair.value}
|
||||
className="text-xs font-mono"
|
||||
>
|
||||
{pair.value.split("\n").join(" ")}
|
||||
</Button>
|
||||
) : (
|
||||
<Input
|
||||
setRef={initValueInputRef}
|
||||
hideLabel
|
||||
stateKey={`value.${pair.id}.${stateKey}`}
|
||||
wrapLines={false}
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
readOnly={isDraggingGlobal}
|
||||
containerClassName={classNames("bg-surface", isLast && "border-dashed")}
|
||||
validate={valueValidate}
|
||||
forcedEnvironmentId={forcedEnvironmentId}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
defaultValue={pair.value}
|
||||
label="Value"
|
||||
name={`value[${index}]`}
|
||||
onChange={handleChangeValueText}
|
||||
onFocus={handleFocusValue}
|
||||
type={isLast ? "text" : typeof valueType === "function" ? valueType(pair) : valueType}
|
||||
placeholder={valuePlaceholder ?? "value"}
|
||||
autocomplete={valueAutocomplete?.(pair.name)}
|
||||
autocompleteFunctions={valueAutocompleteFunctions}
|
||||
autocompleteVariables={valueAutocompleteVariablesFiltered}
|
||||
showOptionsPicker={showOptionsPicker}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{allowFileValues ? (
|
||||
<FileActionsDropdown
|
||||
pair={pair}
|
||||
onChangeFile={handleChangeValueFile}
|
||||
onChangeText={handleChangeValueText}
|
||||
onChangeContentType={handleChangeValueContentType}
|
||||
onChangeFilename={handleChangeValueFilename}
|
||||
onDelete={handleDelete}
|
||||
editMultiLine={handleEditMultiLineValue}
|
||||
/>
|
||||
) : (
|
||||
<Dropdown items={defaultItems}>
|
||||
<IconButton
|
||||
iconSize="sm"
|
||||
size="xs"
|
||||
// Ellipsis rather than a chevron: the name and value fields now carry chevrons for
|
||||
// picking a suggestion, and this menu acts on the row instead of filling in a field.
|
||||
icon={isLast || disabled ? "empty" : "ellipsis_vertical"}
|
||||
title="More actions"
|
||||
className="text-text-subtlest"
|
||||
/>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fileItems: RadioDropdownItem<string>[] = [
|
||||
{ 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<DropdownItem[]>(
|
||||
() => [
|
||||
{
|
||||
label: "Edit Multi-Line",
|
||||
leftSlot: <Icon icon="file_code" />,
|
||||
hidden: pair.isFile,
|
||||
onSelect: editMultiLine,
|
||||
},
|
||||
{
|
||||
label: "Set Content-Type",
|
||||
leftSlot: <Icon icon="pencil" />,
|
||||
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: <Icon icon="file_code" />,
|
||||
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: <Icon icon="x" />,
|
||||
hidden: pair.isFile,
|
||||
onSelect: async () => {
|
||||
onChangeFile({ filePath: null });
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
onSelect: onDelete,
|
||||
variant: "danger",
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
color: "danger",
|
||||
},
|
||||
],
|
||||
[
|
||||
editMultiLine,
|
||||
onChangeContentType,
|
||||
onChangeFile,
|
||||
onDelete,
|
||||
pair.contentType,
|
||||
pair.isFile,
|
||||
onChangeFilename,
|
||||
pair.filename,
|
||||
pair,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<RadioDropdown
|
||||
value={pair.isFile ? "file" : "text"}
|
||||
onChange={onChange}
|
||||
items={fileItems}
|
||||
itemsAfter={itemsAfter}
|
||||
>
|
||||
<IconButton
|
||||
iconSize="sm"
|
||||
size="xs"
|
||||
// Matches the plain row menu, so the actions column reads the same for file and text rows
|
||||
icon="ellipsis_vertical"
|
||||
title="Select form data type"
|
||||
className="text-text-subtlest"
|
||||
/>
|
||||
</RadioDropdown>
|
||||
);
|
||||
}
|
||||
|
||||
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<string>(defaultValue);
|
||||
const language = languageFromContentType(contentType, value);
|
||||
return (
|
||||
<div className="w-screen max-w-160 h-[50vh] max-h-full grid grid-rows-[minmax(0,1fr)_auto]">
|
||||
<Editor
|
||||
heightMode="auto"
|
||||
defaultValue={defaultValue}
|
||||
language={language}
|
||||
onChange={setValue}
|
||||
stateKey={null}
|
||||
autocompleteFunctions
|
||||
autocompleteVariables
|
||||
/>
|
||||
<div>
|
||||
<Button
|
||||
color="primary"
|
||||
className="ml-auto my-2"
|
||||
onClick={() => {
|
||||
onChange(value);
|
||||
hide();
|
||||
}}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
PairWithId,
|
||||
} from "@yaakapp-internal/ui";
|
||||
export { PairEditor, PairEditorRow } from "@yaakapp-internal/ui";
|
||||
@@ -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<T extends Pair>(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";
|
||||
@@ -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<boolean>({
|
||||
namespace: "global",
|
||||
key: ["bulk_edit", preferenceName],
|
||||
fallback: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full group/wrapper">
|
||||
{useBulk ? <BulkPairEditor {...props} /> : <PairEditor {...props} />}
|
||||
<div className="absolute right-0 bottom-0">
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="border"
|
||||
title={useBulk ? "Enable form edit" : "Enable bulk edit"}
|
||||
className={classNames(
|
||||
"transition-opacity opacity-0 group-hover:opacity-80 hover:opacity-100! shadow",
|
||||
"bg-surface hover:text group-hover/wrapper:opacity-100",
|
||||
)}
|
||||
onClick={() => setUseBulk((b) => !b)}
|
||||
icon={useBulk ? "table" : "file_code"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export { PairOrBulkEditor } from "@yaakapp-internal/ui";
|
||||
@@ -1,14 +1 @@
|
||||
import classNames from "classnames";
|
||||
import type { ButtonProps } from "./Button";
|
||||
import { Button } from "./Button";
|
||||
|
||||
export function PillButton({ className, ...props }: ButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
size="2xs"
|
||||
variant="border"
|
||||
className={classNames(className, "rounded-full! mx-1 px-3!")}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export { PillButton } from "@yaakapp-internal/ui";
|
||||
@@ -1,69 +1,2 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo } from "react";
|
||||
import type { DropdownItem, DropdownItemSeparator, DropdownProps } from "./Dropdown";
|
||||
import { Dropdown } from "./Dropdown";
|
||||
import { Icon } from "@yaakapp-internal/ui";
|
||||
|
||||
export type RadioDropdownItem<T = string | null> =
|
||||
| {
|
||||
type?: "default";
|
||||
label: ReactNode;
|
||||
shortLabel?: ReactNode;
|
||||
value: T;
|
||||
rightSlot?: ReactNode;
|
||||
}
|
||||
| DropdownItemSeparator;
|
||||
|
||||
export interface RadioDropdownProps<T = string | null> {
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
itemsBefore?: DropdownItem[];
|
||||
items: RadioDropdownItem<T>[];
|
||||
itemsAfter?: DropdownItem[];
|
||||
children: DropdownProps["children"];
|
||||
}
|
||||
|
||||
export function RadioDropdown<T = string | null>({
|
||||
value,
|
||||
items,
|
||||
itemsAfter,
|
||||
itemsBefore,
|
||||
onChange,
|
||||
children,
|
||||
}: RadioDropdownProps<T>) {
|
||||
const dropdownItems = useMemo(
|
||||
() => [
|
||||
...((itemsBefore
|
||||
? [
|
||||
...itemsBefore,
|
||||
{
|
||||
type: "separator",
|
||||
hidden: itemsBefore[itemsBefore.length - 1]?.type === "separator",
|
||||
},
|
||||
]
|
||||
: []) as DropdownItem[]),
|
||||
...items.map((item) => {
|
||||
if (item.type === "separator") {
|
||||
return item;
|
||||
}
|
||||
return {
|
||||
key: item.value,
|
||||
label: item.label,
|
||||
rightSlot: item.rightSlot,
|
||||
onSelect: () => onChange(item.value),
|
||||
leftSlot: <Icon icon={value === item.value ? "check" : "empty"} />,
|
||||
} as DropdownItem;
|
||||
}),
|
||||
...((itemsAfter
|
||||
? [{ type: "separator", hidden: itemsAfter[0]?.type === "separator" }, ...itemsAfter]
|
||||
: []) as DropdownItem[]),
|
||||
],
|
||||
[itemsBefore, items, itemsAfter, value, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown fullWidth items={dropdownItems}>
|
||||
{children}
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
export type { RadioDropdownItem, RadioDropdownProps } from "@yaakapp-internal/ui";
|
||||
export { RadioDropdown } from "@yaakapp-internal/ui";
|
||||
@@ -1,99 +1,28 @@
|
||||
import {
|
||||
Icon,
|
||||
type StoredValue,
|
||||
TabContent as BaseTabContent,
|
||||
type TabContentProps,
|
||||
type TabItem as BaseTabItem,
|
||||
type TabItem,
|
||||
Tabs as BaseTabs,
|
||||
type TabsProps as BaseTabsProps,
|
||||
type TabsProps,
|
||||
type TabsRef,
|
||||
type TabsStorage,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { ReactNode } from "react";
|
||||
import { forwardRef, memo, useMemo } from "react";
|
||||
import { useKeyValue } from "../../../hooks/useKeyValue";
|
||||
import { forwardRef, memo } from "react";
|
||||
import { ErrorBoundary } from "../../ErrorBoundary";
|
||||
import { Button } from "../Button";
|
||||
import type { RadioDropdownProps } from "../RadioDropdown";
|
||||
import { RadioDropdown } from "../RadioDropdown";
|
||||
|
||||
export type { TabsRef };
|
||||
export type { TabItem, TabsRef };
|
||||
|
||||
export type TabItem =
|
||||
| Extract<BaseTabItem, { label: string }>
|
||||
| {
|
||||
value: string;
|
||||
options: Omit<RadioDropdownProps, "children">;
|
||||
leftSlot?: ReactNode;
|
||||
rightSlot?: ReactNode;
|
||||
};
|
||||
|
||||
type Props = Omit<BaseTabsProps, "tabs" | "useStoredValue"> & { tabs: TabItem[] };
|
||||
|
||||
export const Tabs = forwardRef<TabsRef, Props>(function Tabs({ tabs, ...props }, ref) {
|
||||
const baseTabs = useMemo(() => tabs.map(toBaseTab), [tabs]);
|
||||
return <BaseTabs ref={ref} tabs={baseTabs} useStoredValue={useTabsStoredValue} {...props} />;
|
||||
});
|
||||
// The client's EditorHostProvider keeps tab order and the active tab in its key-value store
|
||||
export const Tabs = forwardRef<TabsRef, Omit<TabsProps, "useStoredValue">>(
|
||||
function Tabs(props, ref) {
|
||||
return <BaseTabs ref={ref} {...props} />;
|
||||
},
|
||||
);
|
||||
|
||||
export const TabContent = memo(function TabContent(props: Omit<TabContentProps, "ErrorBoundary">) {
|
||||
return <BaseTabContent {...props} ErrorBoundary={ErrorBoundary} />;
|
||||
});
|
||||
|
||||
function toBaseTab(tab: TabItem): BaseTabItem {
|
||||
if (!("options" in tab)) return tab;
|
||||
const { options } = tab;
|
||||
return {
|
||||
value: tab.value,
|
||||
leftSlot: tab.leftSlot,
|
||||
rightSlot: tab.rightSlot,
|
||||
renderButton: ({ buttonProps, isActive }) => {
|
||||
const option = options.items.find((i) => "value" in i && i.value === options.value);
|
||||
return (
|
||||
<RadioDropdown
|
||||
key={tab.value}
|
||||
items={options.items}
|
||||
itemsAfter={options.itemsAfter}
|
||||
itemsBefore={options.itemsBefore}
|
||||
value={options.value}
|
||||
onChange={options.onChange}
|
||||
>
|
||||
<Button
|
||||
leftSlot={tab.leftSlot}
|
||||
rightSlot={
|
||||
<div className="flex items-center">
|
||||
{tab.rightSlot}
|
||||
<Icon
|
||||
size="sm"
|
||||
icon="chevron_down"
|
||||
className={classNames(
|
||||
"ml-1",
|
||||
isActive ? "text-text-subtle" : "text-text-subtlest",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
{...buttonProps}
|
||||
>
|
||||
{option && "shortLabel" in option && option.shortLabel
|
||||
? option.shortLabel
|
||||
: (option?.label ?? "Unknown")}
|
||||
</Button>
|
||||
</RadioDropdown>
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function useTabsStoredValue<T extends StoredValue>(key: string | string[] | null, fallback: T) {
|
||||
const { value, set } = useKeyValue<T>({
|
||||
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).
|
||||
|
||||
@@ -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 <WebPageViewer html={rawTextBody.data ?? ""} baseUrl={response.url} />;
|
||||
}
|
||||
if (rawTextBody.data == null) {
|
||||
return <EmptyStateText>Empty response</EmptyStateText>;
|
||||
}
|
||||
return (
|
||||
<HttpTextViewer
|
||||
response={response}
|
||||
text={rawTextBody.data}
|
||||
language={language}
|
||||
pretty={pretty}
|
||||
className={textViewerClassName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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) => (
|
||||
<div className="py-0.5 flex">
|
||||
<RecentFiltersDropdown
|
||||
recentFilters={recentFilters}
|
||||
activeFilter={filter.appliedFilter}
|
||||
onSelect={filter.replaceFilter}
|
||||
onRemove={removeFilter}
|
||||
onTogglePin={togglePin}
|
||||
onClear={clearFilters}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
[recentFilters, removeFilter, togglePin, clearFilters],
|
||||
);
|
||||
const saveResponse = useSaveResponse(response);
|
||||
const copyResponse = useCopyHttpResponse(response);
|
||||
const actionsDisabled = response.state !== "closed" && response.status >= 100;
|
||||
|
||||
return (
|
||||
<TextViewer
|
||||
text={text}
|
||||
language={language}
|
||||
stateKey={`response.body.${response.id}`}
|
||||
pretty={pretty}
|
||||
className={className}
|
||||
footerActions={[
|
||||
<IconButton
|
||||
key="save"
|
||||
size="sm"
|
||||
icon="save"
|
||||
title="Save response to file"
|
||||
disabled={actionsDisabled}
|
||||
onClick={() => saveResponse.mutate()}
|
||||
className="border !border-border-subtle"
|
||||
/>,
|
||||
<IconButton
|
||||
key="copy"
|
||||
size="sm"
|
||||
icon="copy"
|
||||
title="Copy response body"
|
||||
disabled={actionsDisabled}
|
||||
onClick={() => copyResponse.mutate()}
|
||||
className="border !border-border-subtle"
|
||||
/>,
|
||||
]}
|
||||
filterText={filterText}
|
||||
filterStateKey={filterStateKey}
|
||||
onFilterApplied={addFilter}
|
||||
filterLeftSlot={filterLeftSlot}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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<T> {
|
||||
data: T | undefined;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
function result<T>({ data, isLoading, error }: QueryLike<T>): HttpBodyResult<T> {
|
||||
return { data, isLoading, error };
|
||||
}
|
||||
|
||||
const noUrl: HttpBodyResult<string | null> = { 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]);
|
||||
}
|
||||
@@ -1,7 +1,2 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
export function useToggle(initialValue = false) {
|
||||
const [value, setValue] = useState<boolean>(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";
|
||||
@@ -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";
|
||||
@@ -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: [] });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
@@ -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() {
|
||||
<JotaiProvider store={jotaiStore}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Hotkeys>
|
||||
<LazyMotion strict features={motionFeatures}>
|
||||
<MotionConfig transition={{ duration: 0.1 }}>
|
||||
<Suspense>
|
||||
<Toasts />
|
||||
<Dialogs />
|
||||
<ContextMenus />
|
||||
</Suspense>
|
||||
<Layout />
|
||||
<GlobalHooks />
|
||||
</MotionConfig>
|
||||
</LazyMotion>
|
||||
<ClientEditorHost>
|
||||
<LazyMotion strict features={motionFeatures}>
|
||||
<MotionConfig transition={{ duration: 0.1 }}>
|
||||
<Suspense>
|
||||
<Toasts />
|
||||
<Dialogs />
|
||||
<ContextMenus />
|
||||
</Suspense>
|
||||
<Layout />
|
||||
<GlobalHooks />
|
||||
</MotionConfig>
|
||||
</LazyMotion>
|
||||
</ClientEditorHost>
|
||||
</Hotkeys>
|
||||
</QueryClientProvider>
|
||||
</JotaiProvider>
|
||||
|
||||
@@ -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<Partial<EditorHost>>(
|
||||
() => ({
|
||||
TextViewer: (props: TextViewerProps) => <TextViewer formatText={formatText} {...props} />,
|
||||
MultipartViewer: (props: MultipartViewerProps) => (
|
||||
<MultipartViewer textViewerProps={{ formatText }} {...props} />
|
||||
),
|
||||
}),
|
||||
[formatText],
|
||||
);
|
||||
return <EditorHostProvider value={host}>{children}</EditorHostProvider>;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Yaak Proxy, shared panes harness</title>
|
||||
<style>
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="text-base">
|
||||
<!-- Dev only, served by `vp dev`; the build's only entry is index.html -->
|
||||
<div id="root"></div>
|
||||
<script>
|
||||
// Outside the app there's no Tauri; the platform package still looks for its window
|
||||
window.__TAURI_INTERNALS__ = {
|
||||
metadata: {
|
||||
currentWindow: { label: "harness" },
|
||||
currentWebview: { label: "harness", windowLabel: "harness" },
|
||||
},
|
||||
invoke: () => Promise.reject(new Error("Not running in the app")),
|
||||
transformCallback: () => 0,
|
||||
};
|
||||
</script>
|
||||
<script type="module" src="/font-size.ts"></script>
|
||||
<script type="module" src="/harness/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 (
|
||||
<ProxyEditorHost formatText={formatLocally}>
|
||||
<div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] gap-2 p-2 bg-surface">
|
||||
<div className="flex gap-2 text-sm">
|
||||
{fixtures.map((f, i) => (
|
||||
<button
|
||||
key={f.flow.id}
|
||||
type="button"
|
||||
className={i === index ? "text-text" : "text-text-subtle"}
|
||||
onClick={() => setIndex(i)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<FlowPanes key={fixture.flow.id} fixture={fixture} />
|
||||
</div>
|
||||
</ProxyEditorHost>
|
||||
);
|
||||
}
|
||||
|
||||
function FlowPanes({ fixture }: { fixture: FlowFixture }) {
|
||||
const { flow } = fixture;
|
||||
const [viewMode, setViewMode] = useState<ResponseViewMode>("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 (
|
||||
<div className="grid grid-cols-2 gap-2 min-h-0">
|
||||
<div className="min-h-0 px-2">
|
||||
<HttpRequestPaneView
|
||||
request={request}
|
||||
fullHeight
|
||||
forceUpdateKey={flow.updatedAt}
|
||||
tabsStorageKey="flow_request_tabs"
|
||||
bodyLoader={requestLoader}
|
||||
/>
|
||||
</div>
|
||||
<HttpResponsePaneView
|
||||
response={response}
|
||||
body={responseLoader}
|
||||
requestBody={requestLoader}
|
||||
emptyState={<div className="p-3 text-text-subtle">Waiting for a response</div>}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
tabsStorageKey="flow_response_tabs"
|
||||
activeTabKey={flow.id}
|
||||
onCopyAsCurl={() => undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function loaded(data: FlowBodyData): HttpBodyResult<FlowBodyData | null> {
|
||||
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<string> {
|
||||
if (language !== "json") return text;
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(text), null, 2);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
@@ -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 =
|
||||
"<!doctype html><html><body style='font-family:sans-serif'><h1>Signed in</h1><p>You can close this window.</p></body></html>";
|
||||
// A 24x24 PNG gradient
|
||||
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(""),
|
||||
},
|
||||
];
|
||||
@@ -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(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<Provider store={createStore()}>
|
||||
<FlowPanesHarness />
|
||||
</Provider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<ArrayBuffer>;
|
||||
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<FlowBodyData | null>;
|
||||
contentType: string | null;
|
||||
filterText?: (expression: string) => Promise<string | null>;
|
||||
}): 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<T>(
|
||||
body: HttpBodyResult<FlowBodyData | null>,
|
||||
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<ArrayBuffer> | null, contentType: string | null) {
|
||||
const [url, setUrl] = useState<string | null>(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<string, string> = {
|
||||
basic: "Basic",
|
||||
bearer: "Bearer",
|
||||
digest: "Digest",
|
||||
ntlm: "NTLM",
|
||||
negotiate: "Negotiate",
|
||||
"aws4-hmac-sha256": "AWS",
|
||||
};
|
||||
@@ -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"
|
||||
|
||||
@@ -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<CodeEditorProps, "language" | "extensions" | "keymap"> &
|
||||
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<ButtonProps, "type" | "onChange"> & {
|
||||
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<DialogProps, "open" | "children" | "onClose">;
|
||||
|
||||
export type StoredValueNamespace = "global" | "no_sync";
|
||||
|
||||
/** Like `UseStoredValue`, but told whether the value is a synced preference or local state */
|
||||
export type UseHostStoredValue = <T extends StoredValue>(
|
||||
key: string | string[] | null,
|
||||
fallback: T,
|
||||
namespace?: StoredValueNamespace,
|
||||
) => readonly [T, SetStoredValue<T>];
|
||||
|
||||
/**
|
||||
* 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<HostEditorProps>;
|
||||
Input: ComponentType<HostInputProps>;
|
||||
SelectFile: ComponentType<SelectFileProps> | null;
|
||||
showDialog: ((args: HostDialogArgs) => void) | null;
|
||||
showPrompt: ((args: HostPromptArgs) => Promise<string | null>) | null;
|
||||
basename: (path: string) => Promise<string | null>;
|
||||
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<TextViewerProps>;
|
||||
/** The multipart viewer, with whatever the app adds to it (a PDF viewer) */
|
||||
MultipartViewer: ComponentType<MultipartViewerProps>;
|
||||
/** 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<EditorHost>(defaultEditorHost);
|
||||
|
||||
export function EditorHostProvider({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: Partial<EditorHost>;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const parent = useContext(EditorHostContext);
|
||||
const merged = useMemo(() => ({ ...parent, ...value }), [parent, value]);
|
||||
return <EditorHostContext.Provider value={merged}>{children}</EditorHostContext.Provider>;
|
||||
}
|
||||
|
||||
export function useEditorHost(): EditorHost {
|
||||
return useContext(EditorHostContext);
|
||||
}
|
||||
|
||||
/** Drops what only a host's editor understands, leaving what `CodeEditor` does */
|
||||
function stripTemplating<T extends TemplatingProps & { language?: HostEditorLanguage }>({
|
||||
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 <LazyCodeEditor {...stripTemplating(props)} />;
|
||||
}
|
||||
|
||||
function PlainInputEditor({ showOptionsPicker: _showOptionsPicker, ...props }: HostInputProps) {
|
||||
return <Input {...stripTemplating(props)} />;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className={classNames("w-full h-full pb-2", wrapperClassName)}>
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
"rounded-lg border border-dashed border-border-subtle",
|
||||
"h-full py-2 text-text-subtlest flex items-center justify-center italic",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+2
-2
@@ -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;
|
||||
+1
-1
@@ -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", () => ({
|
||||
@@ -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 (
|
||||
<div className={classNames(className, "h-full flex items-center justify-center")}>
|
||||
<div className="grid gap-2 grid-cols-[auto_auto]">
|
||||
{hotkeys.map((hotkey) => (
|
||||
<Fragment key={hotkey}>
|
||||
<HotkeyLabel className="truncate" action={hotkey} />
|
||||
{/* Keep this grid cell when Hotkey renders nothing so later rows stay aligned. */}
|
||||
<div className="ml-4">
|
||||
<Hotkey action={hotkey} />
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
{bottomSlot}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import classNames from "classnames";
|
||||
import type { ButtonProps } from "./Button";
|
||||
import { Button } from "./Button";
|
||||
|
||||
export function PillButton({ className, ...props }: ButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
size="2xs"
|
||||
variant="border"
|
||||
className={classNames(className, "rounded-full! mx-1 px-3!")}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo } from "react";
|
||||
import type { DropdownItem, DropdownItemSeparator, DropdownProps } from "./Dropdown";
|
||||
import { Dropdown } from "./Dropdown";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
export type RadioDropdownItem<T = string | null> =
|
||||
| {
|
||||
type?: "default";
|
||||
label: ReactNode;
|
||||
shortLabel?: ReactNode;
|
||||
value: T;
|
||||
rightSlot?: ReactNode;
|
||||
}
|
||||
| DropdownItemSeparator;
|
||||
|
||||
export interface RadioDropdownProps<T = string | null> {
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
itemsBefore?: DropdownItem[];
|
||||
items: RadioDropdownItem<T>[];
|
||||
itemsAfter?: DropdownItem[];
|
||||
children: DropdownProps["children"];
|
||||
}
|
||||
|
||||
export function RadioDropdown<T = string | null>({
|
||||
value,
|
||||
items,
|
||||
itemsAfter,
|
||||
itemsBefore,
|
||||
onChange,
|
||||
children,
|
||||
}: RadioDropdownProps<T>) {
|
||||
const dropdownItems = useMemo(
|
||||
() => [
|
||||
...((itemsBefore
|
||||
? [
|
||||
...itemsBefore,
|
||||
{
|
||||
type: "separator",
|
||||
hidden: itemsBefore[itemsBefore.length - 1]?.type === "separator",
|
||||
},
|
||||
]
|
||||
: []) as DropdownItem[]),
|
||||
...items.map((item) => {
|
||||
if (item.type === "separator") {
|
||||
return item;
|
||||
}
|
||||
return {
|
||||
key: item.value,
|
||||
label: item.label,
|
||||
rightSlot: item.rightSlot,
|
||||
onSelect: () => onChange(item.value),
|
||||
leftSlot: <Icon icon={value === item.value ? "check" : "empty"} />,
|
||||
} as DropdownItem;
|
||||
}),
|
||||
...((itemsAfter
|
||||
? [{ type: "separator", hidden: itemsAfter[0]?.type === "separator" }, ...itemsAfter]
|
||||
: []) as DropdownItem[]),
|
||||
],
|
||||
[itemsBefore, items, itemsAfter, value, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown fullWidth items={dropdownItems}>
|
||||
{children}
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import { useToggle } from "../../hooks/useToggle";
|
||||
import { isProbablyTextContentType } from "../../lib/mimeType";
|
||||
import { Banner } from "../Banner";
|
||||
import { Button } from "../Button";
|
||||
import { InlineCode } from "../InlineCode";
|
||||
import { HStack } from "../Stacks";
|
||||
import { SizeTag } from "../tags/SizeTag";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
/** Bytes of the body being guarded */
|
||||
contentLength: number | null;
|
||||
contentType: string | null;
|
||||
/** What's being shown, in the notice: "responses" or "content" */
|
||||
noun?: string;
|
||||
/** The reveal button's label */
|
||||
revealLabel?: string;
|
||||
/** Save the body to a file, when the app can */
|
||||
onSave?: () => void;
|
||||
/** Read the body as text for copying, when the app can */
|
||||
getText?: () => Promise<string | null>;
|
||||
/** The copy button, rendered by the app so it reports the copy its own way */
|
||||
renderCopyButton?: (getText: () => Promise<string | null>) => ReactNode;
|
||||
}
|
||||
|
||||
const LARGE_BYTES = 2 * 1000 * 1000;
|
||||
|
||||
/** Holds back a body big enough to slow the window down, until asked to show it */
|
||||
export function ConfirmLargeResponse({
|
||||
children,
|
||||
contentLength,
|
||||
contentType,
|
||||
noun = "responses",
|
||||
revealLabel = "Reveal Response",
|
||||
onSave,
|
||||
getText,
|
||||
renderCopyButton,
|
||||
}: Props) {
|
||||
const [showLargeResponse, toggleShowLargeResponse] = useToggle();
|
||||
const isProbablyText = useMemo(() => isProbablyTextContentType(contentType), [contentType]);
|
||||
|
||||
const isLarge = (contentLength ?? 0) > LARGE_BYTES;
|
||||
if (!showLargeResponse && isLarge) {
|
||||
return (
|
||||
<Banner color="primary" className="flex flex-col gap-3">
|
||||
<p>
|
||||
Showing {noun} over{" "}
|
||||
<InlineCode>
|
||||
<SizeTag contentLength={LARGE_BYTES} />
|
||||
</InlineCode>{" "}
|
||||
may impact performance
|
||||
</p>
|
||||
<HStack wrap space={2}>
|
||||
<Button color="primary" size="xs" onClick={toggleShowLargeResponse}>
|
||||
{revealLabel}
|
||||
</Button>
|
||||
{onSave != null && (
|
||||
<Button color="secondary" variant="border" size="xs" onClick={onSave}>
|
||||
Save to File
|
||||
</Button>
|
||||
)}
|
||||
{isProbablyText && getText != null && renderCopyButton?.(getText)}
|
||||
</HStack>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
+22
-11
@@ -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<Pair[]>(
|
||||
() =>
|
||||
(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<PairEditorProps["onChange"]>(
|
||||
(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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<Pair[]>(
|
||||
() =>
|
||||
(Array.isArray(form) ? form : []).map((p) => ({
|
||||
enabled: !!p.enabled,
|
||||
name: p.name || "",
|
||||
value: p.value || "",
|
||||
id: p.id,
|
||||
})),
|
||||
[form],
|
||||
);
|
||||
|
||||
const handleChange = useCallback<PairEditorProps["onChange"]>(
|
||||
(pairs) =>
|
||||
onChange?.({
|
||||
form: pairs.map((p) => ({ enabled: p.enabled, name: p.name, value: p.value })),
|
||||
}),
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<PairOrBulkEditor
|
||||
allowMultilineValues
|
||||
preferenceName="form_urlencoded"
|
||||
valueAutocompleteFunctions
|
||||
valueAutocompleteVariables
|
||||
nameAutocompleteFunctions
|
||||
nameAutocompleteVariables
|
||||
namePlaceholder="entry_name"
|
||||
valuePlaceholder="Value"
|
||||
pairs={pairs}
|
||||
onChange={handleChange}
|
||||
readOnly={readOnly}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
stateKey={`urlencoded.${requestId}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
className={
|
||||
hasInheritedHeaders
|
||||
? "@container w-full h-full grid grid-rows-[auto_minmax(0,1fr)] gap-y-1.5"
|
||||
: "@container w-full h-full"
|
||||
}
|
||||
>
|
||||
{hasInheritedHeaders && (
|
||||
<DetailsBanner
|
||||
useStoredValue={useDetailsBannerStoredValue}
|
||||
color="secondary"
|
||||
className="text-sm"
|
||||
summary={
|
||||
<HStack>
|
||||
{inheritedHeadersLabel} <CountBadge count={validInheritedHeaders.length} />
|
||||
</HStack>
|
||||
}
|
||||
>
|
||||
<div className="pb-2">
|
||||
{validInheritedHeaders?.map((pair, i) => (
|
||||
<PairEditorRow
|
||||
key={`${pair.id}.${i}`}
|
||||
index={i}
|
||||
disabled
|
||||
disableDrag
|
||||
className="py-1"
|
||||
pair={ensurePairId(pair)}
|
||||
stateKey={null}
|
||||
nameAutocompleteFunctions
|
||||
nameAutocompleteVariables
|
||||
valueAutocompleteFunctions
|
||||
valueAutocompleteVariables
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</DetailsBanner>
|
||||
)}
|
||||
<PairOrBulkEditor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
nameAutocomplete={nameAutocomplete}
|
||||
nameAutocompleteFunctions
|
||||
nameAutocompleteVariables
|
||||
namePlaceholder="Header-Name"
|
||||
nameValidate={validateHttpHeader}
|
||||
onChange={onChange}
|
||||
pairs={headers}
|
||||
readOnly={readOnly}
|
||||
preferenceName="headers"
|
||||
stateKey={stateKey}
|
||||
valueType={valueType}
|
||||
valueAutocomplete={valueAutocomplete}
|
||||
valueAutocompleteFunctions
|
||||
valueAutocompleteVariables
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MIN_MATCH = 3;
|
||||
|
||||
const headerOptionsMap: Record<string, HeaderValuePreset[]> = {
|
||||
"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
|
||||
},
|
||||
),
|
||||
};
|
||||
@@ -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<TabsRef>;
|
||||
/** 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<void>;
|
||||
/** 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<TabItem[]>(
|
||||
() => [
|
||||
bodyTab ?? {
|
||||
value: REQUEST_TAB_BODY,
|
||||
label: bodyTypeLabel(request.bodyType, request.contentType),
|
||||
rightSlot: numParams > 0 ? <CountBadge count={numParams} /> : null,
|
||||
},
|
||||
{
|
||||
value: REQUEST_TAB_PARAMS,
|
||||
rightSlot: <CountBadge count={paramPairs.length} />,
|
||||
label: "Params",
|
||||
},
|
||||
...(headersTabs ?? [
|
||||
{
|
||||
value: REQUEST_TAB_HEADERS,
|
||||
label: "Headers",
|
||||
rightSlot: (
|
||||
<CountBadge
|
||||
count={request.headers.filter((h) => 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 = (
|
||||
<RequestBodyEditor
|
||||
request={request}
|
||||
readOnly={readOnly}
|
||||
fullHeight={fullHeight}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
bodyLoader={bodyLoader}
|
||||
onBodyChange={onBodyChange}
|
||||
onBodyTextChange={onBodyTextChange}
|
||||
onSendJsonCommentsChange={onSendJsonCommentsChange}
|
||||
jsonNoticeStorageKey={jsonNoticeStorageKey ?? request.id}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
className={classNames(className, "h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1")}
|
||||
>
|
||||
<UrlBar
|
||||
stateKey={`url.${request.id}`}
|
||||
key={urlBarKey}
|
||||
url={request.url}
|
||||
placeholder={urlPlaceholder}
|
||||
onPasteOverwrite={onUrlPasteOverwrite}
|
||||
autocomplete={urlAutocomplete}
|
||||
onSend={onSend}
|
||||
onCancel={onCancel}
|
||||
onUrlChange={onUrlChange}
|
||||
readOnly={readOnly}
|
||||
sendHotkeyAction={readOnly ? undefined : sendHotkeyAction}
|
||||
focusHotkeyAction={readOnly ? undefined : focusHotkeyAction}
|
||||
leftSlot={
|
||||
<div className="py-0.5">
|
||||
{methodSlot ?? (
|
||||
<RequestMethodDropdown
|
||||
method={request.method}
|
||||
colored={methodColored}
|
||||
onChange={onMethodChange}
|
||||
readOnly={readOnly}
|
||||
className="ml-0.5 h-full!"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
forceUpdateKey={urlForceUpdateKey ?? forceUpdateKey}
|
||||
isLoading={isSending}
|
||||
/>
|
||||
<Tabs
|
||||
ref={tabsRef}
|
||||
label="Request"
|
||||
tabs={tabs}
|
||||
tabListClassName="mt-1 -mb-1.5"
|
||||
storageKey={tabsStorageKey}
|
||||
activeTabKey={request.id}
|
||||
>
|
||||
<TabContent value={REQUEST_TAB_AUTH} ErrorBoundary={ErrorBoundary}>
|
||||
{authContent ?? (request.auth != null && <AuthDisplay auth={request.auth} />)}
|
||||
</TabContent>
|
||||
<TabContent value={REQUEST_TAB_HEADERS} ErrorBoundary={ErrorBoundary}>
|
||||
<HeadersEditor
|
||||
inheritedHeaders={inheritedHeaders}
|
||||
forceUpdateKey={headersForceUpdateKey ?? forceUpdateKey}
|
||||
headers={request.headers}
|
||||
stateKey={`headers.${request.id}`}
|
||||
onChange={onHeadersChange}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent value={REQUEST_TAB_PARAMS} ErrorBoundary={ErrorBoundary}>
|
||||
{renderUrlParameters?.(paramPairs) ?? (
|
||||
<UrlParametersEditor
|
||||
stateKey={`params.${request.id}`}
|
||||
forceUpdateKey={urlParametersForceUpdateKey ?? forceUpdateKey}
|
||||
pairs={paramPairs}
|
||||
onChange={onUrlParametersChange}
|
||||
setRef={onUrlParametersRef}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
)}
|
||||
</TabContent>
|
||||
<TabContent value={REQUEST_TAB_BODY} ErrorBoundary={ErrorBoundary}>
|
||||
{renderBody ? renderBody(sharedBody) : sharedBody}
|
||||
</TabContent>
|
||||
{extraTabs?.map(({ tab, content }) => (
|
||||
<TabContent key={tab.value} value={tab.value} ErrorBoundary={ErrorBoundary}>
|
||||
{content}
|
||||
</TabContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<RequestBodyViewer id={request.id} contentType={request.contentType} body={bodyLoader} />
|
||||
);
|
||||
}
|
||||
|
||||
if (bodyType === BODY_TYPE_JSON) {
|
||||
return (
|
||||
<JsonBodyEditor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
heightMode={fullHeight ? "full" : "auto"}
|
||||
requestId={request.id}
|
||||
text={request.body?.text ?? ""}
|
||||
sendJsonComments={request.body?.sendJsonComments === true}
|
||||
onChange={handleTextChange}
|
||||
onChangeSendJsonComments={onSendJsonCommentsChange}
|
||||
noticeStorageKey={jsonNoticeStorageKey}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (bodyType === BODY_TYPE_XML) {
|
||||
return (
|
||||
<Editor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
autocompleteFunctions
|
||||
autocompleteVariables
|
||||
placeholder="..."
|
||||
heightMode={fullHeight ? "full" : "auto"}
|
||||
defaultValue={`${request.body?.text ?? ""}`}
|
||||
language="xml"
|
||||
onChange={handleTextChange}
|
||||
readOnly={readOnly}
|
||||
stateKey={`xml.${request.id}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (bodyType === BODY_TYPE_FORM_URLENCODED) {
|
||||
return (
|
||||
<FormUrlencodedEditor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
requestId={request.id}
|
||||
form={request.body.form}
|
||||
onChange={onBodyChange}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (bodyType === BODY_TYPE_FORM_MULTIPART) {
|
||||
return (
|
||||
<FormMultipartEditor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
requestId={request.id}
|
||||
form={request.body.form}
|
||||
onChange={onBodyChange}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (typeof bodyType === "string" && bodyType !== BODY_TYPE_BINARY) {
|
||||
return (
|
||||
<Editor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
autocompleteFunctions
|
||||
autocompleteVariables
|
||||
language={languageFromContentType(request.contentType)}
|
||||
placeholder="..."
|
||||
heightMode={fullHeight ? "full" : "auto"}
|
||||
defaultValue={`${request.body?.text ?? ""}`}
|
||||
onChange={handleTextChange}
|
||||
readOnly={readOnly}
|
||||
stateKey={`other.${request.id}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <EmptyStateText>No Body</EmptyStateText>;
|
||||
}
|
||||
|
||||
function AuthDisplay({ auth }: { auth: NonNullable<HttpRequestView["auth"]> }) {
|
||||
return (
|
||||
<KeyValueRows>
|
||||
{auth.values.map((v) => (
|
||||
<KeyValueRow key={v.name} labelColor="primary" label={v.name}>
|
||||
{v.value}
|
||||
</KeyValueRow>
|
||||
))}
|
||||
</KeyValueRows>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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";
|
||||
}
|
||||
}
|
||||
@@ -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<string | null>) => 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<TabItem[]>(() => {
|
||||
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: <Icon icon="save" />,
|
||||
hidden: bodyActionsHidden,
|
||||
disabled: bodyActionsDisabled,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(onCopyBody != null
|
||||
? [
|
||||
{
|
||||
label: "Copy Body",
|
||||
onSelect: onCopyBody,
|
||||
leftSlot: <Icon icon="copy" />,
|
||||
hidden: bodyActionsHidden,
|
||||
disabled: bodyActionsDisabled,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(onCopyAsCurl != null
|
||||
? [{ label: "Copy as cURL", onSelect: onCopyAsCurl, leftSlot: <Icon icon="copy" /> }]
|
||||
: []),
|
||||
];
|
||||
|
||||
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 ? <CountBadge count={true} /> : null,
|
||||
},
|
||||
{
|
||||
value: RESPONSE_TAB_HEADERS,
|
||||
label: "Headers",
|
||||
rightSlot: (
|
||||
<CountBadge
|
||||
count={response?.requestHeaders.length ?? 0}
|
||||
count2={response?.headers.length ?? 0}
|
||||
showZero
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: RESPONSE_TAB_COOKIES,
|
||||
label: "Cookies",
|
||||
rightSlot:
|
||||
cookieCounts.sent > 0 || cookieCounts.received > 0 ? (
|
||||
<CountBadge count={cookieCounts.sent} count2={cookieCounts.received} showZero />
|
||||
) : null,
|
||||
},
|
||||
...(extraTabs?.map((t) => t.tab) ?? []),
|
||||
];
|
||||
}, [
|
||||
cookieCounts,
|
||||
extraTabs,
|
||||
mimeType,
|
||||
onCopyAsCurl,
|
||||
onCopyBody,
|
||||
onSaveBody,
|
||||
onViewModeChange,
|
||||
response,
|
||||
viewMode,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
className={classNames(
|
||||
className,
|
||||
"x-theme-responsePane",
|
||||
"max-h-full h-full",
|
||||
"bg-surface rounded-md border border-border-subtle overflow-hidden",
|
||||
"relative",
|
||||
)}
|
||||
>
|
||||
{response == null ? (
|
||||
emptyState
|
||||
) : (
|
||||
<div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1">
|
||||
<HStack
|
||||
className={classNames(
|
||||
"text-text-subtle w-full shrink-0",
|
||||
// Remove a bit of space because the tabs have lots too
|
||||
"-mb-1.5",
|
||||
)}
|
||||
>
|
||||
<ResponseStatusLine
|
||||
response={response}
|
||||
center={statusLineCenter}
|
||||
right={statusLineRight}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<div className="overflow-hidden flex flex-col min-h-0">
|
||||
{response.error && (
|
||||
<Banner color="danger" className="mx-3 mt-1 shrink-0">
|
||||
{response.error}
|
||||
</Banner>
|
||||
)}
|
||||
{/* Show tabs if we have any data (headers, body, etc.) even if there's an error */}
|
||||
<Tabs
|
||||
tabs={tabs}
|
||||
label="Response"
|
||||
className="ml-3 mr-3 mb-3 min-h-0 flex-1"
|
||||
tabListClassName="mt-0.5 -mb-1.5"
|
||||
storageKey={tabsStorageKey}
|
||||
activeTabKey={activeTabKey}
|
||||
>
|
||||
<TabContent value={RESPONSE_TAB_BODY} ErrorBoundary={ErrorBoundary}>
|
||||
<ErrorBoundary name="Http Response Viewer">
|
||||
<Suspense>
|
||||
<ConfirmLargeResponse
|
||||
contentLength={response.contentLength}
|
||||
contentType={response.contentType}
|
||||
onSave={onSaveBody}
|
||||
getText={body?.getText}
|
||||
renderCopyButton={renderCopyButton}
|
||||
>
|
||||
{body != null && (
|
||||
<ResponseBodyViewer
|
||||
response={response}
|
||||
body={body}
|
||||
viewMode={viewMode}
|
||||
textViewer={textViewer}
|
||||
onCancel={onCancel}
|
||||
renderBodyViewer={renderBodyViewer}
|
||||
/>
|
||||
)}
|
||||
</ConfirmLargeResponse>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</TabContent>
|
||||
<TabContent value={RESPONSE_TAB_REQUEST} ErrorBoundary={ErrorBoundary}>
|
||||
<ConfirmLargeResponse
|
||||
contentLength={response.requestContentLength}
|
||||
contentType={response.contentType}
|
||||
noun="content"
|
||||
revealLabel="Reveal Request Body"
|
||||
getText={requestBody?.getText}
|
||||
renderCopyButton={renderCopyButton}
|
||||
>
|
||||
{requestBody != null && (
|
||||
<RequestBodyViewer
|
||||
id={response.id}
|
||||
contentType={findHeader(response.requestHeaders, "content-type")}
|
||||
body={requestBody}
|
||||
/>
|
||||
)}
|
||||
</ConfirmLargeResponse>
|
||||
</TabContent>
|
||||
<TabContent value={RESPONSE_TAB_HEADERS} ErrorBoundary={ErrorBoundary}>
|
||||
<ResponseHeaders response={response} infoRows={infoRows} />
|
||||
</TabContent>
|
||||
<TabContent value={RESPONSE_TAB_COOKIES} ErrorBoundary={ErrorBoundary}>
|
||||
<ResponseCookies response={response} />
|
||||
</TabContent>
|
||||
{extraTabs?.map(({ tab, content }) => (
|
||||
<TabContent key={tab.value} value={tab.value} ErrorBoundary={ErrorBoundary}>
|
||||
{content}
|
||||
</TabContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div
|
||||
className={classNames(
|
||||
"grid grid-cols-[auto_minmax(4rem,1fr)_auto]",
|
||||
// As tall as an `h-sm` button with `m-0.5` (the client's response history menu), so the
|
||||
// status sits the same distance from the card's edge with or without one on the right
|
||||
"min-h-[calc(var(--height-sm)+0.25rem)]",
|
||||
"cursor-default select-none",
|
||||
"whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars",
|
||||
)}
|
||||
>
|
||||
<HStack space={2} className="w-full shrink-0">
|
||||
{response.state !== "closed" && <LoadingIcon size="sm" />}
|
||||
<HttpStatusTagRaw
|
||||
showReason
|
||||
status={response.status}
|
||||
state={response.state}
|
||||
statusReason={response.statusReason}
|
||||
/>
|
||||
<span>•</span>
|
||||
<HttpResponseDurationTagRaw
|
||||
closed={response.state === "closed"}
|
||||
startedAt={response.startedAt}
|
||||
elapsedMs={response.elapsedMs}
|
||||
elapsedHeadersMs={response.elapsedHeadersMs}
|
||||
elapsedDnsMs={response.elapsedDnsMs}
|
||||
/>
|
||||
<span>•</span>
|
||||
<SizeTag
|
||||
contentLength={response.contentLength ?? 0}
|
||||
contentLengthCompressed={response.contentLengthCompressed}
|
||||
/>
|
||||
</HStack>
|
||||
{center ?? <span />}
|
||||
<div className="justify-self-end shrink-0">{right}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function findHeader(headers: { name: string; value: string }[], name: string): string | null {
|
||||
return headers.find((h) => h.name.toLowerCase() === name)?.value ?? null;
|
||||
}
|
||||
@@ -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<boolean>(
|
||||
["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<HostEditorProps["actions"]>(
|
||||
() =>
|
||||
readOnly
|
||||
? undefined
|
||||
: [
|
||||
showBanner && (
|
||||
<Banner
|
||||
color="notice"
|
||||
className="opacity-100! h-sm py-0! px-2! flex items-center text-xs"
|
||||
>
|
||||
<p className="inline-flex items-center gap-1 min-w-0">
|
||||
<span className="truncate">Auto-fix enabled</span>
|
||||
<Icon icon="arrow_right" size="sm" className="opacity-disabled" />
|
||||
</p>
|
||||
</Banner>
|
||||
),
|
||||
<div key="settings" className="opacity-100! shadow!">
|
||||
<Dropdown
|
||||
onOpen={handleDropdownOpen}
|
||||
items={
|
||||
[
|
||||
{
|
||||
label: "Automatically Fix JSON",
|
||||
keepOpenOnSelect: true,
|
||||
onSelect: handleToggleAutoFix,
|
||||
rightSlot: <IconTooltip content={stripMessage} />,
|
||||
leftSlot: (
|
||||
<Icon icon={autoFix ? "check_square_checked" : "check_square_unchecked"} />
|
||||
),
|
||||
},
|
||||
] satisfies DropdownItem[]
|
||||
}
|
||||
>
|
||||
<IconButton size="sm" variant="border" icon="settings" title="JSON Settings" />
|
||||
</Dropdown>
|
||||
</div>,
|
||||
],
|
||||
[handleDropdownOpen, handleToggleAutoFix, autoFix, readOnly, showBanner],
|
||||
);
|
||||
|
||||
return (
|
||||
<Editor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
autocompleteFunctions
|
||||
autocompleteVariables
|
||||
placeholder="..."
|
||||
heightMode={heightMode}
|
||||
defaultValue={text}
|
||||
language="json"
|
||||
onChange={onChange}
|
||||
readOnly={readOnly}
|
||||
stateKey={`json.${requestId}`}
|
||||
actions={actions}
|
||||
lintExtension={lintExtension}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 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));
|
||||
}
|
||||
@@ -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 <RequestBodyViewerInner key={id} id={id} contentType={contentType} body={body} />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<EmptyStateText>
|
||||
<LoadingIcon />
|
||||
</EmptyStateText>
|
||||
);
|
||||
}
|
||||
|
||||
const error = text.error ?? bytes.error;
|
||||
if (error) {
|
||||
return <EmptyStateText>Error loading request body: {error.message}</EmptyStateText>;
|
||||
}
|
||||
|
||||
// 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 <EmptyStateText>No request body</EmptyStateText>;
|
||||
}
|
||||
|
||||
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 <MultipartViewer data={bodyCopy} boundary={boundary} idPrefix={`request.${id}`} />;
|
||||
}
|
||||
|
||||
if (mimeType?.match(/^image/i) && !mimeType.match(/^image\/svg/i)) {
|
||||
return <ImageViewer data={bodyBytes.buffer as ArrayBuffer} />;
|
||||
}
|
||||
|
||||
if (mimeType?.match(/^audio/i)) {
|
||||
return <AudioViewer data={bodyBytes} />;
|
||||
}
|
||||
|
||||
if (mimeType?.match(/^video/i)) {
|
||||
return <VideoViewer data={bodyBytes} />;
|
||||
}
|
||||
|
||||
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 ?? <EmptyStateText>Binary body, {formatSize(bodyBytes.length)}</EmptyStateText>
|
||||
);
|
||||
}
|
||||
|
||||
if (mimeType?.match(/^image\/svg/i)) {
|
||||
return <SvgViewer text={bodyText} />;
|
||||
}
|
||||
|
||||
if (mimeType?.match(/csv|tab-separated/i)) {
|
||||
return <CsvViewer text={bodyText} />;
|
||||
}
|
||||
|
||||
if (mimeType?.match(/^text\/html/i)) {
|
||||
return <WebPageViewer html={bodyText} />;
|
||||
}
|
||||
|
||||
if (appViewer != null) return appViewer;
|
||||
|
||||
const language = languageFromContentType(contentType, bodyText);
|
||||
return <TextViewer text={bodyText} language={language} stateKey={`request.body.${id}`} />;
|
||||
}
|
||||
@@ -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<void>;
|
||||
/** 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<RadioDropdownItem<string>[]>(
|
||||
() =>
|
||||
METHODS.map((m) => ({ value: m, label: <HttpMethodTagRaw method={m} colored={colored} /> })),
|
||||
[colored],
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
async (method: string) => {
|
||||
await onChange?.(method);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const itemsAfter = useMemo<DropdownItem[]>(
|
||||
() => [
|
||||
{
|
||||
key: "custom",
|
||||
label: "CUSTOM",
|
||||
leftSlot: <Icon icon="sparkles" />,
|
||||
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 = (
|
||||
<Button size="xs" className={classNames(className, "text-text-subtle hover:text")}>
|
||||
<HttpMethodTagRaw method={method} colored={colored} />
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (readOnly) return button;
|
||||
|
||||
return (
|
||||
<RadioDropdown
|
||||
value={method}
|
||||
items={radioItems}
|
||||
itemsAfter={itemsAfter}
|
||||
onChange={handleChange}
|
||||
>
|
||||
{button}
|
||||
</RadioDropdown>
|
||||
);
|
||||
});
|
||||
@@ -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 (
|
||||
<EmptyStateText>
|
||||
<VStack space={3}>
|
||||
<HStack space={3}>
|
||||
<LoadingIcon className="text-text-subtlest" />
|
||||
Sending Request
|
||||
</HStack>
|
||||
{onCancel != null && (
|
||||
<Button size="sm" variant="border" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</VStack>
|
||||
</EmptyStateText>
|
||||
);
|
||||
}
|
||||
|
||||
if (response.state === "closed" && (response.contentLength ?? 0) === 0) {
|
||||
return <EmptyStateText>Empty</EmptyStateText>;
|
||||
}
|
||||
|
||||
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 <BodySvgViewer body={body} />;
|
||||
}
|
||||
if (mimeType?.match(/^image/i)) {
|
||||
return <EnsureCompleteResponse response={response} body={body} Component={ImageViewer} />;
|
||||
}
|
||||
if (mimeType?.match(/^audio/i)) {
|
||||
return <EnsureCompleteResponse response={response} body={body} Component={AudioViewer} />;
|
||||
}
|
||||
if (mimeType?.match(/^video/i)) {
|
||||
return <EnsureCompleteResponse response={response} body={body} Component={VideoViewer} />;
|
||||
}
|
||||
if (mimeType?.match(/^multipart/i) && viewMode === "pretty") {
|
||||
return <BodyMultipartViewer response={response} body={body} />;
|
||||
}
|
||||
if (mimeType?.match(/csv|tab-separated/i) && viewMode === "pretty") {
|
||||
return <BodyCsvViewer className="pb-2" body={body} />;
|
||||
}
|
||||
return (
|
||||
<HTMLOrTextViewer
|
||||
textViewerClassName="-mr-2 bg-surface" // Pull to the right
|
||||
response={response}
|
||||
body={body}
|
||||
pretty={viewMode === "pretty"}
|
||||
textViewer={textViewer}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<EmptyStateText>
|
||||
{length > 0 ? `Binary body, ${formatSize(length)}` : "Empty response"}
|
||||
</EmptyStateText>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<EmptyStateText>
|
||||
<LoadingIcon />
|
||||
</EmptyStateText>
|
||||
);
|
||||
}
|
||||
|
||||
if (bodyUrl.error) {
|
||||
return <Banner color="danger">{String(bodyUrl.error)}</Banner>;
|
||||
}
|
||||
|
||||
if (bodyUrl.data == null) {
|
||||
return <div>Empty response body</div>;
|
||||
}
|
||||
|
||||
return <Component bodyUrl={bodyUrl.data} />;
|
||||
}
|
||||
|
||||
function BodySvgViewer({ body }: { body: HttpBodyLoader }) {
|
||||
const text = body.useText();
|
||||
if (!text.data) return null;
|
||||
return <SvgViewer text={text.data} />;
|
||||
}
|
||||
|
||||
function BodyCsvViewer({ body, className }: { body: HttpBodyLoader; className?: string }) {
|
||||
const text = body.useText();
|
||||
return <CsvViewer text={text.data ?? null} className={className} />;
|
||||
}
|
||||
|
||||
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 <MultipartViewer data={bytes.data} boundary={boundary} idPrefix={response.id} />;
|
||||
}
|
||||
|
||||
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 <WebPageViewer html={rawTextBody.data ?? ""} baseUrl={response.url} />;
|
||||
}
|
||||
if (rawTextBody.data == null) {
|
||||
return <BinaryOrEmptyBody body={body} />;
|
||||
}
|
||||
return (
|
||||
<TextViewer
|
||||
text={rawTextBody.data}
|
||||
language={language}
|
||||
stateKey={`response.body.${response.id}`}
|
||||
pretty={pretty}
|
||||
className={textViewerClassName}
|
||||
filterText={body.filterText}
|
||||
filterStateKey={`response.body.${response.ownerId}`}
|
||||
{...textViewer}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+35
-38
@@ -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<string, { name: string; value: string }>();
|
||||
const receivedMap = new Map<string, ParsedCookie>();
|
||||
|
||||
const { sentCookies, receivedCookies } = useMemo(() => {
|
||||
if (!events) return { sentCookies: [], receivedCookies: [] };
|
||||
|
||||
// Use Maps to deduplicate by cookie name (latest value wins)
|
||||
const sentMap = new Map<string, { name: string; value: string }>();
|
||||
const receivedMap = new Map<string, ParsedCookie>();
|
||||
|
||||
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 (
|
||||
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
|
||||
<DetailsBanner
|
||||
defaultOpen
|
||||
storageKey={`${response.requestId}.sent_cookies`}
|
||||
useStoredValue={useDetailsBannerStoredValue}
|
||||
storageKey={`${response.ownerId}.sent_cookies`}
|
||||
summary={
|
||||
<h2 className="flex items-center">
|
||||
Sent Cookies <CountBadge showZero count={sentCookies.length} />
|
||||
@@ -141,7 +137,8 @@ export function ResponseCookies({ response }: Props) {
|
||||
|
||||
<DetailsBanner
|
||||
defaultOpen
|
||||
storageKey={`${response.requestId}.received_cookies`}
|
||||
useStoredValue={useDetailsBannerStoredValue}
|
||||
storageKey={`${response.ownerId}.received_cookies`}
|
||||
summary={
|
||||
<h2 className="flex items-center">
|
||||
Received Cookies <CountBadge showZero count={receivedCookies.length} />
|
||||
@@ -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 (
|
||||
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
|
||||
<DetailsBanner
|
||||
storageKey={`${response.ownerId}.general`}
|
||||
summary={<h2>Info</h2>}
|
||||
useStoredValue={useDetailsBannerStoredValue}
|
||||
>
|
||||
<KeyValueRows>
|
||||
{[
|
||||
<KeyValueRow key="sent" labelColor="secondary" label="Sent">
|
||||
<time
|
||||
dateTime={new Date(response.startedAt).toISOString()}
|
||||
title={formatDistanceToNowStrict(response.startedAt, { addSuffix: true })}
|
||||
>
|
||||
{format(response.startedAt, "MMM d, yyyy, h:mm:ss a O")}
|
||||
</time>
|
||||
</KeyValueRow>,
|
||||
<KeyValueRow key="url" labelColor="secondary" label="Request URL">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="select-text cursor-text">{response.url}</span>
|
||||
{openUrl != null && (
|
||||
<IconButton
|
||||
iconSize="sm"
|
||||
className="inline-block w-auto h-auto! opacity-50 hover:opacity-100"
|
||||
icon="external_link"
|
||||
onClick={() => openUrl(response.url)}
|
||||
title="Open in browser"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</KeyValueRow>,
|
||||
<KeyValueRow key="remote" labelColor="secondary" label="Remote Address">
|
||||
{response.remoteAddr ?? <span className="text-text-subtlest">--</span>}
|
||||
</KeyValueRow>,
|
||||
<KeyValueRow key="version" labelColor="secondary" label="Version">
|
||||
{response.version ?? <span className="text-text-subtlest">--</span>}
|
||||
</KeyValueRow>,
|
||||
...(infoRows ?? []),
|
||||
]}
|
||||
</KeyValueRows>
|
||||
</DetailsBanner>
|
||||
<HeadersTable
|
||||
title="Request Headers"
|
||||
headers={response.requestHeaders}
|
||||
labelColor="primary"
|
||||
storageKey={`${response.ownerId}.request_headers`}
|
||||
useStoredValue={useDetailsBannerStoredValue}
|
||||
/>
|
||||
<HeadersTable
|
||||
defaultOpen
|
||||
title="Response Headers"
|
||||
headers={response.headers}
|
||||
labelColor="info"
|
||||
storageKey={`${response.ownerId}.response_headers`}
|
||||
useStoredValue={useDetailsBannerStoredValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A row that copies its value with the host's clipboard */
|
||||
export function KeyValueRow(props: Omit<KeyValueRowProps, "copy">) {
|
||||
const { copy } = useEditorHost();
|
||||
return <BaseKeyValueRow {...props} copy={copy} />;
|
||||
}
|
||||
@@ -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<InputHandle>(null);
|
||||
const [isFocused, setIsFocused] = useState<boolean>(false);
|
||||
|
||||
const handleInitInputRef = useCallback((h: InputHandle | null) => {
|
||||
inputRef.current = h;
|
||||
}, []);
|
||||
|
||||
useHotKey(focusHotkeyAction ?? null, () => {
|
||||
inputRef.current?.selectAll();
|
||||
});
|
||||
|
||||
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (readOnly) return;
|
||||
if (isLoading) onCancel?.();
|
||||
else onSend?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className={classNames("x-theme-urlBar", className)}>
|
||||
<Input
|
||||
setRef={handleInitInputRef}
|
||||
autocompleteFunctions
|
||||
autocompleteVariables
|
||||
stateKey={stateKey}
|
||||
size="sm"
|
||||
wrapLines={isFocused}
|
||||
hideLabel
|
||||
language="url"
|
||||
className="px-1.5 py-0.5"
|
||||
label="Enter URL"
|
||||
name="url"
|
||||
autocomplete={autocomplete}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
onPaste={onPaste}
|
||||
onPasteOverwrite={onPasteOverwrite}
|
||||
onChange={onUrlChange}
|
||||
readOnly={readOnly}
|
||||
defaultValue={url}
|
||||
placeholder={placeholder}
|
||||
leftSlot={leftSlot}
|
||||
rightSlot={
|
||||
<HStack space={0.5}>
|
||||
{rightSlot && <div className="py-0.5 h-full">{rightSlot}</div>}
|
||||
{submitIcon !== null && !readOnly && (
|
||||
<div className="py-0.5 h-full">
|
||||
<SendButton
|
||||
icon={isLoading ? "x" : submitIcon}
|
||||
hotkeyAction={sendHotkeyAction ?? null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
|
||||
function SendButton({
|
||||
icon,
|
||||
hotkeyAction,
|
||||
}: {
|
||||
icon: IconProps["icon"];
|
||||
hotkeyAction: HotkeyAction | null;
|
||||
}) {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const hotkeyTrigger = useFormattedHotkey(hotkeyAction)?.join("");
|
||||
useHotKey(hotkeyAction, () => buttonRef.current?.click());
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
ref={buttonRef}
|
||||
size="xs"
|
||||
iconSize="md"
|
||||
title={hotkeyTrigger ? `Send Request ${hotkeyTrigger}` : "Send Request"}
|
||||
type="submit"
|
||||
className="w-8 mr-0.5 h-full!"
|
||||
iconColor="secondary"
|
||||
icon={icon}
|
||||
onMouseDown={(e) => {
|
||||
// Prevent the button from taking focus
|
||||
e.preventDefault();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<VStack className="h-full">
|
||||
<PairOrBulkEditor
|
||||
setRef={setRef}
|
||||
allowMultilineValues
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
nameAutocompleteFunctions
|
||||
nameAutocompleteVariables
|
||||
namePlaceholder="param_name"
|
||||
onChange={onChange}
|
||||
pairs={pairs}
|
||||
preferenceName="url_parameters"
|
||||
readOnly={readOnly}
|
||||
stateKey={stateKey}
|
||||
valueAutocompleteFunctions
|
||||
valueAutocompleteVariables
|
||||
valuePlaceholder="Value"
|
||||
/>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function ignoreChange() {}
|
||||
@@ -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 (
|
||||
<div data-input={name} data-readonly={String(!!readOnly)}>
|
||||
{leftSlot}
|
||||
{defaultValue}
|
||||
{rightSlot}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StubEditor({ defaultValue, readOnly, actions }: HostEditorProps) {
|
||||
return (
|
||||
<div data-editor data-readonly={String(!!readOnly)}>
|
||||
{defaultValue}
|
||||
{actions}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StubSelectFile({ filePath }: SelectFileProps) {
|
||||
return (
|
||||
<button type="button" data-picker>
|
||||
{filePath}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function render(node: ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<EditorHostProvider value={{ Input: StubInput, Editor: StubEditor }}>
|
||||
{node}
|
||||
</EditorHostProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("read-only modes", () => {
|
||||
test("a pair row shows its fields read-only, with no drag handle or row menu", () => {
|
||||
const markup = render(
|
||||
<PairEditorRow
|
||||
readOnly
|
||||
index={0}
|
||||
stateKey="test"
|
||||
pair={{ id: "p1", enabled: true, name: "Accept", value: "*/*" }}
|
||||
/>,
|
||||
);
|
||||
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(/<input[^>]*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(
|
||||
<EditorHostProvider value={host}>
|
||||
<PairEditorRow readOnly allowFileValues index={0} stateKey="test" pair={pair} />
|
||||
</EditorHostProvider>,
|
||||
);
|
||||
expect(readOnly).not.toContain("data-picker");
|
||||
expect(readOnly).toContain("data-file-value");
|
||||
expect(readOnly).toContain("/Users/me/avatar.png");
|
||||
|
||||
const editable = renderToStaticMarkup(
|
||||
<EditorHostProvider value={host}>
|
||||
<PairEditorRow allowFileValues index={0} stateKey="test" pair={pair} />
|
||||
</EditorHostProvider>,
|
||||
);
|
||||
expect(editable).toContain("data-picker");
|
||||
});
|
||||
|
||||
test("an editable pair row keeps its row menu", () => {
|
||||
const markup = render(
|
||||
<PairEditorRow
|
||||
index={0}
|
||||
stateKey="test"
|
||||
pair={{ id: "p1", enabled: true, name: "Accept", value: "*/*" }}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<UrlBar url="https://yaak.app" placeholder="" forceUpdateKey="" stateKey={null} readOnly />,
|
||||
);
|
||||
expect(readOnly).toContain('data-input="url" data-readonly="true"');
|
||||
expect(readOnly).not.toContain("Send Request");
|
||||
|
||||
const editable = render(
|
||||
<UrlBar url="https://yaak.app" placeholder="" forceUpdateKey="" stateKey={null} />,
|
||||
);
|
||||
expect(editable).toContain("Send Request");
|
||||
});
|
||||
|
||||
test("the method shows without a menu when read-only", () => {
|
||||
const readOnly = render(<RequestMethodDropdown method="POST" readOnly />);
|
||||
const editable = render(<RequestMethodDropdown method="POST" onChange={() => {}} />);
|
||||
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(<JsonBodyEditor {...props} readOnly />);
|
||||
expect(readOnly).toContain('data-readonly="true"');
|
||||
expect(readOnly).not.toContain("JSON Settings");
|
||||
|
||||
const editable = render(<JsonBodyEditor {...props} onChange={() => {}} />);
|
||||
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(
|
||||
<RequestBodyViewer id="b1" contentType="application/octet-stream" body={binary} />,
|
||||
);
|
||||
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(<RequestBodyViewer id="b2" contentType="image/png" body={binary} />);
|
||||
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(
|
||||
<HTMLOrTextViewer response={response} body={loader(new Uint8Array([0xff, 0, 1]))} pretty />,
|
||||
);
|
||||
expect(binary).toContain("Binary body, 3");
|
||||
|
||||
const empty = render(
|
||||
<HTMLOrTextViewer response={response} body={loader(new Uint8Array())} pretty />,
|
||||
);
|
||||
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(<ResponseStatusLine response={response} />);
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -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<T> {
|
||||
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<string | null>;
|
||||
/** The body's bytes, null when there's none */
|
||||
useBytes: () => HttpBodyResult<Uint8Array | null>;
|
||||
/** A URL a media element can load the body from, null when there's none or not `enabled` */
|
||||
useUrl: (enabled: boolean) => HttpBodyResult<string | null>;
|
||||
/** Run a JSONPath or XPath filter over the body */
|
||||
filterText?: (expression: string) => Promise<string | null>;
|
||||
/** The body's text, for copying, without rendering it */
|
||||
getText?: () => Promise<string | null>;
|
||||
}
|
||||
File renamed without changes.
+6
-3
@@ -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}
|
||||
/>
|
||||
);
|
||||
@@ -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 <PairEditorInner key={props.stateKey} {...props} />;
|
||||
}
|
||||
|
||||
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<number | null>(null);
|
||||
const [isDragging, setIsDragging] = useState<EditablePairWithId | null>(null);
|
||||
const [pairs, setPairs] = useState<EditablePairWithId[]>([]);
|
||||
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<Record<string, RowHandle | null>>({});
|
||||
|
||||
const pendingFocusFrame = useRef<number | null>(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<PairEditorHandle>(
|
||||
() => ({
|
||||
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 (
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
"@container relative",
|
||||
"pb-2 mb-auto h-full",
|
||||
!noScroll && "overflow-y-auto max-h-full",
|
||||
// Move over the width of the drag handle
|
||||
"-mr-2 pr-2",
|
||||
// Pad to make room for the drag divider
|
||||
"pt-0.5",
|
||||
"grid grid-rows-[auto_1fr]",
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<DndContext
|
||||
autoScroll
|
||||
sensors={sensors}
|
||||
onDragMove={onDragMove}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragStart={onDragStart}
|
||||
onDragCancel={onDragCancel}
|
||||
collisionDetection={pointerWithin}
|
||||
>
|
||||
{pairs.map((p, i) => {
|
||||
if (!showAll && i > MAX_INITIAL_PAIRS) return null;
|
||||
|
||||
const isLast = !readOnly && i === pairs.length - 1;
|
||||
return (
|
||||
<Fragment key={p.id}>
|
||||
{hoveredIndex === i && <DropMarker />}
|
||||
<PairEditorRow
|
||||
readOnly={readOnly}
|
||||
setRef={initPairEditorRow}
|
||||
allowFileValues={allowFileValues}
|
||||
allowMultilineValues={allowMultilineValues}
|
||||
className="py-1"
|
||||
forcedEnvironmentId={forcedEnvironmentId}
|
||||
forceUpdateKey={localForceUpdateKey}
|
||||
index={i}
|
||||
isLast={isLast}
|
||||
isDraggingGlobal={!!isDragging}
|
||||
nameAutocomplete={nameAutocomplete}
|
||||
nameAutocompleteFunctions={nameAutocompleteFunctions}
|
||||
nameAutocompleteVariables={nameAutocompleteVariables}
|
||||
namePlaceholder={namePlaceholder}
|
||||
nameValidate={nameValidate}
|
||||
onChange={handleChange}
|
||||
onDelete={handleDelete}
|
||||
onFocusName={handleFocusName}
|
||||
onFocusValue={handleFocusValue}
|
||||
pair={p}
|
||||
stateKey={stateKey}
|
||||
valueAutocomplete={valueAutocomplete}
|
||||
valueAutocompleteFunctions={valueAutocompleteFunctions}
|
||||
valueAutocompleteVariables={valueAutocompleteVariables}
|
||||
valuePlaceholder={valuePlaceholder}
|
||||
valueType={valueType}
|
||||
valueValidate={valueValidate}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{!showAll && pairs.length > MAX_INITIAL_PAIRS && (
|
||||
<Button onClick={toggleShowAll} variant="border" className="m-2" size="xs">
|
||||
Show {pairs.length - MAX_INITIAL_PAIRS} More
|
||||
</Button>
|
||||
)}
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{isDragging && (
|
||||
<PairEditorRow
|
||||
namePlaceholder={namePlaceholder}
|
||||
valuePlaceholder={valuePlaceholder}
|
||||
className="opacity-80"
|
||||
pair={isDragging}
|
||||
index={0}
|
||||
stateKey={null}
|
||||
/>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
|
||||
<div
|
||||
// There's a weird bug where clicking below one of the above Codemirror inputs will cause
|
||||
// it to focus. Putting this element here prevents that
|
||||
aria-hidden
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<InputHandle>(null);
|
||||
const valueInputRef = useRef<InputHandle>(null);
|
||||
const handle = useRef<RowHandle>({
|
||||
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<string | null>(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 }) => (
|
||||
<MultilineEditDialog
|
||||
hide={hide}
|
||||
onChange={handleChangeValueText}
|
||||
defaultValue={pair.value}
|
||||
contentType={pair.contentType ?? null}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
[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 (
|
||||
<div
|
||||
ref={handleSetRef}
|
||||
className={classNames(
|
||||
className,
|
||||
"group/pair-row grid grid-cols-[auto_auto_minmax(0,1fr)_auto]",
|
||||
"grid-rows-1 items-center",
|
||||
!pair.enabled && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
hideLabel
|
||||
title={pair.enabled ? "Disable item" : "Enable item"}
|
||||
disabled={isLast || disabled || readOnly}
|
||||
checked={isLast ? false : !!pair.enabled}
|
||||
className={classNames(isLast && "opacity-disabled!")}
|
||||
onChange={handleChangeEnabled}
|
||||
/>
|
||||
{!isLast && !disableDrag && !readOnly ? (
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={classNames(
|
||||
"py-2 h-7 w-4 flex items-center",
|
||||
"justify-center opacity-0 group-hover/pair-row:opacity-70",
|
||||
)}
|
||||
>
|
||||
<Icon size="sm" icon="grip_vertical" className="pointer-events-none" />
|
||||
</div>
|
||||
) : (
|
||||
<span className="w-4" />
|
||||
)}
|
||||
<div
|
||||
className={classNames(
|
||||
"grid items-center",
|
||||
"@xs:gap-2 @xs:grid-rows-1! @xs:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]!",
|
||||
"gap-0.5 grid-cols-1 grid-rows-2",
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
setRef={initNameInputRef}
|
||||
hideLabel
|
||||
stateKey={`name.${pair.id}.${stateKey}`}
|
||||
disabled={disabled}
|
||||
wrapLines={false}
|
||||
readOnly={readOnly || isDraggingGlobal}
|
||||
size="sm"
|
||||
required={!isLast && !!pair.enabled && !!pair.value}
|
||||
validate={nameValidate}
|
||||
forcedEnvironmentId={forcedEnvironmentId}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
containerClassName={classNames("bg-surface", isLast && "border-dashed")}
|
||||
defaultValue={pair.name}
|
||||
label="Name"
|
||||
name={`name[${index}]`}
|
||||
onBlur={handleBlurName}
|
||||
onChange={handleChangeName}
|
||||
onFocus={handleFocusName}
|
||||
placeholder={namePlaceholder ?? "name"}
|
||||
autocomplete={nameAutocomplete}
|
||||
autocompleteVariables={nameAutocompleteVariables}
|
||||
autocompleteFunctions={nameAutocompleteFunctions}
|
||||
showOptionsPicker={showOptionsPicker}
|
||||
/>
|
||||
<div className="w-full grid grid-cols-[minmax(0,1fr)_auto] gap-1 items-center">
|
||||
{pair.isFile && readOnly ? (
|
||||
// A file value to read, not to pick: no picker and nothing to drop a file on
|
||||
<span
|
||||
title={pair.value}
|
||||
data-file-value
|
||||
className="font-mono text-xs truncate px-1.5 text-text-subtle select-text cursor-text"
|
||||
>
|
||||
{pair.filename || pair.value || "No file"}
|
||||
</span>
|
||||
) : pair.isFile && SelectFile != null ? (
|
||||
<SelectFile
|
||||
disabled={disabled}
|
||||
inline
|
||||
size="xs"
|
||||
filePath={pair.value}
|
||||
nameOverride={pair.filename || null}
|
||||
onChange={handleChangeValueFile}
|
||||
/>
|
||||
) : pair.value.includes("\n") ? (
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={readOnly ? undefined : handleEditMultiLineValue}
|
||||
title={pair.value}
|
||||
className="text-xs font-mono"
|
||||
>
|
||||
{pair.value.split("\n").join(" ")}
|
||||
</Button>
|
||||
) : (
|
||||
<Input
|
||||
setRef={initValueInputRef}
|
||||
hideLabel
|
||||
stateKey={`value.${pair.id}.${stateKey}`}
|
||||
wrapLines={false}
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
readOnly={readOnly || isDraggingGlobal}
|
||||
containerClassName={classNames("bg-surface", isLast && "border-dashed")}
|
||||
validate={valueValidate}
|
||||
forcedEnvironmentId={forcedEnvironmentId}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
defaultValue={pair.value}
|
||||
label="Value"
|
||||
name={`value[${index}]`}
|
||||
onChange={handleChangeValueText}
|
||||
onFocus={handleFocusValue}
|
||||
type={isLast ? "text" : typeof valueType === "function" ? valueType(pair) : valueType}
|
||||
placeholder={valuePlaceholder ?? "value"}
|
||||
autocomplete={valueAutocomplete?.(pair.name)}
|
||||
autocompleteFunctions={valueAutocompleteFunctions}
|
||||
autocompleteVariables={valueAutocompleteVariablesFiltered}
|
||||
showOptionsPicker={showOptionsPicker}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{readOnly ? null : allowFileValues ? (
|
||||
<FileActionsDropdown
|
||||
pair={pair}
|
||||
onChangeFile={handleChangeValueFile}
|
||||
onChangeText={handleChangeValueText}
|
||||
onChangeContentType={handleChangeValueContentType}
|
||||
onChangeFilename={handleChangeValueFilename}
|
||||
onDelete={handleDelete}
|
||||
editMultiLine={handleEditMultiLineValue}
|
||||
/>
|
||||
) : (
|
||||
<Dropdown items={defaultItems}>
|
||||
<IconButton
|
||||
iconSize="sm"
|
||||
size="xs"
|
||||
// Ellipsis rather than a chevron: the name and value fields now carry chevrons for
|
||||
// picking a suggestion, and this menu acts on the row instead of filling in a field.
|
||||
icon={isLast || disabled ? "empty" : "ellipsis_vertical"}
|
||||
title="More actions"
|
||||
className="text-text-subtlest"
|
||||
/>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fileItems: RadioDropdownItem<string>[] = [
|
||||
{ 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<DropdownItem[]>(
|
||||
() => [
|
||||
{
|
||||
label: "Edit Multi-Line",
|
||||
leftSlot: <Icon icon="file_code" />,
|
||||
hidden: pair.isFile,
|
||||
onSelect: editMultiLine,
|
||||
},
|
||||
{
|
||||
label: "Set Content-Type",
|
||||
leftSlot: <Icon icon="pencil" />,
|
||||
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: <Icon icon="file_code" />,
|
||||
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: <Icon icon="x" />,
|
||||
hidden: pair.isFile,
|
||||
onSelect: async () => {
|
||||
onChangeFile({ filePath: null });
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
onSelect: onDelete,
|
||||
variant: "danger",
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
color: "danger",
|
||||
},
|
||||
],
|
||||
[
|
||||
editMultiLine,
|
||||
onChangeContentType,
|
||||
onChangeFile,
|
||||
onDelete,
|
||||
pair.contentType,
|
||||
pair.isFile,
|
||||
onChangeFilename,
|
||||
pair.filename,
|
||||
pair,
|
||||
basename,
|
||||
showPrompt,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<RadioDropdown
|
||||
value={pair.isFile ? "file" : "text"}
|
||||
onChange={onChange}
|
||||
items={fileItems}
|
||||
itemsAfter={itemsAfter}
|
||||
>
|
||||
<IconButton
|
||||
iconSize="sm"
|
||||
size="xs"
|
||||
// Matches the plain row menu, so the actions column reads the same for file and text rows
|
||||
icon="ellipsis_vertical"
|
||||
title="Select form data type"
|
||||
className="text-text-subtlest"
|
||||
/>
|
||||
</RadioDropdown>
|
||||
);
|
||||
}
|
||||
|
||||
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<string>(defaultValue);
|
||||
const language = languageFromContentType(contentType, value);
|
||||
return (
|
||||
<div className="w-screen max-w-160 h-[50vh] max-h-full grid grid-rows-[minmax(0,1fr)_auto]">
|
||||
<Editor
|
||||
heightMode="auto"
|
||||
defaultValue={defaultValue}
|
||||
language={language}
|
||||
onChange={setValue}
|
||||
stateKey={null}
|
||||
autocompleteFunctions
|
||||
autocompleteVariables
|
||||
/>
|
||||
<div>
|
||||
<Button
|
||||
color="primary"
|
||||
className="ml-auto my-2"
|
||||
onClick={() => {
|
||||
onChange(value);
|
||||
hide();
|
||||
}}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<T extends Pair>(p: T): T & PairWithId {
|
||||
if (typeof p.id === "string") {
|
||||
return p as T & PairWithId;
|
||||
}
|
||||
return { ...p, id: p.id ?? generateId() };
|
||||
}
|
||||
@@ -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<boolean>(
|
||||
["bulk_edit", preferenceName],
|
||||
false,
|
||||
"global",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full group/wrapper">
|
||||
{useBulk ? <BulkPairEditor {...props} /> : <PairEditor {...props} />}
|
||||
<div className="absolute right-0 bottom-0">
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="border"
|
||||
title={useBulk ? "Enable form edit" : "Enable bulk edit"}
|
||||
className={classNames(
|
||||
"transition-opacity opacity-0 group-hover:opacity-80 hover:opacity-100! shadow",
|
||||
"bg-surface hover:text group-hover/wrapper:opacity-100",
|
||||
)}
|
||||
onClick={() => setUseBulk((b) => !b)}
|
||||
icon={useBulk ? "table" : "file_code"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<ButtonProps>;
|
||||
@@ -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<RadioDropdownProps, "children">;
|
||||
leftSlot?: ReactNode;
|
||||
rightSlot?: ReactNode;
|
||||
}
|
||||
|
||||
export interface TabsStorage {
|
||||
order: string[];
|
||||
@@ -91,19 +104,23 @@ export const Tabs = forwardRef<TabsRef, TabsProps>(function Tabs(
|
||||
layout = "vertical",
|
||||
storageKey,
|
||||
activeTabKey,
|
||||
useStoredValue = useInMemoryStoredValue,
|
||||
useStoredValue: useStoredValueProp,
|
||||
}: TabsProps,
|
||||
forwardedRef: Ref<TabsRef>,
|
||||
) {
|
||||
const ref = useRef<HTMLDivElement | null>(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<TabsStorage | string[]>(storageKey ?? null, {
|
||||
order: [],
|
||||
activeTabs: {},
|
||||
});
|
||||
const useStoredValue: UseHostStoredValue = useStoredValueProp ?? host.useStoredValue;
|
||||
const [rawStorage, setStorage] = useStoredValue<TabsStorage | string[]>(
|
||||
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 ? (
|
||||
<TabOptionsButton tab={tab} buttonProps={btnProps} isActive={isActive} />
|
||||
) : (
|
||||
<Button leftSlot={tab.leftSlot} rightSlot={tab.rightSlot} {...btnProps}>
|
||||
{"label" in tab && tab.label ? tab.label : tab.value}
|
||||
@@ -526,3 +545,40 @@ export const TabContent = memo(function TabContent({
|
||||
);
|
||||
return ErrorBoundary ? <ErrorBoundary name={`Tab ${value}`}>{content}</ErrorBoundary> : content;
|
||||
});
|
||||
|
||||
function TabOptionsButton({
|
||||
tab,
|
||||
buttonProps,
|
||||
isActive,
|
||||
}: TabButtonRenderProps & { tab: TabOptionsItem }) {
|
||||
const { options } = tab;
|
||||
const option = options.items.find((i) => "value" in i && i.value === options.value);
|
||||
return (
|
||||
<RadioDropdown
|
||||
items={options.items}
|
||||
itemsAfter={options.itemsAfter}
|
||||
itemsBefore={options.itemsBefore}
|
||||
value={options.value}
|
||||
onChange={options.onChange}
|
||||
>
|
||||
<Button
|
||||
leftSlot={tab.leftSlot}
|
||||
rightSlot={
|
||||
<div className="flex items-center">
|
||||
{tab.rightSlot}
|
||||
<Icon
|
||||
size="sm"
|
||||
icon="chevron_down"
|
||||
className={classNames("ml-1", isActive ? "text-text-subtle" : "text-text-subtlest")}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
{...buttonProps}
|
||||
>
|
||||
{option && "shortLabel" in option && option.shortLabel
|
||||
? option.shortLabel
|
||||
: (option?.label ?? "Unknown")}
|
||||
</Button>
|
||||
</RadioDropdown>
|
||||
);
|
||||
}
|
||||
@@ -6,23 +6,51 @@ interface Props {
|
||||
}
|
||||
|
||||
export function HttpResponseDurationTag({ response }: Props) {
|
||||
return (
|
||||
<HttpResponseDurationTagRaw
|
||||
closed={response.state === "closed"}
|
||||
startedAt={`${response.createdAt}Z`}
|
||||
elapsedMs={response.elapsed}
|
||||
elapsedHeadersMs={response.elapsedHeaders}
|
||||
elapsedDnsMs={response.elapsedDns}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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<number>(0);
|
||||
const timeout = useRef<ReturnType<typeof setInterval>>(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 (
|
||||
<span className="font-mono" title={title}>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
export function useToggle(initialValue = false) {
|
||||
const [value, setValue] = useState<boolean>(initialValue);
|
||||
const toggle = useCallback(() => setValue((v) => !v), []);
|
||||
return [value, toggle, setValue] as const;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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");
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
Reference in new issue
Block a user