mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-20 15:51:23 +02:00
Refactor desktop app into separate client and proxy apps
This commit is contained in:
30
apps/yaak-client/lib/alert.ts
Normal file
30
apps/yaak-client/lib/alert.ts
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
23
apps/yaak-client/lib/appInfo.ts
Normal file
23
apps/yaak-client/lib/appInfo.ts
Normal file
@@ -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);
|
||||
25
apps/yaak-client/lib/atoms.ts
Normal file
25
apps/yaak-client/lib/atoms.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
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 "../components/core/SplitLayout";
|
||||
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(' ');
|
||||
}
|
||||
3
apps/yaak-client/lib/clamp.ts
Normal file
3
apps/yaak-client/lib/clamp.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
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;
|
||||
}
|
||||
35
apps/yaak-client/lib/confirm.ts
Normal file
35
apps/yaak-client/lib/confirm.ts
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
4
apps/yaak-client/lib/constants.ts
Normal file
4
apps/yaak-client/lib/constants.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const HEADER_SIZE_MD = '27px';
|
||||
export const HEADER_SIZE_LG = '40px';
|
||||
|
||||
export const WINDOW_CONTROLS_WIDTH = '10.5rem';
|
||||
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');
|
||||
}
|
||||
}
|
||||
22
apps/yaak-client/lib/copy.ts
Normal file
22
apps/yaak-client/lib/copy.ts
Normal file
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
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',
|
||||
];
|
||||
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',
|
||||
];
|
||||
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 { 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');
|
||||
60
apps/yaak-client/lib/deleteModelWithConfirm.tsx
Normal file
60
apps/yaak-client/lib/deleteModelWithConfirm.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { AnyModel } from '@yaakapp-internal/models';
|
||||
import { deleteModel, modelTypeLabel } from '@yaakapp-internal/models';
|
||||
import { InlineCode } from '../components/core/InlineCode';
|
||||
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;
|
||||
}
|
||||
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));
|
||||
}
|
||||
15
apps/yaak-client/lib/diffYaml.ts
Normal file
15
apps/yaak-client/lib/diffYaml.ts
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
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';
|
||||
}
|
||||
}
|
||||
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-[60rem]',
|
||||
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 { 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 });
|
||||
}
|
||||
33
apps/yaak-client/lib/formatters.ts
Normal file
33
apps/yaak-client/lib/formatters.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
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 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 (['string', 'number'].includes(typeof node)) {
|
||||
return String(node);
|
||||
}
|
||||
|
||||
if (Array.isArray(node)) {
|
||||
return node.map(getNodeText).join('');
|
||||
}
|
||||
|
||||
if (typeof node === 'object' && node) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: none
|
||||
return getNodeText((node as any).props.children);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
109
apps/yaak-client/lib/importData.tsx
Normal file
109
apps/yaak-client/lib/importData.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { BatchUpsertResult } from '@yaakapp-internal/models';
|
||||
import { Button } from '../components/core/Button';
|
||||
import { FormattedError } from '../components/core/FormattedError';
|
||||
import { VStack } from '../components/core/Stacks';
|
||||
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;
|
||||
}
|
||||
322
apps/yaak-client/lib/initGlobalListeners.tsx
Normal file
322
apps/yaak-client/lib/initGlobalListeners.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
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 { openSettings } from "../commands/openSettings";
|
||||
import { Button } from "../components/core/Button";
|
||||
import { ButtonInfiniteLoading } from "../components/core/ButtonInfiniteLoading";
|
||||
import { Icon } from "../components/core/Icon";
|
||||
import { HStack, VStack } from "../components/core/Stacks";
|
||||
|
||||
// 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 });
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
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);
|
||||
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;
|
||||
},
|
||||
});
|
||||
}
|
||||
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();
|
||||
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('::');
|
||||
}
|
||||
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;
|
||||
}
|
||||
95
apps/yaak-client/lib/model_util.test.ts
Normal file
95
apps/yaak-client/lib/model_util.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { HttpResponseEvent } from '@yaakapp-internal/models';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
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 });
|
||||
});
|
||||
});
|
||||
94
apps/yaak-client/lib/model_util.ts
Normal file
94
apps/yaak-client/lib/model_util.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
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 };
|
||||
}
|
||||
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 };
|
||||
}
|
||||
52
apps/yaak-client/lib/prompt-form.tsx
Normal file
52
apps/yaak-client/lib/prompt-form.tsx
Normal file
@@ -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,
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
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 '../components/core/InlineCode';
|
||||
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 });
|
||||
}
|
||||
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, []);
|
||||
}
|
||||
40
apps/yaak-client/lib/responseBody.ts
Normal file
40
apps/yaak-client/lib/responseBody.ts
Normal file
@@ -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);
|
||||
}
|
||||
9
apps/yaak-client/lib/reveal.ts
Normal file
9
apps/yaak-client/lib/reveal.ts
Normal file
@@ -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';
|
||||
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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
16
apps/yaak-client/lib/sendEphemeralRequest.ts
Normal file
16
apps/yaak-client/lib/sendEphemeralRequest.ts
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
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;
|
||||
}>,
|
||||
) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: none
|
||||
(router as any)
|
||||
.navigate({
|
||||
// biome-ignore lint/suspicious/noExplicitAny: none
|
||||
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 { invoke } from '@tauri-apps/api/core';
|
||||
import type { Settings } from '@yaakapp-internal/models';
|
||||
|
||||
export function getSettings(): Promise<Settings> {
|
||||
return invoke<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 '../components/core/Stacks';
|
||||
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));
|
||||
}
|
||||
62
apps/yaak-client/lib/tauri.ts
Normal file
62
apps/yaak-client/lib/tauri.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
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_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_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;
|
||||
}
|
||||
}
|
||||
48
apps/yaak-client/lib/theme/appearance.ts
Normal file
48
apps/yaak-client/lib/theme/appearance.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
|
||||
|
||||
export type Appearance = 'light' | 'dark';
|
||||
|
||||
export function getCSSAppearance(): Appearance {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
export async function getWindowAppearance(): Promise<Appearance> {
|
||||
const a = await getCurrentWebviewWindow().theme();
|
||||
return a ?? getCSSAppearance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to appearance (dark/light) changes. Note, we use Tauri Window appearance instead of
|
||||
* CSS appearance because CSS won't fire the way we handle window theme management.
|
||||
*/
|
||||
export function subscribeToWindowAppearanceChange(
|
||||
cb: (appearance: Appearance) => void,
|
||||
): () => void {
|
||||
const container = {
|
||||
unsubscribe: () => {},
|
||||
};
|
||||
|
||||
getCurrentWebviewWindow()
|
||||
.onThemeChanged((t) => {
|
||||
cb(t.payload);
|
||||
})
|
||||
.then((l) => {
|
||||
container.unsubscribe = l;
|
||||
});
|
||||
|
||||
return () => container.unsubscribe();
|
||||
}
|
||||
|
||||
export function resolveAppearance(
|
||||
preferredAppearance: Appearance,
|
||||
appearanceSetting: string,
|
||||
): Appearance {
|
||||
const appearance = appearanceSetting === 'system' ? preferredAppearance : appearanceSetting;
|
||||
return appearance === 'dark' ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
export function subscribeToPreferredAppearance(cb: (a: Appearance) => void) {
|
||||
cb(getCSSAppearance());
|
||||
getWindowAppearance().then(cb);
|
||||
subscribeToWindowAppearanceChange(cb);
|
||||
}
|
||||
110
apps/yaak-client/lib/theme/themes.ts
Normal file
110
apps/yaak-client/lib/theme/themes.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { GetThemesResponse } from '@yaakapp-internal/plugins';
|
||||
import { invokeCmd } from '../tauri';
|
||||
import type { Appearance } from './appearance';
|
||||
import { resolveAppearance } from './appearance';
|
||||
|
||||
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: [yaakDark, yaakLight, ...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] ?? yaakDark;
|
||||
const light = lightThemes.find((t) => t.id === themeLight) ?? lightThemes[0] ?? yaakLight;
|
||||
|
||||
const active = appearance === 'dark' ? dark : light;
|
||||
|
||||
return { dark, light, active };
|
||||
}
|
||||
|
||||
const yaakDark = {
|
||||
id: 'yaak-dark',
|
||||
label: 'Yaak',
|
||||
dark: true,
|
||||
base: {
|
||||
surface: 'hsl(244,23%,14%)',
|
||||
surfaceHighlight: 'hsl(244,23%,20%)',
|
||||
text: 'hsl(245,23%,85%)',
|
||||
textSubtle: 'hsl(245,18%,58%)',
|
||||
textSubtlest: 'hsl(245,18%,45%)',
|
||||
border: 'hsl(244,23%,25%)',
|
||||
primary: 'hsl(266,100%,79%)',
|
||||
secondary: 'hsl(245,23%,60%)',
|
||||
info: 'hsl(206,100%,63%)',
|
||||
success: 'hsl(150,99%,44%)',
|
||||
notice: 'hsl(48,80%,63%)',
|
||||
warning: 'hsl(28,100%,61%)',
|
||||
danger: 'hsl(342,90%,68%)',
|
||||
},
|
||||
components: {
|
||||
button: {
|
||||
primary: 'hsl(266,100%,71.1%)',
|
||||
secondary: 'hsl(244,23%,54%)',
|
||||
info: 'hsl(206,100%,56.7%)',
|
||||
success: 'hsl(150,99%,37.4%)',
|
||||
notice: 'hsl(48,80%,50.4%)',
|
||||
warning: 'hsl(28,100%,54.9%)',
|
||||
danger: 'hsl(342,90%,61.2%)',
|
||||
},
|
||||
dialog: {
|
||||
border: 'hsl(244,23%,24%)',
|
||||
},
|
||||
sidebar: {
|
||||
surface: 'hsl(243,23%,16%)',
|
||||
border: 'hsl(244,23%,22%)',
|
||||
},
|
||||
responsePane: {
|
||||
surface: 'hsl(243,23%,16%)',
|
||||
border: 'hsl(246,23%,23%)',
|
||||
},
|
||||
appHeader: {
|
||||
surface: 'hsl(244,23%,12%)',
|
||||
border: 'hsl(244,23%,21%)',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const yaakLight = {
|
||||
id: 'yaak-light',
|
||||
label: 'Yaak',
|
||||
dark: false,
|
||||
base: {
|
||||
surface: 'hsl(0,0%,100%)',
|
||||
surfaceHighlight: 'hsl(218,24%,87%)',
|
||||
text: 'hsl(217,24%,10%)',
|
||||
textSubtle: 'hsl(217,24%,40%)',
|
||||
textSubtlest: 'hsl(217,24%,58%)',
|
||||
border: 'hsl(217,22%,90%)',
|
||||
primary: 'hsl(266,100%,60%)',
|
||||
secondary: 'hsl(220,24%,50%)',
|
||||
info: 'hsl(206,100%,40%)',
|
||||
success: 'hsl(139,66%,34%)',
|
||||
notice: 'hsl(45,100%,34%)',
|
||||
warning: 'hsl(30,100%,36%)',
|
||||
danger: 'hsl(335,75%,48%)',
|
||||
},
|
||||
components: {
|
||||
sidebar: {
|
||||
surface: 'hsl(220,20%,98%)',
|
||||
border: 'hsl(217,22%,88%)',
|
||||
surfaceHighlight: 'hsl(217,25%,90%)',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const defaultDarkTheme = yaakDark;
|
||||
export const defaultLightTheme = yaakLight;
|
||||
386
apps/yaak-client/lib/theme/window.ts
Normal file
386
apps/yaak-client/lib/theme/window.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
import type { Theme, ThemeComponentColors } from '@yaakapp-internal/plugins';
|
||||
import { defaultDarkTheme, defaultLightTheme } from './themes';
|
||||
import { YaakColor } from './yaakColor';
|
||||
|
||||
export type YaakColors = {
|
||||
surface: YaakColor;
|
||||
surfaceHighlight?: YaakColor;
|
||||
surfaceActive?: YaakColor;
|
||||
|
||||
text: YaakColor;
|
||||
textSubtle?: YaakColor;
|
||||
textSubtlest?: YaakColor;
|
||||
|
||||
border?: YaakColor;
|
||||
borderSubtle?: YaakColor;
|
||||
borderFocus?: YaakColor;
|
||||
|
||||
shadow?: YaakColor;
|
||||
backdrop?: YaakColor;
|
||||
selection?: YaakColor;
|
||||
|
||||
primary?: YaakColor;
|
||||
secondary?: YaakColor;
|
||||
info?: YaakColor;
|
||||
success?: YaakColor;
|
||||
notice?: YaakColor;
|
||||
warning?: YaakColor;
|
||||
danger?: YaakColor;
|
||||
};
|
||||
|
||||
export type YaakTheme = {
|
||||
id: string;
|
||||
name: string;
|
||||
base: YaakColors;
|
||||
components?: Partial<{
|
||||
dialog: Partial<YaakColors>;
|
||||
menu: Partial<YaakColors>;
|
||||
toast: Partial<YaakColors>;
|
||||
sidebar: Partial<YaakColors>;
|
||||
responsePane: Partial<YaakColors>;
|
||||
appHeader: Partial<YaakColors>;
|
||||
button: Partial<YaakColors>;
|
||||
banner: Partial<YaakColors>;
|
||||
templateTag: Partial<YaakColors>;
|
||||
urlBar: Partial<YaakColors>;
|
||||
editor: Partial<YaakColors>;
|
||||
input: Partial<YaakColors>;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type YaakColorKey = keyof ThemeComponentColors;
|
||||
|
||||
type ComponentName = keyof NonNullable<YaakTheme['components']>;
|
||||
|
||||
type CSSVariables = Record<YaakColorKey, string | undefined>;
|
||||
|
||||
function themeVariables(
|
||||
theme: Theme,
|
||||
component?: ComponentName,
|
||||
base?: CSSVariables,
|
||||
): CSSVariables | null {
|
||||
const cmp =
|
||||
component == null
|
||||
? theme.base
|
||||
: (theme.components?.[component] ?? ({} as ThemeComponentColors));
|
||||
const c = (s: string | undefined) => yc(theme, s);
|
||||
const vars: CSSVariables = {
|
||||
surface: cmp.surface,
|
||||
surfaceHighlight: cmp.surfaceHighlight ?? c(cmp.surface)?.lift(0.06).css(),
|
||||
surfaceActive: cmp.surfaceActive ?? c(cmp.primary)?.lower(0.2).translucify(0.8).css(),
|
||||
backdrop: cmp.backdrop ?? c(cmp.surface)?.lower(0.2).translucify(0.2).css(),
|
||||
selection: cmp.selection ?? c(cmp.primary)?.lower(0.1).translucify(0.7).css(),
|
||||
border: cmp.border ?? c(cmp.surface)?.lift(0.11)?.css(),
|
||||
borderSubtle: cmp.borderSubtle ?? c(cmp.border)?.lower(0.06)?.css(),
|
||||
borderFocus: c(cmp.info)?.translucify(0.5)?.css(),
|
||||
text: cmp.text,
|
||||
textSubtle: cmp.textSubtle ?? c(cmp.text)?.lower(0.2)?.css(),
|
||||
textSubtlest: cmp.textSubtlest ?? c(cmp.text)?.lower(0.3)?.css(),
|
||||
shadow:
|
||||
cmp.shadow ??
|
||||
YaakColor.black()
|
||||
.translucify(theme.dark ? 0.7 : 0.93)
|
||||
.css(),
|
||||
primary: cmp.primary,
|
||||
secondary: cmp.secondary,
|
||||
info: cmp.info,
|
||||
success: cmp.success,
|
||||
notice: cmp.notice,
|
||||
warning: cmp.warning,
|
||||
danger: cmp.danger,
|
||||
};
|
||||
|
||||
// Extend with base
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
if (!v && base?.[k as YaakColorKey]) {
|
||||
vars[k as YaakColorKey] = base[k as YaakColorKey];
|
||||
}
|
||||
}
|
||||
|
||||
return vars;
|
||||
}
|
||||
|
||||
function templateTagColorVariables(color: YaakColor | null): Partial<CSSVariables> {
|
||||
if (color == null) return {};
|
||||
|
||||
return {
|
||||
text: color.lift(0.7).css(),
|
||||
textSubtle: color.lift(0.4).css(),
|
||||
textSubtlest: color.css(),
|
||||
surface: color.lower(0.2).translucify(0.8).css(),
|
||||
border: color.translucify(0.6).css(),
|
||||
borderSubtle: color.translucify(0.8).css(),
|
||||
surfaceHighlight: color.lower(0.1).translucify(0.7).css(),
|
||||
};
|
||||
}
|
||||
|
||||
function toastColorVariables(color: YaakColor | null): Partial<CSSVariables> {
|
||||
if (color == null) return {};
|
||||
|
||||
return {
|
||||
text: color.lift(0.8).css(),
|
||||
textSubtle: color.lift(0.8).translucify(0.3).css(),
|
||||
surface: color.translucify(0.9).css(),
|
||||
surfaceHighlight: color.translucify(0.8).css(),
|
||||
border: color.lift(0.3).translucify(0.6).css(),
|
||||
};
|
||||
}
|
||||
|
||||
function bannerColorVariables(color: YaakColor | null): Partial<CSSVariables> {
|
||||
if (color == null) return {};
|
||||
|
||||
return {
|
||||
text: color.lift(0.8).css(),
|
||||
textSubtle: color.translucify(0.3).css(),
|
||||
textSubtlest: color.translucify(0.6).css(),
|
||||
surface: color.translucify(0.95).css(),
|
||||
border: color.lift(0.3).translucify(0.8).css(),
|
||||
};
|
||||
}
|
||||
|
||||
function inputCSS(color: YaakColor | null): Partial<CSSVariables> {
|
||||
if (color == null) return {};
|
||||
|
||||
const theme: Partial<ThemeComponentColors> = {
|
||||
border: color.css(),
|
||||
};
|
||||
|
||||
return theme;
|
||||
}
|
||||
|
||||
function buttonSolidColorVariables(
|
||||
color: YaakColor | null,
|
||||
isDefault = false,
|
||||
): Partial<CSSVariables> {
|
||||
if (color == null) return {};
|
||||
|
||||
const theme: Partial<ThemeComponentColors> = {
|
||||
text: 'white',
|
||||
surface: color.lower(0.3).css(),
|
||||
surfaceHighlight: color.lower(0.1).css(),
|
||||
border: color.css(),
|
||||
};
|
||||
|
||||
if (isDefault) {
|
||||
theme.text = undefined; // Inherit from parent
|
||||
theme.surface = undefined; // Inherit from parent
|
||||
theme.surfaceHighlight = color.lift(0.08).css();
|
||||
}
|
||||
|
||||
return theme;
|
||||
}
|
||||
|
||||
function buttonBorderColorVariables(
|
||||
color: YaakColor | null,
|
||||
isDefault = false,
|
||||
): Partial<CSSVariables> {
|
||||
if (color == null) return {};
|
||||
|
||||
const vars: Partial<CSSVariables> = {
|
||||
text: color.lift(0.8).css(),
|
||||
textSubtle: color.lift(0.55).css(),
|
||||
textSubtlest: color.lift(0.4).translucify(0.6).css(),
|
||||
surfaceHighlight: color.translucify(0.8).css(),
|
||||
borderSubtle: color.translucify(0.5).css(),
|
||||
border: color.translucify(0.3).css(),
|
||||
};
|
||||
|
||||
if (isDefault) {
|
||||
vars.borderSubtle = color.lift(0.28).css();
|
||||
vars.border = color.lift(0.5).css();
|
||||
}
|
||||
|
||||
return vars;
|
||||
}
|
||||
|
||||
function variablesToCSS(
|
||||
selector: string | null,
|
||||
vars: Partial<CSSVariables> | null,
|
||||
): string | null {
|
||||
if (vars == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const css = Object.entries(vars ?? {})
|
||||
.filter(([, value]) => value)
|
||||
.map(([name, value]) => `--${name}: ${value};`)
|
||||
.join('\n');
|
||||
|
||||
return selector == null ? css : `${selector} {\n${indent(css)}\n}`;
|
||||
}
|
||||
|
||||
function componentCSS(theme: Theme, component: ComponentName): string | null {
|
||||
if (theme.components == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const themeVars = themeVariables(theme, component);
|
||||
return variablesToCSS(`.x-theme-${component}`, themeVars);
|
||||
}
|
||||
|
||||
function buttonCSS(
|
||||
theme: Theme,
|
||||
color: YaakColorKey,
|
||||
colors?: ThemeComponentColors,
|
||||
): string | null {
|
||||
const yaakColor = yc(theme, colors?.[color]);
|
||||
if (yaakColor == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
variablesToCSS(`.x-theme-button--solid--${color}`, buttonSolidColorVariables(yaakColor)),
|
||||
variablesToCSS(`.x-theme-button--border--${color}`, buttonBorderColorVariables(yaakColor)),
|
||||
].join('\n\n');
|
||||
}
|
||||
|
||||
function bannerCSS(
|
||||
theme: Theme,
|
||||
color: YaakColorKey,
|
||||
colors?: ThemeComponentColors,
|
||||
): string | null {
|
||||
const yaakColor = yc(theme, colors?.[color]);
|
||||
if (yaakColor == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [variablesToCSS(`.x-theme-banner--${color}`, bannerColorVariables(yaakColor))].join(
|
||||
'\n\n',
|
||||
);
|
||||
}
|
||||
|
||||
function toastCSS(theme: Theme, color: YaakColorKey, colors?: ThemeComponentColors): string | null {
|
||||
const yaakColor = yc(theme, colors?.[color]);
|
||||
if (yaakColor == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [variablesToCSS(`.x-theme-toast--${color}`, toastColorVariables(yaakColor))].join('\n\n');
|
||||
}
|
||||
|
||||
function templateTagCSS(
|
||||
theme: Theme,
|
||||
color: YaakColorKey,
|
||||
colors?: ThemeComponentColors,
|
||||
): string | null {
|
||||
const yaakColor = yc(theme, colors?.[color]);
|
||||
if (yaakColor == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
variablesToCSS(`.x-theme-templateTag--${color}`, templateTagColorVariables(yaakColor)),
|
||||
].join('\n\n');
|
||||
}
|
||||
|
||||
export function getThemeCSS(theme: Theme): string {
|
||||
theme.components = theme.components ?? {};
|
||||
// Toast defaults to menu styles
|
||||
theme.components.toast = theme.components.toast ?? theme.components.menu ?? {};
|
||||
const { components, id, label } = theme;
|
||||
const colors = Object.keys(theme.base).reduce((prev, key) => {
|
||||
// biome-ignore lint/performance/noAccumulatingSpread: none
|
||||
return { ...prev, [key]: theme.base[key as YaakColorKey] };
|
||||
}, {}) as ThemeComponentColors;
|
||||
|
||||
let themeCSS = '';
|
||||
try {
|
||||
const baseCss = variablesToCSS(null, themeVariables(theme));
|
||||
themeCSS = [
|
||||
baseCss,
|
||||
...Object.keys(components ?? {}).map((key) => componentCSS(theme, key as ComponentName)),
|
||||
variablesToCSS(
|
||||
'.x-theme-button--solid--default',
|
||||
buttonSolidColorVariables(yc(theme, theme.base.surface), true),
|
||||
),
|
||||
variablesToCSS(
|
||||
'.x-theme-button--border--default',
|
||||
buttonBorderColorVariables(yc(theme, theme.base.surface), true),
|
||||
),
|
||||
...Object.keys(colors ?? {}).map((key) =>
|
||||
buttonCSS(theme, key as YaakColorKey, theme.components?.button ?? colors),
|
||||
),
|
||||
...Object.keys(colors ?? {}).map((key) =>
|
||||
bannerCSS(theme, key as YaakColorKey, theme.components?.banner ?? colors),
|
||||
),
|
||||
...Object.keys(colors ?? {}).map((key) =>
|
||||
toastCSS(theme, key as YaakColorKey, theme.components?.banner ?? colors),
|
||||
),
|
||||
...Object.keys(colors ?? {}).map((key) =>
|
||||
templateTagCSS(theme, key as YaakColorKey, theme.components?.templateTag ?? colors),
|
||||
),
|
||||
].join('\n\n');
|
||||
} catch (err) {
|
||||
console.error('Failed to generate CSS', err);
|
||||
}
|
||||
|
||||
return [`/* ${label} */`, `[data-theme="${id}"] {`, indent(themeCSS), '}'].join('\n');
|
||||
}
|
||||
|
||||
export function addThemeStylesToDocument(rawTheme: Theme | null) {
|
||||
if (rawTheme == null) {
|
||||
console.error('Failed to add theme styles: theme is null');
|
||||
return;
|
||||
}
|
||||
|
||||
const theme = completeTheme(rawTheme);
|
||||
let styleEl = document.head.querySelector('style[data-theme]');
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement('style');
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
|
||||
styleEl.setAttribute('data-theme', theme.id);
|
||||
styleEl.setAttribute('data-updated-at', new Date().toISOString());
|
||||
styleEl.textContent = getThemeCSS(theme);
|
||||
}
|
||||
|
||||
export function setThemeOnDocument(theme: Theme | null) {
|
||||
if (theme == null) {
|
||||
console.error('Failed to set theme: theme is null');
|
||||
return;
|
||||
}
|
||||
|
||||
document.documentElement.setAttribute('data-theme', theme.id);
|
||||
}
|
||||
|
||||
export function indent(text: string, space = ' '): string {
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line) => space + line)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function yc<T extends string | null | undefined>(
|
||||
theme: Theme,
|
||||
s: T,
|
||||
): T extends string ? YaakColor : null {
|
||||
if (s == null) return null as never;
|
||||
return new YaakColor(s, theme.dark ? 'dark' : 'light') as never;
|
||||
}
|
||||
|
||||
export function completeTheme(theme: Theme): Theme {
|
||||
const fallback = theme.dark ? defaultDarkTheme.base : defaultLightTheme.base;
|
||||
const c = (s: string | null | undefined) => yc(theme, s);
|
||||
|
||||
theme.base.primary ??= fallback.primary;
|
||||
theme.base.secondary ??= fallback.secondary;
|
||||
theme.base.info ??= fallback.info;
|
||||
theme.base.success ??= fallback.success;
|
||||
theme.base.notice ??= fallback.notice;
|
||||
theme.base.warning ??= fallback.warning;
|
||||
theme.base.danger ??= fallback.danger;
|
||||
|
||||
theme.base.surface ??= fallback.surface;
|
||||
theme.base.surfaceHighlight ??= c(theme.base.surface)?.lift(0.06)?.css();
|
||||
theme.base.surfaceActive ??= c(theme.base.primary)?.lower(0.2).translucify(0.8).css();
|
||||
|
||||
theme.base.border ??= c(theme.base.surface)?.lift(0.12)?.css();
|
||||
theme.base.borderSubtle ??= c(theme.base.border)?.lower(0.08)?.css();
|
||||
|
||||
theme.base.text ??= fallback.text;
|
||||
theme.base.textSubtle ??= c(theme.base.text)?.lower(0.3)?.css();
|
||||
theme.base.textSubtlest ??= c(theme.base.text)?.lower(0.5)?.css();
|
||||
|
||||
return theme;
|
||||
}
|
||||
158
apps/yaak-client/lib/theme/yaakColor.ts
Normal file
158
apps/yaak-client/lib/theme/yaakColor.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import parseColor from 'parse-color';
|
||||
|
||||
export class YaakColor {
|
||||
private readonly appearance: 'dark' | 'light' = 'light';
|
||||
|
||||
private hue = 0;
|
||||
private saturation = 0;
|
||||
private lightness = 0;
|
||||
private alpha = 1;
|
||||
|
||||
constructor(cssColor: string, appearance: 'dark' | 'light' = 'light') {
|
||||
try {
|
||||
this.set(cssColor);
|
||||
this.appearance = appearance;
|
||||
} catch (err) {
|
||||
console.log('Failed to parse CSS color', cssColor, err);
|
||||
}
|
||||
}
|
||||
|
||||
static transparent(): YaakColor {
|
||||
return new YaakColor('rgb(0,0,0)', 'light').translucify(1);
|
||||
}
|
||||
|
||||
static white(): YaakColor {
|
||||
return new YaakColor('rgb(0,0,0)', 'light').lower(1);
|
||||
}
|
||||
|
||||
static black(): YaakColor {
|
||||
return new YaakColor('rgb(0,0,0)', 'light').lift(1);
|
||||
}
|
||||
|
||||
set(cssColor: string): YaakColor {
|
||||
let fixedCssColor = cssColor;
|
||||
if (cssColor.startsWith('#') && cssColor.length === 9) {
|
||||
const [r, g, b, a] = hexToRgba(cssColor);
|
||||
fixedCssColor = `rgba(${r},${g},${b},${a})`;
|
||||
}
|
||||
const { hsla } = parseColor(fixedCssColor);
|
||||
this.hue = hsla[0];
|
||||
this.saturation = hsla[1];
|
||||
this.lightness = hsla[2];
|
||||
this.alpha = hsla[3] ?? 1;
|
||||
return this;
|
||||
}
|
||||
|
||||
clone(): YaakColor {
|
||||
return new YaakColor(this.css(), this.appearance);
|
||||
}
|
||||
|
||||
lower(mod: number): YaakColor {
|
||||
return this.appearance === 'dark' ? this._darken(mod) : this._lighten(mod);
|
||||
}
|
||||
|
||||
lift(mod: number): YaakColor {
|
||||
return this.appearance === 'dark' ? this._lighten(mod) : this._darken(mod);
|
||||
}
|
||||
|
||||
minLightness(n: number): YaakColor {
|
||||
const c = this.clone();
|
||||
if (c.lightness < n) {
|
||||
c.lightness = n;
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
isDark(): boolean {
|
||||
return this.lightness < 50;
|
||||
}
|
||||
|
||||
translucify(mod: number): YaakColor {
|
||||
const c = this.clone();
|
||||
c.alpha = c.alpha - c.alpha * mod;
|
||||
return c;
|
||||
}
|
||||
|
||||
opacify(mod: number): YaakColor {
|
||||
const c = this.clone();
|
||||
c.alpha = this.alpha + (100 - this.alpha) * mod;
|
||||
return c;
|
||||
}
|
||||
|
||||
desaturate(mod: number): YaakColor {
|
||||
const c = this.clone();
|
||||
c.saturation = c.saturation - c.saturation * mod;
|
||||
return c;
|
||||
}
|
||||
|
||||
saturate(mod: number): YaakColor {
|
||||
const c = this.clone();
|
||||
c.saturation = this.saturation + (100 - this.saturation) * mod;
|
||||
return c;
|
||||
}
|
||||
|
||||
lighterThan(c: YaakColor): boolean {
|
||||
return this.lightness > c.lightness;
|
||||
}
|
||||
|
||||
css(): string {
|
||||
const h = this.hue;
|
||||
const s = this.saturation;
|
||||
const l = this.lightness;
|
||||
const a = this.alpha;
|
||||
|
||||
const [r, g, b] = parseColor(`hsl(${h},${s}%,${l}%)`).rgb;
|
||||
return rgbaToHex(r, g, b, a);
|
||||
}
|
||||
|
||||
hexNoAlpha(): string {
|
||||
const h = this.hue;
|
||||
const s = this.saturation;
|
||||
const l = this.lightness;
|
||||
|
||||
const [r, g, b] = parseColor(`hsl(${h},${s}%,${l}%)`).rgb;
|
||||
return rgbaToHexNoAlpha(r, g, b);
|
||||
}
|
||||
|
||||
private _lighten(mod: number): YaakColor {
|
||||
const c = this.clone();
|
||||
c.lightness = this.lightness + (100 - this.lightness) * mod;
|
||||
return c;
|
||||
}
|
||||
|
||||
private _darken(mod: number): YaakColor {
|
||||
const c = this.clone();
|
||||
c.lightness = this.lightness - this.lightness * mod;
|
||||
return c;
|
||||
}
|
||||
}
|
||||
|
||||
function rgbaToHex(r: number, g: number, b: number, a: number): string {
|
||||
const toHex = (n: number): string => {
|
||||
const hex = Number(Math.round(n)).toString(16);
|
||||
return hex.length === 1 ? `0${hex}` : hex;
|
||||
};
|
||||
return `#${[toHex(r), toHex(g), toHex(b), toHex(a * 255)].join('').toUpperCase()}`;
|
||||
}
|
||||
|
||||
function rgbaToHexNoAlpha(r: number, g: number, b: number): string {
|
||||
const toHex = (n: number): string => {
|
||||
const hex = Number(Math.round(n)).toString(16);
|
||||
return hex.length === 1 ? `0${hex}` : hex;
|
||||
};
|
||||
return `#${[toHex(r), toHex(g), toHex(b)].join('').toUpperCase()}`;
|
||||
}
|
||||
|
||||
function hexToRgba(hex: string): [number, number, number, number] {
|
||||
const fromHex = (h: string): number => {
|
||||
if (h === '') return 255;
|
||||
return Number(`0x${h}`);
|
||||
};
|
||||
|
||||
const r = fromHex(hex.slice(1, 3));
|
||||
const g = fromHex(hex.slice(3, 5));
|
||||
const b = fromHex(hex.slice(5, 7));
|
||||
const a = fromHex(hex.slice(7, 9));
|
||||
|
||||
return [r, g, b, a / 255];
|
||||
}
|
||||
68
apps/yaak-client/lib/toast.tsx
Normal file
68
apps/yaak-client/lib/toast.tsx
Normal file
@@ -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>
|
||||
),
|
||||
});
|
||||
}
|
||||
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