Share Yaak's request and response panes (#815)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Gregory SchierandClaude Opus 5.5 authored and GitHub committed 2026-11-05 09:23:38 -07:00
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
View File
@@ -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
View File
@@ -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";
+165 -194
View File
@@ -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}
/>
);
}
+179 -358
View File
@@ -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>&bull;</span>
<HttpResponseDurationTag response={activeResponse} />
<span>&bull;</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>
);
}
+6 -105
View File
@@ -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";
+7 -969
View File
@@ -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";
+10 -81
View File
@@ -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}
/>
);
}
+1
View File
@@ -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]);
}
+2 -7
View File
@@ -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
View File
@@ -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";
+83
View File
@@ -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: [] });
});
});
+64
View File
@@ -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 };
}
+10 -8
View File
@@ -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") {
+14 -11
View File
@@ -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>;
}
+33
View File
@@ -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;
}
}
+141
View File
@@ -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(""),
},
];
+20
View File
@@ -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>,
);
+222
View File
@@ -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([]);
});
});
+235
View File
@@ -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",
};
+7 -4
View File
@@ -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"
+194
View File
@@ -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>
);
}
@@ -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,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", () => ({
+31
View File
@@ -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>
);
};
+14
View File
@@ -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}</>;
}
@@ -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>&bull;</span>
<HttpResponseDurationTagRaw
closed={response.state === "closed"}
startedAt={response.startedAt}
elapsedMs={response.elapsedMs}
elapsedHeadersMs={response.elapsedHeadersMs}
elapsedDnsMs={response.elapsedDnsMs}
/>
<span>&bull;</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}
/>
);
}
@@ -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} />;
}
+146
View File
@@ -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 });
});
});
+127
View File
@@ -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>;
}
@@ -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>
);
}
+64 -8
View File
@@ -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}>
+7
View File
@@ -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);
+7
View File
@@ -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;
}
+95 -2
View File
@@ -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.
+44
View File
@@ -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");
}
}