snapshot of https://github.com/mountain-loop/yaak.git (main, 2026-09-23; blobs > 131072B omitted)
This commit is contained in:
29
apps/yaak-client/lib/alert.ts
Normal file
29
apps/yaak-client/lib/alert.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { AlertProps } from "../components/core/Alert";
|
||||
import { Alert } from "../components/core/Alert";
|
||||
import type { DialogProps } from "../components/core/Dialog";
|
||||
import { showDialog } from "./dialog";
|
||||
|
||||
interface AlertArgs {
|
||||
id: string;
|
||||
title: DialogProps["title"];
|
||||
body: AlertProps["body"];
|
||||
size?: DialogProps["size"];
|
||||
}
|
||||
|
||||
export function showAlert({ id, title, body, size = "sm" }: AlertArgs) {
|
||||
showDialog({
|
||||
id,
|
||||
title,
|
||||
size,
|
||||
disableClose: true,
|
||||
render: ({ hide }) => Alert({ onHide: hide, body }),
|
||||
});
|
||||
}
|
||||
|
||||
export function showSimpleAlert(title: string, message: string) {
|
||||
showAlert({
|
||||
id: "simple-alert",
|
||||
body: message,
|
||||
title: title,
|
||||
});
|
||||
}
|
||||
23
apps/yaak-client/lib/appInfo.ts
Normal file
23
apps/yaak-client/lib/appInfo.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { rpc } from "./rpc";
|
||||
|
||||
export interface AppInfo {
|
||||
isDev: boolean;
|
||||
version: string;
|
||||
cliVersion: string | null;
|
||||
name: string;
|
||||
appDataDir: string;
|
||||
appLogDir: string;
|
||||
vendoredPluginDir: string;
|
||||
defaultProjectDir: string;
|
||||
identifier: string;
|
||||
featureLicense: boolean;
|
||||
featureUpdater: boolean;
|
||||
}
|
||||
|
||||
export const appInfo = {
|
||||
...(await rpc("cmd_metadata")),
|
||||
identifier: await platform.appIdentifier(),
|
||||
} as AppInfo;
|
||||
|
||||
console.log("App info", appInfo);
|
||||
22
apps/yaak-client/lib/atoms.ts
Normal file
22
apps/yaak-client/lib/atoms.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import deepEqual from "@gilbarbara/deep-equal";
|
||||
import type { UpdateInfo } from "@yaakapp-internal/tauri-client";
|
||||
import type { Atom } from "jotai";
|
||||
import { atom } from "jotai";
|
||||
import { selectAtom } from "jotai/utils";
|
||||
import type { SplitLayoutLayout } from "@yaakapp-internal/ui";
|
||||
import { atomWithKVStorage } from "./atoms/atomWithKVStorage";
|
||||
|
||||
export function deepEqualAtom<T>(a: Atom<T>) {
|
||||
return selectAtom(
|
||||
a,
|
||||
(v) => v,
|
||||
(a, b) => deepEqual(a, b),
|
||||
);
|
||||
}
|
||||
|
||||
export const workspaceLayoutAtom = atomWithKVStorage<SplitLayoutLayout>(
|
||||
"workspace_layout",
|
||||
"horizontal",
|
||||
);
|
||||
|
||||
export const updateAvailableAtom = atom<Omit<UpdateInfo, "replyEventId"> | null>(null);
|
||||
25
apps/yaak-client/lib/atoms/atomWithKVStorage.ts
Normal file
25
apps/yaak-client/lib/atoms/atomWithKVStorage.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { atom } from "jotai";
|
||||
import { getKeyValue, setKeyValue } from "../keyValueStore";
|
||||
|
||||
export function atomWithKVStorage<T extends object | boolean | number | string | null>(
|
||||
key: string | string[],
|
||||
fallback: T,
|
||||
namespace = "global",
|
||||
) {
|
||||
const baseAtom = atom<T>(fallback);
|
||||
|
||||
baseAtom.onMount = (setValue) => {
|
||||
setValue(getKeyValue<T>({ namespace, key, fallback }));
|
||||
};
|
||||
|
||||
const derivedAtom = atom<T, [T | ((prev: T) => T)], void>(
|
||||
(get) => get(baseAtom),
|
||||
(get, set, update) => {
|
||||
const nextValue = typeof update === "function" ? update(get(baseAtom)) : update;
|
||||
set(baseAtom, nextValue);
|
||||
setKeyValue({ namespace, key, value: nextValue }).catch(console.error);
|
||||
},
|
||||
);
|
||||
|
||||
return derivedAtom;
|
||||
}
|
||||
6
apps/yaak-client/lib/capitalize.ts
Normal file
6
apps/yaak-client/lib/capitalize.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export function capitalize(str: string): string {
|
||||
return str
|
||||
.split(" ")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
20
apps/yaak-client/lib/color.ts
Normal file
20
apps/yaak-client/lib/color.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Color } from "@yaakapp-internal/plugins";
|
||||
|
||||
const colors: Record<Color, boolean> = {
|
||||
primary: true,
|
||||
secondary: true,
|
||||
success: true,
|
||||
notice: true,
|
||||
warning: true,
|
||||
danger: true,
|
||||
info: true,
|
||||
};
|
||||
|
||||
export function stringToColor(str: string | null): Color | null {
|
||||
if (!str) return null;
|
||||
const strLower = str.toLowerCase();
|
||||
if (strLower in colors) {
|
||||
return strLower as Color;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
34
apps/yaak-client/lib/confirm.ts
Normal file
34
apps/yaak-client/lib/confirm.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { ConfirmProps } from "../components/core/Confirm";
|
||||
import { Confirm } from "../components/core/Confirm";
|
||||
import type { DialogProps } from "../components/core/Dialog";
|
||||
import { showDialog } from "./dialog";
|
||||
|
||||
type ConfirmArgs = {
|
||||
id: string;
|
||||
} & Pick<DialogProps, "title" | "description" | "size"> &
|
||||
Pick<ConfirmProps, "color" | "confirmText" | "requireTyping">;
|
||||
|
||||
export async function showConfirm({
|
||||
color,
|
||||
confirmText,
|
||||
requireTyping,
|
||||
size = "sm",
|
||||
...extraProps
|
||||
}: ConfirmArgs) {
|
||||
return new Promise((onResult: ConfirmProps["onResult"]) => {
|
||||
showDialog({
|
||||
...extraProps,
|
||||
size,
|
||||
disableClose: true,
|
||||
render: ({ hide }) => Confirm({ onHide: hide, color, onResult, confirmText, requireTyping }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function showConfirmDelete({ confirmText, color, ...extraProps }: ConfirmArgs) {
|
||||
return showConfirm({
|
||||
color: color ?? "danger",
|
||||
confirmText: confirmText ?? "Delete",
|
||||
...extraProps,
|
||||
});
|
||||
}
|
||||
64
apps/yaak-client/lib/confirmWebProxy.test.tsx
Normal file
64
apps/yaak-client/lib/confirmWebProxy.test.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test";
|
||||
import type { DialogInstance } from "../components/Dialogs";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
platform: { httpProxyUrl: "https://web.yaak.app" },
|
||||
showDialog: vi.fn<(dialog: DialogInstance) => void>(),
|
||||
}));
|
||||
|
||||
vi.mock("@yaakapp-internal/platform", () => ({ platform: mocks.platform }));
|
||||
vi.mock("./appInfo", () => ({ appInfo: { identifier: "app.yaak.web" } }));
|
||||
vi.mock("./dialog", () => ({ showDialog: mocks.showDialog }));
|
||||
vi.mock("../hooks/useHotKey", () => ({
|
||||
useHotKey: vi.fn(),
|
||||
useFormattedHotkey: () => null,
|
||||
}));
|
||||
|
||||
describe("proxy consent disclosure", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("localStorage", { getItem: () => null });
|
||||
});
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
test.each([
|
||||
["Yaak-hosted", "https://web.yaak.app"],
|
||||
["self-hosted", "https://yaak.example.com"],
|
||||
["local", "http://localhost:8080"],
|
||||
["split deployment", "https://send.example.com/relay"],
|
||||
])("identifies the %s proxy and requires an explicit decision", async (_, proxyUrl) => {
|
||||
mocks.platform.httpProxyUrl = proxyUrl;
|
||||
const { confirmWebProxy } = await import("./confirmWebProxy");
|
||||
void confirmWebProxy();
|
||||
|
||||
const dialog = mocks.showDialog.mock.calls[0]?.[0];
|
||||
expect(dialog).toMatchObject({
|
||||
id: "web-proxy-consent",
|
||||
title: "Requests in Yaak Web use a proxy",
|
||||
size: "sm",
|
||||
disableClose: true,
|
||||
});
|
||||
if (dialog == null) throw new Error("Expected the proxy consent dialog");
|
||||
|
||||
const disclosure = renderToStaticMarkup(<>{dialog.description}</>);
|
||||
expect(disclosure).toContain(`>${proxyUrl}</code>`);
|
||||
expect(disclosure).toMatch(/request and response data/i);
|
||||
expect(disclosure).toContain("credentials");
|
||||
expect(disclosure).toContain("server shown below");
|
||||
expect(disclosure).not.toContain("Yaak’s servers");
|
||||
expect(disclosure).toContain(
|
||||
'href="https://yaak.app/docs/getting-started/web-proxy?ref=app.yaak.web"',
|
||||
);
|
||||
expect(disclosure).toContain('target="_blank"');
|
||||
expect(disclosure).toContain("how the proxy works");
|
||||
|
||||
const Actions = dialog.render;
|
||||
const actions = renderToStaticMarkup(<Actions hide={vi.fn()} />);
|
||||
expect(actions).toMatch(/<button[^>]*type="submit"/);
|
||||
expect(actions).toContain(">Send via Proxy<");
|
||||
expect(actions).toContain(">Cancel<");
|
||||
});
|
||||
});
|
||||
60
apps/yaak-client/lib/confirmWebProxy.tsx
Normal file
60
apps/yaak-client/lib/confirmWebProxy.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { InlineCode } from "@yaakapp-internal/ui";
|
||||
import { Link } from "../components/core/Link";
|
||||
import { showConfirm } from "./confirm";
|
||||
|
||||
const acceptedProxies = new Set<string>();
|
||||
const pendingConfirmations = new Map<string, Promise<boolean>>();
|
||||
|
||||
/** One decision for all pending sends; only acceptance is remembered. */
|
||||
export function confirmWebProxy(): Promise<boolean> {
|
||||
const proxyUrl = platform.httpProxyUrl;
|
||||
if (proxyUrl == null) return Promise.resolve(true);
|
||||
|
||||
const key = `yaak.webProxyConsent.v1:${proxyUrl}`;
|
||||
try {
|
||||
if (localStorage.getItem(key) === "accepted") return Promise.resolve(true);
|
||||
} catch {
|
||||
// Storage can be unavailable in private or restricted browsing.
|
||||
}
|
||||
if (acceptedProxies.has(proxyUrl)) return Promise.resolve(true);
|
||||
|
||||
const pending = pendingConfirmations.get(proxyUrl);
|
||||
if (pending != null) return pending;
|
||||
|
||||
const confirmation = showConfirm({
|
||||
id: "web-proxy-consent",
|
||||
title: "Requests in Yaak Web use a proxy",
|
||||
confirmText: "Send via Proxy",
|
||||
description: (
|
||||
<div className="space-y-3">
|
||||
<p>
|
||||
Yaak Web sends requests through a hosted proxy. Request and response data, including
|
||||
credentials, pass through the server shown below.
|
||||
</p>
|
||||
<p>
|
||||
<InlineCode className="break-all">{proxyUrl}</InlineCode>
|
||||
</p>
|
||||
<p>
|
||||
Learn more about{" "}
|
||||
<Link href="https://yaak.app/docs/getting-started/web-proxy">how the proxy works</Link>.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
.then((accepted) => {
|
||||
if (accepted) {
|
||||
acceptedProxies.add(proxyUrl);
|
||||
try {
|
||||
localStorage.setItem(key, "accepted");
|
||||
} catch {
|
||||
// Keep acceptance for this tab if it cannot be persisted.
|
||||
}
|
||||
}
|
||||
return accepted;
|
||||
})
|
||||
.finally(() => pendingConfirmations.delete(proxyUrl));
|
||||
|
||||
pendingConfirmations.set(proxyUrl, confirmation);
|
||||
return confirmation;
|
||||
}
|
||||
99
apps/yaak-client/lib/contentType.ts
Normal file
99
apps/yaak-client/lib/contentType.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import MimeType from "whatwg-mimetype";
|
||||
import type { EditorProps } from "../components/core/Editor/Editor";
|
||||
|
||||
export function languageFromContentType(
|
||||
contentType: string | null,
|
||||
content: string | null = null,
|
||||
): EditorProps["language"] {
|
||||
const justContentType = contentType?.split(";")[0] ?? contentType ?? "";
|
||||
if (justContentType.includes("json")) {
|
||||
return "json";
|
||||
}
|
||||
if (justContentType.includes("xml")) {
|
||||
return "xml";
|
||||
}
|
||||
if (justContentType.includes("html")) {
|
||||
const detected = languageFromContent(content);
|
||||
if (detected === "xml") {
|
||||
// If it's detected as XML, but is already HTML, don't change it
|
||||
return "html";
|
||||
}
|
||||
return detected;
|
||||
}
|
||||
if (justContentType.includes("javascript")) {
|
||||
// Sometimes `application/javascript` returns JSON, so try detecting that
|
||||
return languageFromContent(content, "javascript");
|
||||
}
|
||||
if (justContentType.includes("markdown")) {
|
||||
return "markdown";
|
||||
}
|
||||
|
||||
return languageFromContent(content, "text");
|
||||
}
|
||||
|
||||
export function languageFromContent(
|
||||
content: string | null,
|
||||
fallback?: EditorProps["language"],
|
||||
): EditorProps["language"] {
|
||||
if (content == null) return "text";
|
||||
|
||||
const firstBytes = content.slice(0, 20).trim();
|
||||
|
||||
if (firstBytes.startsWith("{") || firstBytes.startsWith("[")) {
|
||||
return "json";
|
||||
}
|
||||
if (
|
||||
firstBytes.toLowerCase().startsWith("<!doctype") ||
|
||||
firstBytes.toLowerCase().startsWith("<html")
|
||||
) {
|
||||
return "html";
|
||||
}
|
||||
if (firstBytes.startsWith("<")) {
|
||||
return "xml";
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function isJSON(content: string | null | undefined): boolean {
|
||||
if (typeof content !== "string") return false;
|
||||
|
||||
try {
|
||||
JSON.parse(content);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isProbablyTextContentType(contentType: string | null): boolean {
|
||||
if (contentType == null) return false;
|
||||
|
||||
const mimeType = getMimeTypeFromContentType(contentType).essence;
|
||||
const normalized = mimeType.toLowerCase();
|
||||
|
||||
// Check if it starts with "text/"
|
||||
if (normalized.startsWith("text/")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Common text mimetypes and suffixes
|
||||
return [
|
||||
"application/json",
|
||||
"application/xml",
|
||||
"application/javascript",
|
||||
"application/yaml",
|
||||
"+json",
|
||||
"+xml",
|
||||
"+yaml",
|
||||
"+text",
|
||||
].some((textType) => normalized === textType || normalized.endsWith(textType));
|
||||
}
|
||||
|
||||
export function getMimeTypeFromContentType(contentType: string): MimeType {
|
||||
try {
|
||||
return new MimeType(contentType);
|
||||
} catch {
|
||||
return new MimeType("text/plain");
|
||||
}
|
||||
}
|
||||
46
apps/yaak-client/lib/contextMenu.ts
Normal file
46
apps/yaak-client/lib/contextMenu.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { atom } from "jotai";
|
||||
import type { DropdownItem } from "../components/core/Dropdown";
|
||||
import { jotaiStore } from "./jotai";
|
||||
|
||||
/**
|
||||
* A menu opened from somewhere that can't render React.
|
||||
*
|
||||
* Most menus in the app are a `Dropdown` wrapped around their own trigger. This is for the
|
||||
* cases where the trigger isn't a React component at all — a CodeMirror widget builds its DOM
|
||||
* synchronously, so it can only hand over a position and a list of items. Same shape as
|
||||
* {@link showDialog}.
|
||||
*/
|
||||
export interface ContextMenuInstance {
|
||||
id: string;
|
||||
/** Where to open, in viewport coordinates */
|
||||
triggerPosition: { x: number; y: number };
|
||||
/** The trigger's box, when it has one, so placement can align to its edges */
|
||||
triggerRect?: Pick<DOMRect, "top" | "bottom" | "left" | "right">;
|
||||
/** The element it belongs to, so clicking that doesn't read as a click outside */
|
||||
triggerEl?: HTMLElement | null;
|
||||
items: DropdownItem[];
|
||||
}
|
||||
|
||||
export const contextMenusAtom = atom<ContextMenuInstance[]>([]);
|
||||
|
||||
export function showContextMenu({ id, ...props }: ContextMenuInstance) {
|
||||
jotaiStore.set(contextMenusAtom, (m) => [...m.filter((c) => c.id !== id), { id, ...props }]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the menu, or closes it if this one is already open.
|
||||
*
|
||||
* What a trigger wants: pressing it a second time should put the menu away. Same shape as
|
||||
* {@link toggleDialog}.
|
||||
*/
|
||||
export function toggleContextMenu({ id, ...props }: ContextMenuInstance) {
|
||||
if (jotaiStore.get(contextMenusAtom).some((c) => c.id === id)) {
|
||||
hideContextMenu(id);
|
||||
} else {
|
||||
showContextMenu({ id, ...props });
|
||||
}
|
||||
}
|
||||
|
||||
export function hideContextMenu(id: string) {
|
||||
jotaiStore.set(contextMenusAtom, (m) => m.filter((c) => c.id !== id));
|
||||
}
|
||||
22
apps/yaak-client/lib/copy.ts
Normal file
22
apps/yaak-client/lib/copy.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { showToast } from "./toast";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
|
||||
export function copyToClipboard(
|
||||
text: string | null,
|
||||
{ disableToast }: { disableToast?: boolean } = {},
|
||||
) {
|
||||
if (text == null) {
|
||||
platform.clipboard.clear().catch(console.error);
|
||||
} else {
|
||||
platform.clipboard.writeText(text).catch(console.error);
|
||||
}
|
||||
|
||||
if (text !== "" && !disableToast) {
|
||||
showToast({
|
||||
id: "copied",
|
||||
color: "success",
|
||||
icon: "copy",
|
||||
message: "Copied to clipboard",
|
||||
});
|
||||
}
|
||||
}
|
||||
31
apps/yaak-client/lib/createRequestAndNavigate.tsx
Normal file
31
apps/yaak-client/lib/createRequestAndNavigate.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models";
|
||||
import { createWorkspaceModel } from "@yaakapp-internal/models";
|
||||
import { activeRequestAtom } from "../hooks/useActiveRequest";
|
||||
import { jotaiStore } from "./jotai";
|
||||
import { router } from "./router";
|
||||
|
||||
export async function createRequestAndNavigate<
|
||||
T extends HttpRequest | GrpcRequest | WebsocketRequest,
|
||||
>(patch: Partial<T> & Pick<T, "model" | "workspaceId">) {
|
||||
const activeRequest = jotaiStore.get(activeRequestAtom);
|
||||
|
||||
if (patch.sortPriority === undefined) {
|
||||
if (activeRequest != null) {
|
||||
// Place below the currently active request
|
||||
patch.sortPriority = activeRequest.sortPriority;
|
||||
} else {
|
||||
// Place at the very top
|
||||
patch.sortPriority = -Date.now();
|
||||
}
|
||||
}
|
||||
patch.folderId = patch.folderId || activeRequest?.folderId;
|
||||
|
||||
const newId = await createWorkspaceModel(patch);
|
||||
|
||||
await router.navigate({
|
||||
to: "/workspaces/$workspaceId",
|
||||
params: { workspaceId: patch.workspaceId },
|
||||
search: (prev) => ({ ...prev, request_id: newId }),
|
||||
});
|
||||
return newId;
|
||||
}
|
||||
15
apps/yaak-client/lib/data/acceptLanguages.ts
Normal file
15
apps/yaak-client/lib/data/acceptLanguages.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// Common Accept-Language values.
|
||||
export const acceptLanguages = [
|
||||
"*",
|
||||
"en",
|
||||
"en-US",
|
||||
"en-US,en;q=0.9",
|
||||
"en-GB,en;q=0.9",
|
||||
"de-DE,de;q=0.9,en;q=0.8",
|
||||
"fr-FR,fr;q=0.9,en;q=0.8",
|
||||
"es-ES,es;q=0.9,en;q=0.8",
|
||||
"ru-RU,ru;q=0.9,en;q=0.8",
|
||||
"uk-UA,uk;q=0.9,en;q=0.8",
|
||||
"zh-CN,zh;q=0.9,en;q=0.8",
|
||||
"ja-JP,ja;q=0.9,en;q=0.8",
|
||||
];
|
||||
16
apps/yaak-client/lib/data/cacheControl.ts
Normal file
16
apps/yaak-client/lib/data/cacheControl.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// Common Cache-Control directives (request and response).
|
||||
export const cacheControlDirectives = [
|
||||
"no-cache",
|
||||
"no-store",
|
||||
"no-transform",
|
||||
"max-age=0",
|
||||
"max-age=3600",
|
||||
"max-age=86400",
|
||||
"s-maxage=3600",
|
||||
"must-revalidate",
|
||||
"proxy-revalidate",
|
||||
"stale-while-revalidate=60",
|
||||
"public",
|
||||
"private",
|
||||
"immutable",
|
||||
];
|
||||
121
apps/yaak-client/lib/data/charsets.ts
Normal file
121
apps/yaak-client/lib/data/charsets.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
export const charsets = [
|
||||
"utf-8",
|
||||
"us-ascii",
|
||||
"950",
|
||||
"ASMO-708",
|
||||
"CP1026",
|
||||
"CP870",
|
||||
"DOS-720",
|
||||
"DOS-862",
|
||||
"EUC-CN",
|
||||
"IBM437",
|
||||
"Johab",
|
||||
"Windows-1252",
|
||||
"X-EBCDIC-Spain",
|
||||
"big5",
|
||||
"cp866",
|
||||
"csISO2022JP",
|
||||
"ebcdic-cp-us",
|
||||
"euc-kr",
|
||||
"gb2312",
|
||||
"hz-gb-2312",
|
||||
"ibm737",
|
||||
"ibm775",
|
||||
"ibm850",
|
||||
"ibm852",
|
||||
"ibm857",
|
||||
"ibm861",
|
||||
"ibm869",
|
||||
"iso-2022-jp",
|
||||
"iso-2022-jp",
|
||||
"iso-2022-kr",
|
||||
"iso-8859-1",
|
||||
"iso-8859-15",
|
||||
"iso-8859-2",
|
||||
"iso-8859-3",
|
||||
"iso-8859-4",
|
||||
"iso-8859-5",
|
||||
"iso-8859-6",
|
||||
"iso-8859-7",
|
||||
"iso-8859-8",
|
||||
"iso-8859-8-i",
|
||||
"iso-8859-9",
|
||||
"koi8-r",
|
||||
"koi8-u",
|
||||
"ks_c_5601-1987",
|
||||
"macintosh",
|
||||
"shift_jis",
|
||||
"unicode",
|
||||
"unicodeFFFE",
|
||||
"utf-7",
|
||||
"windows-1250",
|
||||
"windows-1251",
|
||||
"windows-1253",
|
||||
"windows-1254",
|
||||
"windows-1255",
|
||||
"windows-1256",
|
||||
"windows-1257",
|
||||
"windows-1258",
|
||||
"windows-874",
|
||||
"x-Chinese-CNS",
|
||||
"x-Chinese-Eten",
|
||||
"x-EBCDIC-Arabic",
|
||||
"x-EBCDIC-CyrillicRussian",
|
||||
"x-EBCDIC-CyrillicSerbianBulgarian",
|
||||
"x-EBCDIC-DenmarkNorway",
|
||||
"x-EBCDIC-FinlandSweden",
|
||||
"x-EBCDIC-Germany",
|
||||
"x-EBCDIC-Greek",
|
||||
"x-EBCDIC-GreekModern",
|
||||
"x-EBCDIC-Hebrew",
|
||||
"x-EBCDIC-Icelandic",
|
||||
"x-EBCDIC-Italy",
|
||||
"x-EBCDIC-JapaneseAndJapaneseLatin",
|
||||
"x-EBCDIC-JapaneseAndKana",
|
||||
"x-EBCDIC-JapaneseAndUSCanada",
|
||||
"x-EBCDIC-JapaneseKatakana",
|
||||
"x-EBCDIC-KoreanAndKoreanExtended",
|
||||
"x-EBCDIC-KoreanExtended",
|
||||
"x-EBCDIC-SimplifiedChinese",
|
||||
"x-EBCDIC-Thai",
|
||||
"x-EBCDIC-TraditionalChinese",
|
||||
"x-EBCDIC-Turkish",
|
||||
"x-EBCDIC-UK",
|
||||
"x-Europa",
|
||||
"x-IA5",
|
||||
"x-IA5-German",
|
||||
"x-IA5-Norwegian",
|
||||
"x-IA5-Swedish",
|
||||
"x-ebcdic-cp-us-euro",
|
||||
"x-ebcdic-denmarknorway-euro",
|
||||
"x-ebcdic-finlandsweden-euro",
|
||||
"x-ebcdic-finlandsweden-euro",
|
||||
"x-ebcdic-france-euro",
|
||||
"x-ebcdic-germany-euro",
|
||||
"x-ebcdic-icelandic-euro",
|
||||
"x-ebcdic-international-euro",
|
||||
"x-ebcdic-italy-euro",
|
||||
"x-ebcdic-spain-euro",
|
||||
"x-ebcdic-uk-euro",
|
||||
"x-euc-jp",
|
||||
"x-iscii-as",
|
||||
"x-iscii-be",
|
||||
"x-iscii-de",
|
||||
"x-iscii-gu",
|
||||
"x-iscii-ka",
|
||||
"x-iscii-ma",
|
||||
"x-iscii-or",
|
||||
"x-iscii-pa",
|
||||
"x-iscii-ta",
|
||||
"x-iscii-te",
|
||||
"x-mac-arabic",
|
||||
"x-mac-ce",
|
||||
"x-mac-chinesesimp",
|
||||
"x-mac-cyrillic",
|
||||
"x-mac-greek",
|
||||
"x-mac-hebrew",
|
||||
"x-mac-icelandic",
|
||||
"x-mac-japanese",
|
||||
"x-mac-korean",
|
||||
"x-mac-turkish",
|
||||
];
|
||||
1
apps/yaak-client/lib/data/connections.ts
Normal file
1
apps/yaak-client/lib/data/connections.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const connections = ["close", "keep-alive"];
|
||||
1
apps/yaak-client/lib/data/encodings.ts
Normal file
1
apps/yaak-client/lib/data/encodings.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const encodings = ["*", "gzip", "compress", "deflate", "br", "zstd", "identity"];
|
||||
70
apps/yaak-client/lib/data/headerNames.ts
Normal file
70
apps/yaak-client/lib/data/headerNames.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
||||
|
||||
export const headerNames: (GenericCompletionOption | string)[] = [
|
||||
{
|
||||
type: "constant",
|
||||
label: "Content-Type",
|
||||
info: "The original media type of the resource (prior to any content encoding applied for sending)",
|
||||
},
|
||||
{
|
||||
type: "constant",
|
||||
label: "Content-Length",
|
||||
info: "The size of the message body, in bytes, sent to the recipient",
|
||||
},
|
||||
{
|
||||
type: "constant",
|
||||
label: "Accept",
|
||||
info:
|
||||
"The content types, expressed as MIME types, the client is able to understand. " +
|
||||
"The server uses content negotiation to select one of the proposals and informs " +
|
||||
"the client of the choice with the Content-Type response header. Browsers set required " +
|
||||
"values for this header based on the context of the request. For example, a browser uses " +
|
||||
"different values in a request when fetching a CSS stylesheet, image, video, or a script.",
|
||||
},
|
||||
{
|
||||
type: "constant",
|
||||
label: "Accept-Encoding",
|
||||
info:
|
||||
"The content encoding (usually a compression algorithm) that the client can understand. " +
|
||||
"The server uses content negotiation to select one of the proposals and informs the client " +
|
||||
"of that choice with the Content-Encoding response header.",
|
||||
},
|
||||
{
|
||||
type: "constant",
|
||||
label: "Accept-Language",
|
||||
info:
|
||||
"The natural language and locale that the client prefers. The server uses content " +
|
||||
"negotiation to select one of the proposals and informs the client of the choice with " +
|
||||
"the Content-Language response header.",
|
||||
},
|
||||
{
|
||||
type: "constant",
|
||||
label: "Authorization",
|
||||
info: "Provide credentials that authenticate a user agent with a server, allowing access to a protected resource.",
|
||||
},
|
||||
"Cache-Control",
|
||||
"Cookie",
|
||||
"Connection",
|
||||
"Content-MD5",
|
||||
"Date",
|
||||
"Expect",
|
||||
"Forwarded",
|
||||
"From",
|
||||
"Host",
|
||||
"If-Match",
|
||||
"If-Modified-Since",
|
||||
"If-None-Match",
|
||||
"If-Range",
|
||||
"If-Unmodified-Since",
|
||||
"Max-Forwards",
|
||||
"Origin",
|
||||
"Pragma",
|
||||
"Proxy-Authorization",
|
||||
"Range",
|
||||
"Referer",
|
||||
"TE",
|
||||
"User-Agent",
|
||||
"Upgrade",
|
||||
"Via",
|
||||
"Warning",
|
||||
];
|
||||
7
apps/yaak-client/lib/data/headerValuePresets.ts
Normal file
7
apps/yaak-client/lib/data/headerValuePresets.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* A suggested value for a header. A plain string is used when the displayed
|
||||
* label and the inserted value are the same (e.g. mime types). The object form
|
||||
* lets us show a short, readable `label` (e.g. "Chrome (Windows)") while
|
||||
* inserting a longer `value` (the full User-Agent string).
|
||||
*/
|
||||
export type HeaderValuePreset = string | { label: string; value: string };
|
||||
213
apps/yaak-client/lib/data/mimetypes.ts
Normal file
213
apps/yaak-client/lib/data/mimetypes.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
export const mimeTypes = [
|
||||
"application/json",
|
||||
"application/xml",
|
||||
"application/x-www-form-urlencoded",
|
||||
"multipart/form-data",
|
||||
"multipart/byteranges",
|
||||
"application/octet-stream",
|
||||
"text/plain",
|
||||
"application/javascript",
|
||||
"application/pdf",
|
||||
"text/html",
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"text/css",
|
||||
"application/x-pkcs12",
|
||||
"application/xhtml+xml",
|
||||
"application/andrew-inset",
|
||||
"application/applixware",
|
||||
"application/atom+xml",
|
||||
"application/atomcat+xml",
|
||||
"application/atomsvc+xml",
|
||||
"application/bdoc",
|
||||
"application/cu-seeme",
|
||||
"application/davmount+xml",
|
||||
"application/docbook+xml",
|
||||
"application/dssc+xml",
|
||||
"application/ecmascript",
|
||||
"application/epub+zip",
|
||||
"application/exi",
|
||||
"application/font-tdpfr",
|
||||
"application/font-woff",
|
||||
"application/font-woff2",
|
||||
"application/geo+json",
|
||||
"application/graphql",
|
||||
"application/java-serialized-object",
|
||||
"application/json5",
|
||||
"application/jsonml+json",
|
||||
"application/ld+json",
|
||||
"application/lost+xml",
|
||||
"application/manifest+json",
|
||||
"application/mp4",
|
||||
"application/msword",
|
||||
"application/mxf",
|
||||
"application/n-triples",
|
||||
"application/n-quads",
|
||||
"application/oda",
|
||||
"application/ogg",
|
||||
"application/pgp-encrypted",
|
||||
"application/pgp-signature",
|
||||
"application/pics-rules",
|
||||
"application/pkcs10",
|
||||
"application/pkcs7-mime",
|
||||
"application/pkcs7-signature",
|
||||
"application/pkcs8",
|
||||
"application/postscript",
|
||||
"application/pskc+xml",
|
||||
"application/rdf+xml",
|
||||
"application/resource-lists+xml",
|
||||
"application/resource-lists-diff+xml",
|
||||
"application/rls-services+xml",
|
||||
"application/rsd+xml",
|
||||
"application/rss+xml",
|
||||
"application/rtf",
|
||||
"application/sdp",
|
||||
"application/shf+xml",
|
||||
"application/timestamped-data",
|
||||
"application/trig",
|
||||
"application/vnd.android.package-archive",
|
||||
"application/vnd.api+json",
|
||||
"application/vnd.apple.installer+xml",
|
||||
"application/vnd.apple.mpegurl",
|
||||
"application/vnd.apple.pkpass",
|
||||
"application/vnd.bmi",
|
||||
"application/vnd.curl.car",
|
||||
"application/vnd.curl.pcurl",
|
||||
"application/vnd.dna",
|
||||
"application/vnd.google-apps.document",
|
||||
"application/vnd.google-apps.presentation",
|
||||
"application/vnd.google-apps.spreadsheet",
|
||||
"application/vnd.hal+xml",
|
||||
"application/vnd.handheld-entertainment+xml",
|
||||
"application/vnd.macports.portpkg",
|
||||
"application/vnd.unity",
|
||||
"application/vnd.zul",
|
||||
"application/widget",
|
||||
"application/wsdl+xml",
|
||||
"application/x-7z-compressed",
|
||||
"application/x-ace-compressed",
|
||||
"application/x-bittorrent",
|
||||
"application/x-bzip",
|
||||
"application/x-bzip2",
|
||||
"application/x-cfs-compressed",
|
||||
"application/x-chrome-extension",
|
||||
"application/x-cocoa",
|
||||
"application/x-envoy",
|
||||
"application/x-eva",
|
||||
"font/opentype",
|
||||
"application/x-gca-compressed",
|
||||
"application/x-gtar",
|
||||
"application/x-hdf",
|
||||
"application/x-httpd-php",
|
||||
"application/x-install-instructions",
|
||||
"application/x-latex",
|
||||
"application/x-lua-bytecode",
|
||||
"application/x-lzh-compressed",
|
||||
"application/x-ms-application",
|
||||
"application/x-ms-shortcut",
|
||||
"application/x-ndjson",
|
||||
"application/x-perl",
|
||||
"application/x-pkcs7-certificates",
|
||||
"application/x-pkcs7-certreqresp",
|
||||
"application/x-rar-compressed",
|
||||
"application/x-sh",
|
||||
"application/x-sql",
|
||||
"application/x-subrip",
|
||||
"application/x-t3vm-image",
|
||||
"application/x-tads",
|
||||
"application/x-tar",
|
||||
"application/x-tcl",
|
||||
"application/x-tex",
|
||||
"application/x-x509-ca-cert",
|
||||
"application/xop+xml",
|
||||
"application/xslt+xml",
|
||||
"application/zip",
|
||||
"audio/3gpp",
|
||||
"audio/adpcm",
|
||||
"audio/basic",
|
||||
"audio/midi",
|
||||
"audio/mpeg",
|
||||
"audio/mp4",
|
||||
"audio/ogg",
|
||||
"audio/silk",
|
||||
"audio/wave",
|
||||
"audio/webm",
|
||||
"audio/x-aac",
|
||||
"audio/x-aiff",
|
||||
"audio/x-caf",
|
||||
"audio/x-flac",
|
||||
"audio/xm",
|
||||
"image/bmp",
|
||||
"image/cgm",
|
||||
"image/sgi",
|
||||
"image/svg+xml",
|
||||
"image/tiff",
|
||||
"image/x-3ds",
|
||||
"image/x-freehand",
|
||||
"image/x-icon",
|
||||
"image/x-jng",
|
||||
"image/x-mrsid-image",
|
||||
"image/x-pcx",
|
||||
"image/x-pict",
|
||||
"image/x-rgb",
|
||||
"image/x-tga",
|
||||
"message/rfc822",
|
||||
"text/cache-manifest",
|
||||
"text/calendar",
|
||||
"text/coffeescript",
|
||||
"text/csv",
|
||||
"text/hjson",
|
||||
"text/jade",
|
||||
"text/jsx",
|
||||
"text/less",
|
||||
"text/mathml",
|
||||
"text/n3",
|
||||
"text/richtext",
|
||||
"text/sgml",
|
||||
"text/slim",
|
||||
"text/stylus",
|
||||
"text/tab-separated-values",
|
||||
"text/turtle",
|
||||
"text/uri-list",
|
||||
"text/vcard",
|
||||
"text/vnd.curl",
|
||||
"text/vnd.fly",
|
||||
"text/vtt",
|
||||
"text/x-asm",
|
||||
"text/x-c",
|
||||
"text/x-component",
|
||||
"text/x-fortran",
|
||||
"text/x-handlebars-template",
|
||||
"text/x-java-source",
|
||||
"text/x-lua",
|
||||
"text/x-markdown",
|
||||
"text/x-nfo",
|
||||
"text/x-opml",
|
||||
"text/x-pascal",
|
||||
"text/x-processing",
|
||||
"text/x-sass",
|
||||
"text/x-scss",
|
||||
"text/x-vcalendar",
|
||||
"text/xml",
|
||||
"text/yaml",
|
||||
"video/3gpp",
|
||||
"video/3gpp2",
|
||||
"video/h261",
|
||||
"video/h263",
|
||||
"video/h264",
|
||||
"video/jpeg",
|
||||
"video/jpm",
|
||||
"video/mj2",
|
||||
"video/mp2t",
|
||||
"video/mp4",
|
||||
"video/mpeg",
|
||||
"video/ogg",
|
||||
"video/quicktime",
|
||||
"video/webm",
|
||||
"video/x-f4v",
|
||||
"video/x-fli",
|
||||
"video/x-flv",
|
||||
"video/x-m4v",
|
||||
];
|
||||
51
apps/yaak-client/lib/data/userAgents.ts
Normal file
51
apps/yaak-client/lib/data/userAgents.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { HeaderValuePreset } from "./headerValuePresets";
|
||||
|
||||
// Common, real-world User-Agent strings. The short `label` is shown in the
|
||||
// dropdown/autocomplete, while the full UA string is what gets inserted.
|
||||
//
|
||||
// Browsers freeze parts of their UA: Chrome reports its minor version as 0.0.0
|
||||
// and Safari reports macOS as 10_15_7, regardless of the actual version.
|
||||
export const userAgents: HeaderValuePreset[] = [
|
||||
{
|
||||
label: "Chrome 151 · Windows 10/11 · x64",
|
||||
value:
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36",
|
||||
},
|
||||
{
|
||||
label: "Edge 151 · Windows 10/11 · x64",
|
||||
value:
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36 Edg/151.0.0.0",
|
||||
},
|
||||
{
|
||||
label: "Firefox 153 · Windows 10/11 · x64",
|
||||
value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:153.0) Gecko/20100101 Firefox/153.0",
|
||||
},
|
||||
{
|
||||
label: "Chrome 151 · macOS",
|
||||
value:
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36",
|
||||
},
|
||||
{
|
||||
label: "Safari 27 · macOS",
|
||||
value:
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/27.0 Safari/605.1.15",
|
||||
},
|
||||
{
|
||||
label: "Chrome 151 · Linux · x64",
|
||||
value:
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36",
|
||||
},
|
||||
{
|
||||
label: "Safari 27 · iOS 27 · iPhone",
|
||||
value:
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 27_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/27.0 Mobile/15E148 Safari/604.1",
|
||||
},
|
||||
{
|
||||
label: "Chrome 151 · Android 16 · Pixel 10",
|
||||
value:
|
||||
"Mozilla/5.0 (Linux; Android 16; Pixel 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Mobile Safari/537.36",
|
||||
},
|
||||
{ label: "curl 8.21.0 · CLI", value: "curl/8.21.0" },
|
||||
{ label: "Postman 7.56 · API client", value: "PostmanRuntime/7.56.1" },
|
||||
{ label: "Insomnia 13.1 · API client", value: "insomnia/13.1.0" },
|
||||
];
|
||||
8
apps/yaak-client/lib/defaultHeaders.ts
Normal file
8
apps/yaak-client/lib/defaultHeaders.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { HttpRequestHeader } from "@yaakapp-internal/models";
|
||||
import { rpc } from "./rpc";
|
||||
|
||||
/**
|
||||
* Global default headers fetched from the backend.
|
||||
* These are static and fetched once on module load.
|
||||
*/
|
||||
export const defaultHeaders: HttpRequestHeader[] = await rpc("cmd_default_headers");
|
||||
65
apps/yaak-client/lib/deleteModelWithConfirm.tsx
Normal file
65
apps/yaak-client/lib/deleteModelWithConfirm.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { AnyModel } from "@yaakapp-internal/models";
|
||||
import { deleteModel, modelTypeLabel } from "@yaakapp-internal/models";
|
||||
import { InlineCode } from "@yaakapp-internal/ui";
|
||||
import { Prose } from "../components/Prose";
|
||||
import { showConfirmDelete } from "./confirm";
|
||||
import { pluralizeCount } from "./pluralize";
|
||||
import { resolvedModelName } from "./resolvedModelName";
|
||||
|
||||
export async function deleteModelWithConfirm(
|
||||
model: AnyModel | AnyModel[] | null,
|
||||
options: { confirmName?: string } = {},
|
||||
): Promise<boolean> {
|
||||
if (model == null) {
|
||||
console.warn("Tried to delete null model");
|
||||
return false;
|
||||
}
|
||||
const models = Array.isArray(model) ? model : [model];
|
||||
const firstModel = models[0];
|
||||
if (firstModel == null) return false;
|
||||
|
||||
const descriptor =
|
||||
models.length === 1 ? modelTypeLabel(firstModel) : pluralizeCount("Item", models.length);
|
||||
const confirmed = await showConfirmDelete({
|
||||
id: `delete-model-${models.map((m) => m.id).join(",")}`,
|
||||
title: `Delete ${descriptor}`,
|
||||
requireTyping: options.confirmName,
|
||||
description: (
|
||||
<>
|
||||
Permanently delete{" "}
|
||||
{models.length === 1 ? (
|
||||
<>
|
||||
<InlineCode>{resolvedModelName(firstModel)}</InlineCode>?
|
||||
</>
|
||||
) : models.length < 10 ? (
|
||||
<>
|
||||
the following?
|
||||
<Prose className="mt-2">
|
||||
<ul className="space-y-1">
|
||||
{models.map((m) => (
|
||||
<li key={m.id}>
|
||||
<InlineCode
|
||||
className="inline-block truncate align-bottom max-w-full"
|
||||
title={resolvedModelName(m)}
|
||||
>
|
||||
{resolvedModelName(m)}
|
||||
</InlineCode>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Prose>
|
||||
</>
|
||||
) : (
|
||||
`all ${pluralizeCount("item", models.length)}?`
|
||||
)}
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await Promise.allSettled(models.map((m) => deleteModel(m)));
|
||||
return true;
|
||||
}
|
||||
22
apps/yaak-client/lib/dialog.ts
Normal file
22
apps/yaak-client/lib/dialog.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { atom } from "jotai";
|
||||
import type { DialogInstance } from "../components/Dialogs";
|
||||
import { jotaiStore } from "./jotai";
|
||||
|
||||
export const dialogsAtom = atom<DialogInstance[]>([]);
|
||||
|
||||
export function toggleDialog({ id, ...props }: DialogInstance) {
|
||||
const dialogs = jotaiStore.get(dialogsAtom);
|
||||
if (dialogs.some((d) => d.id === id)) {
|
||||
hideDialog(id);
|
||||
} else {
|
||||
showDialog({ id, ...props });
|
||||
}
|
||||
}
|
||||
|
||||
export function showDialog({ id, ...props }: DialogInstance) {
|
||||
jotaiStore.set(dialogsAtom, (a) => [...a.filter((d) => d.id !== id), { id, ...props }]);
|
||||
}
|
||||
|
||||
export function hideDialog(id: string) {
|
||||
jotaiStore.set(dialogsAtom, (a) => a.filter((d) => d.id !== id));
|
||||
}
|
||||
14
apps/yaak-client/lib/diffYaml.ts
Normal file
14
apps/yaak-client/lib/diffYaml.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { SyncModel } from "@yaakapp-internal/git";
|
||||
import { stringify } from "yaml";
|
||||
|
||||
/**
|
||||
* Convert a SyncModel to a YAML string for diffing.
|
||||
*/
|
||||
export function modelToYaml(model: SyncModel | null): string {
|
||||
if (!model) return "";
|
||||
|
||||
return stringify(model, {
|
||||
indent: 2,
|
||||
lineWidth: 0,
|
||||
});
|
||||
}
|
||||
39
apps/yaak-client/lib/dnd.ts
Normal file
39
apps/yaak-client/lib/dnd.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { DragMoveEvent } from "@dnd-kit/core";
|
||||
|
||||
export function computeSideForDragMove(
|
||||
id: string,
|
||||
e: DragMoveEvent,
|
||||
orientation: "vertical" | "horizontal" = "vertical",
|
||||
): "before" | "after" | null {
|
||||
if (e.over == null || e.over.id !== id) {
|
||||
return null;
|
||||
}
|
||||
if (e.active.rect.current.initial == null) return null;
|
||||
|
||||
const overRect = e.over.rect;
|
||||
|
||||
if (orientation === "horizontal") {
|
||||
// For horizontal layouts (tabs side-by-side), use left/right logic
|
||||
const activeLeft =
|
||||
e.active.rect.current.translated?.left ?? e.active.rect.current.initial.left + e.delta.x;
|
||||
const pointerX = activeLeft + e.active.rect.current.initial.width / 2;
|
||||
|
||||
const hoverLeft = overRect.left;
|
||||
const hoverRight = overRect.right;
|
||||
const hoverMiddleX = hoverLeft + (hoverRight - hoverLeft) / 2;
|
||||
|
||||
return pointerX < hoverMiddleX ? "before" : "after"; // 'before' = left, 'after' = right
|
||||
} else {
|
||||
// For vertical layouts, use top/bottom logic
|
||||
const activeTop =
|
||||
e.active.rect.current.translated?.top ?? e.active.rect.current.initial.top + e.delta.y;
|
||||
const pointerY = activeTop + e.active.rect.current.initial.height / 2;
|
||||
|
||||
const hoverTop = overRect.top;
|
||||
const hoverBottom = overRect.bottom;
|
||||
const hoverMiddleY = (hoverBottom - hoverTop) / 2;
|
||||
const hoverClientY = pointerY - hoverTop;
|
||||
|
||||
return hoverClientY < hoverMiddleY ? "before" : "after";
|
||||
}
|
||||
}
|
||||
67
apps/yaak-client/lib/docFingerprint.test.ts
Normal file
67
apps/yaak-client/lib/docFingerprint.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { docFingerprint } from "./docFingerprint";
|
||||
|
||||
const sampled = (text: string) => docFingerprint(text, { exact: false });
|
||||
const exact = (text: string) => docFingerprint(text, { exact: true });
|
||||
|
||||
/** Same length as `text`, with one character changed at `at` */
|
||||
const changeAt = (text: string, at: number) =>
|
||||
`${text.slice(0, at)}${text[at] === "b" ? "c" : "b"}${text.slice(at + 1)}`;
|
||||
|
||||
describe("docFingerprint", () => {
|
||||
test("is stable for the same text", () => {
|
||||
expect(sampled("hello")).toBe(sampled("hello"));
|
||||
expect(exact("hello")).toBe(exact("hello"));
|
||||
});
|
||||
|
||||
test("differs on different short text", () => {
|
||||
expect(sampled("hello")).not.toBe(sampled("world"));
|
||||
});
|
||||
|
||||
test("differs on length alone", () => {
|
||||
expect(sampled("a".repeat(1_000_000))).not.toBe(sampled("a".repeat(1_000_001)));
|
||||
});
|
||||
|
||||
test("hashes small documents in full, so any change is caught", () => {
|
||||
const doc = "a".repeat(100);
|
||||
for (let i = 0; i < doc.length; i++) {
|
||||
expect(sampled(doc)).not.toBe(sampled(changeAt(doc, i)));
|
||||
}
|
||||
});
|
||||
|
||||
describe("sampled", () => {
|
||||
const doc = "a".repeat(1_000_000);
|
||||
|
||||
test("notices a change at the start", () => {
|
||||
expect(sampled(doc)).not.toBe(sampled(changeAt(doc, 0)));
|
||||
});
|
||||
|
||||
test("notices a change at the end", () => {
|
||||
expect(sampled(doc)).not.toBe(sampled(changeAt(doc, doc.length - 1)));
|
||||
});
|
||||
|
||||
test("notices a change in the middle", () => {
|
||||
expect(sampled(doc)).not.toBe(sampled(changeAt(doc, doc.length / 2)));
|
||||
});
|
||||
|
||||
test("misses a same-length change between the samples", () => {
|
||||
// The documented cost of sampling. Read-only editors accept it because the worst a
|
||||
// collision can do there is restore a fold or the cursor to the wrong place.
|
||||
expect(sampled(doc)).toBe(sampled(changeAt(doc, 250_000)));
|
||||
});
|
||||
});
|
||||
|
||||
describe("exact", () => {
|
||||
const doc = "a".repeat(1_000_000);
|
||||
|
||||
test("catches a same-length change anywhere, which is why editable docs use it", () => {
|
||||
for (const at of [0, 250_000, doc.length / 2, 750_000, doc.length - 1]) {
|
||||
expect(exact(doc)).not.toBe(exact(changeAt(doc, at)));
|
||||
}
|
||||
});
|
||||
|
||||
test("does not collide with the sampled fingerprint of the same text", () => {
|
||||
expect(exact(doc)).not.toBe(sampled(doc));
|
||||
});
|
||||
});
|
||||
});
|
||||
33
apps/yaak-client/lib/docFingerprint.ts
Normal file
33
apps/yaak-client/lib/docFingerprint.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { md5 } from "js-md5";
|
||||
|
||||
/** How much of each end and the middle to hash */
|
||||
const SAMPLE_CHARS = 512;
|
||||
|
||||
/**
|
||||
* Identifies a document, so a cached editor state is never restored onto different content.
|
||||
*
|
||||
* Hashing the whole document costs about 4 ms per megabyte and is paid on every editor update
|
||||
* as well as on restore, which is a lot of work for a document nobody can edit.
|
||||
*
|
||||
* `exact` decides how much certainty that buys. Editable documents get a full hash, because a
|
||||
* collision there would restore undo history belonging to other content and let an undo write
|
||||
* nonsense into the body. Read-only documents get a sampled one: they hold the megabyte-sized
|
||||
* responses this exists for, their history can never be applied, and the worst a collision can
|
||||
* do is put a fold or the cursor in the wrong place.
|
||||
*
|
||||
* Sampling still pins the exact length plus three windows, so a colliding pair has to agree on
|
||||
* all four and differ only in between. Documents small enough to hash outright still are.
|
||||
*/
|
||||
export function docFingerprint(text: string, { exact }: { exact: boolean }): string {
|
||||
if (exact || text.length <= SAMPLE_CHARS * 3) {
|
||||
return `${text.length}:${md5(text)}`;
|
||||
}
|
||||
|
||||
const middle = Math.floor((text.length - SAMPLE_CHARS) / 2);
|
||||
return [
|
||||
text.length,
|
||||
md5(text.slice(0, SAMPLE_CHARS)),
|
||||
md5(text.slice(middle, middle + SAMPLE_CHARS)),
|
||||
md5(text.slice(-SAMPLE_CHARS)),
|
||||
].join(":");
|
||||
}
|
||||
19
apps/yaak-client/lib/duplicateRequestOrFolderAndNavigate.tsx
Normal file
19
apps/yaak-client/lib/duplicateRequestOrFolderAndNavigate.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { Folder, GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models";
|
||||
import { duplicateModel } from "@yaakapp-internal/models";
|
||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||
import { jotaiStore } from "./jotai";
|
||||
import { navigateToRequestOrFolderOrWorkspace } from "./setWorkspaceSearchParams";
|
||||
|
||||
export async function duplicateRequestOrFolderAndNavigate(
|
||||
model: Folder | HttpRequest | GrpcRequest | WebsocketRequest | null,
|
||||
) {
|
||||
if (model == null) {
|
||||
throw new Error("Cannot duplicate null item");
|
||||
}
|
||||
|
||||
const newId = await duplicateModel(model);
|
||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||
if (workspaceId == null || model.model === "folder") return;
|
||||
|
||||
navigateToRequestOrFolderOrWorkspace(newId, model.model);
|
||||
}
|
||||
62
apps/yaak-client/lib/editEnvironment.tsx
Normal file
62
apps/yaak-client/lib/editEnvironment.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { Environment, EnvironmentVariable } from "@yaakapp-internal/models";
|
||||
import { updateModel } from "@yaakapp-internal/models";
|
||||
import { openFolderSettings } from "../commands/openFolderSettings";
|
||||
import type { PairEditorHandle } from "../components/core/PairEditor";
|
||||
import { ensurePairId } from "../components/core/PairEditor.util";
|
||||
import { EnvironmentEditDialog } from "../components/EnvironmentEditDialog";
|
||||
import { environmentsBreakdownAtom } from "../hooks/useEnvironmentsBreakdown";
|
||||
import { toggleDialog } from "./dialog";
|
||||
import { jotaiStore } from "./jotai";
|
||||
|
||||
interface Options {
|
||||
addOrFocusVariable?: EnvironmentVariable;
|
||||
}
|
||||
|
||||
export async function editEnvironment(
|
||||
initialEnvironment: Environment | null,
|
||||
options: Options = {},
|
||||
) {
|
||||
if (initialEnvironment?.parentModel === "folder" && initialEnvironment.parentId != null) {
|
||||
openFolderSettings(initialEnvironment.parentId, "variables");
|
||||
} else {
|
||||
const { addOrFocusVariable } = options;
|
||||
const { baseEnvironment } = jotaiStore.get(environmentsBreakdownAtom);
|
||||
let environment = initialEnvironment ?? baseEnvironment;
|
||||
let focusId: string | null = null;
|
||||
|
||||
if (addOrFocusVariable && environment != null) {
|
||||
const existing = environment.variables.find(
|
||||
(v) => v.id === addOrFocusVariable.id || v.name === addOrFocusVariable.name,
|
||||
);
|
||||
if (existing) {
|
||||
focusId = existing.id ?? null;
|
||||
} else {
|
||||
const newVar = ensurePairId(addOrFocusVariable);
|
||||
environment = { ...environment, variables: [...environment.variables, newVar] };
|
||||
await updateModel(environment);
|
||||
environment.variables.push(newVar);
|
||||
focusId = newVar.id;
|
||||
}
|
||||
}
|
||||
|
||||
let didFocusVariable = false;
|
||||
|
||||
toggleDialog({
|
||||
id: "environment-editor",
|
||||
noPadding: true,
|
||||
size: "lg",
|
||||
className: "h-[90vh] max-h-240",
|
||||
render: () => (
|
||||
<EnvironmentEditDialog
|
||||
initialEnvironmentId={environment?.id ?? null}
|
||||
setRef={(pairEditor: PairEditorHandle | null) => {
|
||||
if (focusId && !didFocusVariable) {
|
||||
pairEditor?.focusValue(focusId);
|
||||
didFocusVariable = true;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
57
apps/yaak-client/lib/encryption.ts
Normal file
57
apps/yaak-client/lib/encryption.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { parseTemplate } from "@yaakapp-internal/templates";
|
||||
import { activeEnvironmentIdAtom } from "../hooks/useActiveEnvironment";
|
||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||
import { jotaiStore } from "./jotai";
|
||||
import { rpc } from "./rpc";
|
||||
|
||||
export function analyzeTemplate(template: string): "global_secured" | "local_secured" | "insecure" {
|
||||
let secureTags = 0;
|
||||
let insecureTags = 0;
|
||||
let totalTags = 0;
|
||||
for (const t of parseTemplate(template).tokens) {
|
||||
if (t.type === "eof") continue;
|
||||
|
||||
totalTags++;
|
||||
if (t.type === "tag" && t.val.type === "fn" && t.val.name === "secure") {
|
||||
secureTags++;
|
||||
} else if (t.type === "tag" && t.val.type === "var") {
|
||||
// Variables are secure
|
||||
} else if (t.type === "tag" && t.val.type === "bool") {
|
||||
// Booleans are secure
|
||||
} else {
|
||||
insecureTags++;
|
||||
}
|
||||
}
|
||||
|
||||
if (secureTags === 1 && totalTags === 1) {
|
||||
return "global_secured";
|
||||
}
|
||||
if (insecureTags === 0) {
|
||||
return "local_secured";
|
||||
}
|
||||
return "insecure";
|
||||
}
|
||||
|
||||
export async function convertTemplateToInsecure(template: string) {
|
||||
if (template === "") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom) ?? "n/a";
|
||||
const environmentId = jotaiStore.get(activeEnvironmentIdAtom) ?? null;
|
||||
return rpc<string>("cmd_decrypt_template", { template, workspaceId, environmentId });
|
||||
}
|
||||
|
||||
export async function convertTemplateToSecure(template: string): Promise<string> {
|
||||
if (template === "") {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (analyzeTemplate(template) === "global_secured") {
|
||||
return template;
|
||||
}
|
||||
|
||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom) ?? "n/a";
|
||||
const environmentId = jotaiStore.get(activeEnvironmentIdAtom) ?? null;
|
||||
return rpc<string>("cmd_secure_template", { template, workspaceId, environmentId });
|
||||
}
|
||||
103
apps/yaak-client/lib/featureFeedback.tsx
Normal file
103
apps/yaak-client/lib/featureFeedback.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { settingsAtom } from "@yaakapp-internal/models";
|
||||
import { FeedbackToast } from "../components/FeedbackToast";
|
||||
import { appInfo } from "./appInfo";
|
||||
import type { FeedbackFeature } from "./featureFeedbackConstants";
|
||||
import { dialogsAtom } from "./dialog";
|
||||
import { jotaiStore } from "./jotai";
|
||||
import { getKeyValue, setKeyValue } from "./keyValueStore";
|
||||
import { showToast } from "./toast";
|
||||
|
||||
interface FeatureFeedbackState {
|
||||
uses: number;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
const FEEDBACK_PROMPT_DELAY_MS = 1500;
|
||||
const FEEDBACK_PROMPT_TIMEOUT_MS = 8000;
|
||||
|
||||
// Ask once the user has used a feature enough times to have formed an opinion
|
||||
const PROMPT_AFTER_USES = 3;
|
||||
|
||||
// Show at most one feedback prompt per app session to stay unobtrusive
|
||||
let promptedThisSession = false;
|
||||
|
||||
const lastTrackedAt: Partial<Record<FeedbackFeature, number>> = {};
|
||||
const FEATURE_USE_DEBOUNCE_MS = 10_000;
|
||||
|
||||
const kvArgs = (feature: FeedbackFeature) => ({
|
||||
namespace: "global",
|
||||
key: ["feature-feedback", feature],
|
||||
});
|
||||
|
||||
function getFeatureFeedbackState(feature: FeedbackFeature): FeatureFeedbackState {
|
||||
return getKeyValue<FeatureFeedbackState>({
|
||||
...kvArgs(feature),
|
||||
fallback: { uses: 0, done: false },
|
||||
});
|
||||
}
|
||||
|
||||
function patchFeatureFeedbackState(feature: FeedbackFeature, patch: Partial<FeatureFeedbackState>) {
|
||||
const value = { ...getFeatureFeedbackState(feature), ...patch };
|
||||
setKeyValue({ ...kvArgs(feature), value }).catch(console.error);
|
||||
}
|
||||
|
||||
function markFeatureFeedbackDone(feature: FeedbackFeature) {
|
||||
patchFeatureFeedbackState(feature, { done: true });
|
||||
}
|
||||
|
||||
function showFeedbackToast(feature: FeedbackFeature) {
|
||||
if (!jotaiStore.get(settingsAtom).promptFeedback) return;
|
||||
|
||||
showToast({
|
||||
id: `feature-feedback-${feature}`,
|
||||
timeout: FEEDBACK_PROMPT_TIMEOUT_MS,
|
||||
dynamicHeight: true,
|
||||
hideDismiss: true,
|
||||
message: (
|
||||
<FeedbackToast feature={feature} onDone={() => markFeatureFeedbackDone(feature)} />
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function showFeedbackToastWhenReady(feature: FeedbackFeature) {
|
||||
setTimeout(() => {
|
||||
if (!jotaiStore.get(settingsAtom).promptFeedback) return;
|
||||
|
||||
if (jotaiStore.get(dialogsAtom).length === 0) {
|
||||
showFeedbackToast(feature);
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribe = jotaiStore.sub(dialogsAtom, () => {
|
||||
if (jotaiStore.get(dialogsAtom).length > 0) return;
|
||||
|
||||
unsubscribe();
|
||||
showFeedbackToast(feature);
|
||||
});
|
||||
}, FEEDBACK_PROMPT_DELAY_MS);
|
||||
}
|
||||
|
||||
// Record a successful use of a feature, and prompt for feedback on the Nth use.
|
||||
// Nothing is ever sent to the server from here; showing the toast is local-only
|
||||
// and a submission only happens when the user clicks Send in it.
|
||||
export function trackFeatureUsage(feature: FeedbackFeature) {
|
||||
if (appInfo.featureLicense !== true || !jotaiStore.get(settingsAtom).promptFeedback) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (lastTrackedAt[feature] != null && now - lastTrackedAt[feature] < FEATURE_USE_DEBOUNCE_MS) {
|
||||
return;
|
||||
}
|
||||
lastTrackedAt[feature] = now;
|
||||
|
||||
const state = getFeatureFeedbackState(feature);
|
||||
if (state.done) return;
|
||||
|
||||
const uses = state.uses + 1;
|
||||
const shouldPrompt = uses >= PROMPT_AFTER_USES && !promptedThisSession;
|
||||
|
||||
patchFeatureFeedbackState(feature, { uses });
|
||||
if (!shouldPrompt) return;
|
||||
|
||||
promptedThisSession = true;
|
||||
showFeedbackToastWhenReady(feature);
|
||||
}
|
||||
8
apps/yaak-client/lib/featureFeedbackConstants.ts
Normal file
8
apps/yaak-client/lib/featureFeedbackConstants.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// Feature keys are sent to the server and used to group feedback for analysis.
|
||||
// NEVER rename a key once it has shipped, or historical feedback will be split
|
||||
// across the old and new names.
|
||||
export const FEEDBACK_FEATURES = {
|
||||
"git-sync": "How is Git sync working for you?",
|
||||
} as const;
|
||||
|
||||
export type FeedbackFeature = keyof typeof FEEDBACK_FEATURES;
|
||||
16
apps/yaak-client/lib/fireAndForget.ts
Normal file
16
apps/yaak-client/lib/fireAndForget.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { showErrorToast } from "./toast";
|
||||
|
||||
/**
|
||||
* Handles a fire-and-forget promise by catching and reporting errors
|
||||
* via console.error and a toast notification.
|
||||
*/
|
||||
export function fireAndForget(promise: Promise<unknown>) {
|
||||
promise.catch((err: unknown) => {
|
||||
console.error("Unhandled async error:", err);
|
||||
showErrorToast({
|
||||
id: "async-error",
|
||||
title: "Unexpected Error",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
}
|
||||
45
apps/yaak-client/lib/formatters.ts
Normal file
45
apps/yaak-client/lib/formatters.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import vkBeautify from "vkbeautify";
|
||||
import { rpc } from "./rpc";
|
||||
|
||||
export async function tryFormatJson(text: string): Promise<string> {
|
||||
if (text === "") return text;
|
||||
|
||||
try {
|
||||
const result = await rpc<string>("cmd_format_json", { text });
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.warn("Failed to format JSON", err);
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(text), null, 2);
|
||||
} catch (err) {
|
||||
console.log("JSON beautify failed", err);
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
export async function tryFormatGraphql(text: string): Promise<string> {
|
||||
if (text === "") return text;
|
||||
|
||||
try {
|
||||
return await rpc<string>("cmd_format_graphql", { text });
|
||||
} catch (err) {
|
||||
console.warn("Failed to format GraphQL", err);
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
export async function tryFormatXml(text: string): Promise<string> {
|
||||
if (text === "") return text;
|
||||
|
||||
try {
|
||||
return vkBeautify.xml(text, " ");
|
||||
} catch (err) {
|
||||
console.warn("Failed to format XML", err);
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
7
apps/yaak-client/lib/generateId.ts
Normal file
7
apps/yaak-client/lib/generateId.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { customAlphabet } from "nanoid";
|
||||
|
||||
const nanoid = customAlphabet("023456789abcdefghijkmnpqrstuvwxyzABCDEFGHIJKMNPQRSTUVWXYZ", 10);
|
||||
|
||||
export function generateId(): string {
|
||||
return nanoid();
|
||||
}
|
||||
22
apps/yaak-client/lib/getNodeText.ts
Normal file
22
apps/yaak-client/lib/getNodeText.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Get the text content from a ReactNode
|
||||
* https://stackoverflow.com/questions/50428910/get-text-content-from-node-in-react
|
||||
*/
|
||||
export function getNodeText(node: ReactNode): string {
|
||||
if (typeof node === "string" || typeof node === "number") {
|
||||
return String(node);
|
||||
}
|
||||
|
||||
if (Array.isArray(node)) {
|
||||
return node.map(getNodeText).join("");
|
||||
}
|
||||
|
||||
if (typeof node === "object" && node) {
|
||||
// oxlint-disable-next-line no-explicit-any
|
||||
return getNodeText((node as any).props.children);
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
22
apps/yaak-client/lib/gitWorktreeStatus.ts
Normal file
22
apps/yaak-client/lib/gitWorktreeStatus.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { GitWorktreeStatus, GitWorktreeStatusEntry } from "@yaakapp-internal/git";
|
||||
import { atom } from "jotai";
|
||||
import { atomFamily } from "jotai-family";
|
||||
import { selectAtom } from "jotai/utils";
|
||||
|
||||
export const gitWorktreeStatusAtom = atom<GitWorktreeStatus | null>(null);
|
||||
|
||||
export const gitWorktreeStatusByModelIdAtom = atom<Record<string, GitWorktreeStatusEntry>>({});
|
||||
|
||||
export const gitWorktreeStatusFamily = atomFamily(
|
||||
(modelId: string) =>
|
||||
selectAtom(
|
||||
gitWorktreeStatusByModelIdAtom,
|
||||
(statusByModelId) => statusByModelId[modelId] ?? null,
|
||||
(a, b) =>
|
||||
a?.relaPath === b?.relaPath &&
|
||||
a?.status === b?.status &&
|
||||
a?.staged === b?.staged &&
|
||||
a?.modelId === b?.modelId,
|
||||
),
|
||||
Object.is,
|
||||
);
|
||||
37
apps/yaak-client/lib/graphqlOperationNames.test.ts
Normal file
37
apps/yaak-client/lib/graphqlOperationNames.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { getGraphQLOperationNames, parseGraphQLOperationNames } from "./graphqlOperationNames";
|
||||
|
||||
describe("getGraphQLOperationNames", () => {
|
||||
test("returns named operations from a GraphQL document", () => {
|
||||
expect(
|
||||
getGraphQLOperationNames(`
|
||||
query GetUser { user { id } }
|
||||
mutation UpdateUser { updateUser { id } }
|
||||
subscription UserChanged { userChanged { id } }
|
||||
fragment UserFields on User { id }
|
||||
`),
|
||||
).toEqual(["GetUser", "UpdateUser", "UserChanged"]);
|
||||
});
|
||||
|
||||
test("ignores anonymous operations", () => {
|
||||
expect(getGraphQLOperationNames(`{ user { id } }`)).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns unique operation names in document order", () => {
|
||||
expect(
|
||||
getGraphQLOperationNames(`
|
||||
query GetUser { user { id } }
|
||||
query GetUser { user { name } }
|
||||
query ListUsers { users { id } }
|
||||
`),
|
||||
).toEqual(["GetUser", "ListUsers"]);
|
||||
});
|
||||
|
||||
test("returns no operations for invalid in-progress documents", () => {
|
||||
expect(getGraphQLOperationNames(`query GetUser { user {`)).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns null when parsing invalid in-progress documents", () => {
|
||||
expect(parseGraphQLOperationNames(`query GetUser { user {`)).toBeNull();
|
||||
});
|
||||
});
|
||||
26
apps/yaak-client/lib/graphqlOperationNames.ts
Normal file
26
apps/yaak-client/lib/graphqlOperationNames.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Kind, parse } from "graphql";
|
||||
|
||||
export function getGraphQLOperationNames(query: string): string[] {
|
||||
return parseGraphQLOperationNames(query) ?? [];
|
||||
}
|
||||
|
||||
export function parseGraphQLOperationNames(query: string): string[] | null {
|
||||
try {
|
||||
const names: string[] = [];
|
||||
|
||||
for (const definition of parse(query).definitions) {
|
||||
if (definition.kind !== Kind.OPERATION_DEFINITION || definition.name == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = definition.name.value;
|
||||
if (!names.includes(name)) {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
return names;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
85
apps/yaak-client/lib/graphqlSchema.test.ts
Normal file
85
apps/yaak-client/lib/graphqlSchema.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { buildSchema, introspectionFromSchema } from "graphql";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { tryBuildIntrospectionFromFile } from "./graphqlSchema";
|
||||
|
||||
const sdl = `
|
||||
type Query {
|
||||
hello: String!
|
||||
user(id: ID!): User
|
||||
}
|
||||
|
||||
type User {
|
||||
id: ID!
|
||||
name: String
|
||||
}
|
||||
`;
|
||||
|
||||
const introspection = introspectionFromSchema(buildSchema(sdl));
|
||||
|
||||
describe("tryBuildIntrospectionFromFile", () => {
|
||||
test("accepts introspection JSON wrapped in { data: ... }", () => {
|
||||
const input = JSON.stringify({ data: introspection });
|
||||
const result = tryBuildIntrospectionFromFile(input);
|
||||
|
||||
expect("schema" in result).toBe(true);
|
||||
if ("schema" in result) {
|
||||
expect(result.schema.getQueryType()?.getFields()).toHaveProperty("hello");
|
||||
// Output content is the normalized, persistable shape.
|
||||
expect(JSON.parse(result.content)).toHaveProperty("data.__schema");
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts bare introspection JSON without a data wrapper", () => {
|
||||
const input = JSON.stringify(introspection);
|
||||
const result = tryBuildIntrospectionFromFile(input);
|
||||
|
||||
expect("schema" in result).toBe(true);
|
||||
if ("schema" in result) {
|
||||
expect(result.schema.getQueryType()?.getFields()).toHaveProperty("user");
|
||||
// Bare input is wrapped on the way out.
|
||||
expect(JSON.parse(result.content)).toHaveProperty("data.__schema");
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts a GraphQL SDL string", () => {
|
||||
const result = tryBuildIntrospectionFromFile(sdl);
|
||||
|
||||
expect("schema" in result).toBe(true);
|
||||
if ("schema" in result) {
|
||||
const fields = result.schema.getQueryType()?.getFields() ?? {};
|
||||
expect(fields).toHaveProperty("hello");
|
||||
expect(fields).toHaveProperty("user");
|
||||
// SDL is converted to introspection JSON for storage.
|
||||
expect(JSON.parse(result.content)).toHaveProperty("data.__schema");
|
||||
}
|
||||
});
|
||||
|
||||
test("returns an error for JSON that is neither introspection nor SDL", () => {
|
||||
const result = tryBuildIntrospectionFromFile('{"unrelated":"value"}');
|
||||
|
||||
expect("error" in result).toBe(true);
|
||||
if ("error" in result) {
|
||||
expect(result.error).toMatch(/Could not parse file as introspection JSON or GraphQL SDL/);
|
||||
}
|
||||
});
|
||||
|
||||
test("returns an error for content that is neither valid JSON nor valid SDL", () => {
|
||||
const result = tryBuildIntrospectionFromFile("not a schema!@#$");
|
||||
|
||||
expect("error" in result).toBe(true);
|
||||
if ("error" in result) {
|
||||
expect(result.error).toMatch(/Could not parse file as introspection JSON or GraphQL SDL/);
|
||||
}
|
||||
});
|
||||
|
||||
test("returns an error when introspection JSON has a malformed __schema", () => {
|
||||
// Has the data.__schema shape but the contents are invalid for buildClientSchema.
|
||||
const input = JSON.stringify({ data: { __schema: { broken: true } } });
|
||||
const result = tryBuildIntrospectionFromFile(input);
|
||||
|
||||
expect("error" in result).toBe(true);
|
||||
if ("error" in result) {
|
||||
expect(result.error).toMatch(/Failed to build schema from introspection JSON/);
|
||||
}
|
||||
});
|
||||
});
|
||||
51
apps/yaak-client/lib/graphqlSchema.ts
Normal file
51
apps/yaak-client/lib/graphqlSchema.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { GraphQLSchema, IntrospectionQuery } from "graphql";
|
||||
import { buildClientSchema, buildSchema, introspectionFromSchema } from "graphql";
|
||||
|
||||
// Accepts either a GraphQL introspection JSON ({ data: { __schema } } or
|
||||
// { __schema }) or an SDL string and normalizes both into the wrapped
|
||||
// { data: <introspection> } JSON shape used by the introspection store.
|
||||
export function tryBuildIntrospectionFromFile(
|
||||
fileContent: string,
|
||||
): { schema: GraphQLSchema; content: string } | { error: string } {
|
||||
let parsedJson: unknown;
|
||||
try {
|
||||
parsedJson = JSON.parse(fileContent);
|
||||
} catch {
|
||||
parsedJson = undefined;
|
||||
}
|
||||
|
||||
if (parsedJson != null && typeof parsedJson === "object") {
|
||||
const candidates: unknown[] = [(parsedJson as { data?: unknown }).data, parsedJson];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (
|
||||
candidate != null &&
|
||||
typeof candidate === "object" &&
|
||||
"__schema" in (candidate as Record<string, unknown>)
|
||||
) {
|
||||
try {
|
||||
const schema = buildClientSchema(candidate as IntrospectionQuery, {});
|
||||
return { schema, content: JSON.stringify({ data: candidate }) };
|
||||
} catch (e) {
|
||||
return {
|
||||
error: `Failed to build schema from introspection JSON: ${errorMessage(e)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const schema = buildSchema(fileContent);
|
||||
const introspection = introspectionFromSchema(schema);
|
||||
return { schema, content: JSON.stringify({ data: introspection }) };
|
||||
} catch (e) {
|
||||
return {
|
||||
error: `Could not parse file as introspection JSON or GraphQL SDL: ${errorMessage(e)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
136
apps/yaak-client/lib/importData.tsx
Normal file
136
apps/yaak-client/lib/importData.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
type BatchUpsertResult,
|
||||
type ImportDestination,
|
||||
type ImportPlan,
|
||||
type ImportSource,
|
||||
workspacesAtom,
|
||||
} from "@yaakapp-internal/models";
|
||||
import { FormattedError, VStack } from "@yaakapp-internal/ui";
|
||||
import { Button } from "../components/core/Button";
|
||||
import { ImportDataDialog } from "../components/ImportDataDialog";
|
||||
import { activeFolderAtom } from "../hooks/useActiveFolder";
|
||||
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";
|
||||
|
||||
// Stable identities so the dialog's effects don't re-run (and cancel in-flight
|
||||
// fetches) every time the dialog container re-renders.
|
||||
const planFile = (filePath: string, destination: ImportDestination) =>
|
||||
rpc<ImportPlan>("cmd_import_data", { filePath, destination });
|
||||
const planUrl = (url: string, destination: ImportDestination) =>
|
||||
rpc<ImportPlan>("cmd_import_url", { url, destination });
|
||||
const listSources = (workspaceId: string) =>
|
||||
rpc<ImportSource[]>("cmd_list_import_sources", { workspaceId });
|
||||
const findSourcesForOrigin = (args: { filePath?: string; url?: string }) =>
|
||||
rpc<ImportSource[]>("cmd_import_sources_for_origin", args);
|
||||
|
||||
export const importData = createFastMutation({
|
||||
mutationKey: ["import_data"],
|
||||
onError: (err: string) => {
|
||||
showAlert({
|
||||
id: "import-failed",
|
||||
title: "Import Failed",
|
||||
size: "md",
|
||||
body: <FormattedError>{err}</FormattedError>,
|
||||
});
|
||||
},
|
||||
mutationFn: async () => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const currentWorkspace = jotaiStore.get(activeWorkspaceAtom);
|
||||
const workspaces = jotaiStore.get(workspacesAtom);
|
||||
const selectedFolder = jotaiStore.get(activeFolderAtom);
|
||||
showDialog({
|
||||
id: "import",
|
||||
title: "Import Data",
|
||||
size: "lg",
|
||||
disableClose: true,
|
||||
render: ({ hide }) => {
|
||||
const cancel = () => {
|
||||
hide();
|
||||
resolve();
|
||||
};
|
||||
const fail = (err: unknown) => {
|
||||
hide();
|
||||
reject(err);
|
||||
};
|
||||
const commit = async (plan: ImportPlan) => {
|
||||
const imported = await rpc<BatchUpsertResult>("cmd_commit_import", { plan });
|
||||
hide();
|
||||
await finishImport(imported);
|
||||
resolve();
|
||||
};
|
||||
return (
|
||||
<ImportDataDialog
|
||||
currentWorkspace={currentWorkspace}
|
||||
workspaces={workspaces}
|
||||
selectedFolder={selectedFolder}
|
||||
planFile={planFile}
|
||||
planUrl={planUrl}
|
||||
listSources={listSources}
|
||||
findSourcesForOrigin={findSourcesForOrigin}
|
||||
commit={commit}
|
||||
cancel={cancel}
|
||||
onError={fail}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
async function finishImport(imported: BatchUpsertResult): Promise<void> {
|
||||
const importedWorkspace = imported.workspaces[0];
|
||||
|
||||
showDialog({
|
||||
id: "import-complete",
|
||||
title: "Import Complete",
|
||||
size: "sm",
|
||||
hideX: true,
|
||||
render: ({ hide }) => {
|
||||
return (
|
||||
<VStack space={3} className="pb-4">
|
||||
<ul className="list-disc pl-6">
|
||||
{imported.workspaces.length > 0 && (
|
||||
<li>{pluralizeCount("Workspace", imported.workspaces.length)}</li>
|
||||
)}
|
||||
{imported.environments.length > 0 && (
|
||||
<li>{pluralizeCount("Environment", imported.environments.length)}</li>
|
||||
)}
|
||||
{imported.folders.length > 0 && (
|
||||
<li>{pluralizeCount("Folder", imported.folders.length)}</li>
|
||||
)}
|
||||
{imported.httpRequests.length > 0 && (
|
||||
<li>{pluralizeCount("HTTP Request", imported.httpRequests.length)}</li>
|
||||
)}
|
||||
{imported.grpcRequests.length > 0 && (
|
||||
<li>{pluralizeCount("GRPC Request", imported.grpcRequests.length)}</li>
|
||||
)}
|
||||
{imported.websocketRequests.length > 0 && (
|
||||
<li>{pluralizeCount("Websocket Request", imported.websocketRequests.length)}</li>
|
||||
)}
|
||||
</ul>
|
||||
<div>
|
||||
<Button className="ml-auto" onClick={hide} color="primary">
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</VStack>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
if (importedWorkspace != null) {
|
||||
const environmentId = imported.environments[0]?.id ?? null;
|
||||
await router.navigate({
|
||||
to: "/workspaces/$workspaceId",
|
||||
params: { workspaceId: importedWorkspace.id },
|
||||
search: { environment_id: environmentId },
|
||||
});
|
||||
}
|
||||
}
|
||||
370
apps/yaak-client/lib/initGlobalListeners.tsx
Normal file
370
apps/yaak-client/lib/initGlobalListeners.tsx
Normal file
@@ -0,0 +1,370 @@
|
||||
import { debounce } from "@yaakapp-internal/lib";
|
||||
import type {
|
||||
FormInput,
|
||||
InternalEvent,
|
||||
JsonPrimitive,
|
||||
ShowToastRequest,
|
||||
} from "@yaakapp-internal/plugins";
|
||||
import { updateAllPlugins } from "@yaakapp-internal/plugins";
|
||||
import type {
|
||||
PluginUpdateNotification,
|
||||
UpdateInfo,
|
||||
UpdateResponse,
|
||||
YaakNotification,
|
||||
} from "@yaakapp-internal/tauri-client";
|
||||
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { openSettings } from "../commands/openSettings";
|
||||
import { Button } from "../components/core/Button";
|
||||
import { ButtonInfiniteLoading } from "../components/core/ButtonInfiniteLoading";
|
||||
|
||||
// Listen for toasts
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { updateAvailableAtom } from "./atoms";
|
||||
import { stringToColor } from "./color";
|
||||
import { generateId } from "./generateId";
|
||||
import { jotaiStore } from "./jotai";
|
||||
import { showPrompt } from "./prompt";
|
||||
import { showPromptForm } from "./prompt-form";
|
||||
import { rpc } from "./rpc";
|
||||
import { showToast } from "./toast";
|
||||
|
||||
export function initGlobalListeners() {
|
||||
platform.listen<ShowToastRequest>("show_toast", (payload) => {
|
||||
showToast({ ...payload });
|
||||
});
|
||||
|
||||
// Show errors for any plugins that failed to load during startup
|
||||
void rpc<[string, string][]>("cmd_plugin_init_errors").then((errors) => {
|
||||
for (const [dir, err] of errors) {
|
||||
const name = dir.split(/[/\\]/).pop() ?? dir;
|
||||
showToast({
|
||||
id: `plugin-init-error-${name}`,
|
||||
color: "danger",
|
||||
timeout: null,
|
||||
message: `Failed to load plugin "${name}": ${err}`,
|
||||
action: ({ hide }) => (
|
||||
<Button
|
||||
size="xs"
|
||||
color="danger"
|
||||
variant="border"
|
||||
onClick={() => {
|
||||
hide();
|
||||
openSettings.mutate("plugins:installed");
|
||||
}}
|
||||
>
|
||||
Manage Plugins
|
||||
</Button>
|
||||
),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
platform.listen("settings", () => openSettings.mutate(null));
|
||||
|
||||
// Track active dynamic form dialogs so follow-up input updates can reach them
|
||||
const activeForms = new Map<string, (inputs: FormInput[]) => void>();
|
||||
|
||||
// Listen for plugin events
|
||||
platform.listen<InternalEvent>("plugin_event", async (event) => {
|
||||
if (event.payload.type === "prompt_text_request") {
|
||||
const value = await showPrompt(event.payload);
|
||||
const result: InternalEvent = {
|
||||
id: generateId(),
|
||||
replyId: event.id,
|
||||
pluginName: event.pluginName,
|
||||
pluginRefId: event.pluginRefId,
|
||||
context: event.context,
|
||||
payload: {
|
||||
type: "prompt_text_response",
|
||||
value,
|
||||
},
|
||||
};
|
||||
await platform.emit(event.id, result);
|
||||
} else if (event.payload.type === "prompt_form_request") {
|
||||
if (event.replyId != null) {
|
||||
// Follow-up update from plugin runtime — update the active dialog's inputs
|
||||
const updateInputs = activeForms.get(event.replyId);
|
||||
if (updateInputs) {
|
||||
updateInputs(event.payload.inputs);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Initial request — show the dialog with bidirectional support
|
||||
const emitFormResponse = (values: Record<string, JsonPrimitive> | null, done: boolean) => {
|
||||
const result: InternalEvent = {
|
||||
id: generateId(),
|
||||
replyId: event.id,
|
||||
pluginName: event.pluginName,
|
||||
pluginRefId: event.pluginRefId,
|
||||
context: event.context,
|
||||
payload: {
|
||||
type: "prompt_form_response",
|
||||
values,
|
||||
done,
|
||||
},
|
||||
};
|
||||
void platform.emit(event.id, result);
|
||||
};
|
||||
|
||||
const values = await showPromptForm({
|
||||
id: event.payload.id,
|
||||
title: event.payload.title,
|
||||
description: event.payload.description,
|
||||
size: event.payload.size,
|
||||
inputs: event.payload.inputs,
|
||||
confirmText: event.payload.confirmText,
|
||||
cancelText: event.payload.cancelText,
|
||||
onValuesChange: debounce((values) => emitFormResponse(values, false), 150),
|
||||
onInputsUpdated: (cb) => activeForms.set(event.id, cb),
|
||||
});
|
||||
|
||||
// Clean up and send final response
|
||||
activeForms.delete(event.id);
|
||||
emitFormResponse(values, true);
|
||||
}
|
||||
});
|
||||
|
||||
platform.listen<string>("update_installed", async (version) => {
|
||||
console.log("Got update installed event", version);
|
||||
showUpdateInstalledToast(version);
|
||||
});
|
||||
|
||||
// Listen for update events
|
||||
platform.listen<UpdateInfo>("update_available", async (payload) => {
|
||||
console.log("Got update available", payload);
|
||||
void showUpdateAvailableToast(payload);
|
||||
});
|
||||
|
||||
platform.listen<YaakNotification>("notification", (payload) => {
|
||||
console.log("Got notification event", payload);
|
||||
showNotificationToast(payload);
|
||||
});
|
||||
|
||||
// Listen for plugin update events
|
||||
platform.listen<PluginUpdateNotification>("plugin_updates_available", (payload) => {
|
||||
console.log("Got plugin updates event", payload);
|
||||
showPluginUpdatesToast(payload);
|
||||
});
|
||||
}
|
||||
|
||||
function showUpdateInstalledToast(version: string) {
|
||||
const UPDATE_TOAST_ID = "update-info";
|
||||
|
||||
showToast({
|
||||
id: UPDATE_TOAST_ID,
|
||||
color: "primary",
|
||||
timeout: null,
|
||||
message: (
|
||||
<VStack>
|
||||
<h2 className="font-semibold">Yaak {version} was installed</h2>
|
||||
<p className="text-text-subtle text-sm">Start using the new version now?</p>
|
||||
</VStack>
|
||||
),
|
||||
action: ({ hide }) => (
|
||||
<ButtonInfiniteLoading
|
||||
size="xs"
|
||||
className="mr-auto min-w-20"
|
||||
color="primary"
|
||||
loadingChildren="Restarting..."
|
||||
onClick={() => {
|
||||
hide();
|
||||
setTimeout(() => rpc("cmd_restart", {}), 200);
|
||||
}}
|
||||
>
|
||||
Relaunch Yaak
|
||||
</ButtonInfiniteLoading>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
async function showUpdateAvailableToast(updateInfo: UpdateInfo) {
|
||||
const UPDATE_TOAST_ID = "update-info";
|
||||
const { version, replyEventId, downloaded, install } = updateInfo;
|
||||
|
||||
jotaiStore.set(updateAvailableAtom, { version, downloaded, install });
|
||||
|
||||
const whatsNewButton = (
|
||||
<Button
|
||||
size="xs"
|
||||
color="info"
|
||||
variant="border"
|
||||
rightSlot={<Icon icon="external_link" />}
|
||||
onClick={async () => {
|
||||
await platform.openUrl(`https://yaak.app/changelog/${version}`);
|
||||
}}
|
||||
>
|
||||
What's New
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (install !== "integrated") {
|
||||
// Nothing to reply to here; the backend only told us so we can say how to update
|
||||
const flatpak = install === "flatpak";
|
||||
showToast({
|
||||
id: UPDATE_TOAST_ID,
|
||||
color: "info",
|
||||
timeout: null,
|
||||
message: (
|
||||
<VStack>
|
||||
<h2 className="font-semibold">Yaak {version} is available</h2>
|
||||
<p className="text-text-subtle text-sm">
|
||||
{flatpak ? (
|
||||
<>
|
||||
Update with <InlineCode>flatpak update</InlineCode> or your software center.
|
||||
</>
|
||||
) : (
|
||||
"Download the new version to upgrade."
|
||||
)}
|
||||
</p>
|
||||
</VStack>
|
||||
),
|
||||
action: () => (
|
||||
<HStack space={1.5}>
|
||||
{!flatpak && (
|
||||
<Button
|
||||
size="xs"
|
||||
color="info"
|
||||
rightSlot={<Icon icon="external_link" />}
|
||||
onClick={async () => {
|
||||
await platform.openUrl("https://yaak.app/download");
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
)}
|
||||
{whatsNewButton}
|
||||
</HStack>
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Acknowledge the event, so we don't time out and try the fallback update logic
|
||||
await platform.emit(replyEventId, { type: "ack" } satisfies UpdateResponse);
|
||||
|
||||
showToast({
|
||||
id: UPDATE_TOAST_ID,
|
||||
color: "info",
|
||||
timeout: null,
|
||||
message: (
|
||||
<VStack>
|
||||
<h2 className="font-semibold">Yaak {version} is available</h2>
|
||||
<p className="text-text-subtle text-sm">
|
||||
{downloaded ? "Do you want to install" : "Download and install"} the update?
|
||||
</p>
|
||||
</VStack>
|
||||
),
|
||||
action: () => (
|
||||
<HStack space={1.5}>
|
||||
<ButtonInfiniteLoading
|
||||
size="xs"
|
||||
color="info"
|
||||
className="min-w-40"
|
||||
loadingChildren={downloaded ? "Installing..." : "Downloading..."}
|
||||
onClick={async () => {
|
||||
await platform.emit(replyEventId, {
|
||||
type: "action",
|
||||
action: "install",
|
||||
} satisfies UpdateResponse);
|
||||
}}
|
||||
>
|
||||
{downloaded ? "Install Now" : "Download and Install"}
|
||||
</ButtonInfiniteLoading>
|
||||
{whatsNewButton}
|
||||
</HStack>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function showPluginUpdatesToast(updateInfo: PluginUpdateNotification) {
|
||||
const PLUGIN_UPDATE_TOAST_ID = "plugin-updates";
|
||||
const count = updateInfo.updateCount;
|
||||
const pluginNames = updateInfo.plugins.map((p: { name: string }) => p.name);
|
||||
|
||||
showToast({
|
||||
id: PLUGIN_UPDATE_TOAST_ID,
|
||||
color: "info",
|
||||
timeout: null,
|
||||
message: (
|
||||
<VStack>
|
||||
<h2 className="font-semibold">
|
||||
{count === 1 ? "1 plugin update" : `${count} plugin updates`} available
|
||||
</h2>
|
||||
<p className="text-text-subtle text-sm">
|
||||
{count === 1
|
||||
? pluginNames[0]
|
||||
: `${pluginNames.slice(0, 2).join(", ")}${count > 2 ? `, and ${count - 2} more` : ""}`}
|
||||
</p>
|
||||
</VStack>
|
||||
),
|
||||
action: ({ hide }) => (
|
||||
<HStack space={1.5}>
|
||||
<ButtonInfiniteLoading
|
||||
size="xs"
|
||||
color="info"
|
||||
className="min-w-20"
|
||||
loadingChildren="Updating..."
|
||||
onClick={async () => {
|
||||
const updated = await updateAllPlugins();
|
||||
hide();
|
||||
if (updated.length > 0) {
|
||||
showToast({
|
||||
color: "success",
|
||||
message: `Successfully updated ${updated.length} plugin${updated.length === 1 ? "" : "s"}`,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Update All
|
||||
</ButtonInfiniteLoading>
|
||||
<Button
|
||||
size="xs"
|
||||
color="info"
|
||||
variant="border"
|
||||
onClick={() => {
|
||||
hide();
|
||||
openSettings.mutate("plugins:installed");
|
||||
}}
|
||||
>
|
||||
View Updates
|
||||
</Button>
|
||||
</HStack>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function showNotificationToast(n: YaakNotification) {
|
||||
const actionUrl = n.action?.url;
|
||||
const actionLabel = n.action?.label;
|
||||
showToast({
|
||||
id: n.id,
|
||||
timeout: n.timeout ?? null,
|
||||
color: stringToColor(n.color) ?? undefined,
|
||||
message: (
|
||||
<VStack>
|
||||
{n.title && <h2 className="font-semibold">{n.title}</h2>}
|
||||
<p className="text-text-subtle text-sm">{n.message}</p>
|
||||
</VStack>
|
||||
),
|
||||
onClose: () => {
|
||||
rpc("cmd_dismiss_notification", { notificationId: n.id }).catch(console.error);
|
||||
},
|
||||
action: ({ hide }) => {
|
||||
return actionLabel && actionUrl ? (
|
||||
<Button
|
||||
size="xs"
|
||||
color={stringToColor(n.color) ?? undefined}
|
||||
className="mr-auto min-w-20"
|
||||
rightSlot={<Icon icon="external_link" />}
|
||||
onClick={() => {
|
||||
hide();
|
||||
return platform.openUrl(actionUrl);
|
||||
}}
|
||||
>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
) : null;
|
||||
},
|
||||
});
|
||||
}
|
||||
3
apps/yaak-client/lib/jotai.ts
Normal file
3
apps/yaak-client/lib/jotai.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { createStore } from "jotai";
|
||||
|
||||
export const jotaiStore = createStore();
|
||||
30
apps/yaak-client/lib/jsonComments.ts
Normal file
30
apps/yaak-client/lib/jsonComments.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Simple heuristic to detect if a string likely contains JSON/JSONC comments.
|
||||
* Checks for // and /* patterns that are NOT inside double-quoted strings.
|
||||
* Used for UI hints only — doesn't need to be perfect.
|
||||
*/
|
||||
export function textLikelyContainsJsonComments(text: string): boolean {
|
||||
let inString = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (inString) {
|
||||
if (ch === '"') {
|
||||
inString = false;
|
||||
} else if (ch === "\\") {
|
||||
i++; // skip escaped char
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === "/" && i + 1 < text.length) {
|
||||
const next = text[i + 1];
|
||||
if (next === "/" || next === "*") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
251
apps/yaak-client/lib/jsonSchemaExample.test.ts
Normal file
251
apps/yaak-client/lib/jsonSchemaExample.test.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import type { JsonSchema } from "./jsonSchemaExample";
|
||||
import { buildExampleFromSchema } from "./jsonSchemaExample";
|
||||
|
||||
describe("buildExampleFromSchema", () => {
|
||||
test("fills scalar fields with placeholders", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
age: { type: "number", format: "int32" },
|
||||
active: { type: "boolean" },
|
||||
data: { type: "string", format: "byte" },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({
|
||||
name: "",
|
||||
age: 0,
|
||||
active: false,
|
||||
data: "",
|
||||
});
|
||||
});
|
||||
|
||||
test("encodes 64-bit integers as strings", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", format: "int64" },
|
||||
count: { type: "string", format: "uint64" },
|
||||
offset: { type: "string", format: "sfixed64" },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({ id: "0", count: "0", offset: "0" });
|
||||
});
|
||||
|
||||
test("fills date-time with a parseable timestamp", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: { createdAt: { type: "string", format: "date-time" } },
|
||||
};
|
||||
|
||||
const example = buildExampleFromSchema(schema) as { createdAt: string };
|
||||
expect(Number.isNaN(Date.parse(example.createdAt))).toBe(false);
|
||||
});
|
||||
|
||||
test("fills a duration with a value that parses", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: { timeout: { type: "string", format: "duration" } },
|
||||
};
|
||||
|
||||
// An empty string fails protobuf's Duration parsing, so the message wouldn't send
|
||||
expect(buildExampleFromSchema(schema)).toEqual({ timeout: "0s" });
|
||||
});
|
||||
|
||||
test("expands nested messages through $defs", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: { user: { $ref: "#/$defs/example.User" } },
|
||||
$defs: {
|
||||
"example.User": {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
address: { $ref: "#/$defs/example.Address" },
|
||||
},
|
||||
},
|
||||
"example.Address": {
|
||||
type: "object",
|
||||
properties: { city: { type: "string" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({
|
||||
user: { name: "", address: { city: "" } },
|
||||
});
|
||||
});
|
||||
|
||||
test("gives repeated fields a single placeholder item", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
tags: { type: "array", items: { type: "string" } },
|
||||
users: { type: "array", items: { $ref: "#/$defs/example.User" } },
|
||||
unknown: { type: "array" },
|
||||
},
|
||||
$defs: {
|
||||
"example.User": { type: "object", properties: { name: { type: "string" } } },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({
|
||||
tags: [""],
|
||||
users: [{ name: "" }],
|
||||
unknown: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("uses the first value of an enum", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { type: "string", enum: ["STATUS_UNSPECIFIED", "STATUS_ACTIVE"] },
|
||||
empty: { type: "string", enum: [] },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({ status: "STATUS_UNSPECIFIED", empty: "" });
|
||||
});
|
||||
|
||||
test("gives maps a single placeholder entry", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
labels: { type: "object", additionalProperties: { type: "string" } },
|
||||
users: { type: "object", additionalProperties: { $ref: "#/$defs/example.User" } },
|
||||
},
|
||||
$defs: {
|
||||
"example.User": { type: "object", properties: { name: { type: "string" } } },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({
|
||||
labels: { key: "" },
|
||||
users: { key: { name: "" } },
|
||||
});
|
||||
});
|
||||
|
||||
test("stops at the root self-reference", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
value: { type: "string" },
|
||||
children: { type: "array", items: { $ref: "#" } },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({ value: "", children: [{}] });
|
||||
});
|
||||
|
||||
test("stops at a cycle between messages", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: { node: { $ref: "#/$defs/example.Node" } },
|
||||
$defs: {
|
||||
"example.Node": {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
parent: { $ref: "#/$defs/example.Node" },
|
||||
leaf: { $ref: "#/$defs/example.Leaf" },
|
||||
},
|
||||
},
|
||||
"example.Leaf": {
|
||||
type: "object",
|
||||
properties: { node: { $ref: "#/$defs/example.Node" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({
|
||||
node: { name: "", parent: {}, leaf: { node: {} } },
|
||||
});
|
||||
});
|
||||
|
||||
test("expands the same message twice when it is not on the same path", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
from: { $ref: "#/$defs/example.User" },
|
||||
to: { $ref: "#/$defs/example.User" },
|
||||
},
|
||||
$defs: {
|
||||
"example.User": { type: "object", properties: { name: { type: "string" } } },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({ from: { name: "" }, to: { name: "" } });
|
||||
});
|
||||
|
||||
test("fills every branch of a flattened oneof", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
text: { type: "string" },
|
||||
image: { $ref: "#/$defs/example.Image" },
|
||||
},
|
||||
$defs: {
|
||||
"example.Image": { type: "object", properties: { url: { type: "string" } } },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({
|
||||
id: "",
|
||||
text: "",
|
||||
image: { url: "" },
|
||||
});
|
||||
});
|
||||
|
||||
test("stops expanding once the node budget runs out", () => {
|
||||
// Every level references the next one twice, so an unbounded walk would build 2^depth
|
||||
// nodes without ever repeating a ref on the same path.
|
||||
const depth = 16;
|
||||
const $defs: Record<string, JsonSchema> = { [`d${depth}`]: { type: "string" } };
|
||||
for (let i = 0; i < depth; i++) {
|
||||
$defs[`d${i}`] = {
|
||||
type: "object",
|
||||
properties: {
|
||||
a: { $ref: `#/$defs/d${i + 1}` },
|
||||
b: { $ref: `#/$defs/d${i + 1}` },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const example = buildExampleFromSchema({
|
||||
type: "object",
|
||||
properties: { root: { $ref: "#/$defs/d0" } },
|
||||
$defs,
|
||||
});
|
||||
|
||||
// 2 ** 16 nodes unbounded; the budget holds it to a couple of thousand
|
||||
expect(countNodes(example)).toBeLessThan(10_000);
|
||||
});
|
||||
|
||||
test("handles messages without a known type", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
empty: {},
|
||||
struct: { type: "object" },
|
||||
missing: { $ref: "#/$defs/example.Nope" },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({ empty: null, struct: {}, missing: {} });
|
||||
});
|
||||
});
|
||||
|
||||
function countNodes(value: unknown): number {
|
||||
if (Array.isArray(value)) {
|
||||
return 1 + value.reduce((total: number, v) => total + countNodes(v), 0);
|
||||
}
|
||||
if (value !== null && typeof value === "object") {
|
||||
return 1 + Object.values(value).reduce((total: number, v) => total + countNodes(v), 0);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
121
apps/yaak-client/lib/jsonSchemaExample.ts
Normal file
121
apps/yaak-client/lib/jsonSchemaExample.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Subset of JSON Schema emitted by the gRPC reflection layer for a method's
|
||||
* input message. See `message_to_json_schema` in the `yaak-grpc` crate.
|
||||
*/
|
||||
export type JsonSchema = {
|
||||
type?: string;
|
||||
format?: string;
|
||||
properties?: Record<string, JsonSchema>;
|
||||
items?: JsonSchema;
|
||||
additionalProperties?: JsonSchema;
|
||||
enum?: unknown[];
|
||||
$defs?: Record<string, JsonSchema>;
|
||||
$ref?: string;
|
||||
};
|
||||
|
||||
const DEFS_PREFIX = "#/$defs/";
|
||||
const ROOT_REF = "#";
|
||||
|
||||
// Protobuf 64-bit integers are encoded as strings in the JSON mapping
|
||||
const STRING_NUMBER_FORMATS = ["int64", "uint64", "sint64", "fixed64", "sfixed64"];
|
||||
|
||||
// Refs on sibling branches each expand their own subtree, so a schema that references the
|
||||
// same messages repeatedly can produce exponentially many nodes without ever cycling.
|
||||
const MAX_NODES = 5000;
|
||||
|
||||
type Budget = { remaining: number };
|
||||
|
||||
/** Build a sample message with placeholder values for every field in the schema */
|
||||
export function buildExampleFromSchema(schema: JsonSchema): unknown {
|
||||
// The root is already being built, so a `#` ref anywhere below it is a cycle
|
||||
return buildValue(schema, schema, new Set([ROOT_REF]), { remaining: MAX_NODES });
|
||||
}
|
||||
|
||||
function buildValue(
|
||||
schema: JsonSchema,
|
||||
root: JsonSchema,
|
||||
refPath: Set<string>,
|
||||
budget: Budget,
|
||||
): unknown {
|
||||
if (schema == null || typeof schema !== "object" || budget.remaining <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
budget.remaining -= 1;
|
||||
|
||||
if (typeof schema.$ref === "string") {
|
||||
if (refPath.has(schema.$ref)) {
|
||||
return {};
|
||||
}
|
||||
const resolved = resolveRef(schema.$ref, root);
|
||||
if (resolved == null) {
|
||||
return {};
|
||||
}
|
||||
return buildValue(resolved, root, new Set(refPath).add(schema.$ref), budget);
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.enum)) {
|
||||
return schema.enum[0] ?? "";
|
||||
}
|
||||
|
||||
switch (schema.type) {
|
||||
case "object":
|
||||
return buildObject(schema, root, refPath, budget);
|
||||
case "array":
|
||||
return schema.items == null ? [] : [buildValue(schema.items, root, refPath, budget)];
|
||||
case "string":
|
||||
return buildString(schema.format);
|
||||
case "number":
|
||||
return 0;
|
||||
case "boolean":
|
||||
return false;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildObject(
|
||||
schema: JsonSchema,
|
||||
root: JsonSchema,
|
||||
refPath: Set<string>,
|
||||
budget: Budget,
|
||||
): unknown {
|
||||
if (schema.properties != null && typeof schema.properties === "object") {
|
||||
const example: Record<string, unknown> = {};
|
||||
for (const [name, propertySchema] of Object.entries(schema.properties)) {
|
||||
example[name] = buildValue(propertySchema, root, refPath, budget);
|
||||
}
|
||||
return example;
|
||||
}
|
||||
|
||||
// Maps have no properties, only a value schema
|
||||
if (schema.additionalProperties != null) {
|
||||
return { key: buildValue(schema.additionalProperties, root, refPath, budget) };
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function buildString(format: string | undefined): string {
|
||||
if (format === "date-time") {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
// Duration JSON is a decimal string with an `s` suffix, and an empty one fails to parse
|
||||
if (format === "duration") {
|
||||
return "0s";
|
||||
}
|
||||
if (format != null && STRING_NUMBER_FORMATS.includes(format)) {
|
||||
return "0";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function resolveRef(ref: string, root: JsonSchema): JsonSchema | null {
|
||||
if (ref === ROOT_REF) {
|
||||
return root;
|
||||
}
|
||||
if (!ref.startsWith(DEFS_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
return root.$defs?.[ref.slice(DEFS_PREFIX.length)] ?? null;
|
||||
}
|
||||
70
apps/yaak-client/lib/keyValueStore.ts
Normal file
70
apps/yaak-client/lib/keyValueStore.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { KeyValue } from "@yaakapp-internal/models";
|
||||
import { createGlobalModel, keyValuesAtom, patchModel } from "@yaakapp-internal/models";
|
||||
import { jotaiStore } from "./jotai";
|
||||
|
||||
export async function setKeyValue<T>({
|
||||
namespace = "global",
|
||||
key: keyOrKeys,
|
||||
value: rawValue,
|
||||
}: {
|
||||
namespace?: string;
|
||||
key: string | string[];
|
||||
value: T;
|
||||
}): Promise<void> {
|
||||
const kv = getKeyValueRaw({ namespace, key: keyOrKeys });
|
||||
const key = buildKeyValueKey(keyOrKeys);
|
||||
const value = JSON.stringify(rawValue);
|
||||
|
||||
if (kv) {
|
||||
await patchModel(kv, { namespace, key, value });
|
||||
} else {
|
||||
await createGlobalModel({ model: "key_value", namespace, key, value });
|
||||
}
|
||||
}
|
||||
|
||||
export function getKeyValueRaw({
|
||||
namespace = "global",
|
||||
key: keyOrKeys,
|
||||
}: {
|
||||
namespace?: string;
|
||||
key: string | string[];
|
||||
}) {
|
||||
const key = buildKeyValueKey(keyOrKeys);
|
||||
const keyValues = jotaiStore.get(keyValuesAtom);
|
||||
const kv = keyValues.find((kv) => kv.namespace === namespace && kv?.key === key);
|
||||
return kv ?? null;
|
||||
}
|
||||
|
||||
export function getKeyValue<T>({
|
||||
namespace = "global",
|
||||
key,
|
||||
fallback,
|
||||
}: {
|
||||
namespace?: string;
|
||||
key: string | string[];
|
||||
fallback: T;
|
||||
}) {
|
||||
const kv = getKeyValueRaw({ namespace, key });
|
||||
return extractKeyValueOrFallback(kv, fallback);
|
||||
}
|
||||
|
||||
export function extractKeyValue<T>(kv: KeyValue | null): T | undefined {
|
||||
if (kv === null) return undefined;
|
||||
try {
|
||||
return JSON.parse(kv.value) as T;
|
||||
} catch (err) {
|
||||
console.log("Failed to parse kv value", kv.value, err);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractKeyValueOrFallback<T>(kv: KeyValue | null, fallback: T): T {
|
||||
const v = extractKeyValue<T>(kv);
|
||||
if (v === undefined) return fallback;
|
||||
return v;
|
||||
}
|
||||
|
||||
export function buildKeyValueKey(key: string | string[]): string {
|
||||
if (typeof key === "string") return key;
|
||||
return key.join("::");
|
||||
}
|
||||
236
apps/yaak-client/lib/largeValue.ts
Normal file
236
apps/yaak-client/lib/largeValue.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { Icon } from "@yaakapp-internal/ui";
|
||||
import mime from "mime";
|
||||
import { createElement } from "react";
|
||||
import type { DropdownItem } from "../components/core/Dropdown";
|
||||
import type { SniffedValue } from "../components/core/Editor/sniffValue";
|
||||
import { isEncodedRun } from "../components/core/Editor/sniffValue";
|
||||
import { copyToClipboard } from "./copy";
|
||||
import { fireAndForget } from "./fireAndForget";
|
||||
import { showToast } from "./toast";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
|
||||
/**
|
||||
* How the value is written, which is the thing worth knowing about it — that it is base64
|
||||
* explains why it reads as gibberish far better than its length does.
|
||||
*
|
||||
* A value we couldn't identify may still be plainly encoded, so it is checked here too: an
|
||||
* unrecognised format and an unrecognised encoding are different things to be told.
|
||||
*
|
||||
* `head` need only be the first {@link SNIFF_HEAD_CHARS} characters of the value.
|
||||
*/
|
||||
export function encodingLabel(head: string, sniffed: SniffedValue | null, chars: number): string {
|
||||
const size = `${chars.toLocaleString()} chars`;
|
||||
if (sniffed?.encoding === "percent") {
|
||||
return `Percent-encoded · ${size}`;
|
||||
}
|
||||
const encoded = sniffed?.encoding === "base64" || isEncodedRun(head, sniffed?.offset ?? 0);
|
||||
return encoded ? `Base64 · ${size}` : size;
|
||||
}
|
||||
|
||||
interface ActionOptions {
|
||||
/** The whole value. A function, so opening a menu never copies megabytes to build it. */
|
||||
value: () => string;
|
||||
sniffed: SniffedValue | null;
|
||||
/** What to copy. The tag copies only what it hides; the viewer copies what it shows. */
|
||||
copyText: () => string;
|
||||
/** Left out inside the viewer itself, where there is nothing further to open */
|
||||
onView?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The things you can do with a collapsed value, as menu items.
|
||||
*
|
||||
* Shared so the tag in the editor and the dialog it opens offer the same list, rather than
|
||||
* drifting apart.
|
||||
*/
|
||||
export function largeValueActions({
|
||||
value,
|
||||
sniffed,
|
||||
copyText,
|
||||
onView,
|
||||
}: ActionOptions): DropdownItem[] {
|
||||
const items: DropdownItem[] = [];
|
||||
|
||||
if (onView != null) {
|
||||
items.push({
|
||||
label: sniffed == null ? "View" : `View ${sniffed.label}`,
|
||||
leftSlot: createElement(Icon, { icon: "eye" }),
|
||||
onSelect: onView,
|
||||
});
|
||||
}
|
||||
|
||||
// Two ways to copy a picture, because either can be the one you wanted: the image to paste
|
||||
// somewhere that takes one, or the text to paste back into a request
|
||||
const image = isCopyableImage(sniffed);
|
||||
if (image) {
|
||||
items.push({
|
||||
label: "Copy Image",
|
||||
leftSlot: createElement(Icon, { icon: "copy" }),
|
||||
onSelect: () => copyImage(value(), sniffed, copyText()),
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
label: sniffed?.encoding === "base64" ? "Copy Base64" : "Copy",
|
||||
// Second of a pair reads as one thing with two options, so it keeps the indent without
|
||||
// repeating the icon above it
|
||||
leftSlot: createElement(Icon, { icon: image ? "empty" : "copy" }),
|
||||
onSelect: () => copyToClipboard(copyText()),
|
||||
});
|
||||
|
||||
items.push({
|
||||
label: "Save to File",
|
||||
leftSlot: createElement(Icon, { icon: "download" }),
|
||||
onSelect: () =>
|
||||
fireAndForget(saveValue(value(), sniffed, sniffed?.label.toLowerCase() ?? "value")),
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/** The payload, with the `data:` header taken off. */
|
||||
function payloadOf(text: string, sniffed: SniffedValue): string {
|
||||
return text.slice(sniffed.offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* A payload every strict base64 decoder will accept.
|
||||
*
|
||||
* The url-safe alphabet stands for the same bytes, and a ragged final group is dropped rather
|
||||
* than padded — it can only ever be worth a single byte, and both `atob` and the Rust decoder
|
||||
* behind the save command reject one outright.
|
||||
*/
|
||||
function normalizeBase64(payload: string): string {
|
||||
const clean = payload.replace(/\s+/g, "").replaceAll("-", "+").replaceAll("_", "/");
|
||||
return clean.slice(0, clean.length - (clean.length % 4));
|
||||
}
|
||||
|
||||
/**
|
||||
* The bytes a collapsed value stands for.
|
||||
*
|
||||
* `atob` rather than `Uint8Array.fromBase64`, which is too new to rely on across the webviews we
|
||||
* ship on. Only ever called for something the user asked to see, never during layout.
|
||||
*/
|
||||
export function decodeValue(text: string, sniffed: SniffedValue): Uint8Array<ArrayBuffer> {
|
||||
const payload = payloadOf(text, sniffed);
|
||||
if (sniffed.encoding === "percent") {
|
||||
return new TextEncoder().encode(decodeURIComponent(payload));
|
||||
}
|
||||
|
||||
const binary = atob(normalizeBase64(payload));
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the clipboard can take this as a picture rather than as text.
|
||||
*
|
||||
* SVG is left out on purpose: it is markup, so it pastes usefully as text, and rasterising it
|
||||
* would throw away the thing that makes it worth having.
|
||||
*/
|
||||
function isCopyableImage(sniffed: SniffedValue | null): sniffed is SniffedValue {
|
||||
return (
|
||||
sniffed != null && sniffed.mime.startsWith("image/") && !sniffed.mime.startsWith("image/svg")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The value as a PNG.
|
||||
*
|
||||
* Not a preference: PNG is the only image format the Clipboard API requires a browser to accept
|
||||
* on write — `text/plain`, `text/html` and `image/png` are the spec's mandatory data types — and
|
||||
* engines reject `image/jpeg` outright. So anything else is rasterised through a canvas, which
|
||||
* costs a decode and an encode but means a JPEG or a WEBP pastes just as readily as a PNG.
|
||||
*
|
||||
* `ClipboardItem.supports()` exists to ask whether a format could be written as it stands, and
|
||||
* skipping the conversion would be worth real time on a large photo. It was tried: WebKit says
|
||||
* no to `image/jpeg`, so the check only ever chose PNG and was removed again.
|
||||
*
|
||||
* The re-encode is lossless, so nothing is degraded, but a photograph lands on the clipboard far
|
||||
* larger than its JPEG. That matters less than it looks: the system pasteboard holds images
|
||||
* uncompressed regardless of what we hand it.
|
||||
*/
|
||||
async function toPngBlob(text: string, sniffed: SniffedValue): Promise<Blob> {
|
||||
const blob = new Blob([decodeValue(text, sniffed)], { type: sniffed.mime });
|
||||
if (sniffed.mime === "image/png") {
|
||||
return blob;
|
||||
}
|
||||
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx == null) {
|
||||
throw new Error("Could not get a canvas to convert the image");
|
||||
}
|
||||
ctx.drawImage(bitmap, 0, 0);
|
||||
bitmap.close();
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(png) => (png == null ? reject(new Error("Could not encode the image")) : resolve(png)),
|
||||
"image/png",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the picture on the clipboard, so it can be pasted anywhere that takes an image.
|
||||
*
|
||||
* Not awaited, and deliberately so. The webview only allows a clipboard write that starts inside
|
||||
* the click that asked for it, and decoding a few megabytes takes longer than that lasts — so
|
||||
* the item is handed the still-pending promise rather than a finished blob.
|
||||
*/
|
||||
export function copyImage(text: string, sniffed: SniffedValue, fallback: string) {
|
||||
if (typeof ClipboardItem === "undefined") {
|
||||
copyToClipboard(fallback);
|
||||
return;
|
||||
}
|
||||
|
||||
const png = toPngBlob(text, sniffed);
|
||||
navigator.clipboard
|
||||
.write([new ClipboardItem({ "image/png": png })])
|
||||
.then(() =>
|
||||
showToast({
|
||||
id: "copied",
|
||||
color: "success",
|
||||
icon: "copy",
|
||||
// Says PNG because that is what lands on the clipboard, whatever the value held
|
||||
message: "Copied as PNG",
|
||||
}),
|
||||
)
|
||||
.catch((err: unknown) => {
|
||||
// Anything from a webview that won't take an image to a file we couldn't decode. The
|
||||
// encoded text is always there to fall back on.
|
||||
console.error("Failed to copy image, copying text instead", err);
|
||||
copyToClipboard(fallback);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a collapsed value to a file the user picks.
|
||||
*
|
||||
* A value that is already base64 is handed over as base64. These are the values
|
||||
* the editor collapsed for being large, so decoding one here only for a host to
|
||||
* encode it again would walk megabytes twice for nothing.
|
||||
*/
|
||||
export async function saveValue(text: string, sniffed: SniffedValue | null, name: string) {
|
||||
const ext = sniffed == null ? "txt" : (mime.getExtension(sniffed.mime) ?? "bin");
|
||||
const content =
|
||||
sniffed == null
|
||||
? new TextEncoder().encode(text)
|
||||
: sniffed.encoding === "base64"
|
||||
? { base64: normalizeBase64(payloadOf(text, sniffed)) }
|
||||
: decodeValue(text, sniffed);
|
||||
|
||||
const savedTo = await platform.files.save(`${name}.${ext}`, content);
|
||||
if (savedTo == null) {
|
||||
return; // Cancelled
|
||||
}
|
||||
|
||||
showToast({ message: `Saved to ${savedTo}` });
|
||||
}
|
||||
27
apps/yaak-client/lib/markdown.ts
Normal file
27
apps/yaak-client/lib/markdown.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import rehypeStringify from "rehype-stringify";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkParse from "remark-parse";
|
||||
import remarkRehype from "remark-rehype";
|
||||
import { unified } from "unified";
|
||||
|
||||
const renderer = unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkGfm)
|
||||
.use(remarkRehype, {
|
||||
// handlers: {
|
||||
// link: (state, node, parent) => {
|
||||
// return node;
|
||||
// },
|
||||
// },
|
||||
})
|
||||
.use(rehypeStringify);
|
||||
|
||||
export async function renderMarkdown(md: string): Promise<string> {
|
||||
try {
|
||||
const r = await renderer.process(md);
|
||||
return r.toString();
|
||||
} catch (err) {
|
||||
console.log("FAILED TO RENDER MARKDOWN", err);
|
||||
return "error";
|
||||
}
|
||||
}
|
||||
19
apps/yaak-client/lib/minPromiseMillis.ts
Normal file
19
apps/yaak-client/lib/minPromiseMillis.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { sleep } from "./sleep";
|
||||
|
||||
/** Ensures a promise takes at least a certain number of milliseconds to resolve */
|
||||
export async function minPromiseMillis<T>(promise: Promise<T>, millis = 300) {
|
||||
const start = Date.now();
|
||||
let result: T;
|
||||
|
||||
try {
|
||||
result = await promise;
|
||||
} catch (e) {
|
||||
const delayFor = millis - (Date.now() - start);
|
||||
await sleep(delayFor);
|
||||
throw e;
|
||||
}
|
||||
|
||||
const delayFor = millis - (Date.now() - start);
|
||||
await sleep(delayFor);
|
||||
return result;
|
||||
}
|
||||
91
apps/yaak-client/lib/model_util.test.ts
Normal file
91
apps/yaak-client/lib/model_util.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { HttpResponseEvent } from "@yaakapp-internal/models";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { getCookieCounts } from "./model_util";
|
||||
|
||||
function makeEvent(type: string, name: string, value: string): HttpResponseEvent {
|
||||
return {
|
||||
id: "test",
|
||||
model: "http_response_event",
|
||||
responseId: "resp",
|
||||
workspaceId: "ws",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
event: { type, name, value } as HttpResponseEvent["event"],
|
||||
};
|
||||
}
|
||||
|
||||
describe("getCookieCounts", () => {
|
||||
test("returns zeros for undefined events", () => {
|
||||
expect(getCookieCounts(undefined)).toEqual({ sent: 0, received: 0 });
|
||||
});
|
||||
|
||||
test("returns zeros for empty events", () => {
|
||||
expect(getCookieCounts([])).toEqual({ sent: 0, received: 0 });
|
||||
});
|
||||
|
||||
test("counts single sent cookie", () => {
|
||||
const events = [makeEvent("header_up", "Cookie", "session=abc123")];
|
||||
expect(getCookieCounts(events)).toEqual({ sent: 1, received: 0 });
|
||||
});
|
||||
|
||||
test("counts multiple sent cookies in one header", () => {
|
||||
const events = [makeEvent("header_up", "Cookie", "a=1; b=2; c=3")];
|
||||
expect(getCookieCounts(events)).toEqual({ sent: 3, received: 0 });
|
||||
});
|
||||
|
||||
test("counts single received cookie", () => {
|
||||
const events = [makeEvent("header_down", "Set-Cookie", "session=abc123; Path=/")];
|
||||
expect(getCookieCounts(events)).toEqual({ sent: 0, received: 1 });
|
||||
});
|
||||
|
||||
test("counts multiple received cookies from multiple headers", () => {
|
||||
const events = [
|
||||
makeEvent("header_down", "Set-Cookie", "a=1; Path=/"),
|
||||
makeEvent("header_down", "Set-Cookie", "b=2; HttpOnly"),
|
||||
makeEvent("header_down", "Set-Cookie", "c=3; Secure"),
|
||||
];
|
||||
expect(getCookieCounts(events)).toEqual({ sent: 0, received: 3 });
|
||||
});
|
||||
|
||||
test("deduplicates sent cookies by name", () => {
|
||||
const events = [
|
||||
makeEvent("header_up", "Cookie", "session=old"),
|
||||
makeEvent("header_up", "Cookie", "session=new"),
|
||||
];
|
||||
expect(getCookieCounts(events)).toEqual({ sent: 1, received: 0 });
|
||||
});
|
||||
|
||||
test("deduplicates received cookies by name", () => {
|
||||
const events = [
|
||||
makeEvent("header_down", "Set-Cookie", "token=abc; Path=/"),
|
||||
makeEvent("header_down", "Set-Cookie", "token=xyz; Path=/"),
|
||||
];
|
||||
expect(getCookieCounts(events)).toEqual({ sent: 0, received: 1 });
|
||||
});
|
||||
|
||||
test("counts both sent and received cookies", () => {
|
||||
const events = [
|
||||
makeEvent("header_up", "Cookie", "a=1; b=2; c=3"),
|
||||
makeEvent("header_down", "Set-Cookie", "x=10; Path=/"),
|
||||
makeEvent("header_down", "Set-Cookie", "y=20; Path=/"),
|
||||
makeEvent("header_down", "Set-Cookie", "z=30; Path=/"),
|
||||
];
|
||||
expect(getCookieCounts(events)).toEqual({ sent: 3, received: 3 });
|
||||
});
|
||||
|
||||
test("ignores non-cookie headers", () => {
|
||||
const events = [
|
||||
makeEvent("header_up", "Content-Type", "application/json"),
|
||||
makeEvent("header_down", "Content-Length", "123"),
|
||||
];
|
||||
expect(getCookieCounts(events)).toEqual({ sent: 0, received: 0 });
|
||||
});
|
||||
|
||||
test("handles case-insensitive header names", () => {
|
||||
const events = [
|
||||
makeEvent("header_up", "COOKIE", "a=1"),
|
||||
makeEvent("header_down", "SET-COOKIE", "b=2; Path=/"),
|
||||
];
|
||||
expect(getCookieCounts(events)).toEqual({ sent: 1, received: 1 });
|
||||
});
|
||||
});
|
||||
95
apps/yaak-client/lib/model_util.ts
Normal file
95
apps/yaak-client/lib/model_util.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type {
|
||||
AnyModel,
|
||||
Cookie,
|
||||
Environment,
|
||||
HttpResponseEvent,
|
||||
HttpResponseHeader,
|
||||
} from "@yaakapp-internal/models";
|
||||
import { getMimeTypeFromContentType } from "./contentType";
|
||||
|
||||
export const BODY_TYPE_NONE = null;
|
||||
export const BODY_TYPE_GRAPHQL = "graphql";
|
||||
export const BODY_TYPE_JSON = "application/json";
|
||||
export const BODY_TYPE_BINARY = "binary";
|
||||
export const BODY_TYPE_OTHER = "other";
|
||||
export const BODY_TYPE_FORM_URLENCODED = "application/x-www-form-urlencoded";
|
||||
export const BODY_TYPE_FORM_MULTIPART = "multipart/form-data";
|
||||
export const BODY_TYPE_XML = "text/xml";
|
||||
|
||||
export function cookieDomain(cookie: Cookie): string {
|
||||
if (cookie.domain === "NotPresent" || cookie.domain === "Empty") {
|
||||
return "n/a";
|
||||
}
|
||||
if ("HostOnly" in cookie.domain) {
|
||||
return cookie.domain.HostOnly;
|
||||
}
|
||||
if ("Suffix" in cookie.domain) {
|
||||
return cookie.domain.Suffix;
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function modelsEq(a: AnyModel, b: AnyModel) {
|
||||
if (a.model !== b.model) {
|
||||
return false;
|
||||
}
|
||||
if (a.model === "key_value" && b.model === "key_value") {
|
||||
return a.key === b.key && a.namespace === b.namespace;
|
||||
}
|
||||
if ("id" in a && "id" in b) {
|
||||
return a.id === b.id;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getContentTypeFromHeaders(headers: HttpResponseHeader[] | null): string | null {
|
||||
return headers?.find((h) => h.name.toLowerCase() === "content-type")?.value ?? null;
|
||||
}
|
||||
|
||||
export function getCharsetFromContentType(headers: HttpResponseHeader[]): string | null {
|
||||
const contentType = getContentTypeFromHeaders(headers);
|
||||
if (contentType == null) return null;
|
||||
|
||||
const mimeType = getMimeTypeFromContentType(contentType);
|
||||
return mimeType.parameters.get("charset") ?? null;
|
||||
}
|
||||
|
||||
export function isBaseEnvironment(environment: Environment): boolean {
|
||||
return environment.parentModel === "workspace";
|
||||
}
|
||||
|
||||
export function isSubEnvironment(environment: Environment): boolean {
|
||||
return environment.parentModel === "environment";
|
||||
}
|
||||
|
||||
export function isFolderEnvironment(environment: Environment): boolean {
|
||||
return environment.parentModel === "folder";
|
||||
}
|
||||
|
||||
export function getCookieCounts(events: HttpResponseEvent[] | undefined): {
|
||||
sent: number;
|
||||
received: number;
|
||||
} {
|
||||
if (!events) return { sent: 0, received: 0 };
|
||||
|
||||
// Use Sets to deduplicate by cookie name
|
||||
const sentNames = new Set<string>();
|
||||
const receivedNames = new Set<string>();
|
||||
|
||||
for (const event of events) {
|
||||
const e = event.event;
|
||||
if (e.type === "header_up" && e.name.toLowerCase() === "cookie") {
|
||||
// Parse "Cookie: name=value; name2=value2" format
|
||||
for (const pair of e.value.split(";")) {
|
||||
const name = pair.split("=")[0]?.trim();
|
||||
if (name) sentNames.add(name);
|
||||
}
|
||||
} else if (e.type === "header_down" && e.name.toLowerCase() === "set-cookie") {
|
||||
// Parse "Set-Cookie: name=value; ..." - first part before ; is name=value
|
||||
const name = e.value.split(";")[0]?.split("=")[0]?.trim();
|
||||
if (name) receivedNames.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
return { sent: sentNames.size, received: receivedNames.size };
|
||||
}
|
||||
44
apps/yaak-client/lib/onboarding.ts
Normal file
44
apps/yaak-client/lib/onboarding.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { BatchUpsertResult } from "@yaakapp-internal/models";
|
||||
import { createGlobalModel } from "@yaakapp-internal/models";
|
||||
import { router } from "./router";
|
||||
import { rpc } from "./rpc";
|
||||
import { setKeyValue } from "./keyValueStore";
|
||||
|
||||
export type OnboardingChoice =
|
||||
| "import"
|
||||
| "import_yaak"
|
||||
| "example"
|
||||
| "fresh"
|
||||
| "open_folder"
|
||||
| "clone_git";
|
||||
|
||||
const NEW_WORKSPACE_NAME = "My Workspace";
|
||||
|
||||
/**
|
||||
* Remembered so later surfaces can lean toward what the user came for (an importer, the
|
||||
* example, or a blank slate). Nothing reads it yet.
|
||||
*/
|
||||
export function recordOnboardingChoice(choice: OnboardingChoice) {
|
||||
setKeyValue({
|
||||
namespace: "global",
|
||||
key: "onboarding",
|
||||
value: { choice, at: new Date().toISOString() },
|
||||
}).catch(console.error);
|
||||
}
|
||||
|
||||
export async function createFreshWorkspace() {
|
||||
const workspaceId = await createGlobalModel({ model: "workspace", name: NEW_WORKSPACE_NAME });
|
||||
await router.navigate({ to: "/workspaces/$workspaceId", params: { workspaceId } });
|
||||
}
|
||||
|
||||
export async function createExampleWorkspace() {
|
||||
const created = await rpc<BatchUpsertResult>("cmd_create_example_workspace", {});
|
||||
const workspace = created.workspaces[0];
|
||||
if (workspace == null) throw new Error("Example workspace was not created");
|
||||
const firstRequest = created.httpRequests.find((r) => r.name === "List posts");
|
||||
await router.navigate({
|
||||
to: "/workspaces/$workspaceId",
|
||||
params: { workspaceId: workspace.id },
|
||||
search: firstRequest ? { request_id: firstRequest.id } : {},
|
||||
});
|
||||
}
|
||||
214
apps/yaak-client/lib/pathPlaceholders.test.ts
Normal file
214
apps/yaak-client/lib/pathPlaceholders.test.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import {
|
||||
derivePathPlaceholderPairs,
|
||||
extractPathPlaceholders,
|
||||
renamePathPlaceholder,
|
||||
} from "./pathPlaceholders";
|
||||
|
||||
describe("extractPathPlaceholders", () => {
|
||||
test("extracts a single placeholder", () => {
|
||||
expect(extractPathPlaceholders("/users/:id")).toEqual([":id"]);
|
||||
});
|
||||
|
||||
test("extracts multiple placeholders", () => {
|
||||
expect(extractPathPlaceholders("/users/:id/posts/:postId")).toEqual([":id", ":postId"]);
|
||||
});
|
||||
|
||||
test("stops at a literal `:` in the same segment", () => {
|
||||
expect(extractPathPlaceholders("/tasks/:id:cancel")).toEqual([":id"]);
|
||||
});
|
||||
|
||||
test("does not match `:foo` mid-segment", () => {
|
||||
expect(extractPathPlaceholders("/users/abc:def")).toEqual([]);
|
||||
});
|
||||
|
||||
test("does not match `:` in a host port", () => {
|
||||
expect(extractPathPlaceholders("https://example.com:8080/users/:id")).toEqual([":id"]);
|
||||
});
|
||||
|
||||
test("returns empty for a URL with no placeholders", () => {
|
||||
expect(extractPathPlaceholders("https://example.com/foo/bar?q=1#hash")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("derivePathPlaceholderPairs", () => {
|
||||
const neverRename = () => false;
|
||||
|
||||
test("adds a row for a placeholder with no parameter", () => {
|
||||
const { urlParameterPairs } = derivePathPlaceholderPairs("/users/:id", [], neverRename);
|
||||
expect(urlParameterPairs).toMatchObject([{ name: ":id", value: "", enabled: true }]);
|
||||
expect(urlParameterPairs[0]?.commitName).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
test("gives the existing parameter for a placeholder a commitName, without mutating it", () => {
|
||||
const parameter = { name: ":id", value: "123", enabled: true, id: "p1" };
|
||||
const { urlParameterPairs } = derivePathPlaceholderPairs(
|
||||
"/users/:id",
|
||||
[parameter],
|
||||
neverRename,
|
||||
);
|
||||
expect(urlParameterPairs[0]).toMatchObject({ name: ":id", value: "123", id: "p1" });
|
||||
expect(urlParameterPairs[0]?.commitName).toBeTypeOf("function");
|
||||
expect(parameter).toEqual({ name: ":id", value: "123", enabled: true, id: "p1" });
|
||||
});
|
||||
|
||||
test("leaves query parameters alone", () => {
|
||||
const { urlParameterPairs } = derivePathPlaceholderPairs(
|
||||
"/users/:id",
|
||||
[{ name: "q", value: "hi", enabled: true, id: "p1" }],
|
||||
neverRename,
|
||||
);
|
||||
expect(urlParameterPairs[0]).toEqual({ name: "q", value: "hi", enabled: true, id: "p1" });
|
||||
expect(urlParameterPairs[1]?.commitName).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
test("commitName renames this row's placeholder", () => {
|
||||
const renames: [string, string][] = [];
|
||||
const { urlParameterPairs } = derivePathPlaceholderPairs(
|
||||
"/a/:x/b/:y",
|
||||
[],
|
||||
(oldName, newName) => {
|
||||
renames.push([oldName, newName]);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
urlParameterPairs[1]?.commitName?.(":z");
|
||||
expect(renames).toEqual([[":y", ":z"]]);
|
||||
});
|
||||
|
||||
test("drops empty parameters", () => {
|
||||
const { urlParameterPairs } = derivePathPlaceholderPairs(
|
||||
"/users",
|
||||
[
|
||||
{ name: "", value: "", enabled: true, id: "p1" },
|
||||
{ name: "q", value: "", enabled: true, id: "p2" },
|
||||
],
|
||||
neverRename,
|
||||
);
|
||||
expect(urlParameterPairs).toMatchObject([{ name: "q", id: "p2" }]);
|
||||
});
|
||||
|
||||
test("collapses a placeholder that appears twice into one row", () => {
|
||||
const { urlParameterPairs } = derivePathPlaceholderPairs("/a/:id/b/:id", [], neverRename);
|
||||
expect(urlParameterPairs).toMatchObject([{ name: ":id" }]);
|
||||
});
|
||||
|
||||
test("gives a derived row the same id every time, so re-deriving is stable", () => {
|
||||
const first = derivePathPlaceholderPairs("/users/:id", [], neverRename);
|
||||
const second = derivePathPlaceholderPairs("/users/:id", [], neverRename);
|
||||
expect(first.urlParameterPairs[0]?.id).toEqual(second.urlParameterPairs[0]?.id);
|
||||
});
|
||||
|
||||
test("derived row ids avoid colliding with a persisted derived id", () => {
|
||||
// A derived id sticks to the parameter once the user gives the row a value. If its placeholder
|
||||
// is then renamed away in the URL bar, the parameter survives as a stray still holding the id,
|
||||
// and the replacement placeholder's row must not collide with it.
|
||||
const stray = { name: ":old", value: "42", enabled: true, id: "path-placeholder:0" };
|
||||
const { urlParameterPairs } = derivePathPlaceholderPairs("/pets/:new", [stray], neverRename);
|
||||
const ids = urlParameterPairs.map((p) => p.id);
|
||||
expect(new Set(ids).size).toEqual(ids.length);
|
||||
});
|
||||
|
||||
test("keeps a derived row's id stable across a rename", () => {
|
||||
const before = derivePathPlaceholderPairs("/a/:x/b/:y", [], neverRename);
|
||||
const after = derivePathPlaceholderPairs("/a/:x2/b/:y", [], neverRename);
|
||||
expect(after.urlParameterPairs.map((p) => p.id)).toEqual(
|
||||
before.urlParameterPairs.map((p) => p.id),
|
||||
);
|
||||
});
|
||||
|
||||
test("keys off the placeholder names", () => {
|
||||
expect(derivePathPlaceholderPairs("/a/:x/b/:y", [], neverRename).urlParametersKey).toEqual(
|
||||
":x,:y",
|
||||
);
|
||||
expect(derivePathPlaceholderPairs("/a/b", [], neverRename).urlParametersKey).toEqual("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renamePathPlaceholder", () => {
|
||||
const model = (url: string, urlParameters: { name: string; value: string }[] = []) => ({
|
||||
url,
|
||||
urlParameters,
|
||||
});
|
||||
|
||||
test("renames the placeholder in the URL", () => {
|
||||
expect(
|
||||
renamePathPlaceholder(model("https://x.com/pets/:petId/info"), ":petId", ":animalId"),
|
||||
).toEqual({ url: "https://x.com/pets/:animalId/info", urlParameters: [] });
|
||||
});
|
||||
|
||||
test("carries the parameter value over to the new name", () => {
|
||||
const patch = renamePathPlaceholder(
|
||||
model("/pets/:petId", [
|
||||
{ name: "q", value: "1" },
|
||||
{ name: ":petId", value: "42" },
|
||||
]),
|
||||
":petId",
|
||||
":animalId",
|
||||
);
|
||||
expect(patch).toEqual({
|
||||
url: "/pets/:animalId",
|
||||
urlParameters: [
|
||||
{ name: "q", value: "1" },
|
||||
{ name: ":animalId", value: "42" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("renames every occurrence of a repeated placeholder", () => {
|
||||
expect(renamePathPlaceholder(model("/a/:id/b/:id"), ":id", ":key")?.url).toEqual(
|
||||
"/a/:key/b/:key",
|
||||
);
|
||||
});
|
||||
|
||||
test("adds a missing leading colon", () => {
|
||||
expect(renamePathPlaceholder(model("/pets/:petId"), ":petId", "animalId")?.url).toEqual(
|
||||
"/pets/:animalId",
|
||||
);
|
||||
});
|
||||
|
||||
test("renames a placeholder followed by a literal colon", () => {
|
||||
expect(renamePathPlaceholder(model("/tasks/:id:cancel"), ":id", ":taskId")?.url).toEqual(
|
||||
"/tasks/:taskId:cancel",
|
||||
);
|
||||
});
|
||||
|
||||
test("does not rename a placeholder the new name is a prefix of", () => {
|
||||
expect(renamePathPlaceholder(model("/a/:id/b/:idx"), ":id", ":key")?.url).toEqual(
|
||||
"/a/:key/b/:idx",
|
||||
);
|
||||
});
|
||||
|
||||
test("does not touch a same-named segment that isn't a placeholder", () => {
|
||||
expect(renamePathPlaceholder(model("/id/:id?x=:id"), ":id", ":key")?.url).toEqual(
|
||||
"/id/:key?x=:id",
|
||||
);
|
||||
});
|
||||
|
||||
test("treats regex characters in the old name literally", () => {
|
||||
expect(renamePathPlaceholder(model("/a/:i.d/b/:iXd"), ":i.d", ":key")?.url).toEqual(
|
||||
"/a/:key/b/:iXd",
|
||||
);
|
||||
});
|
||||
|
||||
test.each([[""], [":"], [":a/b"], [":a?b"], [":a#b"], [":a:b"], [":a b"], [":a\tb"]])(
|
||||
"rejects the unusable name %j",
|
||||
(name) => {
|
||||
expect(renamePathPlaceholder(model("/pets/:petId"), ":petId", name)).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
test("rejects a name already used by another placeholder", () => {
|
||||
expect(renamePathPlaceholder(model("/pets/:petId/:ownerId"), ":petId", ":ownerId")).toBeNull();
|
||||
});
|
||||
|
||||
test("allows renaming a placeholder to itself", () => {
|
||||
expect(renamePathPlaceholder(model("/pets/:petId"), ":petId", ":petId")?.url).toEqual(
|
||||
"/pets/:petId",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects renaming a placeholder that isn't in the URL", () => {
|
||||
expect(renamePathPlaceholder(model("/pets/:petId"), ":other", ":animalId")).toBeNull();
|
||||
});
|
||||
});
|
||||
102
apps/yaak-client/lib/pathPlaceholders.ts
Normal file
102
apps/yaak-client/lib/pathPlaceholders.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { HttpUrlParameter } from "@yaakapp-internal/models";
|
||||
import type { EditablePair } from "../components/core/PairEditor";
|
||||
|
||||
/**
|
||||
* Extract `:name`-style path placeholders from a URL string.
|
||||
*
|
||||
* A placeholder is `:` followed by one-or-more characters that are not `/`, `?`,
|
||||
* `#`, or `:`. The `:` boundary means a placeholder ends where a literal colon
|
||||
* starts in the same segment, e.g. `/tasks/:id:increment-importance` yields one
|
||||
* placeholder `:id` and `:increment-importance` is literal text.
|
||||
*
|
||||
* Only `:` that sits at the start of a `/`-delimited segment counts — `/abc:def`
|
||||
* has no placeholders. Returned names include the leading colon.
|
||||
*/
|
||||
export function extractPathPlaceholders(url: string): string[] {
|
||||
return Array.from(url.matchAll(/\/(:[^/?#:]+)/g)).map((m) => m[1] ?? "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the rows for the Params tab: the request's URL parameters, plus a row for each path
|
||||
* placeholder in the URL that doesn't have one yet. A placeholder that appears more than once
|
||||
* in the URL still gets a single row.
|
||||
*
|
||||
* Only placeholder rows get a `commitName`, which makes the editor hold name edits until blur and
|
||||
* hand them to `renamePlaceholder` instead of writing on every keystroke — renaming has to rewrite
|
||||
* the URL too. `renamePlaceholder` returns false to reject the new name, which reverts the field.
|
||||
*
|
||||
* `urlParametersKey` changes whenever the URL's placeholders do, and is used to reset the pair
|
||||
* editor so derived rows appear and disappear along with the URL.
|
||||
*/
|
||||
export function derivePathPlaceholderPairs(
|
||||
url: string,
|
||||
urlParameters: HttpUrlParameter[],
|
||||
renamePlaceholder: (oldName: string, newName: string) => boolean,
|
||||
): { urlParameterPairs: EditablePair[]; urlParametersKey: string } {
|
||||
const placeholderNames = extractPathPlaceholders(url);
|
||||
const commitNameFor = (oldName: string) => (newName: string) =>
|
||||
renamePlaceholder(oldName, newName);
|
||||
|
||||
// NOTE: Copy each parameter because `commitName` is UI-only. Adding it in place would mutate the
|
||||
// persisted model.
|
||||
const urlParameterPairs: EditablePair[] = urlParameters
|
||||
.filter((p) => p.name || p.value)
|
||||
.map((p) =>
|
||||
placeholderNames.includes(p.name) ? { ...p, commitName: commitNameFor(p.name) } : { ...p },
|
||||
);
|
||||
|
||||
// NOTE: Ids are derived from the placeholder's position instead of generated, so neither
|
||||
// re-deriving nor renaming hands a row a new identity. The pair editor keys rows by id, so a
|
||||
// changed id remounts the row and drops the user's focus.
|
||||
//
|
||||
// A derived id sticks to the parameter once the user gives the row a value, so a parameter that
|
||||
// outlives its placeholder (renamed away in the URL bar) still holds one. Skip past taken ids
|
||||
// so a new placeholder at that position can't collide with it.
|
||||
const takenIds = new Set(urlParameterPairs.map((p) => p.id));
|
||||
const uniquePlaceholderNames = [...new Set(placeholderNames)];
|
||||
for (const [index, name] of uniquePlaceholderNames.entries()) {
|
||||
if (urlParameterPairs.some((p) => p.name === name)) continue;
|
||||
|
||||
let id = `path-placeholder:${index}`;
|
||||
for (let bump = index + 1; takenIds.has(id); bump++) id = `path-placeholder:${bump}`;
|
||||
takenIds.add(id);
|
||||
|
||||
urlParameterPairs.push({ name, value: "", enabled: true, commitName: commitNameFor(name), id });
|
||||
}
|
||||
|
||||
return { urlParameterPairs, urlParametersKey: placeholderNames.join(",") };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the patch for renaming a path placeholder: every occurrence replaced in the URL, and
|
||||
* the matching URL parameter renamed so the user's value follows along. Both have to be applied
|
||||
* together, or the value detaches from the placeholder.
|
||||
*
|
||||
* Returns `null` when the rename can't be applied, meaning the caller should leave the model
|
||||
* alone. That's the case when the new name wouldn't parse as a placeholder anymore (empty, or
|
||||
* containing `/`, `?`, `#`, `:`, or whitespace) or when it's already used by another placeholder
|
||||
* in the URL. A missing leading `:` is added rather than rejected, since focusing the name field
|
||||
* selects all of its text and typing over it is the natural way to rename.
|
||||
*/
|
||||
export function renamePathPlaceholder(
|
||||
model: { url: string; urlParameters: HttpUrlParameter[] },
|
||||
oldName: string,
|
||||
newName: string,
|
||||
): { url: string; urlParameters: HttpUrlParameter[] } | null {
|
||||
const name = newName.startsWith(":") ? newName : `:${newName}`;
|
||||
if (!/^:[^/?#:\s]+$/.test(name)) return null;
|
||||
|
||||
const placeholderNames = extractPathPlaceholders(model.url);
|
||||
if (!placeholderNames.includes(oldName)) return null;
|
||||
if (name !== oldName && placeholderNames.includes(name)) return null;
|
||||
|
||||
const pattern = new RegExp(`(/)${escapeRegExp(oldName)}(?=[/?#:]|$)`, "g");
|
||||
return {
|
||||
url: model.url.replace(pattern, (_match, slash: string) => `${slash}${name}`),
|
||||
urlParameters: model.urlParameters.map((p) => (p.name === oldName ? { ...p, name } : p)),
|
||||
};
|
||||
}
|
||||
|
||||
function escapeRegExp(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
20
apps/yaak-client/lib/pluralize.ts
Normal file
20
apps/yaak-client/lib/pluralize.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export function pluralize(word: string, count: number): string {
|
||||
if (count === 1) {
|
||||
return word;
|
||||
}
|
||||
return `${word}s`;
|
||||
}
|
||||
|
||||
export function pluralizeCount(
|
||||
word: string,
|
||||
count: number,
|
||||
opt: { omitSingle?: boolean; noneWord?: string } = {},
|
||||
): string {
|
||||
if (opt.omitSingle && count === 1) {
|
||||
return word;
|
||||
}
|
||||
if (opt.noneWord && count === 0) {
|
||||
return opt.noneWord;
|
||||
}
|
||||
return `${count} ${pluralize(word, count)}`;
|
||||
}
|
||||
25
apps/yaak-client/lib/prepareImportQuerystring.ts
Normal file
25
apps/yaak-client/lib/prepareImportQuerystring.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { HttpUrlParameter } from "@yaakapp-internal/models";
|
||||
import { generateId } from "./generateId";
|
||||
|
||||
export function prepareImportQuerystring(
|
||||
url: string,
|
||||
): { url: string; urlParameters: HttpUrlParameter[] } | null {
|
||||
const split = url.split(/\?(.*)/s);
|
||||
const baseUrl = split[0] ?? "";
|
||||
const querystring = split[1] ?? "";
|
||||
|
||||
// No querystring in url
|
||||
if (!querystring) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedParams = Array.from(new URLSearchParams(querystring).entries());
|
||||
const urlParameters: HttpUrlParameter[] = parsedParams.map(([name, value]) => ({
|
||||
name,
|
||||
value,
|
||||
enabled: true,
|
||||
id: generateId(),
|
||||
}));
|
||||
|
||||
return { url: baseUrl, urlParameters };
|
||||
}
|
||||
3
apps/yaak-client/lib/pricingUrl.ts
Normal file
3
apps/yaak-client/lib/pricingUrl.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function pricingUrl(intent: string): string {
|
||||
return `https://yaak.app/pricing?intent=${encodeURIComponent(intent)}`;
|
||||
}
|
||||
47
apps/yaak-client/lib/prompt-form.tsx
Normal file
47
apps/yaak-client/lib/prompt-form.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { FormInput, JsonPrimitive } from "@yaakapp-internal/plugins";
|
||||
import type { DialogProps } from "../components/core/Dialog";
|
||||
import type { PromptProps } from "../components/core/Prompt";
|
||||
import { Prompt } from "../components/core/Prompt";
|
||||
import { showDialog } from "./dialog";
|
||||
|
||||
type FormArgs = Pick<DialogProps, "title" | "description" | "size"> &
|
||||
Omit<PromptProps, "onClose" | "onCancel" | "onResult"> & {
|
||||
id: string;
|
||||
onValuesChange?: (values: Record<string, JsonPrimitive>) => void;
|
||||
onInputsUpdated?: (cb: (inputs: FormInput[]) => void) => void;
|
||||
};
|
||||
|
||||
export async function showPromptForm({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
size,
|
||||
onValuesChange,
|
||||
onInputsUpdated,
|
||||
...props
|
||||
}: FormArgs) {
|
||||
return new Promise((resolve: PromptProps["onResult"]) => {
|
||||
showDialog({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
size: size ?? "sm",
|
||||
disableClose: true,
|
||||
render: ({ hide }) =>
|
||||
Prompt({
|
||||
onCancel: () => {
|
||||
// Click cancel button within dialog
|
||||
resolve(null);
|
||||
hide();
|
||||
},
|
||||
onResult: (v) => {
|
||||
resolve(v);
|
||||
hide();
|
||||
},
|
||||
onValuesChange,
|
||||
onInputsUpdated,
|
||||
...props,
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
45
apps/yaak-client/lib/prompt.ts
Normal file
45
apps/yaak-client/lib/prompt.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { FormInput, PromptTextRequest } from "@yaakapp-internal/plugins";
|
||||
import type { ReactNode } from "react";
|
||||
import type { DialogProps } from "../components/core/Dialog";
|
||||
import { showPromptForm } from "./prompt-form";
|
||||
|
||||
type PromptProps = Omit<PromptTextRequest, "id" | "title" | "description"> & {
|
||||
description?: ReactNode;
|
||||
onCancel: () => void;
|
||||
onResult: (value: string | null) => void;
|
||||
};
|
||||
|
||||
type PromptArgs = Pick<DialogProps, "title" | "description"> &
|
||||
Omit<PromptProps, "onClose" | "onCancel" | "onResult"> & { id: string };
|
||||
|
||||
export async function showPrompt({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
cancelText,
|
||||
confirmText,
|
||||
required,
|
||||
...props
|
||||
}: PromptArgs) {
|
||||
const inputs: FormInput[] = [
|
||||
{
|
||||
...props,
|
||||
optional: !required,
|
||||
type: "text",
|
||||
name: "value",
|
||||
},
|
||||
];
|
||||
|
||||
const result = await showPromptForm({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
inputs,
|
||||
cancelText,
|
||||
confirmText,
|
||||
});
|
||||
|
||||
if (result == null) return null; // Cancelled
|
||||
if (typeof result.value === "string") return result.value;
|
||||
return props.defaultValue ?? "";
|
||||
}
|
||||
18
apps/yaak-client/lib/queryClient.ts
Normal file
18
apps/yaak-client/lib/queryClient.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { QueryCache, QueryClient } from "@tanstack/react-query";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
queryCache: new QueryCache({
|
||||
onError: (err, query) => {
|
||||
console.log("Query client error", { err, query });
|
||||
},
|
||||
}),
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
networkMode: "always",
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnMount: false, // Don't refetch when a hook mounts
|
||||
},
|
||||
},
|
||||
});
|
||||
32
apps/yaak-client/lib/renameModelWithPrompt.tsx
Normal file
32
apps/yaak-client/lib/renameModelWithPrompt.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { AnyModel } from "@yaakapp-internal/models";
|
||||
import { patchModel } from "@yaakapp-internal/models";
|
||||
import { InlineCode } from "@yaakapp-internal/ui";
|
||||
import { showPrompt } from "./prompt";
|
||||
|
||||
export async function renameModelWithPrompt(model: Extract<AnyModel, { name: string }> | null) {
|
||||
if (model == null) {
|
||||
throw new Error("Tried to rename null model");
|
||||
}
|
||||
|
||||
const name = await showPrompt({
|
||||
id: "rename-request",
|
||||
title: "Rename Request",
|
||||
required: false,
|
||||
description:
|
||||
model.name === "" ? (
|
||||
"Enter a new name"
|
||||
) : (
|
||||
<>
|
||||
Enter a new name for <InlineCode>{model.name}</InlineCode>
|
||||
</>
|
||||
),
|
||||
label: "Name",
|
||||
placeholder: "New Name",
|
||||
defaultValue: model.name,
|
||||
confirmText: "Save",
|
||||
});
|
||||
|
||||
if (name == null) return;
|
||||
|
||||
await patchModel(model, { name });
|
||||
}
|
||||
152
apps/yaak-client/lib/requestBodyConversion.test.ts
Normal file
152
apps/yaak-client/lib/requestBodyConversion.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import {
|
||||
BODY_TYPE_BINARY,
|
||||
BODY_TYPE_FORM_URLENCODED,
|
||||
BODY_TYPE_GRAPHQL,
|
||||
BODY_TYPE_JSON,
|
||||
BODY_TYPE_NONE,
|
||||
BODY_TYPE_OTHER,
|
||||
BODY_TYPE_XML,
|
||||
} from "./model_util";
|
||||
import { convertRequestBody } from "./requestBodyConversion";
|
||||
|
||||
describe("convertRequestBody", () => {
|
||||
test("converts imported JSON GraphQL bodies to GraphQL shape", () => {
|
||||
const body = convertRequestBody({
|
||||
fromBodyType: BODY_TYPE_JSON,
|
||||
toBodyType: BODY_TYPE_GRAPHQL,
|
||||
body: {
|
||||
text: JSON.stringify({
|
||||
query: "query GetUser($id: ID!) { user(id: $id) { name } }",
|
||||
variables: { id: "123" },
|
||||
operationName: "GetUser",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(body).toEqual({
|
||||
query: "query GetUser($id: ID!) { user(id: $id) { name } }",
|
||||
variables: '{\n "id": "123"\n}',
|
||||
operationName: "GetUser",
|
||||
});
|
||||
});
|
||||
|
||||
test("converts GraphQL bodies to JSON text", () => {
|
||||
const body = convertRequestBody({
|
||||
fromBodyType: BODY_TYPE_GRAPHQL,
|
||||
toBodyType: BODY_TYPE_JSON,
|
||||
body: {
|
||||
query: "query GetUser($id: ID!) { user(id: $id) { name } }",
|
||||
variables: '{ "id": "123" }',
|
||||
operationName: "GetUser",
|
||||
},
|
||||
});
|
||||
|
||||
expect(body).toEqual({
|
||||
text: JSON.stringify(
|
||||
{
|
||||
query: "query GetUser($id: ID!) { user(id: $id) { name } }",
|
||||
variables: { id: "123" },
|
||||
operationName: "GetUser",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
test("converts urlencoded forms to urlencoded text for text-like bodies", () => {
|
||||
const body = convertRequestBody({
|
||||
fromBodyType: BODY_TYPE_FORM_URLENCODED,
|
||||
toBodyType: BODY_TYPE_OTHER,
|
||||
body: {
|
||||
form: [
|
||||
{ enabled: true, name: "basic", value: "aaa" },
|
||||
{ enabled: true, name: "funky stuff", value: "*)%&#$)@ *$#)@&" },
|
||||
{ enabled: false, name: "disabled", value: "hidden" },
|
||||
{ enabled: true, name: "", value: "unnamed" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(body).toEqual({
|
||||
text: "basic=aaa&funky+stuff=*%29%25%26%23%24%29%40+*%24%23%29%40%26",
|
||||
});
|
||||
});
|
||||
|
||||
test("converts urlencoded forms to JSON text for JSON bodies", () => {
|
||||
const body = convertRequestBody({
|
||||
fromBodyType: BODY_TYPE_FORM_URLENCODED,
|
||||
toBodyType: BODY_TYPE_JSON,
|
||||
body: {
|
||||
form: [
|
||||
{ enabled: true, name: "tag", value: "one" },
|
||||
{ enabled: true, name: "tag", value: "two" },
|
||||
{ enabled: true, name: "limit", value: "10" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(body).toEqual({
|
||||
text: JSON.stringify({ tag: ["one", "two"], limit: "10" }, null, 2),
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves text when converting to form bodies cannot build form pairs", () => {
|
||||
const body = convertRequestBody({
|
||||
fromBodyType: BODY_TYPE_XML,
|
||||
toBodyType: BODY_TYPE_FORM_URLENCODED,
|
||||
body: { text: "a=1&b=two+words" },
|
||||
});
|
||||
|
||||
expect(body).toEqual({
|
||||
text: "a=1&b=two+words",
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves JSON text that is not a GraphQL envelope", () => {
|
||||
const body = convertRequestBody({
|
||||
fromBodyType: BODY_TYPE_JSON,
|
||||
toBodyType: BODY_TYPE_GRAPHQL,
|
||||
body: { text: JSON.stringify({ name: "Yaak" }) },
|
||||
});
|
||||
|
||||
expect(body).toEqual({
|
||||
text: JSON.stringify({ name: "Yaak" }),
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves JSON arrays and primitives when converting to GraphQL", () => {
|
||||
for (const text of [JSON.stringify([1, 2, 3]), JSON.stringify("query"), "123", "null"]) {
|
||||
const body = convertRequestBody({
|
||||
fromBodyType: BODY_TYPE_JSON,
|
||||
toBodyType: BODY_TYPE_GRAPHQL,
|
||||
body: { text },
|
||||
});
|
||||
|
||||
expect(body).toEqual({ text });
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves text when converting to binary cannot build a file body", () => {
|
||||
const body = convertRequestBody({
|
||||
fromBodyType: BODY_TYPE_JSON,
|
||||
toBodyType: BODY_TYPE_BINARY,
|
||||
body: { text: '{ "name": "Yaak" }' },
|
||||
});
|
||||
|
||||
expect(body).toEqual({
|
||||
text: '{ "name": "Yaak" }',
|
||||
});
|
||||
});
|
||||
|
||||
test("clears body when converting to no body", () => {
|
||||
const body = convertRequestBody({
|
||||
fromBodyType: BODY_TYPE_JSON,
|
||||
toBodyType: BODY_TYPE_NONE,
|
||||
body: { text: '{ "name": "Yaak" }' },
|
||||
});
|
||||
|
||||
expect(body).toEqual({});
|
||||
});
|
||||
});
|
||||
199
apps/yaak-client/lib/requestBodyConversion.ts
Normal file
199
apps/yaak-client/lib/requestBodyConversion.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||
import {
|
||||
BODY_TYPE_BINARY,
|
||||
BODY_TYPE_FORM_MULTIPART,
|
||||
BODY_TYPE_FORM_URLENCODED,
|
||||
BODY_TYPE_GRAPHQL,
|
||||
BODY_TYPE_JSON,
|
||||
BODY_TYPE_NONE,
|
||||
} from "./model_util";
|
||||
|
||||
type Body = HttpRequest["body"];
|
||||
type BodyType = HttpRequest["bodyType"];
|
||||
type GraphQLBody = {
|
||||
query: string;
|
||||
variables: string | undefined;
|
||||
operationName?: string;
|
||||
};
|
||||
|
||||
export function convertRequestBody({
|
||||
body,
|
||||
fromBodyType,
|
||||
toBodyType,
|
||||
}: {
|
||||
body: Body;
|
||||
fromBodyType: BodyType;
|
||||
toBodyType: BodyType;
|
||||
}): Body {
|
||||
if (toBodyType === BODY_TYPE_NONE) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (toBodyType === BODY_TYPE_GRAPHQL) {
|
||||
return toGraphQLBody(body) ?? body;
|
||||
}
|
||||
|
||||
if (toBodyType === BODY_TYPE_FORM_URLENCODED || toBodyType === BODY_TYPE_FORM_MULTIPART) {
|
||||
return toFormBody(body) ?? body;
|
||||
}
|
||||
|
||||
if (toBodyType === BODY_TYPE_BINARY) {
|
||||
return typeof body.filePath === "string" ? { filePath: body.filePath } : body;
|
||||
}
|
||||
|
||||
return toTextBody(body, fromBodyType, toBodyType) ?? body;
|
||||
}
|
||||
|
||||
export function normalizeGraphQLBody(body: Body): GraphQLBody {
|
||||
return toGraphQLBody(body) ?? { query: "", variables: undefined };
|
||||
}
|
||||
|
||||
function toGraphQLBody(body: Body): GraphQLBody | null {
|
||||
if (typeof body.query === "string") {
|
||||
const result: GraphQLBody = {
|
||||
query: body.query,
|
||||
variables: typeof body.variables === "string" ? body.variables : undefined,
|
||||
};
|
||||
if (typeof body.operationName === "string") {
|
||||
result.operationName = body.operationName;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
if (typeof body.text === "string") {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(body.text);
|
||||
if (!isRecord(parsed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof parsed.query !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const query = parsed.query;
|
||||
const variables =
|
||||
parsed.variables == null ? undefined : JSON.stringify(parsed.variables, null, 2);
|
||||
|
||||
const result: GraphQLBody = { query, variables };
|
||||
if (typeof parsed.operationName === "string") {
|
||||
result.operationName = parsed.operationName;
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch {
|
||||
return { query: body.text, variables: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function toFormBody(body: Body): Body | null {
|
||||
if (Array.isArray(body.form)) {
|
||||
return {
|
||||
form: body.form.map((p) => ({
|
||||
enabled: p.enabled !== false,
|
||||
name: typeof p.name === "string" ? p.name : "",
|
||||
value: stringifyFormValue(p.value ?? p.file),
|
||||
contentType: typeof p.contentType === "string" ? p.contentType : undefined,
|
||||
filename: typeof p.filename === "string" ? p.filename : undefined,
|
||||
file: typeof p.file === "string" ? p.file : undefined,
|
||||
id: typeof p.id === "string" ? p.id : undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function toTextBody(body: Body, fromBodyType: BodyType, toBodyType: BodyType): Body | null {
|
||||
const sendJsonComments =
|
||||
typeof body.sendJsonComments === "boolean" ? { sendJsonComments: body.sendJsonComments } : {};
|
||||
|
||||
if (typeof body.text === "string") {
|
||||
return { text: body.text, ...sendJsonComments };
|
||||
}
|
||||
|
||||
if (Array.isArray(body.form)) {
|
||||
if (toBodyType === BODY_TYPE_JSON) {
|
||||
return { text: JSON.stringify(formBodyToObject(body.form), null, 2) };
|
||||
}
|
||||
|
||||
return { text: formBodyToUrlEncodedText(body.form) };
|
||||
}
|
||||
|
||||
if (typeof body.query === "string") {
|
||||
if (toBodyType === BODY_TYPE_JSON || fromBodyType === BODY_TYPE_GRAPHQL) {
|
||||
const value: Record<string, unknown> = { query: body.query };
|
||||
if (typeof body.variables === "string" && body.variables.trim() !== "") {
|
||||
value.variables = parseJson(body.variables) ?? body.variables;
|
||||
}
|
||||
if (typeof body.operationName === "string" && body.operationName.trim() !== "") {
|
||||
value.operationName = body.operationName;
|
||||
}
|
||||
|
||||
return { text: JSON.stringify(value, null, 2) };
|
||||
}
|
||||
|
||||
return { text: body.query };
|
||||
}
|
||||
|
||||
if (typeof body.filePath === "string") {
|
||||
return { text: body.filePath };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function formBodyToUrlEncodedText(form: unknown[]): string {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
for (const pair of form) {
|
||||
if (!isRecord(pair)) continue;
|
||||
if (pair.enabled === false) continue;
|
||||
if (typeof pair.name !== "string" || pair.name === "") continue;
|
||||
params.append(pair.name, stringifyFormValue(pair.value));
|
||||
}
|
||||
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function formBodyToObject(form: unknown[]) {
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
for (const pair of form) {
|
||||
if (!isRecord(pair)) continue;
|
||||
if (pair.enabled === false) continue;
|
||||
if (typeof pair.name !== "string" || pair.name === "") continue;
|
||||
|
||||
const value = stringifyFormValue(pair.value);
|
||||
if (pair.name in result) {
|
||||
const existing = result[pair.name];
|
||||
result[pair.name] = Array.isArray(existing) ? [...existing, value] : [existing, value];
|
||||
} else {
|
||||
result[pair.name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function stringifyFormValue(value: unknown): string {
|
||||
if (value == null) return "";
|
||||
if (typeof value === "string") return value;
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function parseJson(text: string): unknown | null {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
99
apps/yaak-client/lib/requestSettings.ts
Normal file
99
apps/yaak-client/lib/requestSettings.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { AnyModel, Workspace } from "@yaakapp-internal/models";
|
||||
|
||||
type ModelType = AnyModel["model"];
|
||||
|
||||
type WorkspaceRequestSettings = Pick<
|
||||
Workspace,
|
||||
| "settingFollowRedirects"
|
||||
| "settingHttpVersion"
|
||||
| "settingRequestMessageSize"
|
||||
| "settingRequestTimeout"
|
||||
| "settingSendCookies"
|
||||
| "settingStoreCookies"
|
||||
| "settingValidateCertificates"
|
||||
>;
|
||||
|
||||
type ModelForType<T extends ModelType> = Extract<AnyModel, { model: T }>;
|
||||
|
||||
type ModelTypeWithSetting<K extends RequestSettingKey> = {
|
||||
[M in ModelType]: K extends keyof ModelForType<M> ? M : never;
|
||||
}[ModelType];
|
||||
|
||||
export type RequestSettingDefinition<K extends RequestSettingKey = RequestSettingKey> = {
|
||||
defaultValue: WorkspaceRequestSettings[K];
|
||||
description: string;
|
||||
modelKey: K;
|
||||
models: readonly ModelTypeWithSetting<K>[];
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type RequestSettingKey = keyof WorkspaceRequestSettings;
|
||||
|
||||
function defineRequestSetting<const K extends RequestSettingKey>(
|
||||
setting: RequestSettingDefinition<K>,
|
||||
) {
|
||||
return setting;
|
||||
}
|
||||
|
||||
export const SETTING_REQUEST_TIMEOUT = defineRequestSetting({
|
||||
defaultValue: 0,
|
||||
description: "Maximum request duration in milliseconds. Set to 0 to disable.",
|
||||
modelKey: "settingRequestTimeout",
|
||||
models: ["workspace", "folder", "http_request"],
|
||||
title: "Request Timeout",
|
||||
});
|
||||
|
||||
export const SETTING_REQUEST_MESSAGE_SIZE = defineRequestSetting({
|
||||
defaultValue: 64 * 1024 * 1024,
|
||||
description: "Maximum gRPC or WebSocket message size in MB. Set to 0 to disable.",
|
||||
modelKey: "settingRequestMessageSize",
|
||||
models: ["workspace", "folder", "websocket_request", "grpc_request"],
|
||||
title: "Message Size Limit",
|
||||
});
|
||||
|
||||
export const SETTING_VALIDATE_CERTIFICATES = defineRequestSetting({
|
||||
defaultValue: true,
|
||||
description: "When disabled, skip validation of server certificates.",
|
||||
modelKey: "settingValidateCertificates",
|
||||
models: ["workspace", "folder", "http_request", "websocket_request", "grpc_request"],
|
||||
title: "Validate TLS certificates",
|
||||
});
|
||||
|
||||
export const SETTING_FOLLOW_REDIRECTS = defineRequestSetting({
|
||||
defaultValue: true,
|
||||
description: "Follow HTTP redirects automatically.",
|
||||
modelKey: "settingFollowRedirects",
|
||||
models: ["workspace", "folder", "http_request"],
|
||||
title: "Follow redirects",
|
||||
});
|
||||
|
||||
export const SETTING_HTTP_VERSION = defineRequestSetting({
|
||||
defaultValue: "auto",
|
||||
description: "Force HTTP/1.1 or HTTP/2 for servers that don't negotiate the version correctly.",
|
||||
modelKey: "settingHttpVersion",
|
||||
models: ["workspace", "folder", "http_request"],
|
||||
title: "HTTP version",
|
||||
});
|
||||
|
||||
export const SETTING_SEND_COOKIES = defineRequestSetting({
|
||||
defaultValue: true,
|
||||
description: "Attach matching cookies from the active cookie jar to outgoing requests.",
|
||||
modelKey: "settingSendCookies",
|
||||
models: ["workspace", "folder", "http_request", "websocket_request"],
|
||||
title: "Automatically send cookies",
|
||||
});
|
||||
|
||||
export const SETTING_STORE_COOKIES = defineRequestSetting({
|
||||
defaultValue: true,
|
||||
description: "Save cookies from Set-Cookie response headers to the active cookie jar.",
|
||||
modelKey: "settingStoreCookies",
|
||||
models: ["workspace", "folder", "http_request", "websocket_request"],
|
||||
title: "Automatically store cookies",
|
||||
});
|
||||
|
||||
export function modelSupportsSetting<K extends RequestSettingKey>(
|
||||
model: Pick<AnyModel, "model">,
|
||||
setting: RequestSettingDefinition<K>,
|
||||
) {
|
||||
return setting.models.some((modelType) => modelType === model.model);
|
||||
}
|
||||
61
apps/yaak-client/lib/resolvedModelName.ts
Normal file
61
apps/yaak-client/lib/resolvedModelName.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { AnyModel } from "@yaakapp-internal/models";
|
||||
import { foldersAtom } from "@yaakapp-internal/models";
|
||||
import { jotaiStore } from "./jotai";
|
||||
|
||||
export function resolvedModelName(r: AnyModel | null): string {
|
||||
if (r == null) return "";
|
||||
|
||||
if (!("url" in r) || r.model === "plugin") {
|
||||
return "name" in r ? r.name : "";
|
||||
}
|
||||
|
||||
// Return name if it has one
|
||||
if ("name" in r && r.name) {
|
||||
return r.name;
|
||||
}
|
||||
|
||||
// Replace variable syntax with variable name
|
||||
const withoutVariables = r.url.replace(/\$\{\[\s*([^\]\s]+)\s*]}/g, "$1");
|
||||
if (withoutVariables.trim() === "") {
|
||||
return r.model === "http_request"
|
||||
? r.bodyType && r.bodyType === "graphql"
|
||||
? "GraphQL Request"
|
||||
: "HTTP Request"
|
||||
: r.model === "websocket_request"
|
||||
? "WebSocket Request"
|
||||
: "gRPC Request";
|
||||
}
|
||||
|
||||
// GRPC gets nice short names
|
||||
if (r.model === "grpc_request" && r.service != null && r.method != null) {
|
||||
const shortService = r.service.split(".").pop();
|
||||
return `${shortService}/${r.method}`;
|
||||
}
|
||||
|
||||
// Strip unnecessary protocol
|
||||
const withoutProto = withoutVariables.replace(/^(http|https|ws|wss):\/\//, "");
|
||||
|
||||
return withoutProto;
|
||||
}
|
||||
|
||||
export function resolvedModelNameWithFolders(model: AnyModel | null): string {
|
||||
return resolvedModelNameWithFoldersArray(model).join(" / ");
|
||||
}
|
||||
|
||||
export function resolvedModelNameWithFoldersArray(model: AnyModel | null): string[] {
|
||||
if (model == null) return [];
|
||||
const folders = jotaiStore.get(foldersAtom) ?? [];
|
||||
|
||||
const getParents = (m: AnyModel, names: string[]) => {
|
||||
let newNames = [...names, resolvedModelName(m)];
|
||||
if ("folderId" in m) {
|
||||
const parent = folders.find((f) => f.id === m.folderId);
|
||||
if (parent) {
|
||||
newNames = [...resolvedModelNameWithFoldersArray(parent), ...newNames];
|
||||
}
|
||||
}
|
||||
return newNames;
|
||||
};
|
||||
|
||||
return getParents(model, []);
|
||||
}
|
||||
78
apps/yaak-client/lib/responseBody.ts
Normal file
78
apps/yaak-client/lib/responseBody.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import type { FilterResponse } from "@yaakapp-internal/plugins";
|
||||
import type { ServerSentEvent, SseSummary } from "@yaakapp-internal/sse";
|
||||
import { candidateJsonPayloadsFromSseText, computeSseSummary } from "@yaakapp-internal/sse";
|
||||
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,
|
||||
}: {
|
||||
response: HttpResponse;
|
||||
filter: string | null;
|
||||
}): Promise<string | null> {
|
||||
const result = await rpc<FilterResponse>("cmd_http_response_body", {
|
||||
responseId: response.id,
|
||||
filter,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
return result.content;
|
||||
}
|
||||
|
||||
export async function getResponseBodyEventSource(
|
||||
response: HttpResponse,
|
||||
): Promise<ServerSentEvent[]> {
|
||||
try {
|
||||
const events = await rpc<ServerSentEvent[]>("cmd_get_sse_events", {
|
||||
responseId: response.id,
|
||||
});
|
||||
if (events.length > 0) {
|
||||
return events;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to raw JSON frame parsing for non-standard SSE-like responses.
|
||||
}
|
||||
|
||||
const text = await getResponseBodyDecoded(response);
|
||||
if (text == null) return [];
|
||||
|
||||
return candidateJsonPayloadsFromSseText(text).map((data, index) => ({
|
||||
data,
|
||||
eventType: "",
|
||||
id: String(index),
|
||||
retry: null,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getResponseBodySseSummary(
|
||||
response: HttpResponse,
|
||||
resultKeyPath: string,
|
||||
): Promise<SseSummary> {
|
||||
const text = await getResponseBodyDecoded(response);
|
||||
if (text == null) return { fragmentCount: 0, summary: "" };
|
||||
|
||||
return computeSseSummary(text, resultKeyPath);
|
||||
}
|
||||
|
||||
export async function getResponseBodyBytes(
|
||||
response: HttpResponse,
|
||||
): Promise<Uint8Array<ArrayBuffer> | null> {
|
||||
// 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);
|
||||
}
|
||||
9
apps/yaak-client/lib/reveal.ts
Normal file
9
apps/yaak-client/lib/reveal.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
|
||||
const os = platform.osType();
|
||||
export const revealInFinderText =
|
||||
os === "macos"
|
||||
? "Reveal in Finder"
|
||||
: os === "windows"
|
||||
? "Show in Explorer"
|
||||
: "Show in File Manager";
|
||||
12
apps/yaak-client/lib/router.ts
Normal file
12
apps/yaak-client/lib/router.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// Create a new router instance
|
||||
import { createRouter } from "@tanstack/react-router";
|
||||
import { routeTree } from "../routeTree.gen";
|
||||
|
||||
export const router = createRouter({ routeTree });
|
||||
|
||||
// Register the router instance for type safety
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
19
apps/yaak-client/lib/rpc.ts
Normal file
19
apps/yaak-client/lib/rpc.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { RpcPayload } from "@yaakapp-internal/platform";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import type { RpcSchema } from "@yaakapp-internal/rpc-schema";
|
||||
|
||||
/**
|
||||
* Every backend command the app can call: the generated wire schema, one field
|
||||
* per `RpcRouter` registration on the Rust side. A typo'd or unregistered
|
||||
* command name is a compile error. Host plugin commands (license, fonts,
|
||||
* mac-window) don't appear here — each lives behind its own package's facade.
|
||||
*
|
||||
* `RpcSchema` also carries each command's request and response payload types;
|
||||
* adopting them at call sites is an incremental follow-up.
|
||||
*/
|
||||
type AppCmd = keyof RpcSchema;
|
||||
|
||||
/** Call a backend command. */
|
||||
export function rpc<T>(cmd: AppCmd, payload?: RpcPayload): Promise<T> {
|
||||
return platform.rpc<T>(cmd, payload);
|
||||
}
|
||||
3
apps/yaak-client/lib/scopes.ts
Normal file
3
apps/yaak-client/lib/scopes.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function isSidebarFocused() {
|
||||
return document.activeElement?.closest(".x-theme-sidebar") != null;
|
||||
}
|
||||
17
apps/yaak-client/lib/sendEphemeralRequest.ts
Normal file
17
apps/yaak-client/lib/sendEphemeralRequest.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
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<EphemeralHttpResponse> {
|
||||
// Remove some things that we don't want to associate
|
||||
const newRequest = { ...request };
|
||||
return rpc("cmd_send_ephemeral_request", {
|
||||
request: newRequest,
|
||||
environmentId,
|
||||
cookieJarId: getActiveCookieJar()?.id,
|
||||
});
|
||||
}
|
||||
47
apps/yaak-client/lib/setWorkspaceSearchParams.ts
Normal file
47
apps/yaak-client/lib/setWorkspaceSearchParams.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { Folder, GrpcRequest, WebsocketRequest, Workspace } from "@yaakapp-internal/models";
|
||||
import type { HttpRequest } from "@yaakapp-internal/sync";
|
||||
import { router } from "./router.js";
|
||||
|
||||
/**
|
||||
* Setting search params using "from" on the global router instance in tanstack router does not
|
||||
* currently behave very well, so this is a wrapper function that gives a typesafe interface
|
||||
* for the same thing.
|
||||
*/
|
||||
export function setWorkspaceSearchParams(
|
||||
search: Partial<{
|
||||
cookie_jar_id: string | null;
|
||||
environment_id: string | null;
|
||||
request_id: string | null;
|
||||
folder_id: string | null;
|
||||
}>,
|
||||
) {
|
||||
// oxlint-disable-next-line no-explicit-any
|
||||
(router as any)
|
||||
.navigate({
|
||||
// oxlint-disable-next-line no-explicit-any
|
||||
search: (prev: any) => {
|
||||
// console.log('Navigating to', { prev, search });
|
||||
const o = { ...prev, ...search };
|
||||
for (const k of Object.keys(o)) {
|
||||
if (o[k] == null) {
|
||||
delete o[k];
|
||||
}
|
||||
}
|
||||
return o;
|
||||
},
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
export function navigateToRequestOrFolderOrWorkspace(
|
||||
id: string,
|
||||
model: (Workspace | Folder | HttpRequest | GrpcRequest | WebsocketRequest)["model"],
|
||||
) {
|
||||
if (model === "workspace") {
|
||||
setWorkspaceSearchParams({ request_id: null, folder_id: null });
|
||||
} else if (model === "folder") {
|
||||
setWorkspaceSearchParams({ request_id: null, folder_id: id });
|
||||
} else {
|
||||
setWorkspaceSearchParams({ request_id: id, folder_id: null });
|
||||
}
|
||||
}
|
||||
6
apps/yaak-client/lib/settings.ts
Normal file
6
apps/yaak-client/lib/settings.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { Settings } from "@yaakapp-internal/models";
|
||||
import { rpc } from "./rpc";
|
||||
|
||||
export function getSettings(): Promise<Settings> {
|
||||
return rpc<Settings>("models_get_settings");
|
||||
}
|
||||
32
apps/yaak-client/lib/setupOrConfigureEncryption.tsx
Normal file
32
apps/yaak-client/lib/setupOrConfigureEncryption.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { VStack } from "@yaakapp-internal/ui";
|
||||
import { WorkspaceEncryptionSetting } from "../components/WorkspaceEncryptionSetting";
|
||||
import { activeWorkspaceMetaAtom } from "../hooks/useActiveWorkspace";
|
||||
import { showDialog } from "./dialog";
|
||||
import { jotaiStore } from "./jotai";
|
||||
|
||||
export function setupOrConfigureEncryption() {
|
||||
setupOrConfigure();
|
||||
}
|
||||
|
||||
export function withEncryptionEnabled(callback?: () => void) {
|
||||
const workspaceMeta = jotaiStore.get(activeWorkspaceMetaAtom);
|
||||
if (workspaceMeta?.encryptionKey != null) {
|
||||
callback?.(); // Already set up
|
||||
return;
|
||||
}
|
||||
|
||||
setupOrConfigure(callback);
|
||||
}
|
||||
|
||||
function setupOrConfigure(onEnable?: () => void) {
|
||||
showDialog({
|
||||
id: "workspace-encryption",
|
||||
title: "Workspace Encryption",
|
||||
size: "md",
|
||||
render: ({ hide }) => (
|
||||
<VStack space={3} className="pb-2" alignItems="end">
|
||||
<WorkspaceEncryptionSetting expanded onDone={hide} onEnabledEncryption={onEnable} />
|
||||
</VStack>
|
||||
),
|
||||
});
|
||||
}
|
||||
23
apps/yaak-client/lib/showColorPicker.tsx
Normal file
23
apps/yaak-client/lib/showColorPicker.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { Environment } from "@yaakapp-internal/models";
|
||||
import { patchModel } from "@yaakapp-internal/models";
|
||||
import { EnvironmentColorPicker } from "../components/EnvironmentColorPicker";
|
||||
import { showDialog } from "./dialog";
|
||||
|
||||
export function showColorPicker(environment: Environment) {
|
||||
showDialog({
|
||||
title: "Environment Color",
|
||||
id: "color-picker",
|
||||
size: "sm",
|
||||
render: ({ hide }) => {
|
||||
return (
|
||||
<EnvironmentColorPicker
|
||||
color={environment.color}
|
||||
onChange={async (color) => {
|
||||
await patchModel(environment, { color });
|
||||
hide();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
3
apps/yaak-client/lib/sleep.ts
Normal file
3
apps/yaak-client/lib/sleep.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export async function sleep(millis: number) {
|
||||
await new Promise((resolve) => setTimeout(resolve, millis));
|
||||
}
|
||||
36
apps/yaak-client/lib/themes.ts
Normal file
36
apps/yaak-client/lib/themes.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { GetThemesResponse } from "@yaakapp-internal/plugins";
|
||||
import {
|
||||
defaultDarkTheme,
|
||||
defaultLightTheme,
|
||||
resolveAppearance,
|
||||
type Appearance,
|
||||
} from "@yaakapp-internal/theme";
|
||||
import { rpc } from "./rpc";
|
||||
|
||||
export async function getThemes() {
|
||||
const themes = (await rpc<GetThemesResponse[]>("cmd_get_themes")).flatMap((t) => t.themes);
|
||||
themes.sort((a, b) => a.label.localeCompare(b.label));
|
||||
// Remove duplicates, in case multiple plugins provide the same theme
|
||||
const uniqueThemes = Array.from(new Map(themes.map((t) => [t.id, t])).values());
|
||||
return { themes: [defaultDarkTheme, defaultLightTheme, ...uniqueThemes] };
|
||||
}
|
||||
|
||||
export async function getResolvedTheme(
|
||||
preferredAppearance: Appearance,
|
||||
appearanceSetting: string,
|
||||
themeLight: string,
|
||||
themeDark: string,
|
||||
) {
|
||||
const appearance = resolveAppearance(preferredAppearance, appearanceSetting);
|
||||
const { themes } = await getThemes();
|
||||
|
||||
const darkThemes = themes.filter((t) => t.dark);
|
||||
const lightThemes = themes.filter((t) => !t.dark);
|
||||
|
||||
const dark = darkThemes.find((t) => t.id === themeDark) ?? darkThemes[0] ?? defaultDarkTheme;
|
||||
const light = lightThemes.find((t) => t.id === themeLight) ?? lightThemes[0] ?? defaultLightTheme;
|
||||
|
||||
const active = appearance === "dark" ? dark : light;
|
||||
|
||||
return { dark, light, active };
|
||||
}
|
||||
70
apps/yaak-client/lib/toast.tsx
Normal file
70
apps/yaak-client/lib/toast.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { atom } from "jotai";
|
||||
import type { ToastInstance } from "../components/Toasts";
|
||||
import { generateId } from "./generateId";
|
||||
import { jotaiStore } from "./jotai";
|
||||
|
||||
export const toastsAtom = atom<ToastInstance[]>([]);
|
||||
|
||||
export function showToast({
|
||||
id,
|
||||
timeout = 5000,
|
||||
...props
|
||||
}: Omit<ToastInstance, "id" | "timeout" | "uniqueKey"> & {
|
||||
id?: ToastInstance["id"];
|
||||
timeout?: ToastInstance["timeout"];
|
||||
}) {
|
||||
id = id ?? generateId();
|
||||
const uniqueKey = generateId();
|
||||
|
||||
const toasts = jotaiStore.get(toastsAtom);
|
||||
const toastWithSameId = toasts.find((t) => t.id === id);
|
||||
|
||||
let delay = 0;
|
||||
if (toastWithSameId) {
|
||||
hideToast(toastWithSameId);
|
||||
// Allow enough time for old toast to animate out
|
||||
delay = 200;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
const newToast: ToastInstance = { id, uniqueKey, timeout, ...props };
|
||||
jotaiStore.set(toastsAtom, (prev) => [...prev, newToast]);
|
||||
}, delay);
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
export function hideToastById(id: string) {
|
||||
const toast = jotaiStore.get(toastsAtom).find((t) => t.id === id);
|
||||
if (toast) hideToast(toast);
|
||||
}
|
||||
|
||||
export function hideToast(toHide: ToastInstance) {
|
||||
jotaiStore.set(toastsAtom, (all) => {
|
||||
const t = all.find((t) => t.uniqueKey === toHide.uniqueKey);
|
||||
t?.onClose?.();
|
||||
return all.filter((t) => t.uniqueKey !== toHide.uniqueKey);
|
||||
});
|
||||
}
|
||||
|
||||
export function showErrorToast<T>({
|
||||
id,
|
||||
title,
|
||||
message,
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
message: T;
|
||||
}) {
|
||||
return showToast({
|
||||
id,
|
||||
color: "danger",
|
||||
timeout: null,
|
||||
message: (
|
||||
<div className="w-full">
|
||||
<h2 className="text-lg font-bold mb-2">{title}</h2>
|
||||
<div className="whitespace-pre-wrap wrap-break-word">{String(message)}</div>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
4
apps/yaak-client/lib/truncate.ts
Normal file
4
apps/yaak-client/lib/truncate.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export function truncate(text: string, len: number): string {
|
||||
if (text.length <= len) return text;
|
||||
return `${text.slice(0, len)}…`;
|
||||
}
|
||||
Reference in New Issue
Block a user