Compare commits

...
36 changed files with 2109 additions and 504 deletions
@@ -8,6 +8,7 @@ import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse";
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { useResponseBodyBytes, useResponseBodyText } from "../hooks/useResponseBodyText";
import { useResponseBodyUrl } from "../hooks/useResponseBodyUrl";
import { useResponseViewMode } from "../hooks/useResponseViewMode";
import { useSaveResponse } from "../hooks/useSaveResponse";
import { useTimelineViewMode } from "../hooks/useTimelineViewMode";
@@ -409,14 +410,13 @@ function EnsureCompleteResponse({
Component,
}: {
response: HttpResponse;
Component: ComponentType<{ bodyPath: string }>;
Component: ComponentType<{ bodyUrl: string }>;
}) {
if (response.bodyPath === null) {
return <div>Empty response body</div>;
}
// Wait until the response has been fully-downloaded before asking for it
const complete = response.state === "closed";
const bodyUrl = useResponseBodyUrl(complete ? response : null);
// Wait until the response has been fully-downloaded
if (response.state !== "closed") {
if (!complete || bodyUrl.isPending) {
return (
<EmptyStateText>
<LoadingIcon />
@@ -424,7 +424,15 @@ function EnsureCompleteResponse({
);
}
return <Component bodyPath={response.bodyPath} />;
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 }) {
+122 -40
View File
@@ -1,56 +1,138 @@
import { VStack } from "@yaakapp-internal/ui";
import { useState } from "react";
import { platform } from "@yaakapp-internal/platform";
import { Icon, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useEffect, useRef, useState } from "react";
import { useLocalStorage } from "react-use";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { Button } from "./core/Button";
import { SelectFile } from "./SelectFile";
import { PlainInput } from "./core/PlainInput";
interface Props {
importData: (filePath: string) => Promise<void>;
importFile: (filePath: string) => Promise<void>;
importUrl: (url: string) => Promise<void>;
}
export function ImportDataDialog({ importData }: Props) {
/**
* An absolute or relative path is unambiguously a file. Everything else is treated as a URL, so a
* bare host like `example.com/openapi.json` still works (the backend defaults it to https).
*/
function isFilePath(value: string): boolean {
return (
value.startsWith("/") ||
value.startsWith("./") ||
value.startsWith("../") ||
value.startsWith("~/") ||
value.startsWith("\\\\") ||
/^[a-zA-Z]:[\\/]/.test(value)
);
}
function fileName(path: string): string {
return path.split(/[/\\]/).at(-1) || path;
}
export function ImportDataDialog({ importFile, importUrl }: Props) {
const [isLoading, setIsLoading] = useState<boolean>(false);
const [filePath, setFilePath] = useLocalStorage<string | null>("importFilePath", null);
// A file path or a URL. Both inputs write here, so there is only ever one thing to import
const [source, setSource] = useLocalStorage<string | null>("importPathOrUrl", null);
const [forceUpdateKey, setForceUpdateKey] = useState<number>(0);
const [isHovering, setIsHovering] = useState<boolean>(false);
const ref = useRef<HTMLDivElement>(null);
const trimmedSource = source?.trim() ?? "";
const filePath = isFilePath(trimmedSource) ? trimmedSource : null;
const selectSource = (value: string) => {
setSource(value);
// Remount the input so it shows the path of the newly-picked file
setForceUpdateKey((k) => k + 1);
};
// Accept a file dropped anywhere on the dialog, the way SelectFile does for its button
useEffect(() => {
return platform.window.onDragDrop((event) => {
if (event.type === "over") {
const p = event.position;
const r = ref.current?.getBoundingClientRect();
if (r == null) return;
setIsHovering(p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom);
} else if (event.type === "drop" && isHovering) {
const p = event.paths[0];
if (p) selectSource(p);
setIsHovering(false);
} else {
setIsHovering(false);
}
});
}, [isHovering, setSource]);
const handleSelectFile = async () => {
const selected = await platform.dialog.open({ title: "Select File", multiple: false });
if (selected == null) return;
selectSource(selected);
};
const handleImport = async () => {
setIsLoading(true);
try {
if (filePath != null) {
await importFile(filePath);
} else {
await importUrl(trimmedSource);
}
} finally {
setIsLoading(false);
}
};
return (
<VStack space={5} className="pb-4">
<VStack ref={ref} space={4} className="pb-4">
<CommercialUseBanner source="data-import" title="Importing work data?" />
<VStack space={1}>
<ul className="list-disc pl-5">
<li>OpenAPI 3.0, 3.1</li>
<li>Postman Collection v2, v2.1</li>
<li>Insomnia v4+</li>
<li>Swagger 2.0</li>
<li>
Curl commands <em className="text-text-subtle">(or paste into URL)</em>
</li>
</ul>
</VStack>
<VStack space={2}>
<SelectFile
filePath={filePath ?? null}
onChange={({ filePath }) => setFilePath(filePath)}
/>
{filePath && (
<Button
color="primary"
disabled={!filePath || isLoading}
isLoading={isLoading}
size="sm"
onClick={async () => {
setIsLoading(true);
try {
await importData(filePath);
} finally {
setIsLoading(false);
}
}}
>
{isLoading ? "Importing" : "Import"}
</Button>
<button
type="button"
onClick={handleSelectFile}
className={classNames(
"w-full rounded-lg border border-dashed px-4 py-6",
"flex flex-col items-center gap-1 text-center",
isHovering ? "border-notice bg-surface-highlight" : "border-border hover:border-text",
)}
>
<Icon icon="folder_input" className="text-text-subtlest w-8! h-8! mb-2" />
{/* Fixed height so the region doesn't resize between the empty and selected states */}
<div className="h-6 w-full flex items-center justify-center">
{filePath == null ? (
<div className="text-text">
<strong className="font-semibold">Choose a file</strong> or drag it here
</div>
) : (
<div className="text-text font-mono text-xs max-w-full truncate" title={filePath}>
{fileName(filePath)}
</div>
)}
</div>
<div className="text-xs text-text-subtlest">
Supports OpenAPI, Swagger, Postman, Insomnia, and curl
</div>
</button>
<VStack space={2}>
<PlainInput
label="Or enter a file path or URL"
size="sm"
placeholder="https://example.com/openapi.json"
defaultValue={source ?? ""}
forceUpdateKey={String(forceUpdateKey)}
onChange={setSource}
/>
<Button
color="primary"
disabled={trimmedSource === "" || isLoading}
isLoading={isLoading}
size="sm"
onClick={handleImport}
>
{isLoading ? "Importing" : "Import"}
</Button>
</VStack>
</VStack>
);
+85 -13
View File
@@ -36,12 +36,16 @@ import { fireAndForget } from "../../lib/fireAndForget";
import { ErrorBoundary } from "../ErrorBoundary";
import { Button } from "./Button";
import { Hotkey } from "./Hotkey";
import { IconButton } from "./IconButton";
import type { SeparatorAction } from "./Separator";
import { Separator } from "./Separator";
export type DropdownItemSeparator = {
type: "separator";
label?: ReactNode;
hidden?: boolean;
/** A control shown beside the label, eg. revealing the labelled file on disk. */
action?: SeparatorAction;
};
export type DropdownItemContent = {
@@ -66,6 +70,12 @@ export type DropdownItemDefault = {
submenu?: DropdownItem[];
/** If true, submenu opens on click instead of hover */
submenuOpenOnClick?: boolean;
/**
* How the submenu opens. "row" (default) opens it from the row itself (hover, or click
* with submenuOpenOnClick). "button" keeps the row selectable via onSelect and renders
* a dedicated button on the right that opens the submenu.
*/
submenuTrigger?: "row" | "button";
icon?: IconProps["icon"];
};
@@ -502,9 +512,15 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
}
}
if (!item.keepOpenOnSelect) handleCloseAll();
if (!item.keepOpenOnSelect) {
handleCloseAll();
} else if (isSubmenu) {
// Keep the parent menu open, but close this submenu — its items may no
// longer describe the row after the action (e.g. Pin → Unpin, Remove)
handleClose();
}
},
[handleCloseAll, setSelectedIndex],
[handleCloseAll, handleClose, isSubmenu, setSelectedIndex],
);
useImperativeHandle(ref, () => {
@@ -629,7 +645,7 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
const item = filteredItems[selectedIndex ?? -1];
if (!item || item.type === "separator" || item.type === "content") return;
e.preventDefault();
if (item.submenu) {
if (item.submenu && item.submenuTrigger !== "button") {
const parent = document.activeElement as HTMLButtonElement;
if (parent) {
setActiveSubmenu({ item, parent, viaKeyboard: true });
@@ -648,9 +664,11 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
clearTimeout(submenuTimeoutRef.current);
}
if (item.submenu && !item.submenuOpenOnClick) {
if (item.submenu && !item.submenuOpenOnClick && item.submenuTrigger !== "button") {
setActiveSubmenu({ item, parent });
} else if (activeSubmenu) {
} else if (activeSubmenu && activeSubmenu.item !== item) {
// Hovering the row that owns the open submenu must not dismiss it — the
// pointer travels across the row on its way to a button-triggered submenu
submenuTimeoutRef.current = window.setTimeout(() => {
const submenuEl = submenuRef.current;
if (!submenuEl || !activeSubmenu) {
@@ -776,6 +794,7 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
// oxlint-disable-next-line no-array-index-key -- Nothing else available
key={i}
className={classNames("my-1.5", item.label ? "ml-2" : null)}
action={item.action}
>
{item.label}
</Separator>
@@ -797,6 +816,7 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
onFocus={handleFocus}
onSelect={handleSelect}
onHover={handleItemHover}
onOpenSubmenu={(item, el) => setActiveSubmenu({ item, parent: el })}
// oxlint-disable-next-line no-array-index-key -- It's fine
key={i}
item={item}
@@ -868,6 +888,7 @@ interface MenuItemProps {
onSelect: (item: DropdownItemDefault, el?: HTMLButtonElement) => Promise<void>;
onFocus: (item: DropdownItemDefault) => void;
onHover: (item: DropdownItemDefault, el: HTMLButtonElement) => void;
onOpenSubmenu: (item: DropdownItemDefault, el: HTMLButtonElement) => void;
focused: boolean;
isParentOfActiveSubmenu?: boolean;
}
@@ -879,6 +900,7 @@ function MenuItem({
onHover,
item,
onSelect,
onOpenSubmenu,
isParentOfActiveSubmenu,
...props
}: MenuItemProps) {
@@ -914,19 +936,22 @@ function MenuItem({
e.currentTarget.focus();
};
const rightSlot = item.submenu ? (
<Icon icon="chevron_right" color="secondary" />
) : (
(item.rightSlot ?? <Hotkey variant="text" action={item.hotKeyAction ?? null} />)
);
const hasButtonSubmenu = item.submenu != null && item.submenuTrigger === "button";
return (
const rightSlot =
item.submenu && !hasButtonSubmenu ? (
<Icon icon="chevron_right" color="secondary" />
) : (
(item.rightSlot ?? <Hotkey variant="text" action={item.hotKeyAction ?? null} />)
);
const button = (
<Button
ref={initRef}
size="sm"
tabIndex={-1}
onMouseEnter={handleMouseEnter}
onMouseLeave={(e) => e.currentTarget.blur()}
onMouseEnter={hasButtonSubmenu ? undefined : handleMouseEnter}
onMouseLeave={hasButtonSubmenu ? undefined : (e) => e.currentTarget.blur()}
disabled={item.disabled}
onFocus={handleFocus}
onClick={handleClick}
@@ -947,6 +972,7 @@ function MenuItem({
"min-w-32 outline-hidden px-2 mx-1.5 flex whitespace-nowrap",
"focus:bg-surface-highlight focus:text rounded-sm focus:outline-hidden focus-visible:outline-1",
isParentOfActiveSubmenu && "bg-surface-highlight text rounded-sm",
hasButtonSubmenu && "pr-8",
item.color === "danger" && "text-danger!",
item.color === "primary" && "text-primary!",
item.color === "success" && "text-success!",
@@ -959,6 +985,52 @@ function MenuItem({
<div className={classNames("truncate min-w-20")}>{item.label}</div>
</Button>
);
if (!hasButtonSubmenu) {
return button;
}
// The submenu trigger overlays the row as a sibling (not a child) because the row is
// itself a button and buttons cannot nest. Hover handling lives on this wrapper so the
// row keeps its focus highlight while the mouse is over the trigger.
return (
<div
className="relative grid group/menuitem"
onMouseEnter={() => {
const el = buttonRef.current;
if (el == null) return;
onHover(item, el);
el.focus();
}}
onMouseLeave={() => buttonRef.current?.blur()}
>
{button}
<div
className={classNames(
"absolute right-1.5 inset-y-0 flex items-center",
"opacity-0 group-hover/menuitem:opacity-100 group-focus-within/menuitem:opacity-100",
)}
>
<IconButton
color="custom"
size="2xs"
tabIndex={-1}
icon="ellipsis_vertical"
iconColor="secondary"
title="More actions"
className="h-full! w-7!"
onMouseDown={(e) => {
// Prevent the trigger from stealing focus, which would unhighlight the row
e.preventDefault();
}}
onClick={(e) => {
e.stopPropagation();
onOpenSubmenu(item, e.currentTarget);
}}
/>
</div>
</div>
);
}
interface MenuItemHotKeyProps {
@@ -1,13 +1,31 @@
import type { Color } from "@yaakapp-internal/plugins";
import type { IconProps } from "@yaakapp-internal/ui";
import { IconButton } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { ReactNode } from "react";
/**
* A single control attached to a labelled separator, rendered between the label
* and the rule.
*
* Declared rather than passed as a node so the separator keeps ownership of the
* things that are easy to get wrong by hand: matching the label's colour, and
* staying out of the rule's way when the label is long.
*/
export interface SeparatorAction {
icon: IconProps["icon"];
/** Tooltip and accessible name. Required — the control is icon-only. */
title: string;
onClick: () => void;
}
interface Props {
orientation?: "horizontal" | "vertical";
dashed?: boolean;
className?: string;
children?: ReactNode;
color?: Color;
action?: SeparatorAction;
}
export function Separator({
@@ -16,15 +34,31 @@ export function Separator({
dashed,
orientation = "horizontal",
children,
action,
}: Props) {
return (
<div role="presentation" className={classNames(className, "flex items-center w-full")}>
{children && (
<div className="text-sm text-text-subtlest mr-2 whitespace-nowrap">{children}</div>
)}
{action && (
<IconButton
size="2xs"
iconSize="xs"
className="shrink-0 mr-2 -ml-1"
// Forced, because the button itself sets `text-text` at full strength.
iconClassName="text-text-subtlest!"
icon={action.icon}
title={action.title}
onClick={action.onClick}
/>
)}
<div
className={classNames(
"opacity-60",
// Keep a stub of the line visible no matter how long the label is —
// `w-full` alone gets squeezed to nothing by a wide label.
orientation === "horizontal" && "min-w-8",
color == null && "border-border",
color === "primary" && "border-primary",
color === "secondary" && "border-secondary",
@@ -1,4 +1,5 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import { platform } from "@yaakapp-internal/platform";
import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo } from "react";
@@ -11,6 +12,7 @@ import type { DropdownItem } from "../core/Dropdown";
import { Dropdown } from "../core/Dropdown";
import type { EditorProps } from "../core/Editor/Editor";
import { Editor } from "../core/Editor/LazyEditor";
import { IconButton } from "../core/IconButton";
import type { RadioDropdownItem } from "../core/RadioDropdown";
import { RadioDropdown } from "../core/RadioDropdown";
import { Banner, FormattedError, Icon } from "@yaakapp-internal/ui";
@@ -18,6 +20,7 @@ import { Separator } from "../core/Separator";
import { tryFormatGraphql } from "../../lib/formatters";
import { parseGraphQLOperationNames } from "../../lib/graphqlOperationNames";
import { normalizeGraphQLBody } from "../../lib/requestBodyConversion";
import { revealInFinderText } from "../../lib/reveal";
import { showGraphQLDocExplorerAtom } from "./graphqlAtoms";
type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> & {
@@ -28,6 +31,10 @@ type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> &
const OPERATION_NAME_NOT_SPECIFIED = "";
// How much of the end of a schema filename is pinned when middle-truncating it.
// Enough to keep the extension and a little of the name before it.
const FILE_NAME_TAIL_CHARS = 12;
export function GraphQLEditor(props: Props) {
// There's some weirdness with stale onChange being called when switching requests, so we'll
// key on the request ID as a workaround for now.
@@ -38,9 +45,41 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
const [autoIntrospectDisabled, setAutoIntrospectDisabled] = useLocalStorage<
Record<string, boolean>
>("graphQLAutoIntrospectDisabled", {});
const { schema, isLoading, error, refetch, clear } = useIntrospectGraphQL(baseRequest, {
const {
schema,
isLoading,
error,
refetch,
clear,
loadFromFile,
reloadFromFile,
removeSchemaFile,
filePath,
} = useIntrospectGraphQL(baseRequest, {
disabled: autoIntrospectDisabled?.[baseRequest.id],
});
// Last path segment, for display only. The host owns real path semantics; this
// just needs something short enough to label the divider with.
const fileName = useMemo(() => filePath?.split(/[/\\]/).pop() || filePath, [filePath]);
// Selecting a file is all it takes — the request's source becomes that file,
// which is what keeps automatic introspection from overwriting it.
const handleLoadFromFile = useCallback(async () => {
const selected = await platform.dialog.open({
title: "Load GraphQL Schema",
multiple: false,
filters: [
{
name: "GraphQL Schema",
extensions: ["graphql", "graphqls", "gql", "json"],
},
],
});
if (selected == null) return;
await loadFromFile(selected);
}, [loadFromFile]);
const [currentBody, setCurrentBody] = useStateWithDeps<{
query: string;
variables: string | undefined;
@@ -160,14 +199,37 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
...((schema != null
? [
{
label: "Clear",
label: "Clear Schema",
onSelect: clear,
color: "danger",
leftSlot: <Icon icon="trash" />,
},
{ type: "separator" },
]
: []) satisfies DropdownItem[]),
{
// Labels the source actions below it, so the menu says where the
// schema came from without spending a row on it.
type: "separator",
hidden: schema == null && filePath == null,
label:
fileName == null || filePath == null ? undefined : (
// Middle truncation: the head shrinks and ellipsizes while the
// tail is pinned, so the extension always survives. Full path
// on hover.
<div className="flex min-w-0 max-w-[16rem] font-mono text-xs" title={filePath}>
<span className="truncate">{fileName.slice(0, -FILE_NAME_TAIL_CHARS)}</span>
<span className="shrink-0">{fileName.slice(-FILE_NAME_TAIL_CHARS)}</span>
</div>
),
action:
filePath == null
? undefined
: {
icon: "folder_symlink",
title: revealInFinderText,
onClick: () => platform.revealItemInDir(filePath),
},
},
{
hidden: !error,
label: (
@@ -210,25 +272,33 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
type: "content",
},
{
hidden: schema == null,
label: `${isDocOpen ? "Hide" : "Show"} Documentation`,
leftSlot: <Icon icon="book_open_text" />,
onSelect: () => {
setGraphqlDocStateAtomValue((v) => ({
...v,
[request.id]: isDocOpen ? undefined : null,
}));
// One refresh action for either source: re-read the file, or
// re-introspect the server.
label: "Reload Schema",
leftSlot: <Icon icon="refresh" spin={isLoading} />,
keepOpenOnSelect: true,
// Failures surface through the hook's error state either way.
onSelect: async () => {
if (filePath != null) await reloadFromFile();
else await refetch();
},
},
{
label: "Introspect Schema",
leftSlot: <Icon icon="refresh" spin={isLoading} />,
keepOpenOnSelect: true,
onSelect: refetch,
label: filePath == null ? "Load Schema from File…" : "Load a Different File…",
leftSlot: <Icon icon="import" />,
onSelect: handleLoadFromFile,
},
{ type: "separator", label: "Setting" },
{
label: "Automatic Introspection",
hidden: filePath == null,
label: "Stop Using File",
leftSlot: <Icon icon="x" />,
onSelect: removeSchemaFile,
},
{ type: "separator", label: "Settings" },
{
// Governs both sources: re-introspecting the server, and
// re-reading the file when the request is opened.
label: filePath == null ? "Automatic Introspection" : "Automatic Reload",
keepOpenOnSelect: true,
onSelect: () => {
setAutoIntrospectDisabled({
@@ -261,6 +331,29 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
</Dropdown>
)}
</div>,
// Sits after the schema control it depends on. Always rendered, disabled
// without a schema, so the row never changes shape.
<div key="documentation" className="opacity-100!">
<IconButton
size="sm"
variant="border"
icon="book_open_text"
disabled={schema == null}
title={
schema == null
? "Documentation unavailable without a schema"
: isDocOpen
? "Hide Documentation"
: "Show Documentation"
}
onClick={() => {
setGraphqlDocStateAtomValue((v) => ({
...v,
[request.id]: isDocOpen ? undefined : null,
}));
}}
/>
</div>,
],
[
schema,
@@ -272,6 +365,11 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
isLoading,
operationNames,
refetch,
handleLoadFromFile,
reloadFromFile,
removeSchemaFile,
filePath,
fileName,
autoIntrospectDisabled,
baseRequest.id,
setGraphqlDocStateAtomValue,
@@ -1,29 +1,29 @@
import { useEffect, useState } from "react";
import { platform } from "@yaakapp-internal/platform";
interface Props {
bodyPath?: string;
/** A URL for the body the host already stored. */
bodyUrl?: string;
data?: Uint8Array;
mimeType?: string;
}
export function AudioViewer({ bodyPath, data, mimeType }: Props) {
export function AudioViewer({ bodyUrl, data, mimeType }: Props) {
const [src, setSrc] = useState<string>();
useEffect(() => {
if (bodyPath) {
setSrc(platform.files.url(bodyPath));
if (bodyUrl) {
setSrc(bodyUrl);
} else if (data) {
// The type matters here in a way it doesn't for an image: a media element goes by what
// the blob declares rather than sniffing it, so an Ogg labelled as MP3 won't play
const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "audio/mpeg" });
const url = URL.createObjectURL(blob);
setSrc(url);
return () => URL.revokeObjectURL(url);
const objectUrl = URL.createObjectURL(blob);
setSrc(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
} else {
setSrc(undefined);
}
}, [bodyPath, data, mimeType]);
}, [bodyUrl, data, mimeType]);
// oxlint-disable-next-line jsx-a11y/media-has-caption
return <audio className="w-full" controls src={src} />;
@@ -1,7 +1,9 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { useMemo, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useCallback } from "react";
import { useCopyHttpResponse } from "../../hooks/useCopyHttpResponse";
import { useResponseBodyText } from "../../hooks/useResponseBodyText";
import { responseBodyTextQuery, useResponseBodyText } from "../../hooks/useResponseBodyText";
import { useResponseFilter } from "../../hooks/useResponseFilter";
import { useSaveResponse } from "../../hooks/useSaveResponse";
import { languageFromContentType } from "../../lib/contentType";
import { getContentTypeFromHeaders } from "../../lib/model_util";
@@ -52,30 +54,25 @@ interface HttpTextViewerProps {
}
function HttpTextViewer({ response, text, language, pretty, className }: HttpTextViewerProps) {
const [currentFilter, setCurrentFilter] = useState<string | null>(null);
const filteredBody = useResponseBodyText({ response, filter: currentFilter });
const queryClient = useQueryClient();
const filter = useResponseFilter({
stateKey: `response.body.${response.requestId}`,
// Shares the display query's cache entry, so the verdict costs no extra RPC
runFilter: useCallback(
(f: string) => queryClient.fetchQuery(responseBodyTextQuery({ response, filter: f })),
[queryClient, response],
),
});
const filteredBody = useResponseBodyText({ response, filter: filter.appliedFilter });
const saveResponse = useSaveResponse(response);
const copyResponse = useCopyHttpResponse(response);
const actionsDisabled = response.state !== "closed" && response.status >= 100;
const filterCallback = useMemo(
() => (filter: string) => {
setCurrentFilter(filter);
return {
data: filteredBody.data,
isPending: filteredBody.isPending,
error: !!filteredBody.error,
};
},
[filteredBody],
);
return (
<TextViewer
text={text}
language={language}
stateKey={`response.body.${response.id}`}
filterStateKey={`response.body.${response.requestId}`}
pretty={pretty}
className={className}
footerActions={[
@@ -98,7 +95,12 @@ function HttpTextViewer({ response, text, language, pretty, className }: HttpTex
className="border !border-border-subtle"
/>,
]}
onFilter={filterCallback}
filter={filter}
filterResult={{
data: filteredBody.data,
isPending: filteredBody.isPending,
error: !!filteredBody.error,
}}
/>
);
}
@@ -1,10 +1,10 @@
import classNames from "classnames";
import { useEffect, useState } from "react";
import { platform } from "@yaakapp-internal/platform";
type Props = { className?: string; mimeType?: string } & (
| {
bodyPath: string;
/** A URL for the body the host already stored. */
bodyUrl: string;
}
| {
data: ArrayBuffer;
@@ -13,21 +13,21 @@ type Props = { className?: string; mimeType?: string } & (
export function ImageViewer({ className, mimeType, ...props }: Props) {
const [src, setSrc] = useState<string>();
const bodyPath = "bodyPath" in props ? props.bodyPath : null;
const bodyUrl = "bodyUrl" in props ? props.bodyUrl : null;
const data = "data" in props ? props.data : null;
useEffect(() => {
if (bodyPath != null) {
setSrc(platform.files.url(bodyPath));
if (bodyUrl != null) {
setSrc(bodyUrl);
} else if (data != null) {
const blob = new Blob([data], { type: mimeType ?? "image/png" });
const url = URL.createObjectURL(blob);
setSrc(url);
return () => URL.revokeObjectURL(url);
const objectUrl = URL.createObjectURL(blob);
setSrc(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
} else {
setSrc(undefined);
}
}, [bodyPath, data, mimeType]);
}, [bodyUrl, data, mimeType]);
return (
<img
@@ -6,7 +6,6 @@ import { useMemo, useRef, useState } from "react";
import { Document, Page } from "react-pdf";
import { useContainerSize } from "@yaakapp-internal/ui";
import { fireAndForget } from "../../lib/fireAndForget";
import { platform } from "@yaakapp-internal/platform";
fireAndForget(
import("react-pdf").then(({ pdfjs }) => {
@@ -18,7 +17,8 @@ fireAndForget(
);
interface Props {
bodyPath?: string;
/** A URL for the body the host already stored. */
bodyUrl?: string;
data?: Uint8Array;
}
@@ -27,7 +27,7 @@ const options = {
standardFontDataUrl: "/standard_fonts/",
};
export function PdfViewer({ bodyPath, data }: Props) {
export function PdfViewer({ bodyUrl, data }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [numPages, setNumPages] = useState<number>();
@@ -36,8 +36,8 @@ export function PdfViewer({ bodyPath, data }: Props) {
// During render, not in an effect: an effect leaves the first paint with no file, and
// `Document` renders its "Failed to load PDF file" state for that frame before recovering
const src = useMemo(() => {
if (bodyPath) {
return platform.files.url(bodyPath);
if (bodyUrl) {
return bodyUrl;
}
if (data) {
// Create a copy to avoid "Buffer is already detached" errors
@@ -45,7 +45,7 @@ export function PdfViewer({ bodyPath, data }: Props) {
return { data: new Uint8Array(data) };
}
return undefined;
}, [bodyPath, data]);
}, [bodyUrl, data]);
const onDocumentLoadSuccess = ({ numPages: nextNumPages }: PDFDocumentProxy): void => {
setNumPages(nextNumPages);
@@ -0,0 +1,96 @@
import { Icon } from "@yaakapp-internal/ui";
import type { RecentFilter } from "../../hooks/useRecentFilters";
import { Dropdown, type DropdownItem } from "../core/Dropdown";
import { IconButton } from "../core/IconButton";
interface Props {
recentFilters: RecentFilter[];
activeFilter: string | null;
onSelect: (value: string) => void;
onRemove: (value: string) => void;
onTogglePin: (value: string) => void;
onClear: () => void;
}
export function RecentFiltersDropdown({
recentFilters,
activeFilter,
onSelect,
onRemove,
onTogglePin,
onClear,
}: Props) {
const pinned = recentFilters.filter((f) => f.pinned);
const unpinned = recentFilters.filter((f) => !f.pinned);
const toItem = (filter: RecentFilter): DropdownItem => ({
label: (
<div className="font-mono text-sm truncate max-w-sm" title={filter.value}>
{filter.value}
</div>
),
leftSlot: <Icon icon={filter.value === activeFilter ? "check" : "empty"} />,
onSelect: () => onSelect(filter.value),
submenuTrigger: "button",
submenu: [
{
label: filter.pinned ? "Unpin" : "Pin",
icon: filter.pinned ? "unpin" : "pin",
keepOpenOnSelect: true,
onSelect: () => onTogglePin(filter.value),
},
{
label: "Remove",
icon: "trash",
color: "danger",
keepOpenOnSelect: true,
onSelect: () => onRemove(filter.value),
},
],
});
const items: DropdownItem[] = [];
if (recentFilters.length === 0) {
items.push({
type: "content",
label: (
<span className="block px-4 py-1 text-sm text-text-subtle">
Filters you use are remembered here
</span>
),
});
}
if (pinned.length > 0) {
items.push({ type: "separator", label: "Pinned" }, ...pinned.map(toItem));
}
if (unpinned.length > 0) {
items.push({ type: "separator", label: "Recent" }, ...unpinned.map(toItem));
}
if (recentFilters.length > 0) {
items.push(
{ type: "separator" },
{
label: "Clear All",
leftSlot: <Icon icon="trash" />,
color: "danger",
onSelect: onClear,
},
);
}
return (
<Dropdown items={items}>
<IconButton
size="xs"
icon="filter"
title="Recent filters"
iconColor="secondary"
className="w-8 ml-0.5 mr-1 h-auto!"
/>
</Dropdown>
);
}
@@ -1,14 +1,15 @@
import classNames from "classnames";
import type { ReactNode } from "react";
import { Children, useCallback, useMemo } from "react";
import { createGlobalState } from "react-use";
import { useDebouncedValue } from "@yaakapp-internal/ui";
import { Banner, HStack, Icon, InlineCode } from "@yaakapp-internal/ui";
import { useFormatText } from "../../hooks/useFormatText";
import type { ResponseFilterApi } from "../../hooks/useResponseFilter";
import { Button } from "../core/Button";
import type { EditorProps } from "../core/Editor/Editor";
import { hyperlink } from "../core/Editor/hyperlink/extension";
import { Editor } from "../core/Editor/LazyEditor";
import { IconButton } from "../core/IconButton";
import { Input } from "../core/Input";
import { RecentFiltersDropdown } from "./RecentFiltersDropdown";
const extraExtensions = [hyperlink];
@@ -16,57 +17,45 @@ interface Props {
text: string;
language: EditorProps["language"];
stateKey: string | null;
filterStateKey?: string | null;
pretty?: boolean;
className?: string;
footerActions?: ReactNode;
onFilter?: (filter: string) => {
filter?: ResponseFilterApi;
filterResult?: {
data: string | null | undefined;
isPending: boolean;
error: boolean;
};
}
const useFilterText = createGlobalState<Record<string, string | null>>({});
export function TextViewer({
language,
text,
stateKey,
filterStateKey,
pretty,
className,
footerActions,
onFilter,
filter,
filterResult,
}: Props) {
const filterKey = filterStateKey ?? stateKey;
const [filterTextMap, setFilterTextMap] = useFilterText();
const filterText = filterKey ? (filterTextMap[filterKey] ?? null) : null;
const debouncedFilterText = useDebouncedValue(filterText);
const setFilterText = useCallback(
(v: string | null) => {
if (!filterKey) return;
setFilterTextMap((m) => ({ ...m, [filterKey]: v }));
const canFilter =
filter != null && (language === "json" || language === "xml" || language === "html");
const isSearching = filter?.isSearching ?? false;
const appliedFilter = filter?.appliedFilter ?? null;
const resultError = filterResult?.error ?? false;
const handleFilterKeyDown = useCallback(
(e: KeyboardEvent) => {
if (filter == null) return;
if (e.key === "Escape") {
filter.toggleSearch();
} else if (e.key === "Enter" && filter.filterText != null) {
filter.applyFilter(filter.filterText);
}
},
[filterKey, setFilterTextMap],
[filter],
);
const isSearching = filterText != null;
const filteredResponse =
onFilter && debouncedFilterText
? onFilter(debouncedFilterText)
: { data: null, isPending: false, error: false };
const toggleSearch = useCallback(() => {
if (isSearching) {
setFilterText(null);
} else {
setFilterText("");
}
}, [isSearching, setFilterText]);
const canFilter = onFilter && (language === "json" || language === "xml" || language === "html");
const actions = useMemo<ReactNode[]>(() => {
const nodes: ReactNode[] = isSearching ? [] : Children.toArray(footerActions);
@@ -76,8 +65,8 @@ export function TextViewer({
nodes.push(
<div key="input" className="w-full opacity-100!">
<Input
key={filterKey ?? "filter"}
validate={!filteredResponse.error}
key={filter.stateKey ?? "filter"}
validate={!resultError}
hideLabel
autoFocus
containerClassName="bg-surface"
@@ -85,39 +74,62 @@ export function TextViewer({
placeholder={language === "json" ? "JSONPath expression" : "XPath expression"}
label="Filter expression"
name="filter"
defaultValue={filterText}
onKeyDown={(e) => e.key === "Escape" && toggleSearch()}
onChange={setFilterText}
stateKey={filterKey ? `filter.${filterKey}` : null}
defaultValue={filter.filterText}
forceUpdateKey={filter.filterUpdateKey}
onKeyDown={handleFilterKeyDown}
onChange={filter.setFilterText}
stateKey={filter.stateKey ? `filter.${filter.stateKey}` : null}
leftSlot={
<div className="py-0.5 flex">
<RecentFiltersDropdown
recentFilters={filter.recentFilters}
activeFilter={filter.appliedFilter}
onSelect={filter.replaceFilter}
onRemove={filter.removeRecentFilter}
onTogglePin={filter.togglePinRecentFilter}
onClear={filter.clearRecentFilters}
/>
</div>
}
rightSlot={
<div className="py-0.5 flex">
<IconButton
size="xs"
icon="x"
title="Close filter"
iconColor="secondary"
onClick={filter.toggleSearch}
className="w-8 mr-0.5 h-auto!"
/>
</div>
}
/>
</div>,
);
} else {
nodes.push(
<IconButton
key="icon"
size="sm"
isLoading={filterResult?.isPending ?? false}
icon="filter"
title="Filter response"
onClick={filter.toggleSearch}
className="border border-border-subtle!"
/>,
);
}
nodes.push(
<IconButton
key="icon"
size="sm"
isLoading={filteredResponse.isPending}
icon={isSearching ? "x" : "filter"}
title={isSearching ? "Close filter" : "Filter response"}
onClick={toggleSearch}
className={classNames("border border-border-subtle!", isSearching && "opacity-100!")}
/>,
);
return nodes;
}, [
canFilter,
footerActions,
filterKey,
filterText,
filteredResponse.error,
filteredResponse.isPending,
filter,
filterResult?.isPending,
resultError,
isSearching,
language,
setFilterText,
toggleSearch,
handleFilterKeyDown,
]);
const formattedBody = useFormatText({ text, language, pretty: pretty ?? false });
@@ -126,11 +138,11 @@ export function TextViewer({
}
let body: string;
if (isSearching && filterText?.length > 0) {
if (filteredResponse.error) {
if (appliedFilter) {
if (resultError) {
body = "";
} else {
body = filteredResponse.data != null ? filteredResponse.data : "";
body = filterResult?.data != null ? filterResult.data : "";
}
} else {
body = formattedBody;
@@ -143,15 +155,61 @@ export function TextViewer({
}
return (
<Editor
readOnly
className={className}
defaultValue={body}
language={language}
actions={actions}
extraExtensions={extraExtensions}
stateKey={stateKey}
/>
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full w-full">
{appliedFilter && filter != null ? (
<AppliedFilterBar
filter={appliedFilter}
error={resultError}
onClear={() => filter.replaceFilter("")}
/>
) : (
<span />
)}
<Editor
readOnly
className={className}
defaultValue={body}
language={language}
actions={actions}
extraExtensions={extraExtensions}
stateKey={stateKey}
/>
</div>
);
}
/**
* Shows what's actually filtering the body, which the filter box below can't convey
* once it holds an edited expression that hasn't been applied yet.
*/
function AppliedFilterBar({
filter,
error,
onClear,
}: {
filter: string;
error: boolean;
onClear: () => void;
}) {
return (
<Banner color={error ? "danger" : "info"} className="py-1! mb-2! text-sm">
<HStack space={2} className="min-w-0">
<Icon icon="filter" size="xs" className="shrink-0 opacity-70" />
<span className="truncate min-w-0" title={filter}>
Response filtered by <InlineCode>{filter}</InlineCode>
{error && " (invalid expression)"}
</span>
<Button
size="2xs"
variant="border"
color={error ? "danger" : "info"}
className="ml-auto shrink-0"
onClick={onClear}
>
Clear
</Button>
</HStack>
</Banner>
);
}
@@ -1,28 +1,28 @@
import { useEffect, useState } from "react";
import { platform } from "@yaakapp-internal/platform";
interface Props {
bodyPath?: string;
/** A URL for the body the host already stored. */
bodyUrl?: string;
data?: Uint8Array;
mimeType?: string;
}
export function VideoViewer({ bodyPath, data, mimeType }: Props) {
export function VideoViewer({ bodyUrl, data, mimeType }: Props) {
const [src, setSrc] = useState<string>();
useEffect(() => {
if (bodyPath) {
setSrc(platform.files.url(bodyPath));
if (bodyUrl) {
setSrc(bodyUrl);
} else if (data) {
// As in AudioViewer: a media element trusts the declared type instead of sniffing
const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "video/mp4" });
const url = URL.createObjectURL(blob);
setSrc(url);
return () => URL.revokeObjectURL(url);
const objectUrl = URL.createObjectURL(blob);
setSrc(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
} else {
setSrc(undefined);
}
}, [bodyPath, data, mimeType]);
}, [bodyUrl, data, mimeType]);
// oxlint-disable-next-line jsx-a11y/media-has-caption
return <video className="w-full" controls src={src} />;
@@ -0,0 +1,18 @@
import { useKeyValue } from "./useKeyValue";
// The file a request's GraphQL schema is loaded from, or null when the schema
// comes from an introspection request.
//
// This is the *source*, not the schema. The introspection row it produces is a
// cache that expires on its own; this outlives it and regenerates it, the same
// way gRPC keeps its proto file list separate from a reflection result.
export function graphqlSchemaFileArgs(requestId: string | null) {
return {
namespace: "global" as const,
key: ["graphql_schema_file", requestId ?? "n/a"],
};
}
export function useGraphQLSchemaFile(requestId: string | null) {
return useKeyValue<string | null>({ ...graphqlSchemaFileArgs(requestId), fallback: null });
}
+121 -9
View File
@@ -1,13 +1,14 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { GraphQlIntrospection, HttpRequest } from "@yaakapp-internal/models";
import { platform } from "@yaakapp-internal/platform";
import type { GraphQLSchema, IntrospectionQuery } from "graphql";
import { buildClientSchema, getIntrospectionQuery } from "graphql";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { tryBuildIntrospectionFromFile } from "../lib/graphqlSchema";
import { minPromiseMillis } from "../lib/minPromiseMillis";
import { getResponseBodyText } from "../lib/responseBody";
import { sendEphemeralRequest } from "../lib/sendEphemeralRequest";
import { useActiveEnvironment } from "./useActiveEnvironment";
import { useGraphQLSchemaFile } from "./useGraphQLSchemaFile";
import { useDebouncedValue } from "@yaakapp-internal/ui";
import { rpc } from "../lib/rpc";
@@ -31,6 +32,11 @@ export function useIntrospectGraphQL(
const introspection = useIntrospectionResult(baseRequest);
// The schema's source. Outlives the introspection row it produces, so a
// request configured with a file keeps working after the row is swept.
const schemaFile = useGraphQLSchemaFile(baseRequest.id);
const filePath = schemaFile.value ?? null;
const upsertIntrospection = useCallback(
async (content: string | null) => {
const v = await rpc<GraphQlIntrospection>("models_upsert_graphql_introspection", {
@@ -55,7 +61,7 @@ export function useIntrospectGraphQL(
bodyType: "application/json",
body: { text: introspectionRequestBody },
};
const response = await minPromiseMillis(
const { response, body } = await minPromiseMillis(
sendEphemeralRequest(args, activeEnvironment?.id ?? null),
700,
);
@@ -64,14 +70,16 @@ export function useIntrospectGraphQL(
return setError(response.error);
}
const bodyText = await getResponseBodyText({ response, filter: null });
// The send hands back the only copy of the body — an unsaved response has
// nothing on disk and no row to read it back from
const bodyText = new TextDecoder("utf-8").decode(new Uint8Array(body));
if (response.status < 200 || response.status >= 300) {
return setError(
`Request failed with status ${response.status}.\nThe response text is:\n\n${bodyText}`,
);
}
if (bodyText === null) {
if (bodyText === "") {
return setError("Empty body returned in response");
}
@@ -91,15 +99,109 @@ export function useIntrospectGraphQL(
return;
}
refetch().catch(console.error);
}, [baseRequest.id, debouncedRequest.url, debouncedRequest.method, activeEnvironment?.id]);
// A request pointed at a file gets its schema from that file. Introspecting
// here would overwrite it on the next URL edit.
if (filePath != null) {
return;
}
refetch().catch(console.error);
}, [
baseRequest.id,
debouncedRequest.url,
debouncedRequest.method,
activeEnvironment?.id,
filePath,
]);
// Clears the schema, not the source. Removing a file source is a separate
// action, because the source is what would rebuild this a moment later.
const clear = useCallback(async () => {
setError("");
setSchema(null);
await upsertIntrospection(null);
}, [upsertIntrospection]);
// Reads a schema file and produces an introspection row from it, the same way
// `refetch` produces one from a server. Does not touch the stored source.
const introspectFromFile = useCallback(
async (path: string): Promise<{ ok: true } | { ok: false; error: string }> => {
try {
setIsLoading(true);
setError(undefined);
const fileContent = await platform.files.readText(path);
const result = tryBuildIntrospectionFromFile(fileContent);
if ("error" in result) {
setError(result.error);
return { ok: false, error: result.error };
}
await upsertIntrospection(result.content);
return { ok: true };
} catch (err) {
// The host rejects with a bare string for a missing or unreadable path,
// so this can't assume an Error.
const message = err instanceof Error ? err.message : String(err);
setError(message);
return { ok: false, error: message };
} finally {
setIsLoading(false);
}
},
[upsertIntrospection],
);
// Points the request at a file and immediately builds its schema from it.
const loadFromFile = useCallback(
async (path: string) => {
const result = await introspectFromFile(path);
if (result.ok) await schemaFile.set(path);
return result;
},
[introspectFromFile, schemaFile],
);
const reloadFromFile = useCallback(async () => {
if (filePath == null) return { ok: false as const, error: "No schema file to reload" };
return introspectFromFile(filePath);
}, [filePath, introspectFromFile]);
// The file-source counterpart of automatic introspection: re-read the file
// when the request is opened, so an edited schema is picked up without asking.
//
// A missing row is repaired even with the setting off — that is recovering
// from the 7-day sweep, not keeping the schema fresh, and skipping it would
// make the schema disappear with no visible cause.
const reloadedFor = useRef<string | null>(null);
useEffect(() => {
if (filePath == null || introspection.isLoading) return;
// Only attempt once per path, so an unreadable file doesn't spin.
if (reloadedFor.current === filePath) return;
const hasContent = (introspection.data?.content ?? "") !== "";
if (hasContent && options.disabled) return;
reloadedFor.current = filePath;
introspectFromFile(filePath).catch(console.error);
}, [
filePath,
introspection.data?.content,
introspection.isLoading,
introspectFromFile,
options.disabled,
]);
// Stops using the file. The schema goes with it, since the file is what
// produced it; introspection repopulates if it's set to run automatically.
const removeSchemaFile = useCallback(async () => {
setError("");
setSchema(null);
await schemaFile.set(null);
await upsertIntrospection(null);
}, [schemaFile, upsertIntrospection]);
useEffect(() => {
if (introspection.data?.content == null || introspection.data.content === "") {
return;
@@ -113,7 +215,17 @@ export function useIntrospectGraphQL(
}
}, [introspection.data?.content]);
return { schema, isLoading, error, refetch, clear };
return {
schema,
isLoading,
error,
refetch,
clear,
loadFromFile,
reloadFromFile,
removeSchemaFile,
filePath,
};
}
function useIntrospectionResult(request: HttpRequest) {
@@ -0,0 +1,67 @@
import { useCallback } from "react";
import { useKeyValue } from "./useKeyValue";
export interface RecentFilter {
value: string;
pinned?: boolean;
}
const MAX_RECENT_FILTERS = 20;
const kvKey = (filterStateKey: string) => `recent_filters::${filterStateKey}`;
const namespace = "global";
const fallback: RecentFilter[] = [];
export function useRecentFilters(filterStateKey: string | null) {
const { value, set } = useKeyValue<RecentFilter[]>({
key: kvKey(filterStateKey ?? "n/a"),
namespace,
fallback,
});
const addFilter = useCallback(
async (rawValue: string) => {
const value = rawValue.trim();
if (filterStateKey == null || value === "") return;
await set((prev) => {
// Returning the same reference skips the write, so re-committing the
// expression already at the top (on every blur) costs nothing
if (prev[0]?.value === value) return prev;
const existing = prev.find((f) => f.value === value);
const rest = prev.filter((f) => f.value !== value);
return trim([{ value, pinned: existing?.pinned }, ...rest]);
});
},
[filterStateKey, set],
);
const removeFilter = useCallback(
async (value: string) => set((prev) => prev.filter((f) => f.value !== value)),
[set],
);
const togglePin = useCallback(
async (value: string) =>
set((prev) => prev.map((f) => (f.value === value ? { ...f, pinned: !f.pinned } : f))),
[set],
);
const clearFilters = useCallback(async () => set([]), [set]);
return { recentFilters: value ?? fallback, addFilter, removeFilter, togglePin, clearFilters };
}
/** Bound the list, evicting the oldest unpinned entries before any pinned ones */
function trim(filters: RecentFilter[]): RecentFilter[] {
const excess = filters.length - MAX_RECENT_FILTERS;
if (excess <= 0) return filters;
const evicted = new Set<number>();
for (let i = filters.length - 1; i >= 0 && evicted.size < excess; i--) {
if (!filters[i]?.pinned) evicted.add(i);
}
for (let i = filters.length - 1; i >= 0 && evicted.size < excess; i--) {
evicted.add(i);
}
return filters.filter((_, i) => !evicted.has(i));
}
+20 -8
View File
@@ -2,6 +2,25 @@ import { useQuery } from "@tanstack/react-query";
import type { HttpResponse } from "@yaakapp-internal/models";
import { getResponseBodyBytes, getResponseBodyText } from "../lib/responseBody";
export function responseBodyTextQuery({
response,
filter,
}: {
response: HttpResponse;
filter: string | null;
}) {
return {
queryKey: [
"response_body_text",
response.id,
response.updatedAt,
response.contentLength,
filter ?? "",
],
queryFn: () => getResponseBodyText({ response, filter }),
};
}
export function useResponseBodyText({
response,
filter,
@@ -11,14 +30,7 @@ export function useResponseBodyText({
}) {
return useQuery({
placeholderData: (prev) => prev, // Keep previous data on refetch
queryKey: [
"response_body_text",
response.id,
response.updatedAt,
response.contentLength,
filter ?? "",
],
queryFn: () => getResponseBodyText({ response, filter }),
...responseBodyTextQuery({ response, filter }),
});
}
@@ -0,0 +1,24 @@
import { useQuery } from "@tanstack/react-query";
import type { HttpResponse } from "@yaakapp-internal/models";
import { platform } from "@yaakapp-internal/platform";
/**
* A URL for a stored response body, for the viewers that hand one to an element
* instead of reading the bytes themselves.
*
* Resolved once here rather than inside each viewer: the host may have to ask
* the backend where the body is, and a viewer that computes its source during
* render (the PDF one, deliberately) needs it settled before it mounts.
*
* Null data means the response has no stored body.
*/
export function useResponseBodyUrl(response: HttpResponse | null) {
const responseId = response?.id ?? null;
return useQuery({
queryKey: ["response_body_url", responseId, response?.updatedAt ?? ""],
enabled: responseId != null,
// A response body is stored under the response's own id
queryFn: () => (responseId == null ? null : platform.blobs.url(responseId)),
});
}
+129
View File
@@ -0,0 +1,129 @@
import { useCallback, useState } from "react";
import { createGlobalState } from "react-use";
import type { RecentFilter } from "./useRecentFilters";
import { useRecentFilters } from "./useRecentFilters";
/** What's typed in the filter box. `null` means the filter box is closed */
const useFilterTextMap = createGlobalState<Record<string, string | null>>({});
/** What's actually applied to the response. Only changes on an explicit apply */
const useAppliedFilterMap = createGlobalState<Record<string, string | null>>({});
export interface ResponseFilterApi {
stateKey: string | null;
/** Draft text in the filter box, or `null` when the box is closed */
filterText: string | null;
/** The expression currently filtering the response */
appliedFilter: string | null;
isSearching: boolean;
/** The box holds an expression that isn't the one currently applied */
isDirty: boolean;
/** Bumped when the (uncontrolled) filter input must re-read its defaultValue */
filterUpdateKey: number;
setFilterText: (value: string | null) => void;
/** Apply the expression to the response, recording it if the filter accepts it */
applyFilter: (value: string) => void;
/** Like applyFilter, but also replaces what's shown in the filter box */
replaceFilter: (value: string) => void;
toggleSearch: () => void;
recentFilters: RecentFilter[];
removeRecentFilter: (value: string) => void;
togglePinRecentFilter: (value: string) => void;
clearRecentFilters: () => void;
}
/**
* Draft/applied state and history for a response filter (JSONPath/XPath).
*
* History records at most one entry per apply gesture, and only after `runFilter`
* confirms the plugin accepts the expression — the plugin is the sole judge of
* validity. Because nothing but the gesture ever writes, refetches can't resurrect
* deleted entries and a gesture can't record into another request's history.
*/
export function useResponseFilter({
stateKey,
runFilter,
}: {
stateKey: string | null;
/** Evaluate an expression, rejecting if the filter plugin reports an error */
runFilter: (filter: string) => Promise<unknown>;
}): ResponseFilterApi {
const [filterTextMap, setFilterTextMap] = useFilterTextMap();
const [appliedFilterMap, setAppliedFilterMap] = useAppliedFilterMap();
const filterText = stateKey ? (filterTextMap[stateKey] ?? null) : null;
const appliedFilter = stateKey ? (appliedFilterMap[stateKey] ?? null) : null;
const setFilterText = useCallback(
(v: string | null) => {
if (!stateKey) return;
setFilterTextMap((m) => ({ ...m, [stateKey]: v }));
},
[stateKey, setFilterTextMap],
);
const setAppliedFilter = useCallback(
(v: string | null) => {
if (!stateKey) return;
setAppliedFilterMap((m) => ({ ...m, [stateKey]: v }));
},
[stateKey, setAppliedFilterMap],
);
const {
recentFilters,
addFilter,
removeFilter: removeRecentFilter,
togglePin: togglePinRecentFilter,
clearFilters: clearRecentFilters,
} = useRecentFilters(stateKey);
const applyFilter = useCallback(
(value: string) => {
setFilterText(value);
const applied = value.trim() === "" ? null : value.trim();
setAppliedFilter(applied);
if (applied == null) return;
runFilter(applied).then(
() => addFilter(applied),
() => {}, // Rejected by the filter plugin — don't record
);
},
[setFilterText, setAppliedFilter, runFilter, addFilter],
);
const [filterUpdateKey, setFilterUpdateKey] = useState(0);
const replaceFilter = useCallback(
(value: string) => {
applyFilter(value);
setFilterUpdateKey((k) => k + 1);
},
[applyFilter],
);
const isSearching = filterText != null;
const toggleSearch = useCallback(() => {
if (isSearching) {
setFilterText(null);
setAppliedFilter(null);
} else {
setFilterText("");
}
}, [isSearching, setFilterText, setAppliedFilter]);
return {
stateKey,
filterText,
appliedFilter,
isSearching,
isDirty: filterText != null && filterText.trim() !== (appliedFilter ?? ""),
filterUpdateKey,
setFilterText,
applyFilter,
replaceFilter,
toggleSearch,
recentFilters,
removeRecentFilter,
togglePinRecentFilter,
clearRecentFilters,
};
}
@@ -25,6 +25,10 @@ export function useSaveResponse(response: HttpResponse | null) {
defaultPath: ext ? `${slug}.${ext}` : slug,
title: "Save Response",
});
if (filepath == null) {
return; // Cancelled
}
await rpc("cmd_save_response", { responseId: response.id, filepath });
showToast({
message: (
@@ -0,0 +1,85 @@
import { buildSchema, introspectionFromSchema } from "graphql";
import { describe, expect, test } from "vite-plus/test";
import { tryBuildIntrospectionFromFile } from "./graphqlSchema";
const sdl = `
type Query {
hello: String!
user(id: ID!): User
}
type User {
id: ID!
name: String
}
`;
const introspection = introspectionFromSchema(buildSchema(sdl));
describe("tryBuildIntrospectionFromFile", () => {
test("accepts introspection JSON wrapped in { data: ... }", () => {
const input = JSON.stringify({ data: introspection });
const result = tryBuildIntrospectionFromFile(input);
expect("schema" in result).toBe(true);
if ("schema" in result) {
expect(result.schema.getQueryType()?.getFields()).toHaveProperty("hello");
// Output content is the normalized, persistable shape.
expect(JSON.parse(result.content)).toHaveProperty("data.__schema");
}
});
test("accepts bare introspection JSON without a data wrapper", () => {
const input = JSON.stringify(introspection);
const result = tryBuildIntrospectionFromFile(input);
expect("schema" in result).toBe(true);
if ("schema" in result) {
expect(result.schema.getQueryType()?.getFields()).toHaveProperty("user");
// Bare input is wrapped on the way out.
expect(JSON.parse(result.content)).toHaveProperty("data.__schema");
}
});
test("accepts a GraphQL SDL string", () => {
const result = tryBuildIntrospectionFromFile(sdl);
expect("schema" in result).toBe(true);
if ("schema" in result) {
const fields = result.schema.getQueryType()?.getFields() ?? {};
expect(fields).toHaveProperty("hello");
expect(fields).toHaveProperty("user");
// SDL is converted to introspection JSON for storage.
expect(JSON.parse(result.content)).toHaveProperty("data.__schema");
}
});
test("returns an error for JSON that is neither introspection nor SDL", () => {
const result = tryBuildIntrospectionFromFile('{"unrelated":"value"}');
expect("error" in result).toBe(true);
if ("error" in result) {
expect(result.error).toMatch(/Could not parse file as introspection JSON or GraphQL SDL/);
}
});
test("returns an error for content that is neither valid JSON nor valid SDL", () => {
const result = tryBuildIntrospectionFromFile("not a schema!@#$");
expect("error" in result).toBe(true);
if ("error" in result) {
expect(result.error).toMatch(/Could not parse file as introspection JSON or GraphQL SDL/);
}
});
test("returns an error when introspection JSON has a malformed __schema", () => {
// Has the data.__schema shape but the contents are invalid for buildClientSchema.
const input = JSON.stringify({ data: { __schema: { broken: true } } });
const result = tryBuildIntrospectionFromFile(input);
expect("error" in result).toBe(true);
if ("error" in result) {
expect(result.error).toMatch(/Failed to build schema from introspection JSON/);
}
});
});
+51
View File
@@ -0,0 +1,51 @@
import type { GraphQLSchema, IntrospectionQuery } from "graphql";
import { buildClientSchema, buildSchema, introspectionFromSchema } from "graphql";
// Accepts either a GraphQL introspection JSON ({ data: { __schema } } or
// { __schema }) or an SDL string and normalizes both into the wrapped
// { data: <introspection> } JSON shape used by the introspection store.
export function tryBuildIntrospectionFromFile(
fileContent: string,
): { schema: GraphQLSchema; content: string } | { error: string } {
let parsedJson: unknown;
try {
parsedJson = JSON.parse(fileContent);
} catch {
parsedJson = undefined;
}
if (parsedJson != null && typeof parsedJson === "object") {
const candidates: unknown[] = [(parsedJson as { data?: unknown }).data, parsedJson];
for (const candidate of candidates) {
if (
candidate != null &&
typeof candidate === "object" &&
"__schema" in (candidate as Record<string, unknown>)
) {
try {
const schema = buildClientSchema(candidate as IntrospectionQuery, {});
return { schema, content: JSON.stringify({ data: candidate }) };
} catch (e) {
return {
error: `Failed to build schema from introspection JSON: ${errorMessage(e)}`,
};
}
}
}
}
try {
const schema = buildSchema(fileContent);
const introspection = introspectionFromSchema(schema);
return { schema, content: JSON.stringify({ data: introspection }) };
} catch (e) {
return {
error: `Could not parse file as introspection JSON or GraphQL SDL: ${errorMessage(e)}`,
};
}
}
function errorMessage(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
+13 -17
View File
@@ -2,11 +2,9 @@ import type { BatchUpsertResult } from "@yaakapp-internal/models";
import { FormattedError, VStack } from "@yaakapp-internal/ui";
import { Button } from "../components/core/Button";
import { ImportDataDialog } from "../components/ImportDataDialog";
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { createFastMutation } from "../hooks/useFastMutation";
import { showAlert } from "./alert";
import { showDialog } from "./dialog";
import { jotaiStore } from "./jotai";
import { pluralizeCount } from "./pluralize";
import { router } from "./router";
import { rpc } from "./rpc";
@@ -28,12 +26,9 @@ export const importData = createFastMutation({
title: "Import Data",
size: "sm",
render: ({ hide }) => {
const importAndHide = async (filePath: string) => {
const importAndHide = async (runImport: () => Promise<BatchUpsertResult>) => {
try {
const didImport = await performImport(filePath);
if (!didImport) {
return;
}
await finishImport(await runImport());
resolve();
} catch (err) {
reject(err);
@@ -41,20 +36,23 @@ export const importData = createFastMutation({
hide();
}
};
return <ImportDataDialog importData={importAndHide} />;
return (
<ImportDataDialog
importFile={(filePath) =>
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_data", { filePath }))
}
importUrl={(url) =>
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_url", { url }))
}
/>
);
},
});
});
},
});
async function performImport(filePath: string): Promise<boolean> {
const activeWorkspace = jotaiStore.get(activeWorkspaceAtom);
const imported = await rpc<BatchUpsertResult>("cmd_import_data", {
filePath,
workspaceId: activeWorkspace?.id,
});
async function finishImport(imported: BatchUpsertResult): Promise<void> {
const importedWorkspace = imported.workspaces[0];
showDialog({
@@ -103,6 +101,4 @@ async function performImport(filePath: string): Promise<boolean> {
search: { environment_id: environmentId },
});
}
return true;
}
+20 -10
View File
@@ -5,6 +5,12 @@ import { candidateJsonPayloadsFromSseText, computeSseSummary } from "@yaakapp-in
import { rpc } from "./rpc";
import { platform } from "@yaakapp-internal/platform";
/**
* Reading a response body means naming the response, never the file it lives
* in: the backend resolves an id against its own records, so nothing the UI
* says can point a read somewhere else.
*/
export async function getResponseBodyText({
response,
filter,
@@ -13,7 +19,7 @@ export async function getResponseBodyText({
filter: string | null;
}): Promise<string | null> {
const result = await rpc<FilterResponse>("cmd_http_response_body", {
response,
responseId: response.id,
filter,
});
@@ -27,10 +33,9 @@ export async function getResponseBodyText({
export async function getResponseBodyEventSource(
response: HttpResponse,
): Promise<ServerSentEvent[]> {
if (!response.bodyPath) return [];
try {
const events = await rpc<ServerSentEvent[]>("cmd_get_sse_events", {
filePath: response.bodyPath,
responseId: response.id,
});
if (events.length > 0) {
return events;
@@ -39,8 +44,9 @@ export async function getResponseBodyEventSource(
// Fall back to raw JSON frame parsing for non-standard SSE-like responses.
}
const bytes = await platform.files.readFile(response.bodyPath);
const text = new TextDecoder("utf-8").decode(bytes);
const text = await getResponseBodyDecoded(response);
if (text == null) return [];
return candidateJsonPayloadsFromSseText(text).map((data, index) => ({
data,
eventType: "",
@@ -53,16 +59,20 @@ export async function getResponseBodySseSummary(
response: HttpResponse,
resultKeyPath: string,
): Promise<SseSummary> {
if (!response.bodyPath) return { fragmentCount: 0, summary: "" };
const text = await getResponseBodyDecoded(response);
if (text == null) return { fragmentCount: 0, summary: "" };
const bytes = await platform.files.readFile(response.bodyPath);
const text = new TextDecoder("utf-8").decode(bytes);
return computeSseSummary(text, resultKeyPath);
}
export async function getResponseBodyBytes(
response: HttpResponse,
): Promise<Uint8Array<ArrayBuffer> | null> {
if (!response.bodyPath) return null;
return platform.files.readFile(response.bodyPath);
// A response body is stored under the response's own id
return platform.blobs.read(response.id);
}
async function getResponseBodyDecoded(response: HttpResponse): Promise<string | null> {
const bytes = await getResponseBodyBytes(response);
return bytes == null ? null : new TextDecoder("utf-8").decode(bytes);
}
+3 -2
View File
@@ -1,11 +1,12 @@
import type { HttpRequest, HttpResponse } from "@yaakapp-internal/models";
import type { HttpRequest } from "@yaakapp-internal/models";
import type { EphemeralHttpResponse } from "@yaakapp-internal/tauri-client";
import { getActiveCookieJar } from "../hooks/useActiveCookieJar";
import { rpc } from "./rpc";
export async function sendEphemeralRequest(
request: HttpRequest,
environmentId: string | null,
): Promise<HttpResponse> {
): Promise<EphemeralHttpResponse> {
// Remove some things that we don't want to associate
const newRequest = { ...request };
return rpc("cmd_send_ephemeral_request", {
File diff suppressed because one or more lines are too long
@@ -8,7 +8,7 @@ use std::sync::Arc;
use std::time::Instant;
use tauri::{AppHandle, Manager, Runtime, WebviewWindow};
use tokio::sync::watch::Receiver;
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
use yaak::send::{ResponseBody, SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
use yaak_crypto::manager::EncryptionManager;
use yaak_http::manager::HttpConnectionManager;
use yaak_models::models::{CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseState};
@@ -62,6 +62,12 @@ impl<R: Runtime> ResponseContext<R> {
}
}
/// What a send produced: the response, and where its body went.
pub struct SentHttpRequest {
pub response: HttpResponse,
pub body: ResponseBody,
}
pub async fn send_http_request<R: Runtime>(
window: &WebviewWindow<R>,
unrendered_request: &HttpRequest,
@@ -69,7 +75,7 @@ pub async fn send_http_request<R: Runtime>(
environment: Option<Environment>,
cookie_jar: Option<CookieJar>,
cancelled_rx: &mut Receiver<bool>,
) -> Result<HttpResponse> {
) -> Result<SentHttpRequest> {
send_http_request_with_context(
window,
unrendered_request,
@@ -90,7 +96,7 @@ pub async fn send_http_request_with_context<R: Runtime>(
cookie_jar: Option<CookieJar>,
cancelled_rx: &Receiver<bool>,
plugin_context: &PluginContext,
) -> Result<HttpResponse> {
) -> Result<SentHttpRequest> {
let app_handle = window.app_handle().clone();
let update_source = UpdateSource::from_window_label(window.label());
let mut response_ctx =
@@ -110,7 +116,7 @@ pub async fn send_http_request_with_context<R: Runtime>(
.await;
match result {
Ok(response) => Ok(response),
Ok(sent) => Ok(sent),
Err(e) => {
let error = e.to_string();
let elapsed = start.elapsed().as_millis() as i32;
@@ -123,7 +129,12 @@ pub async fn send_http_request_with_context<R: Runtime>(
}
r.error = Some(error);
});
Ok(response_ctx.response().clone())
// The send failed, so whatever body exists is the partial one
// already on disk under the response's id.
Ok(SentHttpRequest {
response: response_ctx.response().clone(),
body: ResponseBody::Stored,
})
}
}
}
@@ -136,7 +147,7 @@ async fn send_http_request_inner<R: Runtime>(
cancelled_rx: &Receiver<bool>,
plugin_context: &PluginContext,
response_ctx: &mut ResponseContext<R>,
) -> Result<HttpResponse> {
) -> Result<SentHttpRequest> {
let app_handle = window.app_handle().clone();
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
@@ -165,7 +176,7 @@ async fn send_http_request_inner<R: Runtime>(
.await
.map_err(|e| GenericError(e.to_string()))?;
Ok(result.response)
Ok(SentHttpRequest { response: result.response, body: result.response_body })
}
pub fn resolve_http_request<R: Runtime>(
+82 -2
View File
@@ -5,6 +5,7 @@ use std::fs::read_to_string;
use std::io::ErrorKind;
use tauri::{Manager, Runtime, WebviewWindow};
use yaak::import::{self, ImportDataParams};
use yaak_api::{ApiClientKind, yaak_api_client};
use yaak_core::WorkspaceContext;
use yaak_models::util::BatchUpsertResult;
use yaak_plugins::manager::PluginManager;
@@ -13,10 +14,25 @@ use yaak_tauri_utils::window::WorkspaceWindowTrait;
pub(crate) async fn import_data<R: Runtime>(
window: &WebviewWindow<R>,
file_path: &str,
) -> Result<BatchUpsertResult> {
let contents = read_import_file(file_path)?;
import_contents(window, &contents).await
}
pub(crate) async fn import_url<R: Runtime>(
window: &WebviewWindow<R>,
url: &str,
) -> Result<BatchUpsertResult> {
let contents = fetch_import_url(window, url).await?;
import_contents(window, &contents).await
}
async fn import_contents<R: Runtime>(
window: &WebviewWindow<R>,
contents: &str,
) -> Result<BatchUpsertResult> {
let plugin_manager = window.state::<PluginManager>();
let query_manager = window.db_manager();
let file = read_import_file(file_path)?;
let plugin_context = window.plugin_context();
let workspace_context = WorkspaceContext {
workspace_id: window.workspace_id(),
@@ -30,11 +46,57 @@ pub(crate) async fn import_data<R: Runtime>(
plugin_manager: &plugin_manager,
plugin_context: &plugin_context,
workspace_context,
contents: &file,
contents,
})
.await?)
}
/// Download an importable document (OpenAPI, Postman, Insomnia, …) so it can be fed to the same
/// pipeline as a file on disk.
///
/// This uses Yaak's own API client, which follows the OS proxy but not the workspace's proxy,
/// client certificate, or certificate-validation settings. Requests are unauthenticated, so
/// specs behind auth must still be downloaded manually and imported as a file.
async fn fetch_import_url<R: Runtime>(window: &WebviewWindow<R>, url: &str) -> Result<String> {
let url = normalize_import_url(url)?;
let app_version = window.app_handle().package_info().version.to_string();
let response = yaak_api_client(ApiClientKind::App, &app_version)?
.get(&url)
// The API client defaults to JSON, but specs are just as often YAML
.header("Accept", "*/*")
.send()
.await
.map_err(|err| Error::GenericError(format!("Failed to fetch {url}: {err}")))?;
let status = response.status();
if !status.is_success() {
return Err(Error::GenericError(format!("Failed to fetch {url}: responded with {status}")));
}
response
.text()
.await
.map_err(|err| Error::GenericError(format!("Failed to read response from {url}: {err}")))
}
fn normalize_import_url(url: &str) -> Result<String> {
let url = url.trim();
if url.is_empty() {
return Err(Error::GenericError("Import URL must not be empty".to_string()));
}
if url.starts_with("http://") || url.starts_with("https://") {
return Ok(url.to_string());
}
match url.split_once("://") {
Some((scheme, _)) => {
Err(Error::GenericError(format!("Import URL must be http or https, but got {scheme}")))
}
None => Ok(format!("https://{url}")),
}
}
fn read_import_file(file_path: &str) -> Result<String> {
read_to_string(file_path).map_err(|err| {
if err.kind() == ErrorKind::InvalidData {
@@ -71,4 +133,22 @@ mod tests {
remove_file(path).expect("remove binary fixture");
}
#[test]
fn normalize_import_url_defaults_to_https() {
assert_eq!(
normalize_import_url(" example.com/openapi.yaml ").unwrap(),
"https://example.com/openapi.yaml"
);
assert_eq!(
normalize_import_url("http://example.com/openapi.yaml").unwrap(),
"http://example.com/openapi.yaml"
);
}
#[test]
fn normalize_import_url_rejects_other_schemes() {
assert!(normalize_import_url("file:///tmp/openapi.yaml").is_err());
assert!(normalize_import_url(" ").is_err());
}
}
+88 -20
View File
@@ -4,10 +4,11 @@ use crate::error::Error::GenericError;
use crate::error::Result;
use crate::grpc::{build_metadata, metadata_to_map, resolve_grpc_request};
use crate::http_request::{resolve_http_request, send_http_request};
use crate::import::import_data;
use crate::import::{import_data, import_url};
use crate::models_ext::{BlobManagerExt, QueryManagerExt};
use crate::notifications::YaakNotifier;
use crate::render::{render_grpc_request, render_json_value, render_template};
use crate::rpc_ext::EphemeralHttpResponse;
use crate::updates::{UpdateMode, UpdateTrigger, YaakUpdater};
use crate::uri_scheme::handle_deep_link;
use error::Result as YaakResult;
@@ -30,6 +31,7 @@ use tokio::sync::Mutex;
use tokio::task::block_in_place;
use tokio::time;
use yaak::export::{self, ExportDataParams};
use yaak::send::ResponseBody;
use yaak_common::command::new_checked_command;
use yaak_crypto::manager::EncryptionManager;
use yaak_grpc::manager::{GrpcConfig, GrpcHandle};
@@ -981,13 +983,18 @@ async fn cmd_restart<R: Runtime>(app_handle: AppHandle<R>) -> YaakResult<()> {
Ok(())
}
/// Send without saving anything.
///
/// The response never reaches the database, so its body cannot be read back by
/// id later the way a saved response's can. It comes back here instead, which
/// is the only copy the caller gets.
async fn cmd_send_ephemeral_request<R: Runtime>(
mut request: HttpRequest,
environment_id: Option<&str>,
cookie_jar_id: Option<&str>,
window: WebviewWindow<R>,
app_handle: AppHandle<R>,
) -> YaakResult<HttpResponse> {
) -> YaakResult<EphemeralHttpResponse> {
let response = HttpResponse::default();
request.id = "".to_string();
let environment = match environment_id {
@@ -1006,7 +1013,18 @@ async fn cmd_send_ephemeral_request<R: Runtime>(
}
});
send_http_request(&window, &request, &response, environment, cookie_jar, &mut cancel_rx).await
let sent =
send_http_request(&window, &request, &response, environment, cookie_jar, &mut cancel_rx)
.await?;
// Blanking the request id above is what makes this send unsaved, so the
// engine always hands the body back. Failing loudly beats returning an
// empty body that reads as "the server sent nothing".
let ResponseBody::Returned(body) = sent.body else {
return Err(GenericError("Unsaved response did not return a body".to_string()));
};
Ok(EphemeralHttpResponse { response: sent.response, body })
}
async fn cmd_format_json(text: &str) -> YaakResult<String> {
@@ -1020,27 +1038,49 @@ async fn cmd_format_graphql(text: &str) -> YaakResult<String> {
}
}
/// Where a response's body is, and what it is meant to be read as.
struct ResponseBodyLocation {
/// None when the response has no stored body.
path: Option<PathBuf>,
/// The response's declared `Content-Type`, empty when it has none.
content_type: String,
}
/// Find a response's body from its id alone.
///
/// The frontend hands back an id and never a path, so the only bodies reachable
/// here are ones the engine wrote and the database still knows about. A
/// response that was never saved has no entry, and its body came back from the
/// send that made it.
fn locate_response_body<R: Runtime>(
app_handle: &AppHandle<R>,
response_id: &str,
) -> YaakResult<ResponseBodyLocation> {
let response = app_handle.db().get_http_response(response_id)?;
Ok(ResponseBodyLocation {
path: response.body_path.map(PathBuf::from),
content_type: response
.headers
.iter()
.find(|h| h.name.eq_ignore_ascii_case("content-type"))
.map(|h| h.value.clone())
.unwrap_or_default(),
})
}
async fn cmd_http_response_body<R: Runtime>(
window: WebviewWindow<R>,
plugin_manager: State<'_, PluginManager>,
response: HttpResponse,
response_id: &str,
filter: Option<&str>,
) -> YaakResult<FilterResponse> {
let body_path = match response.body_path {
None => {
return Ok(FilterResponse { content: String::new(), error: None });
}
Some(p) => p,
let location = locate_response_body(window.app_handle(), response_id)?;
let Some(body_path) = location.path else {
return Ok(FilterResponse { content: String::new(), error: None });
};
let content_type = response
.headers
.iter()
.find_map(|h| {
if h.name.eq_ignore_ascii_case("content-type") { Some(h.value.as_str()) } else { None }
})
.unwrap_or_default();
let content_type = location.content_type.as_str();
let body = read_response_body(&body_path, content_type)
.await
.ok_or(GenericError("Failed to find response body".to_string()))?;
@@ -1053,6 +1093,20 @@ async fn cmd_http_response_body<R: Runtime>(
}
}
/// The body's path on this machine, for the desktop host to read or hand to the
/// webview's asset protocol.
///
/// The frontend holds response ids; only `packages/platform`'s Tauri host sees
/// the path, and only because it is about to open the file itself. Hosts
/// without a filesystem serve the same bytes over HTTP instead.
async fn cmd_http_response_body_path<R: Runtime>(
app_handle: AppHandle<R>,
response_id: &str,
) -> YaakResult<Option<String>> {
let location = locate_response_body(&app_handle, response_id)?;
Ok(location.path.map(|p| p.to_string_lossy().to_string()))
}
async fn cmd_http_request_body<R: Runtime>(
app_handle: AppHandle<R>,
response_id: &str,
@@ -1069,8 +1123,15 @@ async fn cmd_http_request_body<R: Runtime>(
Ok(Some(body))
}
async fn cmd_get_sse_events(file_path: &str) -> YaakResult<Vec<ServerSentEvent>> {
let body = fs::read(file_path)?;
async fn cmd_get_sse_events<R: Runtime>(
app_handle: AppHandle<R>,
response_id: &str,
) -> YaakResult<Vec<ServerSentEvent>> {
let Some(body_path) = locate_response_body(&app_handle, response_id)?.path else {
return Ok(Vec::new());
};
let body = fs::read(body_path)?;
let mut event_parser = EventParser::new();
event_parser.process_bytes(body.into())?;
@@ -1104,6 +1165,13 @@ async fn cmd_import_data<R: Runtime>(
import_data(&window, file_path).await
}
async fn cmd_import_url<R: Runtime>(
window: WebviewWindow<R>,
url: &str,
) -> YaakResult<BatchUpsertResult> {
import_url(&window, url).await
}
async fn cmd_http_request_actions<R: Runtime>(
window: WebviewWindow<R>,
plugin_manager: State<'_, PluginManager>,
@@ -1488,7 +1556,7 @@ async fn cmd_send_http_request<R: Runtime>(
)
.await
{
Ok(r) => r,
Ok(sent) => sent.response,
Err(e) => {
let resp = app_handle.db().get_http_response(&response.id)?;
app_handle.db().upsert_http_response(
@@ -314,7 +314,7 @@ async fn handle_host_plugin_request<R: Runtime>(
.await?;
Ok(Some(InternalEventPayload::SendHttpRequestResponse(SendHttpRequestResponse {
http_response,
http_response: http_response.response,
})))
}
HostRequest::OpenWindow(req) => {
+44 -7
View File
@@ -250,6 +250,19 @@ pub(crate) struct CmdSendEphemeralRequestReq {
pub cookie_jar_id: Option<String>,
}
/// An unsaved response and its body.
///
/// The body rides along because nothing stored it: there is no database row to
/// look up later and no file for a host to read, so this is the caller's only
/// copy.
#[derive(Debug, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct EphemeralHttpResponse {
pub response: HttpResponse,
pub body: Vec<u8>,
}
#[derive(Debug, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
@@ -268,10 +281,17 @@ pub(crate) struct CmdFormatGraphqlReq {
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub(crate) struct CmdHttpResponseBodyReq {
pub response: HttpResponse,
pub response_id: String,
pub filter: Option<String>,
}
#[derive(Debug, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub(crate) struct CmdHttpResponseBodyPathReq {
pub response_id: String,
}
#[derive(Debug, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
@@ -283,7 +303,7 @@ pub(crate) struct CmdHttpRequestBodyReq {
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub(crate) struct CmdGetSseEventsReq {
pub file_path: String,
pub response_id: String,
}
#[derive(Debug, Deserialize, TS)]
@@ -300,6 +320,13 @@ pub(crate) struct CmdImportDataReq {
pub file_path: String,
}
#[derive(Debug, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub(crate) struct CmdImportUrlReq {
pub url: String,
}
#[derive(Debug, Deserialize, TS)]
#[ts(export, export_to = "gen_rpc.ts")]
pub(crate) struct CmdHttpRequestActionsReq {}
@@ -970,7 +997,7 @@ async fn cmd_restart<R: Runtime>(ctx: ClientCtx<R>, _req: CmdRestartReq) -> Resu
Ok(crate::cmd_restart(ctx.window.app_handle().clone()).await?)
}
async fn cmd_send_ephemeral_request<R: Runtime>(ctx: ClientCtx<R>, req: CmdSendEphemeralRequestReq) -> Result<HttpResponse> {
async fn cmd_send_ephemeral_request<R: Runtime>(ctx: ClientCtx<R>, req: CmdSendEphemeralRequestReq) -> Result<EphemeralHttpResponse> {
Ok(crate::cmd_send_ephemeral_request(req.request, req.environment_id.as_deref(), req.cookie_jar_id.as_deref(), ctx.window.clone(), ctx.window.app_handle().clone()).await?)
}
@@ -983,15 +1010,19 @@ async fn cmd_format_graphql<R: Runtime>(_ctx: ClientCtx<R>, req: CmdFormatGraphq
}
async fn cmd_http_response_body<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpResponseBodyReq) -> Result<FilterResponse> {
Ok(crate::cmd_http_response_body(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>(), req.response, req.filter.as_deref()).await?)
Ok(crate::cmd_http_response_body(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>(), &req.response_id, req.filter.as_deref()).await?)
}
async fn cmd_http_response_body_path<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpResponseBodyPathReq) -> Result<Option<String>> {
Ok(crate::cmd_http_response_body_path(ctx.window.app_handle().clone(), &req.response_id).await?)
}
async fn cmd_http_request_body<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpRequestBodyReq) -> Result<Option<Vec<u8>>> {
Ok(crate::cmd_http_request_body(ctx.window.app_handle().clone(), &req.response_id).await?)
}
async fn cmd_get_sse_events<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGetSseEventsReq) -> Result<Vec<ServerSentEvent>> {
Ok(crate::cmd_get_sse_events(&req.file_path).await?)
async fn cmd_get_sse_events<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetSseEventsReq) -> Result<Vec<ServerSentEvent>> {
Ok(crate::cmd_get_sse_events(ctx.window.app_handle().clone(), &req.response_id).await?)
}
async fn cmd_get_http_response_events<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetHttpResponseEventsReq) -> Result<Vec<HttpResponseEvent>> {
@@ -1002,6 +1033,10 @@ async fn cmd_import_data<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportDataReq) -
Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_path).await?)
}
async fn cmd_import_url<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportUrlReq) -> Result<BatchUpsertResult> {
Ok(crate::cmd_import_url(ctx.window.clone(), &req.url).await?)
}
async fn cmd_http_request_actions<R: Runtime>(ctx: ClientCtx<R>, _req: CmdHttpRequestActionsReq) -> Result<Vec<GetHttpRequestActionsResponse>> {
Ok(crate::cmd_http_request_actions(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
}
@@ -1367,14 +1402,16 @@ rpc_commands! {
cmd_grpc_reflect(CmdGrpcReflectReq) -> Vec<ServiceDefinition>,
cmd_grpc_go(CmdGrpcGoReq) -> String,
cmd_restart(CmdRestartReq) -> (),
cmd_send_ephemeral_request(CmdSendEphemeralRequestReq) -> HttpResponse,
cmd_send_ephemeral_request(CmdSendEphemeralRequestReq) -> EphemeralHttpResponse,
cmd_format_json(CmdFormatJsonReq) -> String,
cmd_format_graphql(CmdFormatGraphqlReq) -> String,
cmd_http_response_body(CmdHttpResponseBodyReq) -> FilterResponse,
cmd_http_response_body_path(CmdHttpResponseBodyPathReq) -> Option<String>,
cmd_http_request_body(CmdHttpRequestBodyReq) -> Option<Vec<u8>>,
cmd_get_sse_events(CmdGetSseEventsReq) -> Vec<ServerSentEvent>,
cmd_get_http_response_events(CmdGetHttpResponseEventsReq) -> Vec<HttpResponseEvent>,
cmd_import_data(CmdImportDataReq) -> BatchUpsertResult,
cmd_import_url(CmdImportUrlReq) -> BatchUpsertResult,
cmd_http_request_actions(CmdHttpRequestActionsReq) -> Vec<GetHttpRequestActionsResponse>,
cmd_websocket_request_actions(CmdWebsocketRequestActionsReq) -> Vec<GetWebsocketRequestActionsResponse>,
cmd_call_websocket_request_action(CmdCallWebsocketRequestActionReq) -> (),
+4 -4
View File
@@ -593,7 +593,7 @@ impl UpsertModelInfo for WorkspaceMeta {
}
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]
#[ts(export, export_to = "gen_models.ts")]
pub enum CookieDomain {
HostOnly(String),
@@ -602,14 +602,14 @@ pub enum CookieDomain {
Empty,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]
#[ts(export, export_to = "gen_models.ts")]
pub enum CookieExpires {
AtUtc(String),
SessionEnd,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]
#[ts(export, export_to = "gen_models.ts")]
pub enum CookieSameSite {
Strict,
@@ -617,7 +617,7 @@ pub enum CookieSameSite {
None,
}
#[derive(Debug, Clone, Serialize, TS)]
#[derive(Debug, Clone, Serialize, TS, PartialEq)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
pub struct Cookie {
+1
View File
@@ -21,3 +21,4 @@ yaak-tls = { workspace = true }
[dev-dependencies]
tempfile = "3"
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
+587 -212
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -49,6 +49,9 @@ export const platform: Platform = {
get files() {
return host().files;
},
get blobs() {
return host().blobs;
},
rpc: (cmd, payload) => host().rpc(cmd, payload),
rpcStream: (cmd, payload, onMessage) => host().rpcStream(cmd, payload, onMessage),
listen: (event, callback) => host().listen(event, callback),
+26 -2
View File
@@ -5,7 +5,7 @@ import { basename, resolveResource } from "@tauri-apps/api/path";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { clear, readText, writeText } from "@tauri-apps/plugin-clipboard-manager";
import { open, save } from "@tauri-apps/plugin-dialog";
import { readDir, readFile } from "@tauri-apps/plugin-fs";
import { readDir, readFile, readTextFile } from "@tauri-apps/plugin-fs";
import { openUrl, revealItemInDir } from "@tauri-apps/plugin-opener";
import { type as osType } from "@tauri-apps/plugin-os";
import type {
@@ -104,6 +104,18 @@ async function rpc<T>(cmd: string, payload?: RpcPayload): Promise<T> {
}
}
/**
* Where this host keeps the bytes stored under an id, or null if it has none.
*
* The desktop stores them as files the engine wrote, so answering means asking
* the backend. Callers hold ids and nothing else; the path exists for exactly
* as long as it takes to open the file, which is the one thing a desktop host
* can do that a tab cannot.
*/
function storedBodyPath(id: string): Promise<string | null> {
return rpc<string | null>("cmd_http_response_body_path", { responseId: id });
}
export function createTauriPlatform(): Platform {
const window = createWindow();
@@ -124,13 +136,25 @@ export function createTauriPlatform(): Platform {
},
files: {
readFile: (path) => readFile(path),
readDir: (path) => readDir(path),
readText: (path) => readTextFile(path),
url: (path) => convertFileSrc(path),
basename: (path) => basename(path),
resolveResource: (path) => resolveResource(path),
},
blobs: {
async read(id) {
const path = await storedBodyPath(id);
return path == null ? null : readFile(path);
},
async url(id) {
const path = await storedBodyPath(id);
return path == null ? null : convertFileSrc(path);
},
},
rpc,
async rpcStream<T, M>(
+36 -2
View File
@@ -135,13 +135,21 @@ export interface PlatformDialog {
* File-ish operations, keyed by paths the backend handed us.
*
* A path here is an opaque handle, not something to parse or construct: the UI
* only ever passes one back to `readFile` or `url`. A host without a filesystem
* only ever passes one back to `url` or `readDir`. A host without a filesystem
* can mint handles of its own (a blob id, a URL) and stay compatible.
*/
export interface PlatformFiles {
readFile(path: string): Promise<Uint8Array<ArrayBuffer>>;
readDir(path: string): Promise<DirEntry[]>;
/**
* The text of a file the user picked, decoded as UTF-8.
*
* Only for paths the host itself handed us — a dialog result or a drag-drop
* payload — never one the page assembled. A host without a filesystem reads
* whatever its own handle points at.
*/
readText(path: string): Promise<string>;
/** A URL the page can load a file from, for `<img>`, `<video>`, and friends. */
url(path: string): string;
@@ -151,12 +159,38 @@ export interface PlatformFiles {
resolveResource(path: string): Promise<string>;
}
/**
* Content the backend stored, addressed by the id it stored it under.
*
* Where those bytes actually live is the one thing every host answers
* differently — a file the engine wrote, a row in a database, a URL on the
* other end of a socket — so finding them is the host's job and nobody else's.
* The caller passes an id and never a location, which is also what keeps a page
* from naming something the backend never wrote.
*
* What the content *means* is the app's business, not this package's.
*/
export interface PlatformBlobs {
/** The bytes, or null if the host has nothing stored under that id. */
read(id: string): Promise<Uint8Array<ArrayBuffer> | null>;
/**
* A URL an element can load the content from, for `<img>`, `<video>` and
* friends, or null if the host has nothing stored under that id.
*
* Asynchronous because the host may have to ask its backend where the bytes
* are, and it only does that the moment before it reads them.
*/
url(id: string): Promise<string | null>;
}
export interface Platform {
readonly capabilities: PlatformCapabilities;
readonly window: PlatformWindow;
readonly clipboard: PlatformClipboard;
readonly dialog: PlatformDialog;
readonly files: PlatformFiles;
readonly blobs: PlatformBlobs;
/** Call a backend command and await its result. */
rpc<T>(cmd: string, payload?: RpcPayload): Promise<T>;