mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-16 08:31:57 +02:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdbbef34f8 | ||
|
|
4838353585 | ||
|
|
93001e3da7 | ||
|
|
b31c066717 | ||
|
|
5d1d24870a | ||
|
|
6f0d0ef275 | ||
|
|
85a9b2a908 | ||
|
|
f3f05502d1 | ||
|
|
2e0f7d1818 | ||
|
|
dc793181bb | ||
|
|
7dfa7e07e3 |
Generated
+18
@@ -11035,6 +11035,7 @@ dependencies = [
|
||||
"yaak-models",
|
||||
"yaak-plugins",
|
||||
"yaak-rpc",
|
||||
"yaak-rpc-schema",
|
||||
"yaak-sse",
|
||||
"yaak-sync",
|
||||
"yaak-system-appearance",
|
||||
@@ -11372,6 +11373,22 @@ dependencies = [
|
||||
"ts-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaak-rpc-schema"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"ts-rs",
|
||||
"yaak-git",
|
||||
"yaak-grpc",
|
||||
"yaak-models",
|
||||
"yaak-plugins",
|
||||
"yaak-sse",
|
||||
"yaak-sync",
|
||||
"yaak-templates",
|
||||
"yaak-ws",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaak-sse"
|
||||
version = "0.1.0"
|
||||
@@ -11437,6 +11454,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"log 0.4.29",
|
||||
"p12",
|
||||
"pem",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"rustls-platform-verifier",
|
||||
|
||||
@@ -5,6 +5,7 @@ members = [
|
||||
# Common/foundation crates
|
||||
"crates/common/yaak-database",
|
||||
"crates/common/yaak-rpc",
|
||||
"crates/common/yaak-rpc-schema",
|
||||
# Shared crates (no Tauri dependency)
|
||||
"crates/yaak-core",
|
||||
"crates/yaak-common",
|
||||
@@ -63,6 +64,7 @@ ts-rs = "11.1.0"
|
||||
# Internal crates - common/foundation
|
||||
yaak-database = { path = "crates/common/yaak-database" }
|
||||
yaak-rpc = { path = "crates/common/yaak-rpc" }
|
||||
yaak-rpc-schema = { path = "crates/common/yaak-rpc-schema" }
|
||||
|
||||
# Internal crates - shared
|
||||
yaak-core = { path = "crates/yaak-core" }
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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)),
|
||||
});
|
||||
}
|
||||
@@ -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/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { RpcPayload } from "@yaakapp-internal/platform";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import type { RpcSchema } from "@yaakapp-internal/tauri-client";
|
||||
import type { RpcSchema } from "@yaakapp-internal/rpc-schema";
|
||||
|
||||
/**
|
||||
* Every backend command the app can call: the generated wire schema, one field
|
||||
|
||||
@@ -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/rpc-schema";
|
||||
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", {
|
||||
|
||||
@@ -73,6 +73,7 @@ url = "2"
|
||||
tokio-util = { version = "0.7", features = ["codec"] }
|
||||
ts-rs = { workspace = true }
|
||||
yaak-rpc = { workspace = true }
|
||||
yaak-rpc-schema = { workspace = true }
|
||||
uuid = "1.12.1"
|
||||
yaak-api = { workspace = true }
|
||||
yaak-common = { workspace = true }
|
||||
|
||||
-4
@@ -1,7 +1,5 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type GitWatchResult = { unlistenEvent: string, };
|
||||
|
||||
export type PluginUpdateInfo = { name: string, currentVersion: string, latestVersion: string, };
|
||||
|
||||
export type PluginUpdateNotification = { updateCount: number, plugins: Array<PluginUpdateInfo>, };
|
||||
@@ -12,8 +10,6 @@ export type UpdateResponse = { "type": "ack" } | { "type": "action", action: Upd
|
||||
|
||||
export type UpdateResponseAction = "install" | "skip";
|
||||
|
||||
export type WatchResult = { unlistenEvent: string, };
|
||||
|
||||
export type YaakNotification = { timestamp: string, timeout: number | null, id: string, title: string | null, message: string, color: string | null, action: YaakNotificationAction | null, };
|
||||
|
||||
export type YaakNotificationAction = { label: string, url: string, };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// ts-rs owns bindings/index.ts and rewrites it on export, so this hand-written
|
||||
// entry point is where the generated files come together.
|
||||
export * from "./bindings/gen_rpc";
|
||||
// ts-rs owns bindings/index.ts and rewrites it on export. What remains here
|
||||
// after the RPC schema moved to @yaakapp-internal/rpc-schema is the
|
||||
// desktop-only surface: updater and notification types.
|
||||
export * from "./bindings/index";
|
||||
|
||||
@@ -2,7 +2,6 @@ use crate::error::{Error, Result};
|
||||
use chrono::Utc;
|
||||
use log::{debug, error, warn};
|
||||
use notify::Watcher;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
@@ -10,18 +9,11 @@ use tauri::{AppHandle, Listener, Runtime};
|
||||
use tokio::select;
|
||||
use tokio::sync::watch;
|
||||
use tokio::time::sleep;
|
||||
use ts_rs::TS;
|
||||
use yaak_git::{GitWorktreeStatus, git_path_is_ignored, git_repository_paths, git_worktree_status};
|
||||
use yaak_rpc_schema::GitWatchResult;
|
||||
|
||||
const GIT_STATUS_COALESCE_WINDOW: Duration = Duration::from_millis(250);
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "index.ts")]
|
||||
pub(crate) struct GitWatchResult {
|
||||
unlisten_event: String,
|
||||
}
|
||||
|
||||
pub(crate) async fn watch_git_worktree_status<R, F>(
|
||||
app_handle: AppHandle<R>,
|
||||
dir: &Path,
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ 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};
|
||||
@@ -30,6 +30,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};
|
||||
@@ -55,6 +56,7 @@ use yaak_plugins::events::{
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_plugins::plugin_meta::{PluginMetadata, get_plugin_meta};
|
||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||
use yaak_rpc_schema::{AppMetaData, EphemeralHttpResponse};
|
||||
use yaak_sse::sse::ServerSentEvent;
|
||||
use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
||||
use yaak_templates::format_json::format_json;
|
||||
@@ -182,22 +184,6 @@ impl<R: Runtime> PluginContextExt<R> for WebviewWindow<R> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, ts_rs::TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct AppMetaData {
|
||||
is_dev: bool,
|
||||
version: String,
|
||||
cli_version: Option<String>,
|
||||
name: String,
|
||||
app_data_dir: String,
|
||||
app_log_dir: String,
|
||||
vendored_plugin_dir: String,
|
||||
default_project_dir: String,
|
||||
feature_updater: bool,
|
||||
feature_license: bool,
|
||||
}
|
||||
|
||||
async fn cmd_metadata<R: Runtime>(app_handle: AppHandle<R>) -> YaakResult<AppMetaData> {
|
||||
let app_data_dir = app_handle.path().app_data_dir()?;
|
||||
let app_log_dir = app_handle.path().app_log_dir()?;
|
||||
@@ -981,13 +967,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 +997,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 +1022,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 +1077,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 +1107,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 +1149,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 +1540,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(
|
||||
|
||||
@@ -144,28 +144,10 @@ pub(crate) fn models_upsert<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
model: AnyModel,
|
||||
) -> Result<String> {
|
||||
use yaak_models::error::Error::GenericError;
|
||||
|
||||
let db = window.db();
|
||||
let blobs = window.blob_manager();
|
||||
let source = &UpdateSource::from_window_label(window.label());
|
||||
let id = match model {
|
||||
AnyModel::CookieJar(m) => db.upsert_cookie_jar(&m, source)?.id,
|
||||
AnyModel::Environment(m) => db.upsert_environment(&m, source)?.id,
|
||||
AnyModel::Folder(m) => db.upsert_folder(&m, source)?.id,
|
||||
AnyModel::GrpcRequest(m) => db.upsert_grpc_request(&m, source)?.id,
|
||||
AnyModel::HttpRequest(m) => db.upsert_http_request(&m, source)?.id,
|
||||
AnyModel::HttpResponse(m) => db.upsert_http_response(&m, source, &blobs)?.id,
|
||||
AnyModel::KeyValue(m) => db.upsert_key_value(&m, source)?.id,
|
||||
AnyModel::Plugin(m) => db.upsert_plugin(&m, source)?.id,
|
||||
AnyModel::Settings(m) => db.upsert_settings(&m, source)?.id,
|
||||
AnyModel::WebsocketRequest(m) => db.upsert_websocket_request(&m, source)?.id,
|
||||
AnyModel::Workspace(m) => db.upsert_workspace(&m, source)?.id,
|
||||
AnyModel::WorkspaceMeta(m) => db.upsert_workspace_meta(&m, source)?.id,
|
||||
a => return Err(GenericError(format!("Cannot upsert AnyModel {a:?})"))),
|
||||
};
|
||||
|
||||
Ok(id)
|
||||
yaak::models_ops::upsert_model(&db, &blobs, model, source)
|
||||
}
|
||||
|
||||
// Async so cascading deletes (e.g. a workspace with thousands of requests) run on a
|
||||
@@ -181,21 +163,7 @@ pub(crate) async fn models_delete<R: Runtime>(
|
||||
// Use transaction for deletions because it might recurse
|
||||
window.with_tx(|tx| {
|
||||
let source = &UpdateSource::from_window_label(window.label());
|
||||
let id = match model {
|
||||
AnyModel::CookieJar(m) => tx.delete_cookie_jar(&m, source)?.id,
|
||||
AnyModel::Environment(m) => tx.delete_environment(&m, source)?.id,
|
||||
AnyModel::Folder(m) => tx.delete_folder(&m, source)?.id,
|
||||
AnyModel::GrpcConnection(m) => tx.delete_grpc_connection(&m, source)?.id,
|
||||
AnyModel::GrpcRequest(m) => tx.delete_grpc_request(&m, source)?.id,
|
||||
AnyModel::HttpRequest(m) => tx.delete_http_request(&m, source)?.id,
|
||||
AnyModel::HttpResponse(m) => tx.delete_http_response(&m, source, &blobs)?.id,
|
||||
AnyModel::Plugin(m) => tx.delete_plugin(&m, source)?.id,
|
||||
AnyModel::WebsocketConnection(m) => tx.delete_websocket_connection(&m, source)?.id,
|
||||
AnyModel::WebsocketRequest(m) => tx.delete_websocket_request(&m, source)?.id,
|
||||
AnyModel::Workspace(m) => tx.delete_workspace(&m, source, &blobs)?.id,
|
||||
a => return Err(GenericError(format!("Cannot delete AnyModel {a:?})"))),
|
||||
};
|
||||
Ok(id)
|
||||
yaak::models_ops::delete_model(tx, &blobs, model, source)
|
||||
})
|
||||
})
|
||||
.await
|
||||
@@ -207,31 +175,10 @@ pub(crate) fn models_duplicate<R: Runtime>(
|
||||
model_type: String,
|
||||
model_id: String,
|
||||
) -> Result<String> {
|
||||
use yaak_models::error::Error::GenericError;
|
||||
|
||||
// Use transaction for duplications because it might recurse
|
||||
window.with_tx(|tx| {
|
||||
let source = &UpdateSource::from_window_label(window.label());
|
||||
// Fetch the model fresh from the DB so the duplicate doesn't come from
|
||||
// a stale frontend snapshot
|
||||
let id = match model_type.as_str() {
|
||||
"environment" => {
|
||||
tx.duplicate_environment(&tx.get_environment(&model_id)?, source)?.id
|
||||
}
|
||||
"folder" => tx.duplicate_folder(&tx.get_folder(&model_id)?, source)?.id,
|
||||
"grpc_request" => {
|
||||
tx.duplicate_grpc_request(&tx.get_grpc_request(&model_id)?, source)?.id
|
||||
}
|
||||
"http_request" => {
|
||||
tx.duplicate_http_request(&tx.get_http_request(&model_id)?, source)?.id
|
||||
}
|
||||
"websocket_request" => {
|
||||
tx.duplicate_websocket_request(&tx.get_websocket_request(&model_id)?, source)?.id
|
||||
}
|
||||
t => return Err(GenericError(format!("Cannot duplicate model type {t}"))),
|
||||
};
|
||||
|
||||
Ok(id)
|
||||
yaak::models_ops::duplicate_model(tx, &model_type, &model_id, source)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,11 +6,10 @@ use crate::error::Result;
|
||||
use crate::models_ext::{BlobManagerExt, QueryManagerExt};
|
||||
use chrono::Utc;
|
||||
use log::warn;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tauri::{AppHandle, Listener, Runtime};
|
||||
use tokio::sync::watch;
|
||||
use ts_rs::TS;
|
||||
use yaak_rpc_schema::WatchResult;
|
||||
use yaak_sync::error::Error::InvalidSyncDirectory;
|
||||
use yaak_sync::sync::{
|
||||
FsCandidate, SyncOp, apply_sync_ops, apply_sync_state_ops, compute_sync_ops, get_db_candidates,
|
||||
@@ -57,13 +56,6 @@ pub(crate) async fn cmd_sync_apply<R: Runtime>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "index.ts")]
|
||||
pub(crate) struct WatchResult {
|
||||
unlisten_event: String,
|
||||
}
|
||||
|
||||
pub(crate) async fn sync_watch<R, F>(
|
||||
app_handle: AppHandle<R>,
|
||||
sync_dir: &Path,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "yaak-rpc-schema"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
authors = ["Gregory Schier"]
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
ts-rs = { workspace = true }
|
||||
yaak-git = { workspace = true }
|
||||
yaak-grpc = { workspace = true }
|
||||
yaak-models = { workspace = true }
|
||||
yaak-plugins = { workspace = true }
|
||||
yaak-sse = { workspace = true }
|
||||
yaak-sync = { workspace = true }
|
||||
yaak-templates = { workspace = true }
|
||||
yaak-ws = { workspace = true }
|
||||
@@ -0,0 +1,44 @@
|
||||
# yaak-rpc-schema
|
||||
|
||||
The wire schema for the app's RPC surface: every command name, its request
|
||||
payload, and its response type, declared once.
|
||||
|
||||
Every host that serves the Yaak UI — the desktop app today, the browser bridge
|
||||
and anything after it — imports these types and implements the commands against
|
||||
them. That is what keeps a request's shape from drifting between hosts, and it
|
||||
is why the TypeScript bindings (`bindings/gen_rpc.ts`, exposed to the frontend
|
||||
as `@yaakapp-internal/rpc-schema`) are generated from one place.
|
||||
|
||||
Nothing here depends on Tauri or on any host. Request structs are plain data,
|
||||
and so are the few response types declared here rather than in an engine crate.
|
||||
Command *bodies* live with the host that runs them.
|
||||
|
||||
## Adding a command
|
||||
|
||||
1. Add its request struct and an entry in `with_commands!` in `src/lib.rs`.
|
||||
2. Write the adapter in each host — the desktop's live in
|
||||
`crates-tauri/yaak-app-client/src/rpc_ext.rs`. A host that does not support
|
||||
the command still has to say so; a missing adapter fails to compile.
|
||||
3. Regenerate the bindings: `cargo test -p yaak-rpc-schema` writes
|
||||
`bindings/gen_rpc.ts`, which is committed.
|
||||
|
||||
## How hosts consume the list
|
||||
|
||||
`with_commands!` takes the name of a `macro_rules!` macro and calls it with the
|
||||
full `name(Req) -> Res` list. Each host writes a small macro that receives that
|
||||
list and builds its router:
|
||||
|
||||
```rust
|
||||
macro_rules! register_commands {
|
||||
( $( $name:ident ( $req:ty ) -> $res:ty ),* $(,)? ) => {
|
||||
pub fn build_router() -> RpcRouter<MyCtx> {
|
||||
let mut router = RpcRouter::new();
|
||||
$( router.register(stringify!($name), rpc_handler_async!($name)); )*
|
||||
router
|
||||
}
|
||||
};
|
||||
}
|
||||
yaak_rpc_schema::with_commands!(register_commands);
|
||||
```
|
||||
|
||||
The schema decides *what* commands exist; the host decides *how* each one runs.
|
||||
crates-tauri/yaak-app-client/bindings/gen_rpc.ts → crates/common/yaak-rpc-schema/bindings/gen_rpc.ts
Generated
+27
-8
File diff suppressed because one or more lines are too long
Generated
Generated
@@ -0,0 +1,4 @@
|
||||
// The RPC wire schema, generated by ts-rs from the Rust declarations in
|
||||
// src/lib.rs. `RpcSchema` maps every command name to its (request, response)
|
||||
// pair; the app's `rpc()` helper derives its command union from it.
|
||||
export * from "./bindings/gen_rpc";
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "@yaakapp-internal/rpc-schema",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "index.ts"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,9 @@ use log::{debug, info, warn};
|
||||
use reqwest::{Client, ClientBuilder, Proxy, redirect};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use yaak_models::models::DnsOverride;
|
||||
use yaak_tls::{ClientCertificateConfig, get_tls_config, load_client_identity_pkcs12};
|
||||
use yaak_tls::{
|
||||
ClientCertificateConfig, NativeClientIdentity, get_tls_config, load_native_client_identity,
|
||||
};
|
||||
|
||||
pub const HTTP2_MAX_RESPONSE_HEADER_LIST_SIZE: u32 = 1024 * 1024;
|
||||
|
||||
@@ -61,12 +63,19 @@ static IDENTITY_IMPORT: Mutex<()> = Mutex::new(());
|
||||
fn build_native_tls_identity(
|
||||
client_cert: Option<ClientCertificateConfig>,
|
||||
) -> Result<Option<native_tls::Identity>> {
|
||||
let Some((pkcs12, password)) = load_client_identity_pkcs12(client_cert)? else {
|
||||
let Some(material) = load_native_client_identity(client_cert)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let _guard = IDENTITY_IMPORT.lock().unwrap_or_else(|e| e.into_inner());
|
||||
Ok(Some(native_tls::Identity::from_pkcs12(&pkcs12, &password)?))
|
||||
Ok(Some(match material {
|
||||
NativeClientIdentity::Pkcs12 { data, password } => {
|
||||
native_tls::Identity::from_pkcs12(&data, &password)?
|
||||
}
|
||||
NativeClientIdentity::Pkcs8 { chain_pem, key_pem } => {
|
||||
native_tls::Identity::from_pkcs8(&chain_pem, &key_pem)?
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import type { WatchResult } from "@yaakapp-internal/tauri-client";
|
||||
import type { WatchResult } from "@yaakapp-internal/rpc-schema";
|
||||
import { SyncOp } from "./bindings/gen_sync";
|
||||
import { WatchEvent } from "./bindings/gen_watch";
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ publish = false
|
||||
[dependencies]
|
||||
log = { workspace = true }
|
||||
p12 = "0.6.3"
|
||||
pem = "3"
|
||||
rustls = { workspace = true, default-features = false, features = ["ring"] }
|
||||
rustls-pemfile = "2"
|
||||
rustls-platform-verifier = { workspace = true }
|
||||
|
||||
+129
-11
@@ -18,7 +18,7 @@ pub mod error;
|
||||
const OID_RSA_ENCRYPTION: &[u64] = &[1, 2, 840, 113549, 1, 1, 1];
|
||||
const OID_EC_PUBLIC_KEY: &[u64] = &[1, 2, 840, 10045, 2, 1];
|
||||
|
||||
/// Password for the PKCS#12 blob [`load_client_identity_pkcs12`] builds from PEM
|
||||
/// Password for the PKCS#12 blob [`load_native_client_identity`] builds from PEM
|
||||
/// files. The blob never leaves the process, so the value only has to agree with
|
||||
/// the caller that immediately re-parses it.
|
||||
const IN_MEMORY_PKCS12_PASSWORD: &str = "yaak";
|
||||
@@ -107,16 +107,33 @@ fn load_client_cert(
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Load the configured client certificate as PKCS#12 DER, along with the
|
||||
/// password needed to open it.
|
||||
/// A client identity in one of the encodings a native TLS stack accepts.
|
||||
pub enum NativeClientIdentity {
|
||||
/// A PKCS#12 archive, with the password needed to open it.
|
||||
Pkcs12 { data: Vec<u8>, password: String },
|
||||
/// A PEM certificate chain, leaf first, with a PKCS#8 PEM private key.
|
||||
Pkcs8 {
|
||||
chain_pem: Vec<u8>,
|
||||
key_pem: Vec<u8>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Whether the platform's native TLS stack should be handed PEM material as
|
||||
/// PKCS#12 rather than PKCS#8.
|
||||
///
|
||||
/// Native TLS stacks accept a client identity as either PKCS#12 or a PKCS#8
|
||||
/// PEM, and the PKCS#8 route rejects EC keys on macOS outright. Going through
|
||||
/// PKCS#12 keeps the key formats we accept identical to the rustls path, which
|
||||
/// reads PKCS#1 and SEC1 keys directly.
|
||||
pub fn load_client_identity_pkcs12(
|
||||
/// Both encodings lose something. PKCS#8 is rejected for EC keys by Security
|
||||
/// Framework on macOS and by SChannel on Windows, which imports keys through an
|
||||
/// RSA-only provider. PKCS#12 as the `p12` crate emits it is encrypted with
|
||||
/// SHA1/40-bit-RC2 (certificates) and SHA1/3DES (key), and OpenSSL 3 moved RC2
|
||||
/// into the legacy provider, so on Linux it fails to decrypt what we just
|
||||
/// wrote. Each platform therefore gets the encoding its own stack can read.
|
||||
const NATIVE_TLS_WANTS_PKCS12: bool = cfg!(any(target_vendor = "apple", target_os = "windows"));
|
||||
|
||||
/// Load the configured client certificate in whichever encoding this platform's
|
||||
/// native TLS stack accepts.
|
||||
pub fn load_native_client_identity(
|
||||
client_cert: Option<ClientCertificateConfig>,
|
||||
) -> Result<Option<(Vec<u8>, String)>> {
|
||||
) -> Result<Option<NativeClientIdentity>> {
|
||||
let config = match client_cert {
|
||||
None => return Ok(None),
|
||||
Some(c) => c,
|
||||
@@ -127,7 +144,10 @@ pub fn load_client_identity_pkcs12(
|
||||
if let Some(pfx_path) = &config.pfx_file {
|
||||
if !pfx_path.is_empty() {
|
||||
let data = fs::read(Path::new(pfx_path))?;
|
||||
return Ok(Some((data, config.passphrase.clone().unwrap_or_default())));
|
||||
return Ok(Some(NativeClientIdentity::Pkcs12 {
|
||||
data,
|
||||
password: config.passphrase.clone().unwrap_or_default(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,13 +156,35 @@ pub fn load_client_identity_pkcs12(
|
||||
};
|
||||
|
||||
let key_der = to_pkcs8_der(&key)?;
|
||||
|
||||
if !NATIVE_TLS_WANTS_PKCS12 {
|
||||
return Ok(Some(to_pkcs8_identity(&certs, &key_der)));
|
||||
}
|
||||
|
||||
let (leaf, cas) = certs.split_first().ok_or(GenericError("No certificates found".into()))?;
|
||||
let cas: Vec<&[u8]> = cas.iter().map(|c| c.as_ref()).collect();
|
||||
|
||||
let pfx = p12::PFX::new_with_cas(leaf, &key_der, &cas, IN_MEMORY_PKCS12_PASSWORD, "yaak")
|
||||
.ok_or(GenericError("Failed to build PKCS#12 from client certificate".into()))?;
|
||||
|
||||
Ok(Some((pfx.to_der(), IN_MEMORY_PKCS12_PASSWORD.to_string())))
|
||||
Ok(Some(NativeClientIdentity::Pkcs12 {
|
||||
data: pfx.to_der(),
|
||||
password: IN_MEMORY_PKCS12_PASSWORD.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Re-encode a certificate chain and PKCS#8 key as the PEM pair native-tls
|
||||
/// expects. It only recognises a key whose first line is the PKCS#8 header, so
|
||||
/// the key has to arrive already converted by [`to_pkcs8_der`].
|
||||
fn to_pkcs8_identity(certs: &[CertificateDer<'static>], key_der: &[u8]) -> NativeClientIdentity {
|
||||
let config = pem::EncodeConfig::new().set_line_ending(pem::LineEnding::LF);
|
||||
let chain: Vec<pem::Pem> =
|
||||
certs.iter().map(|c| pem::Pem::new("CERTIFICATE", c.as_ref())).collect();
|
||||
|
||||
NativeClientIdentity::Pkcs8 {
|
||||
chain_pem: pem::encode_many_config(&chain, config).into_bytes(),
|
||||
key_pem: pem::encode_config(&pem::Pem::new("PRIVATE KEY", key_der), config).into_bytes(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-encode a private key as PKCS#8 DER, wrapping PKCS#1 and SEC1 keys.
|
||||
@@ -379,3 +421,79 @@ pub fn find_client_certificate(
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pkcs8_identity_tests {
|
||||
use super::*;
|
||||
|
||||
const EC_CRT: &str = r#"-----BEGIN CERTIFICATE-----
|
||||
MIIBhTCCASugAwIBAgIUB8703dqXCUOJQbhbyaMUMbVFOjwwCgYIKoZIzj0EAwIw
|
||||
FzEVMBMGA1UEAwwMeWFhay10ZXN0LWVjMCAXDTI2MDgxNDIwNDYyNFoYDzIxMjYw
|
||||
NzIxMjA0NjI0WjAXMRUwEwYDVQQDDAx5YWFrLXRlc3QtZWMwWTATBgcqhkjOPQIB
|
||||
BggqhkjOPQMBBwNCAATCYYKhzgHEaRaGsYVjJSoXvoroL8qe1yeEA0VtfxFzMBg+
|
||||
+bkPQ0nCtMyFfvQQtXWYIakxzsWJyhI8wPjUj6QSo1MwUTAdBgNVHQ4EFgQUKq40
|
||||
Hl+2DziVkBVR/tGsPj9FRo0wHwYDVR0jBBgwFoAUKq40Hl+2DziVkBVR/tGsPj9F
|
||||
Ro0wDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNIADBFAiEAj1dx5XLl9iCZ
|
||||
rD0CW+a3RTluxQ5icXno9WJ9qaS6L08CIFx2t0y9znQr7n5x+SmfXbfZtkDola8e
|
||||
8nEZga/HXSeu
|
||||
-----END CERTIFICATE-----"#;
|
||||
|
||||
const EC_SEC1_KEY: &str = r#"-----BEGIN EC PRIVATE KEY-----
|
||||
MHcCAQEEIIoiiZ/hb4h6eHkZUVBTQFz7KLrVKJqQtWee2ygOjijNoAoGCCqGSM49
|
||||
AwEHoUQDQgAEwmGCoc4BxGkWhrGFYyUqF76K6C/KntcnhANFbX8RczAYPvm5D0NJ
|
||||
wrTMhX70ELV1mCGpMc7FicoSPMD41I+kEg==
|
||||
-----END EC PRIVATE KEY-----"#;
|
||||
|
||||
const EC_PKCS8_KEY: &str = r#"-----BEGIN PRIVATE KEY-----
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgiiKJn+FviHp4eRlR
|
||||
UFNAXPsoutUompC1Z57bKA6OKM2hRANCAATCYYKhzgHEaRaGsYVjJSoXvoroL8qe
|
||||
1yeEA0VtfxFzMBg++bkPQ0nCtMyFfvQQtXWYIakxzsWJyhI8wPjUj6QS
|
||||
-----END PRIVATE KEY-----"#;
|
||||
|
||||
fn pkcs8_identity(crt: &str, key: &str) -> (Vec<u8>, Vec<u8>) {
|
||||
let certs: Vec<CertificateDer<'static>> =
|
||||
rustls_pemfile::certs(&mut BufReader::new(crt.as_bytes()))
|
||||
.map(|c| c.unwrap())
|
||||
.collect();
|
||||
let key_der = to_pkcs8_der(&load_private_key(key.as_bytes()).unwrap()).unwrap();
|
||||
|
||||
match to_pkcs8_identity(&certs, &key_der) {
|
||||
NativeClientIdentity::Pkcs8 { chain_pem, key_pem } => (chain_pem, key_pem),
|
||||
NativeClientIdentity::Pkcs12 { .. } => unreachable!("asked for PKCS#8"),
|
||||
}
|
||||
}
|
||||
|
||||
/// native-tls matches the PKCS#8 header as a literal prefix and rejects the
|
||||
/// key outright when it does not line up, so pin it on every platform even
|
||||
/// though only the OpenSSL backend is handed this encoding.
|
||||
#[test]
|
||||
fn every_key_format_re_encodes_to_a_pkcs8_pem() {
|
||||
for (name, key) in [("SEC1", EC_SEC1_KEY), ("PKCS#8", EC_PKCS8_KEY)] {
|
||||
let (chain_pem, key_pem) = pkcs8_identity(EC_CRT, key);
|
||||
|
||||
assert!(
|
||||
key_pem.starts_with(b"-----BEGIN PRIVATE KEY-----\n"),
|
||||
"{name} key did not re-encode to a PKCS#8 PEM"
|
||||
);
|
||||
|
||||
let round_tripped: Vec<CertificateDer<'static>> =
|
||||
rustls_pemfile::certs(&mut BufReader::new(chain_pem.as_slice()))
|
||||
.map(|c| c.unwrap())
|
||||
.collect();
|
||||
let original: Vec<CertificateDer<'static>> =
|
||||
rustls_pemfile::certs(&mut BufReader::new(EC_CRT.as_bytes()))
|
||||
.map(|c| c.unwrap())
|
||||
.collect();
|
||||
assert_eq!(round_tripped, original, "{name} chain did not round-trip");
|
||||
}
|
||||
}
|
||||
|
||||
/// The two on-disk spellings of one EC key have to converge, because only
|
||||
/// the PKCS#8 one survives the re-encode.
|
||||
#[test]
|
||||
fn sec1_and_pkcs8_spellings_of_one_key_agree() {
|
||||
let (_, from_sec1) = pkcs8_identity(EC_CRT, EC_SEC1_KEY);
|
||||
let (_, from_pkcs8) = pkcs8_identity(EC_CRT, EC_PKCS8_KEY);
|
||||
assert_eq!(from_sec1, from_pkcs8);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,3 +21,4 @@ yaak-tls = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod error;
|
||||
pub mod export;
|
||||
pub mod import;
|
||||
pub mod models_ops;
|
||||
pub mod plugin_events;
|
||||
pub mod render;
|
||||
pub mod send;
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Generic model writes, shared by every host.
|
||||
//!
|
||||
//! `upsert`, `delete` and `duplicate` take an `AnyModel` and fan out to the
|
||||
//! typed query for its variant. That fan-out is long, mechanical, and has to
|
||||
//! grow a new arm every time a model is added — exactly the code that should
|
||||
//! not exist twice. The host supplies the database handles and the
|
||||
//! `UpdateSource` identifying who is writing; nothing here knows whether the
|
||||
//! caller is a desktop window or an HTTP request.
|
||||
|
||||
use yaak_models::blob_manager::BlobManager;
|
||||
use yaak_models::client_db::ClientDb;
|
||||
use yaak_models::error::Error::GenericError;
|
||||
use yaak_models::error::Result;
|
||||
use yaak_models::models::AnyModel;
|
||||
use yaak_models::util::UpdateSource;
|
||||
|
||||
pub fn upsert_model(
|
||||
db: &ClientDb,
|
||||
blobs: &BlobManager,
|
||||
model: AnyModel,
|
||||
source: &UpdateSource,
|
||||
) -> Result<String> {
|
||||
let id = match model {
|
||||
AnyModel::CookieJar(m) => db.upsert_cookie_jar(&m, source)?.id,
|
||||
AnyModel::Environment(m) => db.upsert_environment(&m, source)?.id,
|
||||
AnyModel::Folder(m) => db.upsert_folder(&m, source)?.id,
|
||||
AnyModel::GrpcRequest(m) => db.upsert_grpc_request(&m, source)?.id,
|
||||
AnyModel::HttpRequest(m) => db.upsert_http_request(&m, source)?.id,
|
||||
AnyModel::HttpResponse(m) => db.upsert_http_response(&m, source, blobs)?.id,
|
||||
AnyModel::KeyValue(m) => db.upsert_key_value(&m, source)?.id,
|
||||
AnyModel::Plugin(m) => db.upsert_plugin(&m, source)?.id,
|
||||
AnyModel::Settings(m) => db.upsert_settings(&m, source)?.id,
|
||||
AnyModel::WebsocketRequest(m) => db.upsert_websocket_request(&m, source)?.id,
|
||||
AnyModel::Workspace(m) => db.upsert_workspace(&m, source)?.id,
|
||||
AnyModel::WorkspaceMeta(m) => db.upsert_workspace_meta(&m, source)?.id,
|
||||
a => return Err(GenericError(format!("Cannot upsert AnyModel {a:?})"))),
|
||||
};
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Deletes cascade, so callers run this inside a transaction.
|
||||
pub fn delete_model(
|
||||
tx: &ClientDb,
|
||||
blobs: &BlobManager,
|
||||
model: AnyModel,
|
||||
source: &UpdateSource,
|
||||
) -> Result<String> {
|
||||
let id = match model {
|
||||
AnyModel::CookieJar(m) => tx.delete_cookie_jar(&m, source)?.id,
|
||||
AnyModel::Environment(m) => tx.delete_environment(&m, source)?.id,
|
||||
AnyModel::Folder(m) => tx.delete_folder(&m, source)?.id,
|
||||
AnyModel::GrpcConnection(m) => tx.delete_grpc_connection(&m, source)?.id,
|
||||
AnyModel::GrpcRequest(m) => tx.delete_grpc_request(&m, source)?.id,
|
||||
AnyModel::HttpRequest(m) => tx.delete_http_request(&m, source)?.id,
|
||||
AnyModel::HttpResponse(m) => tx.delete_http_response(&m, source, blobs)?.id,
|
||||
AnyModel::Plugin(m) => tx.delete_plugin(&m, source)?.id,
|
||||
AnyModel::WebsocketConnection(m) => tx.delete_websocket_connection(&m, source)?.id,
|
||||
AnyModel::WebsocketRequest(m) => tx.delete_websocket_request(&m, source)?.id,
|
||||
AnyModel::Workspace(m) => tx.delete_workspace(&m, source, blobs)?.id,
|
||||
a => return Err(GenericError(format!("Cannot delete AnyModel {a:?})"))),
|
||||
};
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Duplicates recurse, so callers run this inside a transaction.
|
||||
///
|
||||
/// The model is re-read from the database rather than taken from the caller, so
|
||||
/// a duplicate never comes from a stale frontend snapshot.
|
||||
pub fn duplicate_model(
|
||||
tx: &ClientDb,
|
||||
model_type: &str,
|
||||
model_id: &str,
|
||||
source: &UpdateSource,
|
||||
) -> Result<String> {
|
||||
let id = match model_type {
|
||||
"environment" => tx.duplicate_environment(&tx.get_environment(model_id)?, source)?.id,
|
||||
"folder" => tx.duplicate_folder(&tx.get_folder(model_id)?, source)?.id,
|
||||
"grpc_request" => tx.duplicate_grpc_request(&tx.get_grpc_request(model_id)?, source)?.id,
|
||||
"http_request" => tx.duplicate_http_request(&tx.get_http_request(model_id)?, source)?.id,
|
||||
"websocket_request" => {
|
||||
tx.duplicate_websocket_request(&tx.get_websocket_request(model_id)?, source)?.id
|
||||
}
|
||||
t => return Err(GenericError(format!("Cannot duplicate model type {t}"))),
|
||||
};
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
+587
-212
File diff suppressed because it is too large
Load Diff
@@ -58,6 +58,7 @@
|
||||
"crates-tauri/yaak-fonts",
|
||||
"crates-tauri/yaak-license",
|
||||
"crates-tauri/yaak-mac-window",
|
||||
"crates/common/yaak-rpc-schema",
|
||||
"crates/yaak-crypto",
|
||||
"crates/yaak-git",
|
||||
"crates/yaak-models",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user