mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-05-17 13:17:01 +02:00
Split codebase (#455)
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
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,
|
||||
hideX: true,
|
||||
size,
|
||||
disableBackdropClose: true, // Prevent accidental dismisses
|
||||
render: ({ hide }) => Alert({ onHide: hide, body }),
|
||||
});
|
||||
}
|
||||
|
||||
export function showSimpleAlert(title: string, message: string) {
|
||||
showAlert({
|
||||
id: "simple-alert",
|
||||
body: message,
|
||||
title: title,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { getIdentifier } from "@tauri-apps/api/app";
|
||||
import { invokeCmd } from "./tauri";
|
||||
|
||||
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 invokeCmd("cmd_metadata")),
|
||||
identifier: await getIdentifier(),
|
||||
} as AppInfo;
|
||||
|
||||
console.log("App info", appInfo);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export function capitalize(str: string): string {
|
||||
return str
|
||||
.split(" ")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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,
|
||||
hideX: true,
|
||||
size,
|
||||
disableBackdropClose: true, // Prevent accidental dismisses
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { clear, writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||
import { showToast } from "./toast";
|
||||
|
||||
export function copyToClipboard(
|
||||
text: string | null,
|
||||
{ disableToast }: { disableToast?: boolean } = {},
|
||||
) {
|
||||
if (text == null) {
|
||||
clear().catch(console.error);
|
||||
} else {
|
||||
writeText(text).catch(console.error);
|
||||
}
|
||||
|
||||
if (text !== "" && !disableToast) {
|
||||
showToast({
|
||||
id: "copied",
|
||||
color: "success",
|
||||
icon: "copy",
|
||||
message: "Copied to clipboard",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
];
|
||||
@@ -0,0 +1 @@
|
||||
export const connections = ["close", "keep-alive"];
|
||||
@@ -0,0 +1 @@
|
||||
export const encodings = ["*", "gzip", "compress", "deflate", "br", "zstd", "identity"];
|
||||
@@ -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",
|
||||
];
|
||||
@@ -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",
|
||||
];
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { HttpRequestHeader } from "@yaakapp-internal/models";
|
||||
import { invokeCmd } from "./tauri";
|
||||
|
||||
/**
|
||||
* Global default headers fetched from the backend.
|
||||
* These are static and fetched once on module load.
|
||||
*/
|
||||
export const defaultHeaders: HttpRequestHeader[] = await invokeCmd("cmd_default_headers");
|
||||
@@ -0,0 +1,60 @@
|
||||
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>
|
||||
{models.map((m) => (
|
||||
<li key={m.id}>
|
||||
<InlineCode>{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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { SyncModel } from "@yaakapp-internal/git";
|
||||
import { stringify } from "yaml";
|
||||
|
||||
/**
|
||||
* Convert a SyncModel to a clean YAML string for diffing.
|
||||
* Removes noisy fields like updatedAt that change on every edit.
|
||||
*/
|
||||
export function modelToYaml(model: SyncModel | null): string {
|
||||
if (!model) return "";
|
||||
|
||||
return stringify(model, {
|
||||
indent: 2,
|
||||
lineWidth: 0,
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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-[60rem]",
|
||||
render: () => (
|
||||
<EnvironmentEditDialog
|
||||
initialEnvironmentId={environment?.id ?? null}
|
||||
setRef={(pairEditor: PairEditorHandle | null) => {
|
||||
if (focusId && !didFocusVariable) {
|
||||
pairEditor?.focusValue(focusId);
|
||||
didFocusVariable = true;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 { invokeCmd } from "./tauri";
|
||||
|
||||
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 invokeCmd<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 invokeCmd<string>("cmd_secure_template", { template, workspaceId, environmentId });
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import vkBeautify from "vkbeautify";
|
||||
import { invokeCmd } from "./tauri";
|
||||
|
||||
export async function tryFormatJson(text: string): Promise<string> {
|
||||
if (text === "") return text;
|
||||
|
||||
try {
|
||||
const result = await invokeCmd<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 invokeCmd<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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { customAlphabet } from "nanoid";
|
||||
|
||||
const nanoid = customAlphabet("023456789abcdefghijkmnpqrstuvwxyzABCDEFGHIJKMNPQRSTUVWXYZ", 10);
|
||||
|
||||
export function generateId(): string {
|
||||
return nanoid();
|
||||
}
|
||||
@@ -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 "";
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { BatchUpsertResult } from "@yaakapp-internal/models";
|
||||
import { FormattedError, VStack } from "@yaakapp-internal/ui";
|
||||
import { Button } from "../components/core/Button";
|
||||
import { ImportDataDialog } from "../components/ImportDataDialog";
|
||||
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
||||
import { createFastMutation } from "../hooks/useFastMutation";
|
||||
import { showAlert } from "./alert";
|
||||
import { showDialog } from "./dialog";
|
||||
import { jotaiStore } from "./jotai";
|
||||
import { pluralizeCount } from "./pluralize";
|
||||
import { router } from "./router";
|
||||
import { invokeCmd } from "./tauri";
|
||||
|
||||
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) => {
|
||||
showDialog({
|
||||
id: "import",
|
||||
title: "Import Data",
|
||||
size: "sm",
|
||||
render: ({ hide }) => {
|
||||
const importAndHide = async (filePath: string) => {
|
||||
try {
|
||||
const didImport = await performImport(filePath);
|
||||
if (!didImport) {
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
} finally {
|
||||
hide();
|
||||
}
|
||||
};
|
||||
return <ImportDataDialog importData={importAndHide} />;
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
async function performImport(filePath: string): Promise<boolean> {
|
||||
const activeWorkspace = jotaiStore.get(activeWorkspaceAtom);
|
||||
const imported = await invokeCmd<BatchUpsertResult>("cmd_import_data", {
|
||||
filePath,
|
||||
workspaceId: activeWorkspace?.id,
|
||||
});
|
||||
|
||||
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 },
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
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, 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 { listenToTauriEvent } from "../hooks/useListenToTauriEvent";
|
||||
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 { invokeCmd } from "./tauri";
|
||||
import { showToast } from "./toast";
|
||||
|
||||
export function initGlobalListeners() {
|
||||
listenToTauriEvent<ShowToastRequest>("show_toast", (event) => {
|
||||
showToast({ ...event.payload });
|
||||
});
|
||||
|
||||
// Show errors for any plugins that failed to load during startup
|
||||
void invokeCmd<[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}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
listenToTauriEvent("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
|
||||
listenToTauriEvent<InternalEvent>("plugin_event", async ({ payload: 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 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 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);
|
||||
}
|
||||
});
|
||||
|
||||
listenToTauriEvent<string>("update_installed", async ({ payload: version }) => {
|
||||
console.log("Got update installed event", version);
|
||||
showUpdateInstalledToast(version);
|
||||
});
|
||||
|
||||
// Listen for update events
|
||||
listenToTauriEvent<UpdateInfo>("update_available", async ({ payload }) => {
|
||||
console.log("Got update available", payload);
|
||||
void showUpdateAvailableToast(payload);
|
||||
});
|
||||
|
||||
listenToTauriEvent<YaakNotification>("notification", ({ payload }) => {
|
||||
console.log("Got notification event", payload);
|
||||
showNotificationToast(payload);
|
||||
});
|
||||
|
||||
// Listen for plugin update events
|
||||
listenToTauriEvent<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-[5rem]"
|
||||
color="primary"
|
||||
loadingChildren="Restarting..."
|
||||
onClick={() => {
|
||||
hide();
|
||||
setTimeout(() => invokeCmd("cmd_restart", {}), 200);
|
||||
}}
|
||||
>
|
||||
Relaunch Yaak
|
||||
</ButtonInfiniteLoading>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
async function showUpdateAvailableToast(updateInfo: UpdateInfo) {
|
||||
const UPDATE_TOAST_ID = "update-info";
|
||||
const { version, replyEventId, downloaded } = updateInfo;
|
||||
|
||||
jotaiStore.set(updateAvailableAtom, { version, downloaded });
|
||||
|
||||
// Acknowledge the event, so we don't time out and try the fallback update logic
|
||||
await emit<UpdateResponse>(replyEventId, { type: "ack" });
|
||||
|
||||
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-[10rem]"
|
||||
loadingChildren={downloaded ? "Installing..." : "Downloading..."}
|
||||
onClick={async () => {
|
||||
await emit<UpdateResponse>(replyEventId, {
|
||||
type: "action",
|
||||
action: "install",
|
||||
});
|
||||
}}
|
||||
>
|
||||
{downloaded ? "Install Now" : "Download and Install"}
|
||||
</ButtonInfiniteLoading>
|
||||
<Button
|
||||
size="xs"
|
||||
color="info"
|
||||
variant="border"
|
||||
rightSlot={<Icon icon="external_link" />}
|
||||
onClick={async () => {
|
||||
await openUrl(`https://yaak.app/changelog/${version}`);
|
||||
}}
|
||||
>
|
||||
What's New
|
||||
</Button>
|
||||
</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-[5rem]"
|
||||
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: () => {
|
||||
invokeCmd("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-[5rem]"
|
||||
rightSlot={<Icon icon="external_link" />}
|
||||
onClick={() => {
|
||||
hide();
|
||||
return openUrl(actionUrl);
|
||||
}}
|
||||
>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
) : null;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createStore } from "jotai";
|
||||
|
||||
export const jotaiStore = createStore();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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("::");
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
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,
|
||||
hideX: true,
|
||||
size: size ?? "sm",
|
||||
disableBackdropClose: true, // Prevent accidental dismisses
|
||||
onClose: () => {
|
||||
// Click backdrop, close, or escape
|
||||
resolve(null);
|
||||
},
|
||||
render: ({ hide }) =>
|
||||
Prompt({
|
||||
onCancel: () => {
|
||||
// Click cancel button within dialog
|
||||
resolve(null);
|
||||
hide();
|
||||
},
|
||||
onResult: (v) => {
|
||||
resolve(v);
|
||||
hide();
|
||||
},
|
||||
onValuesChange,
|
||||
onInputsUpdated,
|
||||
...props,
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 ?? "";
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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, []);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { readFile } from "@tauri-apps/plugin-fs";
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import type { FilterResponse } from "@yaakapp-internal/plugins";
|
||||
import type { ServerSentEvent } from "@yaakapp-internal/sse";
|
||||
import { invokeCmd } from "./tauri";
|
||||
|
||||
export async function getResponseBodyText({
|
||||
response,
|
||||
filter,
|
||||
}: {
|
||||
response: HttpResponse;
|
||||
filter: string | null;
|
||||
}): Promise<string | null> {
|
||||
const result = await invokeCmd<FilterResponse>("cmd_http_response_body", {
|
||||
response,
|
||||
filter,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
return result.content;
|
||||
}
|
||||
|
||||
export async function getResponseBodyEventSource(
|
||||
response: HttpResponse,
|
||||
): Promise<ServerSentEvent[]> {
|
||||
if (!response.bodyPath) return [];
|
||||
return invokeCmd<ServerSentEvent[]>("cmd_get_sse_events", {
|
||||
filePath: response.bodyPath,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getResponseBodyBytes(
|
||||
response: HttpResponse,
|
||||
): Promise<Uint8Array<ArrayBuffer> | null> {
|
||||
if (!response.bodyPath) return null;
|
||||
return readFile(response.bodyPath);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type } from "@tauri-apps/plugin-os";
|
||||
|
||||
const os = type();
|
||||
export const revealInFinderText =
|
||||
os === "macos"
|
||||
? "Reveal in Finder"
|
||||
: os === "windows"
|
||||
? "Show in Explorer"
|
||||
: "Show in File Manager";
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function isSidebarFocused() {
|
||||
return document.activeElement?.closest(".x-theme-sidebar") != null;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { HttpRequest, HttpResponse } from "@yaakapp-internal/models";
|
||||
import { getActiveCookieJar } from "../hooks/useActiveCookieJar";
|
||||
import { invokeCmd } from "./tauri";
|
||||
|
||||
export async function sendEphemeralRequest(
|
||||
request: HttpRequest,
|
||||
environmentId: string | null,
|
||||
): Promise<HttpResponse> {
|
||||
// Remove some things that we don't want to associate
|
||||
const newRequest = { ...request };
|
||||
return invokeCmd("cmd_send_ephemeral_request", {
|
||||
request: newRequest,
|
||||
environmentId,
|
||||
cookieJarId: getActiveCookieJar()?.id,
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Settings } from "@yaakapp-internal/models";
|
||||
|
||||
export function getSettings(): Promise<Settings> {
|
||||
return invoke<Settings>("models_get_settings");
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export async function sleep(millis: number) {
|
||||
await new Promise((resolve) => setTimeout(resolve, millis));
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { InvokeArgs } from "@tauri-apps/api/core";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
type TauriCmd =
|
||||
| "cmd_call_grpc_request_action"
|
||||
| "cmd_call_http_authentication_action"
|
||||
| "cmd_call_http_request_action"
|
||||
| "cmd_call_websocket_request_action"
|
||||
| "cmd_call_workspace_action"
|
||||
| "cmd_call_folder_action"
|
||||
| "cmd_check_for_updates"
|
||||
| "cmd_curl_to_request"
|
||||
| "cmd_decrypt_template"
|
||||
| "cmd_default_headers"
|
||||
| "cmd_delete_all_grpc_connections"
|
||||
| "cmd_delete_all_http_responses"
|
||||
| "cmd_delete_send_history"
|
||||
| "cmd_dismiss_notification"
|
||||
| "cmd_export_data"
|
||||
| "cmd_format_graphql"
|
||||
| "cmd_format_json"
|
||||
| "cmd_get_http_authentication_config"
|
||||
| "cmd_get_http_authentication_summaries"
|
||||
| "cmd_get_http_response_events"
|
||||
| "cmd_get_sse_events"
|
||||
| "cmd_get_themes"
|
||||
| "cmd_get_workspace_meta"
|
||||
| "cmd_git_add_credential"
|
||||
| "cmd_git_clone"
|
||||
| "cmd_grpc_go"
|
||||
| "cmd_grpc_reflect"
|
||||
| "cmd_grpc_request_actions"
|
||||
| "cmd_http_request_actions"
|
||||
| "cmd_websocket_request_actions"
|
||||
| "cmd_workspace_actions"
|
||||
| "cmd_folder_actions"
|
||||
| "cmd_http_request_body"
|
||||
| "cmd_http_response_body"
|
||||
| "cmd_import_data"
|
||||
| "cmd_metadata"
|
||||
| "cmd_restart"
|
||||
| "cmd_new_child_window"
|
||||
| "cmd_new_main_window"
|
||||
| "cmd_plugin_info"
|
||||
| "cmd_plugin_init_errors"
|
||||
| "cmd_reload_plugins"
|
||||
| "cmd_render_template"
|
||||
| "cmd_save_response"
|
||||
| "cmd_secure_template"
|
||||
| "cmd_send_ephemeral_request"
|
||||
| "cmd_send_http_request"
|
||||
| "cmd_template_function_summaries"
|
||||
| "cmd_template_function_config"
|
||||
| "cmd_template_tokens_to_string";
|
||||
|
||||
export async function invokeCmd<T>(cmd: TauriCmd, args?: InvokeArgs): Promise<T> {
|
||||
// console.log('RUN COMMAND', cmd, args);
|
||||
try {
|
||||
return await invoke(cmd, args);
|
||||
} catch (err) {
|
||||
console.warn("Tauri command error", cmd, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type { Appearance } from "@yaakapp-internal/theme";
|
||||
export {
|
||||
getCSSAppearance,
|
||||
getWindowAppearance,
|
||||
resolveAppearance,
|
||||
subscribeToPreferredAppearance,
|
||||
subscribeToWindowAppearanceChange,
|
||||
} from "@yaakapp-internal/theme";
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { GetThemesResponse } from "@yaakapp-internal/plugins";
|
||||
import { defaultDarkTheme, defaultLightTheme } from "@yaakapp-internal/theme";
|
||||
import { invokeCmd } from "../tauri";
|
||||
import type { Appearance } from "./appearance";
|
||||
import { resolveAppearance } from "./appearance";
|
||||
|
||||
export { defaultDarkTheme, defaultLightTheme } from "@yaakapp-internal/theme";
|
||||
|
||||
export async function getThemes() {
|
||||
const themes = (await invokeCmd<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 };
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type { YaakColorKey, YaakColors, YaakTheme } from "@yaakapp-internal/theme";
|
||||
export {
|
||||
addThemeStylesToDocument,
|
||||
applyThemeToDocument,
|
||||
completeTheme,
|
||||
getThemeCSS,
|
||||
indent,
|
||||
setThemeOnDocument,
|
||||
} from "@yaakapp-internal/theme";
|
||||
@@ -0,0 +1 @@
|
||||
export { YaakColor } from "@yaakapp-internal/theme";
|
||||
@@ -0,0 +1,68 @@
|
||||
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 };
|
||||
if (timeout != null) {
|
||||
setTimeout(() => hideToast(newToast), timeout);
|
||||
}
|
||||
jotaiStore.set(toastsAtom, (prev) => [...prev, newToast]);
|
||||
}, delay);
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
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 break-words">{String(message)}</div>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -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