Compare commits

...

12 Commits

Author SHA1 Message Date
Gregory Schier 4b1edb0b4f Add request message size setting 2026-06-29 16:29:42 -07:00
startsevdenis c3aecfdc0c fix: increase tonic gRPC max_decoding_message_size to 64MB 2026-06-29 16:06:02 -07:00
Gregory Schier 09adcda2d9 Add plugin metadata generation (#485) 2026-06-29 12:31:49 -07:00
Gregory Schier 18b983bfe5 Add CLI import and export commands (#484) 2026-06-29 11:43:20 -07:00
Gregory Schier 9ffd8d4810 Flush model writes before sending HTTP requests 2026-06-29 10:25:15 -07:00
Gregory Schier 55d0066efd Fix spell correction prompt showing (#483) 2026-06-29 08:54:01 -07:00
Nguyễn Huỳnh Anh Khoa 1de0a5942c fix(manager): remove stale plugins with missing directories (#481) 2026-06-26 22:33:06 -07:00
Gregory Schier fd0ca6d455 Fix bulk env var parsing (#482) 2026-06-26 21:58:38 -07:00
Gregory Schier 84b89e2708 update theme generation logic 2026-06-21 10:37:43 -07:00
Gregory Schier 7db3e9b879 Fix filter field value highlighting 2026-06-20 00:31:42 -07:00
Gregory Schier 8109a28967 Improve sidebar filter suggestions (#477) 2026-06-20 00:10:05 -07:00
Gregory Schier 3de9a1edd4 Persist response filter per request 2026-06-11 09:09:12 -07:00
83 changed files with 2376 additions and 546 deletions
Generated
+2
View File
@@ -10052,6 +10052,7 @@ dependencies = [
"tempfile",
"thiserror 2.0.17",
"tokio",
"yaak-core",
"yaak-crypto",
"yaak-http",
"yaak-models",
@@ -10182,6 +10183,7 @@ dependencies = [
"webbrowser",
"yaak",
"yaak-api",
"yaak-core",
"yaak-crypto",
"yaak-http",
"yaak-models",
+7 -3
View File
@@ -130,7 +130,7 @@ export const CookieDialog = ({ cookieJarId }: Props) => {
return key !== nextCookieKey;
});
patchModel(cookieJar, { cookies: [...nextCookies, nextCookie] });
void patchModel(cookieJar, { cookies: [...nextCookies, nextCookie] });
setSelectedCookieKey(nextCookieKey);
setEditingCookieKey(null);
setDraftCookie(null);
@@ -210,7 +210,7 @@ export const CookieDialog = ({ cookieJarId }: Props) => {
setEditingCookieKey(null);
setDraftCookie(null);
setDraftExpiresInput("");
patchModel(cookieJar, { cookies: [] });
void patchModel(cookieJar, { cookies: [] });
}}
/>
</TableHeaderCell>
@@ -276,7 +276,7 @@ export const CookieDialog = ({ cookieJarId }: Props) => {
setDraftCookie(null);
setDraftExpiresInput("");
}
patchModel(cookieJar, {
void patchModel(cookieJar, {
cookies: cookieJar.cookies.filter(
(c2: Cookie) => cookieKey(c2) !== key,
),
@@ -570,6 +570,8 @@ function CookieTextInput({
return (
<input
autoFocus={autoFocus}
autoCapitalize="off"
autoCorrect="off"
className={cookieInputClassName}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
@@ -585,6 +587,8 @@ function CookieTextInput({
function CookieTextarea({ onChange, value }: { onChange: (value: string) => void; value: string }) {
return (
<textarea
autoCapitalize="off"
autoCorrect="off"
className={classNames(cookieInputClassName, "min-h-[5rem] resize-y")}
onChange={(event) => onChange(event.target.value)}
value={value}
@@ -4,11 +4,12 @@ import type { ReactNode } from "react";
interface Props {
children: ReactNode;
className?: string;
wrapperClassName?: string;
}
export function EmptyStateText({ children, className }: Props) {
export function EmptyStateText({ children, className, wrapperClassName }: Props) {
return (
<div className="w-full h-full pb-2">
<div className={classNames("w-full h-full pb-2", wrapperClassName)}>
<div
className={classNames(
className,
@@ -13,6 +13,7 @@ import {
modelSupportsSetting,
type RequestSettingDefinition,
SETTING_FOLLOW_REDIRECTS,
SETTING_REQUEST_MESSAGE_SIZE,
SETTING_REQUEST_TIMEOUT,
SETTING_SEND_COOKIES,
SETTING_STORE_COOKIES,
@@ -33,10 +34,29 @@ interface Props {
model: ModelWithSettings;
}
type ModelWithSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
type ModelWithSettings =
| Workspace
| Folder
| HttpRequest
| WebsocketRequest
| GrpcRequest;
type ModelWithHttpSettings = Workspace | Folder | HttpRequest;
type ModelWithTlsSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
type ModelWithCookieSettings = Workspace | Folder | HttpRequest | WebsocketRequest;
type ModelWithTlsSettings =
| Workspace
| Folder
| HttpRequest
| WebsocketRequest
| GrpcRequest;
type ModelWithCookieSettings =
| Workspace
| Folder
| HttpRequest
| WebsocketRequest;
type ModelWithMessageSizeSettings =
| Workspace
| Folder
| WebsocketRequest
| GrpcRequest;
type BooleanSetting = boolean | InheritedBoolSetting;
type IntegerSetting = number | InheritedIntSetting;
type CookieSettingsPatch = {
@@ -50,12 +70,19 @@ type HttpSettingsPatch = {
type TlsSettingsPatch = {
settingValidateCertificates?: ModelWithTlsSettings["settingValidateCertificates"];
};
type MessageSizeSettingsPatch = {
settingRequestMessageSize?: ModelWithMessageSizeSettings["settingRequestMessageSize"];
};
export function ModelSettingsEditor({ model, showSectionTitles = false }: Props) {
export function ModelSettingsEditor({
model,
showSectionTitles = false,
}: Props) {
const ancestors = useModelAncestors(model);
const supportsHttpSettings = modelSupportsHttpSettings(model);
const supportsCookieSettings = modelSupportsCookieSettings(model);
const supportsTlsSettings = modelSupportsTlsSettings(model);
const supportsMessageSizeSettings = modelSupportsMessageSizeSettings(model);
return (
<SettingsList className="space-y-8">
@@ -77,6 +104,22 @@ export function ModelSettingsEditor({ model, showSectionTitles = false }: Props)
}
/>
)}
{supportsMessageSizeSettings && (
<IntegerSettingRow
settingDefinition={SETTING_REQUEST_MESSAGE_SIZE}
setting={model.settingRequestMessageSize}
inheritedValue={resolveInheritedValue(
ancestors,
SETTING_REQUEST_MESSAGE_SIZE.modelKey,
model.settingRequestMessageSize,
)}
onChange={(settingRequestMessageSize) =>
patchMessageSizeSettings(model, {
settingRequestMessageSize,
})
}
/>
)}
<BooleanSettingRow
settingDefinition={SETTING_VALIDATE_CERTIFICATES}
setting={model.settingValidateCertificates}
@@ -110,7 +153,9 @@ export function ModelSettingsEditor({ model, showSectionTitles = false }: Props)
</SettingsSection>
)}
{supportsCookieSettings && (
<SettingsSection title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}>
<SettingsSection
title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}
>
<BooleanSettingRow
settingDefinition={SETTING_SEND_COOKIES}
setting={model.settingSendCookies}
@@ -158,46 +203,93 @@ export function countOverriddenSettings(model: ModelWithSettings) {
settings.push(model.settingFollowRedirects, model.settingRequestTimeout);
}
return settings.filter((setting) => isInheritedSetting(setting) && setting.enabled === true)
.length;
if (modelSupportsMessageSizeSettings(model)) {
settings.push(model.settingRequestMessageSize);
}
return settings.filter(
(setting) => isInheritedSetting(setting) && setting.enabled === true,
).length;
}
function patchCookieSettings(model: ModelWithCookieSettings, patch: Partial<CookieSettingsPatch>) {
if (model.model === "workspace") return patchModel(model, patch as Partial<Workspace>);
if (model.model === "folder") return patchModel(model, patch as Partial<Folder>);
if (model.model === "http_request") return patchModel(model, patch as Partial<HttpRequest>);
function patchCookieSettings(
model: ModelWithCookieSettings,
patch: Partial<CookieSettingsPatch>,
) {
if (model.model === "workspace")
return patchModel(model, patch as Partial<Workspace>);
if (model.model === "folder")
return patchModel(model, patch as Partial<Folder>);
if (model.model === "http_request")
return patchModel(model, patch as Partial<HttpRequest>);
if (model.model === "websocket_request")
return patchModel(model, patch as Partial<WebsocketRequest>);
throw new Error("Unsupported cookie settings model");
}
function patchHttpSettings(model: ModelWithHttpSettings, patch: Partial<HttpSettingsPatch>) {
if (model.model === "workspace") return patchModel(model, patch as Partial<Workspace>);
if (model.model === "folder") return patchModel(model, patch as Partial<Folder>);
function patchHttpSettings(
model: ModelWithHttpSettings,
patch: Partial<HttpSettingsPatch>,
) {
if (model.model === "workspace")
return patchModel(model, patch as Partial<Workspace>);
if (model.model === "folder")
return patchModel(model, patch as Partial<Folder>);
return patchModel(model, patch as Partial<HttpRequest>);
}
function patchTlsSettings(model: ModelWithTlsSettings, patch: Partial<TlsSettingsPatch>) {
if (model.model === "workspace") return patchModel(model, patch as Partial<Workspace>);
if (model.model === "folder") return patchModel(model, patch as Partial<Folder>);
if (model.model === "http_request") return patchModel(model, patch as Partial<HttpRequest>);
function patchTlsSettings(
model: ModelWithTlsSettings,
patch: Partial<TlsSettingsPatch>,
) {
if (model.model === "workspace")
return patchModel(model, patch as Partial<Workspace>);
if (model.model === "folder")
return patchModel(model, patch as Partial<Folder>);
if (model.model === "http_request")
return patchModel(model, patch as Partial<HttpRequest>);
if (model.model === "websocket_request")
return patchModel(model, patch as Partial<WebsocketRequest>);
return patchModel(model, patch as Partial<GrpcRequest>);
}
function modelSupportsHttpSettings(model: ModelWithSettings): model is ModelWithHttpSettings {
function patchMessageSizeSettings(
model: ModelWithMessageSizeSettings,
patch: Partial<MessageSizeSettingsPatch>,
) {
if (model.model === "workspace")
return patchModel(model, patch as Partial<Workspace>);
if (model.model === "folder")
return patchModel(model, patch as Partial<Folder>);
if (model.model === "websocket_request")
return patchModel(model, patch as Partial<WebsocketRequest>);
return patchModel(model, patch as Partial<GrpcRequest>);
}
function modelSupportsHttpSettings(
model: ModelWithSettings,
): model is ModelWithHttpSettings {
return modelSupportsSetting(model, SETTING_REQUEST_TIMEOUT);
}
function modelSupportsCookieSettings(model: ModelWithSettings): model is ModelWithCookieSettings {
function modelSupportsCookieSettings(
model: ModelWithSettings,
): model is ModelWithCookieSettings {
return modelSupportsSetting(model, SETTING_SEND_COOKIES);
}
function modelSupportsTlsSettings(model: ModelWithSettings): model is ModelWithTlsSettings {
function modelSupportsTlsSettings(
model: ModelWithSettings,
): model is ModelWithTlsSettings {
return modelSupportsSetting(model, SETTING_VALIDATE_CERTIFICATES);
}
function modelSupportsMessageSizeSettings(
model: ModelWithSettings,
): model is ModelWithMessageSizeSettings {
return modelSupportsSetting(model, SETTING_REQUEST_MESSAGE_SIZE);
}
function BooleanSettingRow({
inheritedValue,
setting,
@@ -211,7 +303,11 @@ function BooleanSettingRow({
}) {
const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false;
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
const value = inherited
? overridden
? setting.value
: inheritedValue
: setting;
if (!inherited) {
return (
@@ -250,12 +346,18 @@ function IntegerSettingRow({
}: {
inheritedValue: number;
setting: IntegerSetting;
settingDefinition: RequestSettingDefinition<"settingRequestTimeout">;
settingDefinition: RequestSettingDefinition<
"settingRequestTimeout" | "settingRequestMessageSize"
>;
onChange: (setting: IntegerSetting) => void;
}) {
const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false;
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
const value = inherited
? overridden
? setting.value
: inheritedValue
: setting;
if (!inherited) {
return (
@@ -308,7 +410,7 @@ function isInheritedSetting<T>(
function resolveInheritedValue(
ancestors: (Folder | Workspace)[],
key: "settingRequestTimeout",
key: "settingRequestTimeout" | "settingRequestMessageSize",
fallback: IntegerSetting,
): number;
function resolveInheritedValue(
@@ -338,10 +440,14 @@ function resolveInheritedValue(
type WorkspaceSettings = Pick<
Workspace,
| "settingFollowRedirects"
| "settingRequestMessageSize"
| "settingRequestTimeout"
| "settingSendCookies"
| "settingStoreCookies"
| "settingValidateCertificates"
>;
type BooleanWorkspaceSettingKey = Exclude<keyof WorkspaceSettings, "settingRequestTimeout">;
type BooleanWorkspaceSettingKey = Exclude<
keyof WorkspaceSettings,
"settingRequestTimeout" | "settingRequestMessageSize"
>;
+112 -6
View File
@@ -64,7 +64,9 @@ import type { ContextMenuProps, DropdownItem } from "./core/Dropdown";
import { ContextMenu, Dropdown } from "./core/Dropdown";
import type { FieldDef } from "./core/Editor/filter/extension";
import { filter } from "./core/Editor/filter/extension";
import type { Ast } from "./core/Editor/filter/query";
import { evaluate, parseQuery } from "./core/Editor/filter/query";
import { formatFieldFilter } from "./core/Editor/filter/format";
import { HttpMethodTag } from "./core/HttpMethodTag";
import { HttpStatusTag } from "./core/HttpStatusTag";
import {
@@ -79,6 +81,7 @@ import type { TreeNode, TreeHandle, TreeProps, TreeItemProps } from "@yaakapp-in
import { IconButton } from "./core/IconButton";
import type { InputHandle } from "./core/Input";
import { Input } from "./core/Input";
import { EmptyStateText } from "./EmptyStateText";
import { atomWithKVStorage } from "../lib/atoms/atomWithKVStorage";
import { GitDropdown } from "./git/GitDropdown";
import { gitCallbacks } from "./git/callbacks";
@@ -108,7 +111,7 @@ function Sidebar({ className }: { className?: string }) {
const activeWorkspaceId = useAtomValue(activeWorkspaceAtom)?.id;
const treeId = `tree.${activeWorkspaceId ?? "unknown"}`;
const filterText = useAtomValue(sidebarFilterAtom);
const [tree, allFields] = useAtomValue(sidebarTreeAtom) ?? [];
const [tree, allFields, emptyFilterSuggestions] = useAtomValue(sidebarTreeAtom) ?? [];
const wrapperRef = useRef<HTMLElement>(null);
const treeRef = useRef<TreeHandle>(null);
const filterRef = useRef<InputHandle>(null);
@@ -227,7 +230,7 @@ function Sidebar({ className }: { className?: string }) {
);
const clearFilterText = useCallback(() => {
jotaiStore.set(sidebarFilterAtom, { text: "", key: `${Math.random()}` });
setSidebarFilterText("");
requestAnimationFrame(() => {
filterRef.current?.focus();
});
@@ -252,6 +255,13 @@ function Sidebar({ className }: { className?: string }) {
[],
);
const applyFilterExample = useCallback((text: string) => {
setSidebarFilterText(text);
requestAnimationFrame(() => {
filterRef.current?.focus();
});
}, []);
const treeHasFocus = useCallback(() => treeRef.current?.hasFocus() ?? false, []);
const getSelectedTreeModels = useCallback(
@@ -654,8 +664,43 @@ function Sidebar({ className }: { className?: string }) {
)}
</div>
{allHidden ? (
<div className="italic text-text-subtle p-3 text-sm text-center">
No results for <InlineCode>{filterText.text}</InlineCode>
<div className="p-3 text-sm text-center">
{(emptyFilterSuggestions?.length ?? 0) > 0 ? (
<EmptyStateText
wrapperClassName="!h-auto mb-auto"
className="!h-auto py-3 px-3 !text-text-subtle text-sm leading-relaxed text-center"
>
<div>
No results, but found matches for{" "}
{emptyFilterSuggestions?.map((suggestion, i) => (
<span key={suggestion.field}>
{i > 0 && " or "}
<button
type="button"
className="max-w-full rounded align-middle focus-visible:outline focus-visible:outline-2 focus-visible:outline-info"
onClick={() => applyFilterExample(suggestion.filterText)}
>
<InlineCode className="inline-block max-w-36 truncate align-middle whitespace-nowrap transition-colors hover:border-border hover:bg-surface-active hover:text-text">
{suggestion.filterText}
</InlineCode>
</button>
</span>
))}
</div>
</EmptyStateText>
) : (
<EmptyStateText
wrapperClassName="!h-auto mb-auto"
className="!h-auto py-3 px-3 !text-text-subtle text-sm leading-relaxed text-center"
>
<div>
No results for{" "}
<InlineCode className="inline-block max-w-36 truncate align-middle">
{filterText.text}
</InlineCode>
</div>
</EmptyStateText>
)}
</div>
) : (
<Tree
@@ -786,7 +831,48 @@ const sidebarFilterAtom = atom<{ text: string; key: string }>({
key: "",
});
const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get) => {
type SidebarFilterSuggestion = {
field: string;
filterText: string;
};
function setSidebarFilterText(text: string) {
jotaiStore.set(sidebarFilterAtom, { text, key: `${Math.random()}` });
}
function getSidebarSuggestionValue(ast: Ast | null) {
if (ast == null) return null;
if (ast.type === "Term" || ast.type === "Phrase") {
const value = ast.value.trim();
return value.length > 0 ? value : null;
}
if (ast.type === "Field") {
const value = ast.value.trim();
return value.length > 0 ? value : null;
}
return null;
}
function sidebarFieldMatchesValue(fieldValue: string, filterValue: string) {
return fieldValue.toLowerCase().includes(filterValue.toLowerCase());
}
const sidebarSuggestionFieldOrder = [
"url",
"folder",
"method",
"type",
"grpc_service",
"grpc_method",
"name",
];
const sidebarTreeAtom = atom<
[TreeNode<SidebarModel>, FieldDef[], SidebarFilterSuggestion[]] | null
>((get) => {
const allModels = get(memoAllPotentialChildrenAtom);
const activeWorkspace = get(activeWorkspaceAtom);
const filter = get(sidebarFilterAtom);
@@ -807,9 +893,11 @@ const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get)
}
const queryAst = parseQuery(filter.text);
const suggestionValue = getSidebarSuggestionValue(queryAst);
// returns true if this node OR any child matches the filter
const allFields: Record<string, Set<string>> = {};
const suggestionFields = new Set<string>();
const build = (node: TreeNode<SidebarModel>, depth: number): boolean => {
const childItems = childrenMap[node.item.id] ?? [];
let matchesSelf = true;
@@ -821,6 +909,13 @@ const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get)
if (!value) continue;
allFields[field] = allFields[field] ?? new Set();
allFields[field].add(value);
if (
isLeafNode &&
suggestionValue != null &&
sidebarFieldMatchesValue(value, suggestionValue)
) {
suggestionFields.add(field);
}
}
if (queryAst != null) {
@@ -874,7 +969,18 @@ const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get)
values: Array.from(values).filter((v) => v.length < 20),
});
}
return [root, fields] as const;
const suggestions = Array.from(suggestionFields)
.sort((a, b) => {
const aIndex = sidebarSuggestionFieldOrder.indexOf(a);
const bIndex = sidebarSuggestionFieldOrder.indexOf(b);
if (aIndex === -1 && bIndex === -1) return a.localeCompare(b);
return (aIndex === -1 ? Infinity : aIndex) - (bIndex === -1 ? Infinity : bIndex);
})
.map((field) => ({
field,
filterText: formatFieldFilter(field, suggestionValue ?? ""),
}));
return [root, fields, suggestions] as const;
});
const sidebarGitStatusByModelIdAtom = atom<Record<string, GitStatus>>((get) => {
@@ -1,5 +1,5 @@
import { patchModel, workspaceMetasAtom, workspacesAtom } from "@yaakapp-internal/models";
import { Banner, HStack, InlineCode, VStack } from "@yaakapp-internal/ui";
import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useAuthTab } from "../hooks/useAuthTab";
import { useHeadersTab } from "../hooks/useHeadersTab";
@@ -0,0 +1,36 @@
import { describe, expect, test } from "vite-plus/test";
import { parseBulkPairLine } from "./BulkPairEditor";
describe("parseBulkPairLine", () => {
test("parses colon-space pairs as name and value", () => {
expect(parseBulkPairLine("foo: bar")).toMatchObject({
enabled: true,
name: "foo",
value: "bar",
});
});
test("preserves colon-without-space lines as a name with an empty value", () => {
expect(parseBulkPairLine("foo:bar")).toMatchObject({
enabled: true,
name: "foo:bar",
value: "",
});
});
test("preserves malformed lines instead of dropping their contents", () => {
expect(parseBulkPairLine("not a pair")).toMatchObject({
enabled: true,
name: "not a pair",
value: "",
});
});
test("unescapes newlines in parsed values", () => {
expect(parseBulkPairLine("foo: bar\\nbaz")).toMatchObject({
enabled: true,
name: "foo",
value: "bar\nbaz",
});
});
});
@@ -17,7 +17,7 @@ export function BulkPairEditor({
const pairsText = useMemo(() => {
return pairs
.filter((p) => !(p.name.trim() === "" && p.value.trim() === ""))
.map(pairToLine)
.map(formatBulkPairLine)
.join("\n");
}, [pairs]);
@@ -26,7 +26,7 @@ export function BulkPairEditor({
const pairs = text
.split("\n")
.filter((l: string) => l.trim())
.map(lineToPair);
.map(parseBulkPairLine);
onChange(pairs);
},
[onChange],
@@ -47,16 +47,16 @@ export function BulkPairEditor({
);
}
function pairToLine(pair: Pair) {
export function formatBulkPairLine(pair: Pair) {
const value = pair.value.replaceAll("\n", "\\n");
return `${pair.name}: ${value}`;
}
function lineToPair(line: string): PairWithId {
const [, name, value] = line.match(/^(:?[^:]+):\s+(.*)$/) ?? [];
export function parseBulkPairLine(line: string): PairWithId {
const [, name, value] = line.match(/^([^:]+):\s+(.*)$/) ?? [];
return {
enabled: true,
name: (name ?? "").trim(),
name: (name ?? line).trim(),
value: (value ?? "").replaceAll("\\n", "\n").trim(),
id: generateId(),
};
@@ -580,6 +580,10 @@ function getExtensions({
return [
...baseExtensions, // Must be first
EditorView.contentAttributes.of({
autocapitalize: "off",
autocorrect: "off",
}),
EditorView.domEventHandlers({
focus: () => {
onFocus.current?.();
@@ -15,8 +15,9 @@ export interface FilterOptions {
fields: FieldDef[] | null; // e.g., ['method','status','path'] or [{name:'tag', values:()=>cachedTags}]
}
const IDENT = /[A-Za-z0-9_/]+$/;
const IDENT_ONLY = /^[A-Za-z0-9_/]+$/;
const FIELD_IDENT = /[A-Za-z0-9_/]+$/;
const VALUE_IDENT = /\S+$/;
const VALUE_IDENT_ONLY = /^\S+$/;
function normalizeFields(fields: FieldDef[]): {
fieldNames: string[];
@@ -31,14 +32,37 @@ function normalizeFields(fields: FieldDef[]): {
return { fieldNames, fieldMap };
}
function wordBefore(doc: string, pos: number): { from: number; to: number; text: string } | null {
function wordBefore(
doc: string,
pos: number,
pattern: RegExp,
): { from: number; to: number; text: string } | null {
const upto = doc.slice(0, pos);
const m = upto.match(IDENT);
const m = upto.match(pattern);
if (!m) return null;
const from = pos - m[0].length;
return { from, to: pos, text: m[0] };
}
function fieldCompletionFrom(doc: string, pos: number): { from: number; includeAt: boolean } | null {
const w = wordBefore(doc, pos, FIELD_IDENT);
const from = w?.from ?? pos;
const beforeToken = doc[from - 1];
if (from === 0 || (beforeToken != null && /\s/.test(beforeToken))) {
return { from, includeAt: true };
}
if (beforeToken === "@") {
const beforeAt = doc[from - 2];
if (from === 1 || (beforeAt != null && /\s/.test(beforeAt))) {
return { from, includeAt: false };
}
}
return null;
}
function inPhrase(ctx: CompletionContext): boolean {
// Lezer node names from your grammar: Phrase is the quoted token
let n: SyntaxNode | null = syntaxTree(ctx.state).resolveInner(ctx.pos, -1);
@@ -81,7 +105,7 @@ function contextInfo(stateDoc: string, pos: number) {
if (inValue) {
// word before the colon = field name
const beforeColon = stateDoc.slice(0, lastColon);
const m = beforeColon.match(IDENT);
const m = beforeColon.match(FIELD_IDENT);
fieldName = m ? m[0] : null;
// nothing (or only spaces) typed after the colon?
@@ -93,15 +117,16 @@ function contextInfo(stateDoc: string, pos: number) {
}
/** Build a completion list for field names */
function fieldNameCompletions(fieldNames: string[]): Completion[] {
function fieldNameCompletions(fieldNames: string[], includeAt: boolean): Completion[] {
return fieldNames.map((name) => ({
label: name,
type: "property",
apply: (view, _completion, from, to) => {
// Insert "name:" (leave cursor right after colon)
// Leave cursor right after the field filter colon.
const insert = `${includeAt ? "@" : ""}${name}:`;
view.dispatch({
changes: { from, to, insert: `${name}:` },
selection: { anchor: from + name.length + 1 },
changes: { from, to, insert },
selection: { anchor: from + insert.length },
});
startCompletion(view);
},
@@ -115,7 +140,7 @@ function fieldValueCompletions(
if (!def || !def.values) return null;
const vals = Array.isArray(def.values) ? def.values : def.values();
return vals.map((v) => ({
label: v.match(IDENT_ONLY) ? v : `"${v}"`,
label: v.match(VALUE_IDENT_ONLY) ? v : `"${v}"`,
displayLabel: v,
type: "constant",
}));
@@ -132,14 +157,13 @@ function makeCompletionSource(opts: FilterOptions) {
return null;
}
const w = wordBefore(doc, pos);
const from = w?.from ?? pos;
const to = pos;
const { inValue, fieldName, emptyAfterColon } = contextInfo(doc, pos);
// In field value position
if (inValue && fieldName) {
const w = wordBefore(doc, pos, VALUE_IDENT);
const from = w?.from ?? pos;
const to = pos;
const valDefs = fieldMap[fieldName];
const vals = fieldValueCompletions(valDefs);
@@ -162,7 +186,11 @@ function makeCompletionSource(opts: FilterOptions) {
}
// Not in a value: suggest field names (and maybe boolean ops)
const options: Completion[] = fieldNameCompletions(fieldNames);
const completion = fieldCompletionFrom(doc, pos);
if (completion == null) return null;
const { from, includeAt } = completion;
const to = pos;
const options: Completion[] = fieldNameCompletions(fieldNames, includeAt);
return { from, to, options, filter: true };
};
@@ -2,10 +2,11 @@
@skip { space+ }
@tokens {
space { std.whitespace+ }
space { $[ \t\r\n]+ }
LParen { "(" }
RParen { ")" }
At { "@" }
Colon { ":" }
Not { "-" | "NOT" }
@@ -16,8 +17,10 @@
// "quoted phrase" with simple escapes: \" and \\
Phrase { '"' (!["\\] | "\\" _)* '"' }
// field/word characters (keep generous for URLs/paths)
Word { $[A-Za-z0-9_]+ }
// Bare words run until filter syntax or whitespace. Leading '-' remains unary
// negation, but '-' may appear after the first character.
Word { ![ \t\r\n():"@-] ![ \t\r\n():"@]* }
FieldValueWord { ![ \t\r\n"] ![ \t\r\n]* }
@precedence { Not, And, Or, Word }
}
@@ -60,12 +63,12 @@ Field {
}
FieldName {
Word
At? Word
}
FieldValue {
Phrase
| Term
| FieldValueWord
}
Term {
@@ -0,0 +1,42 @@
import { describe, expect, test } from "vite-plus/test";
import { parser } from "./filter";
function getNodeNames(input: string): string[] {
const tree = parser.parse(input);
const nodes: string[] = [];
const cursor = tree.cursor();
do {
if (cursor.name !== "Query") {
nodes.push(cursor.name);
}
} while (cursor.next());
return nodes;
}
describe("filter grammar", () => {
test("parses URL-like field values as one value", () => {
const nodes = getNodeNames("@url:yaak.app/foo-bar");
expect(nodes).not.toContain("⚠");
expect(nodes).toContain("FieldValue");
expect(nodes).toContain("FieldValueWord");
});
test("parses punctuation-heavy field values as one value", () => {
const nodes = getNodeNames("@url:yaa$&#*@tsrna(*)");
expect(nodes).not.toContain("⚠");
expect(nodes).toContain("FieldValue");
expect(nodes).toContain("FieldValueWord");
});
test("parses operator-looking field values as one value", () => {
const negativeValueNodes = getNodeNames("@url:-foo");
const operatorWordNodes = getNodeNames("@url:AND");
expect(negativeValueNodes).not.toContain("⚠");
expect(negativeValueNodes).toContain("FieldValueWord");
expect(operatorWordNodes).not.toContain("⚠");
expect(operatorWordNodes).toContain("FieldValueWord");
});
});
@@ -1,27 +1,22 @@
/* oxlint-disable */
// This file was generated by lezer-generator. You probably shouldn't edit it.
import { LRParser } from "@lezer/lr";
import { highlight } from "./highlight";
import {LRParser} from "@lezer/lr"
import {highlight} from "./highlight"
export const parser = LRParser.deserialize({
version: 14,
states:
"%QOVQPOOPeOPOOOVQPO'#CfOjQPO'#ChO!XQPO'#CgOOQO'#Cc'#CcOVQPO'#CaOOQO'#Ca'#CaO!oQPO'#C`O!|QPO'#C_OOQO'#C^'#C^QOQPOOPOOO'#Cp'#CpP#XOPO)C>jO#`QPO,59QO#eQPO,59ROOQO,58{,58{OVQPO'#CqOOQO'#Cq'#CqO#mQPO,58zOVQPO'#CrO#zQPO,58yPOOO-E6n-E6nOOQO1G.l1G.lOOQO'#Cm'#CmOOQO'#Ck'#CkOOQO1G.m1G.mOOQO,59],59]OOQO-E6o-E6oOOQO,59^,59^OOQO-E6p-E6p",
stateData:
"$]~OiPQ~OUUOXQO]RO`TO~Oi[O~OUaXXaX]aX^[X`aXbaXcaXgaXWaX~O^_O~OUUOXQO]RO`TObaO~OcSXgSXWSX~P!^OcdOgRXWRX~Oi[O~Qh]WgO~O]hO`iO~OcSagSaWSa~P!^OcdOgRaWRa~OUbc]c~",
goto: "#hgPPhnryP!YPP!c!c!lPP!uP!xPP#U#[#bQZOR^QTYOQSXOQRmdUWOQdQ`USbWcRka_VOQUWacd_TOQUWacd_SOQUWacdRj_^TOQUWacdRi_Q]PRf]QcWRlcQeXRne",
nodeNames:
"⚠ Query Expr OrExpr AndExpr Unary Not Primary RParen LParen Group Field FieldName Word Colon FieldValue Phrase Term And Or",
maxTerm: 25,
states: "%WOVQPOOPhOPOOOVQPO'#CfOmQPO'#ChO!_QPO'#ChO!dQPO'#CgOOQO'#Cc'#CcOVQPO'#CaOOQO'#Ca'#CaO!iQPO'#C`O!yQPO'#C_OOQO'#C^'#C^QOQPOOPOOO'#Cr'#CrP#UOPO)C>lO#]QPO,59QOOQO,59S,59SO#bQQO,59ROOQO,58{,58{OVQPO'#CsOOQO'#Cs'#CsO#jQPO,58zOVQPO'#CtO#zQPO,58yPOOO-E6p-E6pOOQO1G.l1G.lOOQO'#Cl'#ClOOQO1G.m1G.mOOQO,59_,59_OOQO-E6q-E6qOOQO,59`,59`OOQO-E6r-E6r",
stateData: "$]~OkPQ~OUVOXQO]SO^ROaUO~Ok]O~OUcXXcX]cX^cX_[XacXdcXecXicXWcX~O^`O~O_aO~OdcOeSXiSXWSX~PVOefOiRXWRX~Ok]O~Qj]WiO~OajObjO~OdcOeSaiSaWSa~PVOefOiRaWRa~OUde^e~",
goto: "#^iPPjpt{P![PP!e!e!nPPP!wPP!ePP!z#Q#WQ[OR_QTZOQSYOQRnfUXOQfQbVSdXeRlc_WOQVXcef_UOQVXcef_TOQVXcefRkaQ^PRh^QeXRmeQgYRog",
nodeNames: "⚠ Query Expr OrExpr AndExpr Unary Not Primary RParen LParen Group Field FieldName At Word Colon FieldValue Phrase FieldValueWord Term And Or",
maxTerm: 27,
nodeProps: [
["openedBy", 8, "LParen"],
["closedBy", 9, "RParen"],
["openedBy", 8,"LParen"],
["closedBy", 9,"RParen"]
],
propSources: [highlight],
skippedNodes: [0, 20],
skippedNodes: [0,22],
repeatNodeCount: 3,
tokenData:
")f~RgX^!jpq!jrs#_xy${yz%Q}!O%V!Q![%[![!]%m!c!d%r!d!p%[!p!q'V!q!r(j!r!}%[#R#S%[#T#o%[#y#z!j$f$g!j#BY#BZ!j$IS$I_!j$I|$JO!j$JT$JU!j$KV$KW!j&FU&FV!j~!oYi~X^!jpq!j#y#z!j$f$g!j#BY#BZ!j$IS$I_!j$I|$JO!j$JT$JU!j$KV$KW!j&FU&FV!j~#bVOr#_rs#ws#O#_#O#P#|#P;'S#_;'S;=`$u<%lO#_~#|O`~~$PRO;'S#_;'S;=`$Y;=`O#_~$]WOr#_rs#ws#O#_#O#P#|#P;'S#_;'S;=`$u;=`<%l#_<%lO#_~$xP;=`<%l#_~%QOX~~%VOW~~%[OU~~%aS]~!Q![%[!c!}%[#R#S%[#T#o%[~%rO^~~%wU]~!Q![%[!c!p%[!p!q&Z!q!}%[#R#S%[#T#o%[~&`U]~!Q![%[!c!f%[!f!g&r!g!}%[#R#S%[#T#o%[~&ySb~]~!Q![%[!c!}%[#R#S%[#T#o%[~'[U]~!Q![%[!c!q%[!q!r'n!r!}%[#R#S%[#T#o%[~'sU]~!Q![%[!c!v%[!v!w(V!w!}%[#R#S%[#T#o%[~(^SU~]~!Q![%[!c!}%[#R#S%[#T#o%[~(oU]~!Q![%[!c!t%[!t!u)R!u!}%[#R#S%[#T#o%[~)YSc~]~!Q![%[!c!}%[#R#S%[#T#o%[",
tokenizers: [0],
topRules: { Query: [0, 1] },
tokenPrec: 145,
});
tokenData: "2h~RiOX!pXY$hYZ$hZ]!p]^$h^p!ppq$hqr!prs$ysx!pxy&gyz'Qz}!p}!O'k!O![!p![!](U!]!b!p!b!c(o!c!d)Y!d!p!p!p!q,q!q!r0Y!r;'S!p;'S;=`$b<%lO!pR!w^bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pQ#xUbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sQ$_P;=`<%l#sR$eP;=`<%l!p~$mSk~XY$hYZ$h]^$hpq$h~$|VOr$yrs%cs#O$y#O#P%h#P;'S$y;'S;=`&a<%lO$y~%hOa~~%kRO;'S$y;'S;=`%t;=`O$y~%wWOr$yrs%cs#O$y#O#P%h#P;'S$y;'S;=`&a;=`<%l$y<%lO$y~&dP;=`<%l$yR&nUbQXPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR'XUbQWPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR'rUbQUPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR(]U_PbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR(vU]PbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR)a`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!p!p!p!q*c!q;'S!p;'S;=`$b<%lO!pR*j`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!f!p!f!g+l!g;'S!p;'S;=`$b<%lO!pR+u^bQdP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pR,x`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!q!p!q!r-z!r;'S!p;'S;=`$b<%lO!pR.R`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!v!p!v!w/T!w;'S!p;'S;=`$b<%lO!pR/^^bQUP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pR0a`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!t!p!t!u1c!u;'S!p;'S;=`$b<%lO!pR1l^bQeP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!p",
tokenizers: [0, 1],
topRules: {"Query":[0,1]},
tokenPrec: 145
})
@@ -0,0 +1,43 @@
import { describe, expect, test } from "vite-plus/test";
import { formatFieldFilter } from "./format";
import { evaluate, parseQuery } from "./query";
function matchesFormattedUrl(value: string) {
return evaluate(parseQuery(formatFieldFilter("url", value)), {
fields: { url: value },
});
}
describe("formatFieldFilter", () => {
test("keeps URL-like values bare", () => {
expect(formatFieldFilter("url", "yaak.app/foo-bar")).toBe("@url:yaak.app/foo-bar");
expect(matchesFormattedUrl("yaak.app/foo-bar")).toBe(true);
});
test("keeps non-syntax punctuation bare", () => {
expect(formatFieldFilter("url", "yaa$&#*@tsrna(*)")).toBe("@url:yaa$&#*@tsrna(*)");
expect(matchesFormattedUrl("yaa$&#*@tsrna(*)")).toBe(true);
});
test("keeps values that start with an operator token bare", () => {
expect(formatFieldFilter("url", "-foo")).toBe("@url:-foo");
expect(matchesFormattedUrl("-foo")).toBe(true);
});
test("keeps boolean operator words bare", () => {
expect(formatFieldFilter("url", "AND")).toBe("@url:AND");
expect(formatFieldFilter("url", "or")).toBe("@url:or");
expect(formatFieldFilter("url", "Not")).toBe("@url:Not");
expect(matchesFormattedUrl("AND")).toBe(true);
});
test("escapes quoted values", () => {
expect(formatFieldFilter("url", 'say "hi"')).toBe('@url:"say \\"hi\\""');
expect(matchesFormattedUrl('say "hi"')).toBe(true);
});
test("quotes values that start with a quote", () => {
expect(formatFieldFilter("url", '"hi"')).toBe('@url:"\\"hi\\""');
expect(matchesFormattedUrl('"hi"')).toBe(true);
});
});
@@ -0,0 +1,7 @@
const bareFieldValue = /^[^\s"]\S*$/;
export function formatFieldFilter(field: string, value: string) {
const escapedValue = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
const filterValue = bareFieldValue.test(value) ? value : `"${escapedValue}"`;
return `@${field}:${filterValue}`;
}
@@ -16,6 +16,7 @@ export const highlight = styleTags({
Phrase: t.string, // "quoted string"
// Fields
"FieldName/At": t.attributeName,
"FieldName/Word": t.attributeName,
"FieldValue/Term/Word": t.attributeValue,
"FieldValue/FieldValueWord": t.attributeValue,
});
@@ -30,7 +30,8 @@ type Tok =
| { kind: "EOF" };
const isSpace = (c: string) => /\s/.test(c);
const isIdent = (c: string) => /[A-Za-z0-9_\-./]/.test(c);
const isWordStart = (c: string) => c !== "" && !isSpace(c) && !/[():"@-]/.test(c);
const isWordChar = (c: string) => c !== "" && !isSpace(c) && !/[():"@]/.test(c);
export function tokenize(input: string): Tok[] {
const toks: Tok[] = [];
@@ -42,7 +43,13 @@ export function tokenize(input: string): Tok[] {
const readWord = () => {
let s = "";
while (i < n && isIdent(peek())) s += advance();
while (i < n && isWordChar(peek())) s += advance();
return s;
};
const readFieldValue = () => {
let s = "";
while (i < n && !isSpace(peek())) s += advance();
return s;
};
@@ -85,6 +92,9 @@ export function tokenize(input: string): Tok[] {
if (c === ":") {
toks.push({ kind: "COLON" });
i++;
if (peek() && !isSpace(peek()) && peek() !== `"`) {
toks.push({ kind: "WORD", text: readFieldValue() });
}
continue;
}
if (c === `"`) {
@@ -99,7 +109,7 @@ export function tokenize(input: string): Tok[] {
}
// WORD / AND / OR / NOT
if (isIdent(c)) {
if (isWordStart(c)) {
const w = readWord();
const upper = w.toUpperCase();
if (upper === "AND") toks.push({ kind: "AND" });
@@ -1,7 +1,7 @@
@top pairs { (Key Sep Value "\n")* }
@tokens {
Sep { ":" }
Sep { ":" $[ \t]+ }
Key { ":"? ![:]+ }
Value { ![\n]+ }
}
@@ -0,0 +1,26 @@
import { describe, expect, test } from "vite-plus/test";
import { parser } from "./pairs";
function getNodeNames(input: string): string[] {
const tree = parser.parse(input);
const nodes: string[] = [];
const cursor = tree.cursor();
do {
if (cursor.name !== "pairs") {
nodes.push(cursor.name);
}
} while (cursor.next());
return nodes;
}
describe("pairs grammar", () => {
test("parses colon-space pairs with a value", () => {
expect(getNodeNames("foo: bar\n")).toEqual(["Key", "Sep", "Value"]);
});
test("does not parse colon-without-space as a value", () => {
const nodes = getNodeNames("foo:bar\n");
expect(nodes).not.toContain("Value");
});
});
@@ -12,7 +12,7 @@ export const parser = LRParser.deserialize({
skippedNodes: [0],
repeatNodeCount: 1,
tokenData:
"$]VRVOYhYZ#[Z![h![!]#o!];'Sh;'S;=`#U<%lOhToVQPSSOYhYZ!UZ![h![!]!m!];'Sh;'S;=`#U<%lOhP!ZSQPO![!U!];'S!U;'S;=`!g<%lO!UP!jP;=`<%l!US!rSSSOY!mZ;'S!m;'S;=`#O<%lO!mS#RP;=`<%l!mT#XP;=`<%lhR#cSVQQPO![!U!];'S!U;'S;=`!g<%lO!UV#vVRQSSOYhYZ!UZ![h![!]!m!];'Sh;'S;=`#U<%lOh",
"%]VRVOYhYZ#[Z![h![!]#o!];'Sh;'S;=`#U<%lOhToVQPSSOYhYZ!UZ![h![!]!m!];'Sh;'S;=`#U<%lOhP!ZSQPO![!U!];'S!U;'S;=`!g<%lO!UP!jP;=`<%l!US!rSSSOY!mZ;'S!m;'S;=`#O<%lO!mS#RP;=`<%l!mT#XP;=`<%lhR#cSVQQPO![!U!];'S!U;'S;=`!g<%lO!UV#tYSSOXhXY$dYZ!UZphpq$dq![h![!]!m!];'Sh;'S;=`#U<%lOhV$mYQPRQSSOXhXY$dYZ!UZphpq$dq![h![!]!m!];'Sh;'S;=`#U<%lOh",
tokenizers: [0, 1, 2],
topRules: { pairs: [0, 1] },
tokenPrec: 0,
@@ -55,6 +55,8 @@ export function KeyValueRow({
const textToCopy =
copyText ??
(typeof children === "string" || typeof children === "number" ? `${children}` : null);
const copyTitle =
typeof label === "string" || typeof label === "number" ? `Copy ${label}` : "Copy value";
const resolvedRightSlot =
rightSlot ??
(enableCopy && textToCopy != null ? (
@@ -62,7 +64,7 @@ export function KeyValueRow({
text={textToCopy}
className="text-text-subtle"
size="2xs"
title={`Copy ${label}`}
title={copyTitle}
iconSize="sm"
/>
) : null);
@@ -1,6 +1,6 @@
import { useGitFileDiffForCommit, useGitLog, useGitMutations } from "@yaakapp-internal/git";
import type { GitCommit } from "@yaakapp-internal/git";
import { InlineCode, SplitLayout } from "@yaakapp-internal/ui";
import { SplitLayout } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { formatDistanceToNowStrict } from "date-fns";
import { useCallback, useEffect, useMemo, useState } from "react";
@@ -8,7 +8,7 @@ import type {
WebsocketRequest,
Workspace,
} from "@yaakapp-internal/models";
import { Banner, HStack, Icon, IconButton, InlineCode, SplitLayout } from "@yaakapp-internal/ui";
import { Banner, HStack, Icon, InlineCode, SplitLayout } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useCallback, useMemo, useState } from "react";
import { modelToYaml } from "../../lib/diffYaml";
@@ -69,6 +69,7 @@ function HttpTextViewer({ response, text, language, pretty, className }: HttpTex
text={text}
language={language}
stateKey={`response.body.${response.id}`}
filterStateKey={`response.body.${response.requestId}`}
pretty={pretty}
className={className}
onFilter={filterCallback}
@@ -16,6 +16,7 @@ interface Props {
text: string;
language: EditorProps["language"];
stateKey: string | null;
filterStateKey?: string | null;
pretty?: boolean;
className?: string;
onFilter?: (filter: string) => {
@@ -27,16 +28,25 @@ interface Props {
const useFilterText = createGlobalState<Record<string, string | null>>({});
export function TextViewer({ language, text, stateKey, pretty, className, onFilter }: Props) {
export function TextViewer({
language,
text,
stateKey,
filterStateKey,
pretty,
className,
onFilter,
}: Props) {
const filterKey = filterStateKey ?? stateKey;
const [filterTextMap, setFilterTextMap] = useFilterText();
const filterText = stateKey ? (filterTextMap[stateKey] ?? null) : null;
const filterText = filterKey ? (filterTextMap[filterKey] ?? null) : null;
const debouncedFilterText = useDebouncedValue(filterText);
const setFilterText = useCallback(
(v: string | null) => {
if (!stateKey) return;
setFilterTextMap((m) => ({ ...m, [stateKey]: v }));
if (!filterKey) return;
setFilterTextMap((m) => ({ ...m, [filterKey]: v }));
},
[setFilterTextMap, stateKey],
[filterKey, setFilterTextMap],
);
const isSearching = filterText != null;
@@ -64,7 +74,7 @@ export function TextViewer({ language, text, stateKey, pretty, className, onFilt
nodes.push(
<div key="input" className="w-full !opacity-100">
<Input
key={stateKey ?? "filter"}
key={filterKey ?? "filter"}
validate={!filteredResponse.error}
hideLabel
autoFocus
@@ -76,7 +86,7 @@ export function TextViewer({ language, text, stateKey, pretty, className, onFilt
defaultValue={filterText}
onKeyDown={(e) => e.key === "Escape" && toggleSearch()}
onChange={setFilterText}
stateKey={stateKey ? `filter.${stateKey}` : null}
stateKey={filterKey ? `filter.${filterKey}` : null}
/>
</div>,
);
@@ -97,12 +107,12 @@ export function TextViewer({ language, text, stateKey, pretty, className, onFilt
return nodes;
}, [
canFilter,
filterKey,
filterText,
filteredResponse.error,
filteredResponse.isPending,
isSearching,
language,
stateKey,
setFilterText,
toggleSearch,
]);
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import type { Appearance } from "../lib/theme/appearance";
import { getCSSAppearance, subscribeToPreferredAppearance } from "../lib/theme/appearance";
import type { Appearance } from "@yaakapp-internal/theme";
import { getCSSAppearance, subscribeToPreferredAppearance } from "@yaakapp-internal/theme";
export function usePreferredAppearance() {
const [preferredAppearance, setPreferredAppearance] = useState<Appearance>(getCSSAppearance());
@@ -1,6 +1,6 @@
import { settingsAtom } from "@yaakapp-internal/models";
import { resolveAppearance } from "@yaakapp-internal/theme";
import { useAtomValue } from "jotai";
import { resolveAppearance } from "../lib/theme/appearance";
import { usePreferredAppearance } from "./usePreferredAppearance";
export function useResolvedAppearance() {
+1 -1
View File
@@ -1,7 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import { settingsAtom } from "@yaakapp-internal/models";
import { useAtomValue } from "jotai";
import { getResolvedTheme, getThemes } from "../lib/theme/themes";
import { getResolvedTheme, getThemes } from "../lib/themes";
import { usePluginsKey } from "./usePlugins";
import { usePreferredAppearance } from "./usePreferredAppearance";
+17 -25
View File
@@ -1,40 +1,32 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { getModel } from "@yaakapp-internal/models";
import { flushAllModelWrites } from "@yaakapp-internal/models";
import { invokeCmd } from "../lib/tauri";
import { getActiveCookieJar } from "./useActiveCookieJar";
import { getActiveEnvironment } from "./useActiveEnvironment";
import { createFastMutation, useFastMutation } from "./useFastMutation";
async function sendAnyHttpRequestById(id: string | null): Promise<HttpResponse | null> {
if (id == null) {
return null;
}
await flushAllModelWrites();
return invokeCmd("cmd_send_http_request", {
requestId: id,
environmentId: getActiveEnvironment()?.id,
cookieJarId: getActiveCookieJar()?.id,
});
}
export function useSendAnyHttpRequest() {
return useFastMutation<HttpResponse | null, string, string | null>({
mutationKey: ["send_any_request"],
mutationFn: async (id) => {
const request = getModel("http_request", id ?? "n/a");
if (request == null) {
return null;
}
return invokeCmd("cmd_send_http_request", {
request,
environmentId: getActiveEnvironment()?.id,
cookieJarId: getActiveCookieJar()?.id,
});
},
mutationFn: sendAnyHttpRequestById,
});
}
export const sendAnyHttpRequest = createFastMutation<HttpResponse | null, string, string | null>({
mutationKey: ["send_any_request"],
mutationFn: async (id) => {
const request = getModel("http_request", id ?? "n/a");
if (request == null) {
return null;
}
return invokeCmd("cmd_send_http_request", {
request,
environmentId: getActiveEnvironment()?.id,
cookieJarId: getActiveCookieJar()?.id,
});
},
mutationFn: sendAnyHttpRequestById,
});
@@ -44,6 +44,19 @@ export function initGlobalListeners() {
color: "danger",
timeout: null,
message: `Failed to load plugin "${name}": ${err}`,
action: ({ hide }) => (
<Button
size="xs"
color="danger"
variant="border"
onClick={() => {
hide();
openSettings.mutate("plugins:installed");
}}
>
Manage Plugins
</Button>
),
});
}
});
+24 -4
View File
@@ -5,6 +5,7 @@ type ModelType = AnyModel["model"];
type WorkspaceRequestSettings = Pick<
Workspace,
| "settingFollowRedirects"
| "settingRequestMessageSize"
| "settingRequestTimeout"
| "settingSendCookies"
| "settingStoreCookies"
@@ -17,7 +18,9 @@ type ModelTypeWithSetting<K extends RequestSettingKey> = {
[M in ModelType]: K extends keyof ModelForType<M> ? M : never;
}[ModelType];
export type RequestSettingDefinition<K extends RequestSettingKey = RequestSettingKey> = {
export type RequestSettingDefinition<
K extends RequestSettingKey = RequestSettingKey,
> = {
defaultValue: WorkspaceRequestSettings[K];
description: string;
modelKey: K;
@@ -41,11 +44,26 @@ export const SETTING_REQUEST_TIMEOUT = defineRequestSetting({
title: "Request Timeout",
});
export const SETTING_REQUEST_MESSAGE_SIZE = defineRequestSetting({
defaultValue: 64 * 1024 * 1024,
description:
"Maximum gRPC or WebSocket message size in bytes. Set to 0 to disable.",
modelKey: "settingRequestMessageSize",
models: ["workspace", "folder", "websocket_request", "grpc_request"],
title: "Message Size Limit",
});
export const SETTING_VALIDATE_CERTIFICATES = defineRequestSetting({
defaultValue: true,
description: "When disabled, skip validation of server certificates.",
modelKey: "settingValidateCertificates",
models: ["workspace", "folder", "http_request", "websocket_request", "grpc_request"],
models: [
"workspace",
"folder",
"http_request",
"websocket_request",
"grpc_request",
],
title: "Validate TLS certificates",
});
@@ -59,7 +77,8 @@ export const SETTING_FOLLOW_REDIRECTS = defineRequestSetting({
export const SETTING_SEND_COOKIES = defineRequestSetting({
defaultValue: true,
description: "Attach matching cookies from the active cookie jar to outgoing requests.",
description:
"Attach matching cookies from the active cookie jar to outgoing requests.",
modelKey: "settingSendCookies",
models: ["workspace", "folder", "http_request", "websocket_request"],
title: "Automatically send cookies",
@@ -67,7 +86,8 @@ export const SETTING_SEND_COOKIES = defineRequestSetting({
export const SETTING_STORE_COOKIES = defineRequestSetting({
defaultValue: true,
description: "Save cookies from Set-Cookie response headers to the active cookie jar.",
description:
"Save cookies from Set-Cookie response headers to the active cookie jar.",
modelKey: "settingStoreCookies",
models: ["workspace", "folder", "http_request", "websocket_request"],
title: "Automatically store cookies",
-8
View File
@@ -1,8 +0,0 @@
export type { Appearance } from "@yaakapp-internal/theme";
export {
getCSSAppearance,
getWindowAppearance,
resolveAppearance,
subscribeToPreferredAppearance,
subscribeToWindowAppearanceChange,
} from "@yaakapp-internal/theme";
-9
View File
@@ -1,9 +0,0 @@
export type { YaakColorKey, YaakColors, YaakTheme } from "@yaakapp-internal/theme";
export {
addThemeStylesToDocument,
applyThemeToDocument,
completeTheme,
getThemeCSS,
indent,
setThemeOnDocument,
} from "@yaakapp-internal/theme";
-1
View File
@@ -1 +0,0 @@
export { YaakColor } from "@yaakapp-internal/theme";
@@ -1,10 +1,11 @@
import type { GetThemesResponse } from "@yaakapp-internal/plugins";
import { defaultDarkTheme, defaultLightTheme } from "@yaakapp-internal/theme";
import { invokeCmd } from "../tauri";
import type { Appearance } from "./appearance";
import { resolveAppearance } from "./appearance";
export { defaultDarkTheme, defaultLightTheme } from "@yaakapp-internal/theme";
import {
defaultDarkTheme,
defaultLightTheme,
resolveAppearance,
type Appearance,
} from "@yaakapp-internal/theme";
import { invokeCmd } from "./tauri";
export async function getThemes() {
const themes = (await invokeCmd<GetThemesResponse[]>("cmd_get_themes")).flatMap((t) => t.themes);
+7 -4
View File
@@ -2,11 +2,14 @@ import { listen } from "@tauri-apps/api/event";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { setWindowTheme } from "@yaakapp-internal/mac-window";
import type { ModelPayload } from "@yaakapp-internal/models";
import type { Appearance } from "@yaakapp-internal/theme";
import {
applyThemeToDocument,
getCSSAppearance,
subscribeToPreferredAppearance,
} from "@yaakapp-internal/theme";
import { getSettings } from "./lib/settings";
import type { Appearance } from "./lib/theme/appearance";
import { getCSSAppearance, subscribeToPreferredAppearance } from "./lib/theme/appearance";
import { getResolvedTheme } from "./lib/theme/themes";
import { applyThemeToDocument } from "@yaakapp-internal/theme";
import { getResolvedTheme } from "./lib/themes";
// NOTE: CSS appearance isn't as accurate as getting it async from the window (next step), but we want
// a good appearance guess so we're not waiting too long
+1
View File
@@ -42,6 +42,7 @@ webbrowser = "1"
zip = "4"
yaak = { workspace = true }
yaak-api = { workspace = true }
yaak-core = { workspace = true }
yaak-crypto = { workspace = true }
yaak-http = { workspace = true }
yaak-models = { workspace = true }
+38
View File
@@ -42,6 +42,12 @@ pub enum Commands {
/// Authentication commands
Auth(AuthArgs),
/// Import API data from Yaak, OpenAPI, Postman, Insomnia, Swagger, or cURL
Import(ImportArgs),
/// Export Yaak workspace data
Export(ExportArgs),
/// Plugin development and publishing commands
Plugin(PluginArgs),
@@ -92,6 +98,34 @@ pub struct SendArgs {
pub fail_fast: bool,
}
#[derive(Args)]
pub struct ImportArgs {
/// Path to the file to import
pub file: PathBuf,
/// Existing workspace ID to import into when supported by the importer
#[arg(long = "workspace-id", value_name = "WORKSPACE_ID")]
pub workspace_id: Option<String>,
}
#[derive(Args)]
pub struct ExportArgs {
/// Path to write the Yaak export JSON file
pub file: PathBuf,
/// Workspace IDs to export (defaults to the only workspace when exactly one exists)
#[arg(value_name = "WORKSPACE_ID")]
pub workspace_ids: Vec<String>,
/// Export all workspaces
#[arg(long, conflicts_with = "workspace_ids")]
pub all: bool,
/// Include private environments in the export
#[arg(long)]
pub include_private_environments: bool,
}
#[derive(Args)]
#[command(disable_help_subcommand = true)]
pub struct CookieJarArgs {
@@ -447,6 +481,10 @@ pub enum PluginCommands {
/// Install a plugin from a local directory or from the registry
Install(InstallPluginArgs),
/// Generate plugin metadata for the registry
#[command(hide = true)]
Metadata(PluginPathArg),
/// Publish a Yaak plugin version to the plugin registry
Publish(PluginPathArg),
}
@@ -0,0 +1,176 @@
use crate::cli::{ExportArgs, ImportArgs};
use crate::context::CliContext;
use crate::utils::workspace::resolve_workspace_id;
use std::fs;
use std::io::ErrorKind;
use yaak::export::{self, ExportDataParams};
use yaak::import;
use yaak_core::WorkspaceContext;
use yaak_models::util::BatchUpsertResult;
use yaak_plugins::events::{ImportResources, PluginContext};
type CommandResult<T = ()> = std::result::Result<T, String>;
pub async fn run_import(ctx: &CliContext, args: ImportArgs) -> i32 {
match import(ctx, args).await {
Ok(result) => {
println!("Imported {}", format_counts(&result));
0
}
Err(error) => {
eprintln!("Error: {error}");
1
}
}
}
pub fn run_export(ctx: &CliContext, args: ExportArgs) -> i32 {
match export(ctx, args) {
Ok(count) => {
println!("Exported {count} workspace(s)");
0
}
Err(error) => {
eprintln!("Error: {error}");
1
}
}
}
async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult<BatchUpsertResult> {
if let Some(workspace_id) = args.workspace_id.as_deref() {
ctx.db()
.get_workspace(workspace_id)
.map_err(|e| format!("Failed to get workspace '{workspace_id}': {e}"))?;
}
let file_contents = read_import_file(&args.file)?;
let plugin_context = PluginContext::new(None, args.workspace_id.clone());
let plugin_manager = ctx.plugin_manager();
let import_result = plugin_manager
.import_data(&plugin_context, &file_contents)
.await
.map_err(|e| format!("Failed to import data: {e}"))?;
let resources = import_result.resources;
let workspace_id = args.workspace_id;
if workspace_id.is_none() && resources_need_current_workspace(&resources) {
return Err(
"This import requires a workspace context. Provide --workspace-id <WORKSPACE_ID>."
.to_string(),
);
}
let workspace_context = WorkspaceContext {
workspace_id,
environment_id: None,
cookie_jar_id: None,
request_id: None,
};
let imported = import::import_resources(ctx.query_manager(), workspace_context, resources)
.map_err(|e| format!("Failed to import data: {e}"))?;
Ok(imported)
}
fn export(ctx: &CliContext, args: ExportArgs) -> CommandResult<usize> {
let workspace_ids = resolve_export_workspace_ids(ctx, args.workspace_ids, args.all)?;
let workspace_id_refs: Vec<&str> = workspace_ids.iter().map(String::as_str).collect();
export::export_data(ExportDataParams {
query_manager: ctx.query_manager(),
yaak_version: env!("CARGO_PKG_VERSION"),
export_path: &args.file,
workspace_ids: workspace_id_refs,
include_private_environments: args.include_private_environments,
})
.map_err(|e| format!("Failed to export data: {e}"))?;
Ok(workspace_ids.len())
}
fn resolve_export_workspace_ids(
ctx: &CliContext,
workspace_ids: Vec<String>,
all: bool,
) -> CommandResult<Vec<String>> {
if all {
let workspaces =
ctx.db().list_workspaces().map_err(|e| format!("Failed to list workspaces: {e}"))?;
if workspaces.is_empty() {
return Err("No workspaces found to export".to_string());
}
return Ok(workspaces.into_iter().map(|w| w.id).collect());
}
if workspace_ids.is_empty() {
return resolve_workspace_id(ctx, None, "export").map(|id| vec![id]);
}
for workspace_id in &workspace_ids {
ctx.db()
.get_workspace(workspace_id)
.map_err(|e| format!("Failed to get workspace '{workspace_id}': {e}"))?;
}
Ok(workspace_ids)
}
fn read_import_file(path: &std::path::Path) -> CommandResult<String> {
fs::read_to_string(path).map_err(|err| {
if err.kind() == ErrorKind::InvalidData {
format!(
"Import file must be UTF-8 text; binary files are not supported: {}",
path.display()
)
} else {
format!("Unable to read import file {}: {err}", path.display())
}
})
}
fn resources_need_current_workspace(resources: &ImportResources) -> bool {
resources.workspaces.iter().any(|w| w.id == "CURRENT_WORKSPACE")
|| resources.environments.iter().any(|e| {
e.workspace_id == "CURRENT_WORKSPACE"
|| e.parent_id.as_deref() == Some("CURRENT_WORKSPACE")
})
|| resources.folders.iter().any(|f| {
f.workspace_id == "CURRENT_WORKSPACE"
|| f.folder_id.as_deref() == Some("CURRENT_WORKSPACE")
})
|| resources.http_requests.iter().any(|r| {
r.workspace_id == "CURRENT_WORKSPACE"
|| r.folder_id.as_deref() == Some("CURRENT_WORKSPACE")
})
|| resources.grpc_requests.iter().any(|r| {
r.workspace_id == "CURRENT_WORKSPACE"
|| r.folder_id.as_deref() == Some("CURRENT_WORKSPACE")
})
|| resources.websocket_requests.iter().any(|r| {
r.workspace_id == "CURRENT_WORKSPACE"
|| r.folder_id.as_deref() == Some("CURRENT_WORKSPACE")
})
}
fn format_counts(result: &BatchUpsertResult) -> String {
let names = [
"workspace",
"environment",
"folder",
"HTTP request",
"gRPC request",
"WebSocket request",
];
let counts = [
(result.workspaces.len(), names[0]),
(result.environments.len(), names[1]),
(result.folders.len(), names[2]),
(result.http_requests.len(), names[3]),
(result.grpc_requests.len(), names[4]),
(result.websocket_requests.len(), names[5]),
];
let non_zero: Vec<String> = counts
.into_iter()
.filter(|(count, _)| *count > 0)
.map(|(count, name)| format!("{count} {name}{}", if count == 1 { "" } else { "s" }))
.collect();
if non_zero.is_empty() { "nothing".to_string() } else { non_zero.join(", ") }
}
+1
View File
@@ -2,6 +2,7 @@ pub mod auth;
pub mod cookie_jar;
pub mod environment;
pub mod folder;
pub mod import_export;
pub mod plugin;
pub mod request;
pub mod send;
+184 -2
View File
@@ -13,6 +13,7 @@ use std::collections::HashSet;
use std::fs;
use std::io::{self, IsTerminal, Read, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use tokio::sync::Mutex;
use walkdir::WalkDir;
@@ -27,6 +28,11 @@ use zip::write::SimpleFileOptions;
type CommandResult<T = ()> = std::result::Result<T, String>;
const KEYRING_USER: &str = "yaak";
const METADATA_NODE_BIN: &str = "node";
const PLUGIN_RUNTIME_NODE_VERSION: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../packages/plugin-runtime/.node-version"
));
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Environment {
@@ -103,6 +109,16 @@ pub async fn run_publish(args: PluginPathArg) -> i32 {
}
}
pub async fn run_metadata(args: PluginPathArg) -> i32 {
match metadata(args) {
Ok(()) => 0,
Err(error) => {
ui::error(&error);
1
}
}
}
async fn build(args: PluginPathArg) -> CommandResult {
let plugin_dir = resolve_plugin_dir(args.path)?;
ensure_plugin_build_inputs(&plugin_dir)?;
@@ -112,10 +128,21 @@ async fn build(args: PluginPathArg) -> CommandResult {
for warning in warnings {
ui::warning(&warning);
}
generate_plugin_metadata(&plugin_dir)?;
ui::success(&format!("Built plugin bundle at {}", plugin_dir.join("build/index.js").display()));
Ok(())
}
fn metadata(args: PluginPathArg) -> CommandResult {
let plugin_dir = resolve_plugin_dir(args.path)?;
generate_plugin_metadata(&plugin_dir)?;
ui::success(&format!(
"Generated plugin metadata at {}",
plugin_dir.join("build/metadata.json").display()
));
Ok(())
}
async fn dev(args: PluginPathArg) -> CommandResult {
let plugin_dir = resolve_plugin_dir(args.path)?;
ensure_plugin_build_inputs(&plugin_dir)?;
@@ -153,7 +180,15 @@ async fn dev(args: PluginPathArg) -> CommandResult {
});
ui::info(&format!("Rebuilding plugin {display_path}"));
}
WatcherEvent::Event(BundleEvent::BundleEnd(_)) => {}
WatcherEvent::Event(BundleEvent::BundleEnd(_)) => {
match generate_plugin_metadata(&watch_root) {
Ok(()) => ui::success(&format!(
"Generated plugin metadata at {}",
watch_root.join("build/metadata.json").display()
)),
Err(error) => ui::error(&error),
}
}
WatcherEvent::Event(BundleEvent::Error(event)) => {
if event.error.diagnostics.is_empty() {
ui::error("Plugin build failed");
@@ -228,6 +263,7 @@ async fn publish(args: PluginPathArg) -> CommandResult {
for warning in warnings {
ui::warning(&warning);
}
generate_plugin_metadata(&plugin_dir)?;
ui::info("Archiving plugin");
let archive = create_publish_archive(&plugin_dir)?;
@@ -379,6 +415,79 @@ async fn build_plugin_bundle(plugin_dir: &Path) -> CommandResult<Vec<String>> {
Ok(output.warnings.into_iter().map(|w| w.to_string()).collect())
}
fn generate_plugin_metadata(plugin_dir: &Path) -> CommandResult {
let entry_path = plugin_dir.join("build/index.js");
if !entry_path.is_file() {
return Err("build/index.js does not exist. Run `yaak plugin build` first.".to_string());
}
ensure_metadata_node_version()?;
let metadata_path = plugin_dir.join("build/metadata.json");
let output = Command::new(METADATA_NODE_BIN)
.arg("-e")
.arg(METADATA_SCRIPT)
.arg(entry_path.canonicalize().map_err(|e| {
format!("Failed to resolve plugin entrypoint {}: {e}", entry_path.display())
})?)
.arg(&metadata_path)
.current_dir(plugin_dir)
.output()
.map_err(|e| format!("Failed to run Node.js to generate plugin metadata: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let message = if stderr.is_empty() {
format!("Node.js exited with status {}", output.status)
} else {
stderr
};
return Err(format!("Failed to generate plugin metadata: {message}"));
}
Ok(())
}
fn ensure_metadata_node_version() -> CommandResult {
let minimum_major = PLUGIN_RUNTIME_NODE_VERSION
.trim()
.trim_start_matches('v')
.split('.')
.next()
.and_then(|part| part.parse::<u32>().ok())
.ok_or_else(|| {
format!(
"Invalid plugin runtime Node.js version {:?} in packages/plugin-runtime/.node-version",
PLUGIN_RUNTIME_NODE_VERSION.trim()
)
})?;
let output = Command::new(METADATA_NODE_BIN)
.arg("--version")
.output()
.map_err(|e| format!("Node.js {minimum_major} or newer is required: {e}"))?;
if !output.status.success() {
return Err(format!(
"`{METADATA_NODE_BIN} --version` failed with status {}",
output.status
));
}
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
let major = version
.trim_start_matches('v')
.split('.')
.next()
.and_then(|part| part.parse::<u32>().ok())
.ok_or_else(|| format!("Could not parse Node.js version {version:?}"))?;
if major >= minimum_major {
return Ok(());
}
Err(format!("Node.js {minimum_major} or newer is required. Found {version}."))
}
fn prepare_build_output_dir(plugin_dir: &Path) -> CommandResult {
let build_dir = plugin_dir.join("build");
if build_dir.exists() {
@@ -578,6 +687,11 @@ const TEMPLATE_PACKAGE_JSON: &str = r#"{
}
"#;
const METADATA_SCRIPT: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../packages/plugin-runtime/src/metadata.ts"
));
const TEMPLATE_TSCONFIG: &str = r#"{
"compilerOptions": {
"target": "es2021",
@@ -636,7 +750,8 @@ describe("Example Plugin", () => {
#[cfg(test)]
mod tests {
use super::create_publish_archive;
use super::{create_publish_archive, generate_plugin_metadata};
use serde_json::Value;
use std::collections::HashSet;
use std::fs;
use std::io::Cursor;
@@ -659,6 +774,7 @@ mod tests {
.expect("write src/index.ts");
fs::write(root.join("build/index.js"), "exports.plugin = {};\n")
.expect("write build/index.js");
fs::write(root.join("build/metadata.json"), "{}\n").expect("write build/metadata.json");
fs::write(root.join("ignored/secret.txt"), "do-not-ship").expect("write ignored file");
let archive = create_publish_archive(root).expect("create archive");
@@ -673,8 +789,74 @@ mod tests {
assert!(names.contains("README.md"));
assert!(names.contains("package.json"));
assert!(names.contains("package-lock.json"));
assert!(names.contains("build/metadata.json"));
assert!(names.contains("src/index.ts"));
assert!(names.contains("build/index.js"));
assert!(!names.contains("ignored/secret.txt"));
}
#[test]
fn generate_plugin_metadata_detects_api_types() {
let dir = TempDir::new().expect("temp dir");
let root = dir.path();
fs::create_dir_all(root.join("build")).expect("create build");
fs::write(
root.join("build/index.js"),
r##"
exports.plugin = {
themes: [{
id: "midnight",
label: "Midnight",
dark: true,
base: { surface: "#000000", text: "#ffffff" },
}],
templateFunctions: [{
name: "signature",
description: "Create a signature",
args: [{ type: "text", name: "secret", dynamic() {} }],
onRender() {},
}],
workspaceActions: [{
label: "Sync workspace",
icon: "info",
onSelect() {},
}],
folderActions: [{
label: "Export folder",
icon: "copy",
onSelect() {},
}],
async init() {},
};
"##,
)
.expect("write build/index.js");
generate_plugin_metadata(root).expect("generate metadata");
let contents = fs::read_to_string(root.join("build/metadata.json")).expect("read metadata");
let metadata: Value = serde_json::from_str(&contents).expect("metadata json");
let api_types = metadata["apiTypes"].as_array().expect("apiTypes array");
for expected in [
"folderActions",
"templateFunctions",
"themes",
"workspaceActions",
"lifecycle",
] {
assert!(
api_types.iter().any(|value| value.as_str() == Some(expected)),
"missing api type {expected}: {api_types:?}"
);
}
assert_eq!(metadata["apis"]["themes"]["items"][0]["id"], "midnight");
assert_eq!(metadata["apis"]["workspaceActions"]["items"][0]["label"], "Sync workspace");
assert_eq!(metadata["apis"]["lifecycle"]["items"][0]["name"], "init");
assert!(metadata["apis"]["templateFunctions"]["items"][0]["onRender"].is_null());
assert!(
metadata["apis"]["templateFunctions"]["items"][0]["args"][0]["dynamic"].is_null()
);
}
}
+18
View File
@@ -37,11 +37,29 @@ async fn main() {
let exit_code = match command {
Commands::Auth(args) => commands::auth::run(args).await,
Commands::Import(args) => {
let mut context = CliContext::new(data_dir.clone(), app_id);
let execution_context = CliExecutionContext {
workspace_id: args.workspace_id.clone(),
..CliExecutionContext::default()
};
context.init_plugins(execution_context).await;
let exit_code = commands::import_export::run_import(&context, args).await;
context.shutdown().await;
exit_code
}
Commands::Export(args) => {
let context = CliContext::new(data_dir.clone(), app_id);
let exit_code = commands::import_export::run_export(&context, args);
context.shutdown().await;
exit_code
}
Commands::Plugin(args) => match args.command {
PluginCommands::Build(args) => commands::plugin::run_build(args).await,
PluginCommands::Dev(args) => commands::plugin::run_dev(args).await,
PluginCommands::Generate(args) => commands::plugin::run_generate(args).await,
PluginCommands::Publish(args) => commands::plugin::run_publish(args).await,
PluginCommands::Metadata(args) => commands::plugin::run_metadata(args).await,
PluginCommands::Install(install_args) => {
let mut context = CliContext::new(data_dir.clone(), app_id);
context.init_plugins(CliExecutionContext::default()).await;
@@ -0,0 +1,162 @@
mod common;
use common::{cli_cmd, parse_created_id, query_manager, seed_request};
use predicates::str::contains;
use serde_json::Value;
use tempfile::TempDir;
#[test]
fn export_writes_yaak_workspace_file() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
let export_path = temp_dir.path().join("export.json");
let create_assert =
cli_cmd(data_dir).args(["workspace", "create", "--name", "Export Me"]).assert().success();
let workspace_id = parse_created_id(&create_assert.get_output().stdout, "workspace create");
seed_request(data_dir, &workspace_id, "req_export");
cli_cmd(data_dir)
.args([
"export",
export_path.to_str().expect("export path is utf-8"),
&workspace_id,
])
.assert()
.success()
.stdout(contains("Exported 1 workspace(s)"));
let exported: Value = serde_json::from_str(
&std::fs::read_to_string(export_path).expect("export file should exist"),
)
.expect("export should be JSON");
assert_eq!(exported["yaakSchema"], 4);
assert_eq!(exported["resources"]["workspaces"][0]["id"], workspace_id);
assert_eq!(exported["resources"]["httpRequests"][0]["id"], "req_export");
}
#[test]
fn import_reads_yaak_workspace_file() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
let import_path = temp_dir.path().join("import.json");
std::fs::write(
&import_path,
r#"{
"yaakVersion": "test",
"yaakSchema": 4,
"resources": {
"workspaces": [
{
"model": "workspace",
"id": "wrk_import",
"name": "Imported Workspace"
}
],
"httpRequests": [
{
"model": "http_request",
"id": "req_import",
"workspaceId": "wrk_import",
"name": "Imported Request",
"method": "GET",
"url": "https://example.com"
}
]
}
}"#,
)
.expect("write import fixture");
cli_cmd(data_dir)
.args([
"import",
import_path.to_str().expect("import path is utf-8"),
])
.assert()
.success()
.stdout(contains("Imported 1 workspace, 1 HTTP request"));
let query_manager = query_manager(data_dir);
let db = query_manager.connect();
assert_eq!(
db.get_workspace("wrk_import").expect("workspace imported").name,
"Imported Workspace"
);
assert_eq!(
db.get_http_request("req_import").expect("request imported").url,
"https://example.com"
);
}
fn write_postman_environment_fixture(path: &std::path::Path) {
std::fs::write(
path,
r#"{
"name": "Local",
"_postman_variable_scope": "environment",
"values": [
{
"key": "token",
"value": "abc123",
"enabled": true
}
]
}"#,
)
.expect("write postman environment fixture");
}
#[test]
fn import_postman_environment_requires_workspace_id() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
let import_path = temp_dir.path().join("postman-env.json");
cli_cmd(data_dir).args(["workspace", "create", "--name", "Env Target"]).assert().success();
write_postman_environment_fixture(&import_path);
cli_cmd(data_dir)
.args([
"import",
import_path.to_str().expect("import path is utf-8"),
])
.assert()
.failure()
.stderr(contains("requires a workspace context"))
.stderr(contains("--workspace-id"));
}
#[test]
fn import_postman_environment_uses_workspace_id() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
let import_path = temp_dir.path().join("postman-env.json");
let create_assert =
cli_cmd(data_dir).args(["workspace", "create", "--name", "Env Target"]).assert().success();
let workspace_id = parse_created_id(&create_assert.get_output().stdout, "workspace create");
write_postman_environment_fixture(&import_path);
cli_cmd(data_dir)
.args([
"import",
import_path.to_str().expect("import path is utf-8"),
"--workspace-id",
&workspace_id,
])
.assert()
.success()
.stdout(contains("Imported 1 environment"));
let query_manager = query_manager(data_dir);
let db = query_manager.connect();
let environments =
db.list_environments_ensure_base(&workspace_id).expect("list imported environments");
let imported_environment =
environments.iter().find(|e| e.name == "Local").expect("postman environment imported");
assert_eq!(imported_environment.workspace_id, workspace_id);
}
@@ -38,6 +38,9 @@ pub enum Error {
#[error(transparent)]
ApiError(#[from] yaak_api::Error),
#[error(transparent)]
YaakError(#[from] yaak::Error),
#[error(transparent)]
ClipboardError(#[from] tauri_plugin_clipboard_manager::Error),
+13 -106
View File
@@ -1,16 +1,12 @@
use crate::PluginContextExt;
use crate::error::{Error, Result};
use crate::models_ext::QueryManagerExt;
use log::info;
use std::collections::BTreeMap;
use std::fs::read_to_string;
use std::io::ErrorKind;
use tauri::{Manager, Runtime, WebviewWindow};
use yaak::import::{self, ImportDataParams};
use yaak_core::WorkspaceContext;
use yaak_models::models::{
Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace,
};
use yaak_models::util::{BatchUpsertResult, UpdateSource, maybe_gen_id, maybe_gen_id_opt};
use yaak_models::util::BatchUpsertResult;
use yaak_plugins::manager::PluginManager;
use yaak_tauri_utils::window::WorkspaceWindowTrait;
@@ -19,113 +15,24 @@ pub(crate) async fn import_data<R: Runtime>(
file_path: &str,
) -> Result<BatchUpsertResult> {
let plugin_manager = window.state::<PluginManager>();
let query_manager = window.db_manager();
let file = read_import_file(file_path)?;
let file_contents = file.as_str();
let import_result = plugin_manager.import_data(&window.plugin_context(), file_contents).await?;
let mut id_map: BTreeMap<String, String> = BTreeMap::new();
// Create WorkspaceContext from window
let ctx = WorkspaceContext {
let plugin_context = window.plugin_context();
let workspace_context = WorkspaceContext {
workspace_id: window.workspace_id(),
environment_id: window.environment_id(),
cookie_jar_id: window.cookie_jar_id(),
request_id: None,
};
let resources = import_result.resources;
let workspaces: Vec<Workspace> = resources
.workspaces
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<Workspace>(&ctx, v.id.as_str(), &mut id_map);
v
})
.collect();
let environments: Vec<Environment> = resources
.environments
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<Environment>(&ctx, v.id.as_str(), &mut id_map);
v.workspace_id = maybe_gen_id::<Workspace>(&ctx, v.workspace_id.as_str(), &mut id_map);
match (v.parent_model.as_str(), v.parent_id.clone().as_deref()) {
("folder", Some(parent_id)) => {
v.parent_id = Some(maybe_gen_id::<Folder>(&ctx, &parent_id, &mut id_map));
}
("", _) => {
// Fix any empty ones
v.parent_model = "workspace".to_string();
}
_ => {
// Parent ID only required for the folder case
v.parent_id = None;
}
};
v
})
.collect();
let folders: Vec<Folder> = resources
.folders
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<Folder>(&ctx, v.id.as_str(), &mut id_map);
v.workspace_id = maybe_gen_id::<Workspace>(&ctx, v.workspace_id.as_str(), &mut id_map);
v.folder_id = maybe_gen_id_opt::<Folder>(&ctx, v.folder_id, &mut id_map);
v
})
.collect();
let http_requests: Vec<HttpRequest> = resources
.http_requests
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<HttpRequest>(&ctx, v.id.as_str(), &mut id_map);
v.workspace_id = maybe_gen_id::<Workspace>(&ctx, v.workspace_id.as_str(), &mut id_map);
v.folder_id = maybe_gen_id_opt::<Folder>(&ctx, v.folder_id, &mut id_map);
v
})
.collect();
let grpc_requests: Vec<GrpcRequest> = resources
.grpc_requests
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<GrpcRequest>(&ctx, v.id.as_str(), &mut id_map);
v.workspace_id = maybe_gen_id::<Workspace>(&ctx, v.workspace_id.as_str(), &mut id_map);
v.folder_id = maybe_gen_id_opt::<Folder>(&ctx, v.folder_id, &mut id_map);
v
})
.collect();
let websocket_requests: Vec<WebsocketRequest> = resources
.websocket_requests
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<WebsocketRequest>(&ctx, v.id.as_str(), &mut id_map);
v.workspace_id = maybe_gen_id::<Workspace>(&ctx, v.workspace_id.as_str(), &mut id_map);
v.folder_id = maybe_gen_id_opt::<Folder>(&ctx, v.folder_id, &mut id_map);
v
})
.collect();
info!("Importing data");
let upserted = window.with_tx(|tx| {
tx.batch_upsert(
workspaces,
environments,
folders,
http_requests,
grpc_requests,
websocket_requests,
&UpdateSource::Import,
)
})?;
Ok(upserted)
Ok(import::import_data(ImportDataParams {
query_manager: &query_manager,
plugin_manager: &plugin_manager,
plugin_context: &plugin_context,
workspace_context,
contents: &file,
})
.await?)
}
fn read_import_file(file_path: &str) -> Result<String> {
+48 -30
View File
@@ -14,8 +14,7 @@ use error::Result as YaakResult;
use eventsource_client::{EventParser, SSE};
use log::{debug, error, info, warn};
use std::collections::HashMap;
use std::fs::File;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::Arc;
use std::time::Duration;
@@ -31,6 +30,7 @@ use tauri_plugin_window_state::{AppHandleExt, StateFlags};
use tokio::sync::Mutex;
use tokio::task::block_in_place;
use tokio::time;
use yaak::export::{self, ExportDataParams};
use yaak_common::command::new_checked_command;
use yaak_crypto::manager::EncryptionManager;
use yaak_grpc::manager::{GrpcConfig, GrpcHandle};
@@ -41,7 +41,7 @@ use yaak_models::models::{
GrpcEventType, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState, Workspace,
WorkspaceMeta,
};
use yaak_models::util::{BatchUpsertResult, UpdateSource, get_workspace_export_resources};
use yaak_models::util::{BatchUpsertResult, UpdateSource};
use yaak_plugins::events::{
CallFolderActionArgs, CallFolderActionRequest, CallGrpcRequestActionArgs,
CallGrpcRequestActionRequest, CallHttpRequestActionArgs, CallHttpRequestActionRequest,
@@ -54,7 +54,7 @@ use yaak_plugins::events::{
InternalEventPayload, JsonPrimitive, PluginContext, RenderPurpose, ShowToastRequest,
};
use yaak_plugins::manager::PluginManager;
use yaak_plugins::plugin_meta::PluginMetadata;
use yaak_plugins::plugin_meta::{PluginMetadata, get_plugin_meta};
use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_sse::sse::ServerSentEvent;
use yaak_tauri_utils::window::WorkspaceWindowTrait;
@@ -295,7 +295,8 @@ async fn cmd_grpc_reflect<R: Runtime>(
unrendered_request.folder_id.as_deref(),
environment_id,
)?;
let resolved_settings = app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?;
let resolved_settings =
app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?;
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
@@ -332,6 +333,7 @@ async fn cmd_grpc_reflect<R: Runtime>(
&metadata,
resolved_settings.validate_certificates.value,
client_certificate,
resolved_settings.request_message_size.value,
)
.await
.map_err(|e| GenericError(e.to_string()))?)
@@ -353,7 +355,8 @@ async fn cmd_grpc_go<R: Runtime>(
unrendered_request.folder_id.as_deref(),
environment_id,
)?;
let resolved_settings = app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?;
let resolved_settings =
app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?;
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
@@ -425,6 +428,7 @@ async fn cmd_grpc_go<R: Runtime>(
&metadata,
resolved_settings.validate_certificates.value,
client_cert.clone(),
resolved_settings.request_message_size.value,
)
.await;
@@ -1384,24 +1388,14 @@ async fn cmd_export_data<R: Runtime>(
workspace_ids: Vec<&str>,
include_private_environments: bool,
) -> YaakResult<()> {
let db = app_handle.db();
let version = app_handle.package_info().version.to_string();
let export_data =
get_workspace_export_resources(&db, &version, workspace_ids, include_private_environments)?;
let f = File::options()
.create(true)
.truncate(true)
.write(true)
.open(export_path)
.expect("Unable to create file");
serde_json::to_writer_pretty(&f, &export_data)
.map_err(|e| GenericError(e.to_string()))
.expect("Failed to write");
f.sync_all().expect("Failed to sync");
Ok(())
Ok(export::export_data(ExportDataParams {
query_manager: &app_handle.db_manager(),
yaak_version: &version,
export_path: Path::new(export_path),
workspace_ids,
include_private_environments,
})?)
}
#[tauri::command]
@@ -1425,11 +1419,10 @@ async fn cmd_send_http_request<R: Runtime>(
window: WebviewWindow<R>,
environment_id: Option<&str>,
cookie_jar_id: Option<&str>,
// NOTE: We receive the entire request because to account for the race
// condition where the user may have just edited a field before sending
// that has not yet been saved in the DB.
request: HttpRequest,
request_id: String,
) -> YaakResult<HttpResponse> {
let request = app_handle.db().get_http_request(&request_id)?;
let blobs = app_handle.blob_manager();
let response = app_handle.db().upsert_http_response(
&HttpResponse {
@@ -1512,11 +1505,36 @@ async fn cmd_plugin_info<R: Runtime>(
plugin_manager: State<'_, PluginManager>,
) -> YaakResult<PluginMetadata> {
let plugin = app_handle.db().get_plugin(id)?;
Ok(plugin_manager
if let Some(plugin_handle) = plugin_manager
.get_plugin_by_dir(plugin.directory.as_str())
.await
.ok_or(GenericError("Failed to find plugin for info".to_string()))?
.info())
{
return Ok(plugin_handle.info());
}
if let Ok(metadata) = get_plugin_meta(&PathBuf::from(&plugin.directory)) {
return Ok(metadata);
}
Ok(fallback_plugin_metadata(&plugin.directory))
}
fn fallback_plugin_metadata(directory: &str) -> PluginMetadata {
let display_name = PathBuf::from(directory)
.file_name()
.and_then(|name| name.to_str())
.filter(|name| !name.is_empty())
.unwrap_or(directory)
.to_string();
PluginMetadata {
version: "Unavailable".to_string(),
name: directory.to_string(),
display_name,
description: Some(format!("Plugin metadata could not be loaded from {directory}")),
homepage_url: None,
repository_url: None,
}
}
#[tauri::command]
@@ -299,6 +299,7 @@ pub async fn cmd_ws_connect<R: Runtime>(
receive_tx,
resolved_settings.validate_certificates.value,
client_cert,
resolved_settings.request_message_size.value,
)
.await
{
+4
View File
@@ -46,6 +46,7 @@ export type Folder = {
settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
settingRequestMessageSize: InheritedIntSetting;
};
export type GrpcRequest = {
@@ -69,6 +70,7 @@ export type GrpcRequest = {
*/
url: string;
settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
};
export type HttpRequest = {
@@ -146,6 +148,7 @@ export type WebsocketRequest = {
settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
};
export type Workspace = {
@@ -162,6 +165,7 @@ export type Workspace = {
settingValidateCertificates: boolean;
settingFollowRedirects: boolean;
settingRequestTimeout: number;
settingRequestMessageSize: number;
settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: boolean;
settingStoreCookies: boolean;
+11 -5
View File
@@ -33,15 +33,21 @@ impl AutoReflectionClient {
uri: &Uri,
validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>,
max_message_size: usize,
) -> Result<Self> {
let client_v1 = v1::server_reflection_client::ServerReflectionClient::with_origin(
get_transport(validate_certificates, client_cert.clone())?,
uri.clone(),
);
let client_v1alpha = v1alpha::server_reflection_client::ServerReflectionClient::with_origin(
get_transport(validate_certificates, client_cert.clone())?,
uri.clone(),
);
)
.max_decoding_message_size(max_message_size)
.max_encoding_message_size(max_message_size);
let client_v1alpha =
v1alpha::server_reflection_client::ServerReflectionClient::with_origin(
get_transport(validate_certificates, client_cert.clone())?,
uri.clone(),
)
.max_decoding_message_size(max_message_size)
.max_encoding_message_size(max_message_size);
Ok(AutoReflectionClient { use_v1alpha: false, client_v1, client_v1alpha })
}
+82 -14
View File
@@ -39,6 +39,7 @@ pub struct GrpcConnection {
conn: Client<HttpsConnector<HttpConnector>, BoxBody>,
pub uri: Uri,
use_reflection: bool,
max_message_size: usize,
}
#[derive(Default, Debug)]
@@ -97,8 +98,15 @@ impl GrpcConnection {
client_cert: Option<ClientCertificateConfig>,
) -> Result<Response<DynamicMessage>> {
if self.use_reflection {
reflect_types_for_message(self.pool.clone(), &self.uri, message, metadata, client_cert)
.await?;
reflect_types_for_message(
self.pool.clone(),
&self.uri,
message,
metadata,
client_cert,
self.max_message_size,
)
.await?;
}
let method = &self.method(&service, &method).await?;
let input_message = method.input();
@@ -107,7 +115,7 @@ impl GrpcConnection {
let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)?;
deserializer.end()?;
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone());
let mut client = grpc_client(self.conn.clone(), self.uri.clone(), self.max_message_size);
let mut req = req_message.into_request();
decorate_req(metadata, &mut req)?;
@@ -132,6 +140,7 @@ impl GrpcConnection {
message,
metadata,
client_cert,
self.max_message_size,
)
.await?;
@@ -171,6 +180,7 @@ impl GrpcConnection {
let md = metadata.clone();
let use_reflection = self.use_reflection.clone();
let client_cert = client_cert.clone();
let max_message_size = self.max_message_size;
stream
.then(move |json| {
let pool = pool.clone();
@@ -183,8 +193,15 @@ impl GrpcConnection {
let json_clone = json.clone();
async move {
if use_reflection {
if let Err(e) =
reflect_types_for_message(pool, &uri, &json, &md, client_cert).await
if let Err(e) = reflect_types_for_message(
pool,
&uri,
&json,
&md,
client_cert,
max_message_size,
)
.await
{
warn!("Failed to resolve Any types: {e}");
}
@@ -206,7 +223,7 @@ impl GrpcConnection {
.filter_map(|x| x)
};
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone());
let mut client = grpc_client(self.conn.clone(), self.uri.clone(), self.max_message_size);
let path = method_desc_to_path(method);
let codec = DynamicCodec::new(method.clone());
@@ -237,6 +254,7 @@ impl GrpcConnection {
let md = metadata.clone();
let use_reflection = self.use_reflection.clone();
let client_cert = client_cert.clone();
let max_message_size = self.max_message_size;
stream
.then(move |json| {
let pool = pool.clone();
@@ -249,8 +267,15 @@ impl GrpcConnection {
let json_clone = json.clone();
async move {
if use_reflection {
if let Err(e) =
reflect_types_for_message(pool, &uri, &json, &md, client_cert).await
if let Err(e) = reflect_types_for_message(
pool,
&uri,
&json,
&md,
client_cert,
max_message_size,
)
.await
{
warn!("Failed to resolve Any types: {e}");
}
@@ -272,7 +297,7 @@ impl GrpcConnection {
.filter_map(|x| x)
};
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone());
let mut client = grpc_client(self.conn.clone(), self.uri.clone(), self.max_message_size);
let path = method_desc_to_path(method);
let codec = DynamicCodec::new(method.clone());
@@ -300,7 +325,7 @@ impl GrpcConnection {
let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)?;
deserializer.end()?;
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone());
let mut client = grpc_client(self.conn.clone(), self.uri.clone(), self.max_message_size);
let mut req = req_message.into_request();
decorate_req(metadata, &mut req)?;
@@ -312,6 +337,23 @@ impl GrpcConnection {
}
}
fn grpc_client(
conn: Client<HttpsConnector<HttpConnector>, BoxBody>,
uri: Uri,
max_message_size: usize,
) -> tonic::client::Grpc<Client<HttpsConnector<HttpConnector>, BoxBody>> {
tonic::client::Grpc::with_origin(conn, uri)
.max_decoding_message_size(max_message_size)
.max_encoding_message_size(max_message_size)
}
fn message_size_limit(setting: i32) -> usize {
match setting.try_into() {
Ok(0) | Err(_) => usize::MAX,
Ok(limit) => limit,
}
}
/// Configuration for GrpcHandle to compile proto files
#[derive(Clone)]
pub struct GrpcConfig {
@@ -348,6 +390,7 @@ impl GrpcHandle {
metadata: &BTreeMap<String, String>,
validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>,
request_message_size: i32,
) -> Result<bool> {
let server_reflection = proto_files.is_empty();
let key = make_pool_key(id, uri, proto_files);
@@ -359,7 +402,14 @@ impl GrpcHandle {
let pool = if server_reflection {
let full_uri = uri_from_str(uri)?;
fill_pool_from_reflection(&full_uri, metadata, validate_certificates, client_cert).await
fill_pool_from_reflection(
&full_uri,
metadata,
validate_certificates,
client_cert,
message_size_limit(request_message_size),
)
.await
} else {
fill_pool_from_files(&self.config, proto_files).await
}?;
@@ -376,12 +426,21 @@ impl GrpcHandle {
metadata: &BTreeMap<String, String>,
validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>,
request_message_size: i32,
) -> Result<Vec<ServiceDefinition>> {
// Ensure we have a pool; reflect only if missing
if self.get_pool(id, uri, proto_files).is_none() {
info!("Reflecting gRPC services for {} at {}", id, uri);
self.reflect(id, uri, proto_files, metadata, validate_certificates, client_cert)
.await?;
self.reflect(
id,
uri,
proto_files,
metadata,
validate_certificates,
client_cert,
request_message_size,
)
.await?;
}
let pool = self
@@ -421,8 +480,10 @@ impl GrpcHandle {
metadata: &BTreeMap<String, String>,
validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>,
request_message_size: i32,
) -> Result<GrpcConnection> {
let use_reflection = proto_files.is_empty();
let max_message_size = message_size_limit(request_message_size);
if self.get_pool(id, uri, proto_files).is_none() {
self.reflect(
id,
@@ -431,6 +492,7 @@ impl GrpcHandle {
metadata,
validate_certificates,
client_cert.clone(),
request_message_size,
)
.await?;
}
@@ -440,7 +502,13 @@ impl GrpcHandle {
.clone();
let uri = uri_from_str(uri)?;
let conn = get_transport(validate_certificates, client_cert.clone())?;
Ok(GrpcConnection { pool: Arc::new(RwLock::new(pool)), use_reflection, conn, uri })
Ok(GrpcConnection {
pool: Arc::new(RwLock::new(pool)),
use_reflection,
conn,
uri,
max_message_size,
})
}
fn get_pool(&self, id: &str, uri: &str, proto_files: &Vec<PathBuf>) -> Option<&DescriptorPool> {
+7 -3
View File
@@ -119,9 +119,11 @@ pub async fn fill_pool_from_reflection(
metadata: &BTreeMap<String, String>,
validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>,
max_message_size: usize,
) -> Result<DescriptorPool> {
let mut pool = DescriptorPool::new();
let mut client = AutoReflectionClient::new(uri, validate_certificates, client_cert)?;
let mut client =
AutoReflectionClient::new(uri, validate_certificates, client_cert, max_message_size)?;
for service in list_services(&mut client, metadata).await? {
if service == "grpc.reflection.v1alpha.ServerReflection" {
@@ -192,6 +194,7 @@ pub(crate) async fn reflect_types_for_message(
json: &str,
metadata: &BTreeMap<String, String>,
client_cert: Option<ClientCertificateConfig>,
max_message_size: usize,
) -> Result<()> {
// 1. Collect all Any types in the JSON
let mut extra_types = Vec::new();
@@ -201,7 +204,7 @@ pub(crate) async fn reflect_types_for_message(
return Ok(()); // nothing to do
}
let mut client = AutoReflectionClient::new(uri, false, client_cert)?;
let mut client = AutoReflectionClient::new(uri, false, client_cert, max_message_size)?;
for extra_type in extra_types {
{
let guard = pool.read().await;
@@ -239,6 +242,7 @@ pub(crate) async fn reflect_types_for_dynamic_message(
message: &DynamicMessage,
metadata: &BTreeMap<String, String>,
client_cert: Option<ClientCertificateConfig>,
max_message_size: usize,
) -> Result<()> {
let mut extra_types = HashSet::new();
collect_any_types_from_dynamic_message(message, &mut extra_types);
@@ -247,7 +251,7 @@ pub(crate) async fn reflect_types_for_dynamic_message(
return Ok(());
}
let mut client = AutoReflectionClient::new(uri, false, client_cert)?;
let mut client = AutoReflectionClient::new(uri, false, client_cert, max_message_size)?;
for extra_type in extra_types {
{
let guard = pool.read().await;
+4
View File
@@ -109,6 +109,7 @@ export type Folder = {
settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
settingRequestMessageSize: InheritedIntSetting;
};
export type GraphQlIntrospection = {
@@ -184,6 +185,7 @@ export type GrpcRequest = {
*/
url: string;
settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
};
export type HttpRequest = {
@@ -482,6 +484,7 @@ export type WebsocketRequest = {
settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
};
export type Workspace = {
@@ -498,6 +501,7 @@ export type Workspace = {
settingValidateCertificates: boolean;
settingFollowRedirects: boolean;
settingRequestTimeout: number;
settingRequestMessageSize: number;
settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: boolean;
settingStoreCookies: boolean;
+24 -5
View File
@@ -8,6 +8,8 @@ import { newStoreData } from "./util";
let _store: JotaiStore | null = null;
const pendingModelWrites = new Set<Promise<unknown>>();
export function initModelStore(store: JotaiStore) {
_store = store;
@@ -42,6 +44,23 @@ function mustStore(): JotaiStore {
return _store;
}
function trackModelWrite<T>(write: Promise<T>): Promise<T> {
const tracked = write.finally(() => {
pendingModelWrites.delete(tracked);
});
pendingModelWrites.add(tracked);
return tracked;
}
export async function flushAllModelWrites(): Promise<void> {
const results = await Promise.allSettled([...pendingModelWrites]);
const rejected = results.find((result) => result.status === "rejected");
if (rejected?.status === "rejected") {
throw rejected.reason;
}
}
let _activeWorkspaceId: string | null = null;
export async function changeModelStoreWorkspace(workspaceId: string | null) {
@@ -117,7 +136,7 @@ export async function patchModel<M extends AnyModel["model"], T extends ExtractM
export async function updateModel<M extends AnyModel["model"], T extends ExtractModel<AnyModel, M>>(
model: T,
): Promise<string> {
return invoke<string>("models_upsert", { model });
return trackModelWrite(invoke<string>("models_upsert", { model }));
}
export async function deleteModelById<
@@ -134,7 +153,7 @@ export async function deleteModel<M extends AnyModel["model"], T extends Extract
if (model == null) {
throw new Error("Failed to delete null model");
}
await invoke<string>("models_delete", { model });
await trackModelWrite(invoke<string>("models_delete", { model }));
}
export function duplicateModel<M extends AnyModel["model"], T extends ExtractModel<AnyModel, M>>(
@@ -174,19 +193,19 @@ export function duplicateModel<M extends AnyModel["model"], T extends ExtractMod
}
}
return invoke<string>("models_duplicate", { model: { ...model, name } });
return trackModelWrite(invoke<string>("models_duplicate", { model: { ...model, name } }));
}
export async function createGlobalModel<T extends Exclude<AnyModel, { workspaceId: string }>>(
patch: Partial<T> & Pick<T, "model">,
): Promise<string> {
return invoke<string>("models_upsert", { model: patch });
return trackModelWrite(invoke<string>("models_upsert", { model: patch }));
}
export async function createWorkspaceModel<T extends Extract<AnyModel, { workspaceId: string }>>(
patch: Partial<T> & Pick<T, "model" | "workspaceId">,
): Promise<string> {
return invoke<string>("models_upsert", { model: patch });
return trackModelWrite(invoke<string>("models_upsert", { model: patch }));
}
export function replaceModelsInStore<
@@ -0,0 +1,7 @@
ALTER TABLE workspaces ADD COLUMN setting_request_message_size INTEGER DEFAULT 67108864 NOT NULL;
ALTER TABLE folders ADD COLUMN setting_request_message_size TEXT DEFAULT '{"enabled":false,"value":67108864}' NOT NULL;
ALTER TABLE websocket_requests ADD COLUMN setting_request_message_size TEXT DEFAULT '{"enabled":false,"value":67108864}' NOT NULL;
ALTER TABLE grpc_requests ADD COLUMN setting_request_message_size TEXT DEFAULT '{"enabled":false,"value":67108864}' NOT NULL;
+47 -1
View File
@@ -21,6 +21,8 @@ use ts_rs::TS;
use yaak_database::{Result as DbResult, UpdateSource};
pub use yaak_database::{UpsertModelInfo, upsert_date};
pub const DEFAULT_REQUEST_MESSAGE_SIZE: i32 = 64 * 1024 * 1024;
#[macro_export]
macro_rules! impl_model {
($t:ty, $variant:ident) => {
@@ -120,6 +122,7 @@ pub struct ResolvedHttpRequestSettings {
pub validate_certificates: ResolvedSetting<bool>,
pub follow_redirects: ResolvedSetting<bool>,
pub request_timeout: ResolvedSetting<i32>,
pub request_message_size: ResolvedSetting<i32>,
pub send_cookies: ResolvedSetting<bool>,
pub store_cookies: ResolvedSetting<bool>,
}
@@ -130,6 +133,7 @@ impl Default for ResolvedHttpRequestSettings {
validate_certificates: ResolvedSetting::default_source(true),
follow_redirects: ResolvedSetting::default_source(true),
request_timeout: ResolvedSetting::default_source(0),
request_message_size: ResolvedSetting::default_source(DEFAULT_REQUEST_MESSAGE_SIZE),
send_cookies: ResolvedSetting::default_source(true),
store_cookies: ResolvedSetting::default_source(true),
}
@@ -400,6 +404,8 @@ pub struct Workspace {
#[serde(default = "default_true")]
pub setting_follow_redirects: bool,
pub setting_request_timeout: i32,
#[serde(default = "default_request_message_size")]
pub setting_request_message_size: i32,
#[serde(default)]
pub setting_dns_overrides: Vec<DnsOverride>,
#[serde(default = "default_true")]
@@ -445,6 +451,7 @@ impl UpsertModelInfo for Workspace {
(EncryptionKeyChallenge, self.encryption_key_challenge.into()),
(SettingFollowRedirects, self.setting_follow_redirects.into()),
(SettingRequestTimeout, self.setting_request_timeout.into()),
(SettingRequestMessageSize, self.setting_request_message_size.into()),
(SettingValidateCertificates, self.setting_validate_certificates.into()),
(SettingDnsOverrides, serde_json::to_string(&self.setting_dns_overrides)?.into()),
(SettingSendCookies, self.setting_send_cookies.into()),
@@ -463,7 +470,7 @@ impl UpsertModelInfo for Workspace {
WorkspaceIden::EncryptionKeyChallenge,
WorkspaceIden::SettingRequestTimeout,
WorkspaceIden::SettingFollowRedirects,
WorkspaceIden::SettingRequestTimeout,
WorkspaceIden::SettingRequestMessageSize,
WorkspaceIden::SettingValidateCertificates,
WorkspaceIden::SettingDnsOverrides,
WorkspaceIden::SettingSendCookies,
@@ -491,6 +498,7 @@ impl UpsertModelInfo for Workspace {
authentication_type: row.get("authentication_type")?,
setting_follow_redirects: row.get("setting_follow_redirects")?,
setting_request_timeout: row.get("setting_request_timeout")?,
setting_request_message_size: row.get("setting_request_message_size")?,
setting_validate_certificates: row.get("setting_validate_certificates")?,
setting_dns_overrides: serde_json::from_str(&setting_dns_overrides).unwrap_or_default(),
setting_send_cookies: row.get("setting_send_cookies")?,
@@ -962,6 +970,8 @@ pub struct Folder {
pub setting_validate_certificates: InheritedBoolSetting,
pub setting_follow_redirects: InheritedBoolSetting,
pub setting_request_timeout: InheritedIntSetting,
#[serde(default = "default_request_message_size_setting")]
pub setting_request_message_size: InheritedIntSetting,
}
impl UpsertModelInfo for Folder {
@@ -1009,6 +1019,10 @@ impl UpsertModelInfo for Folder {
),
(SettingFollowRedirects, serde_json::to_string(&self.setting_follow_redirects)?.into()),
(SettingRequestTimeout, serde_json::to_string(&self.setting_request_timeout)?.into()),
(
SettingRequestMessageSize,
serde_json::to_string(&self.setting_request_message_size)?.into(),
),
])
}
@@ -1027,6 +1041,7 @@ impl UpsertModelInfo for Folder {
FolderIden::SettingValidateCertificates,
FolderIden::SettingFollowRedirects,
FolderIden::SettingRequestTimeout,
FolderIden::SettingRequestMessageSize,
]
}
@@ -1041,6 +1056,7 @@ impl UpsertModelInfo for Folder {
let setting_validate_certificates: String = row.get("setting_validate_certificates")?;
let setting_follow_redirects: String = row.get("setting_follow_redirects")?;
let setting_request_timeout: String = row.get("setting_request_timeout")?;
let setting_request_message_size: String = row.get("setting_request_message_size")?;
Ok(Self {
id: row.get("id")?,
model: row.get("model")?,
@@ -1062,6 +1078,8 @@ impl UpsertModelInfo for Folder {
.unwrap_or_default(),
setting_request_timeout: serde_json::from_str(&setting_request_timeout)
.unwrap_or_default(),
setting_request_message_size: serde_json::from_str(&setting_request_message_size)
.unwrap_or_else(|_| default_request_message_size_setting()),
})
}
}
@@ -1398,6 +1416,8 @@ pub struct WebsocketRequest {
pub setting_send_cookies: InheritedBoolSetting,
pub setting_store_cookies: InheritedBoolSetting,
pub setting_validate_certificates: InheritedBoolSetting,
#[serde(default = "default_request_message_size_setting")]
pub setting_request_message_size: InheritedIntSetting,
}
impl UpsertModelInfo for WebsocketRequest {
@@ -1446,6 +1466,10 @@ impl UpsertModelInfo for WebsocketRequest {
SettingValidateCertificates,
serde_json::to_string(&self.setting_validate_certificates)?.into(),
),
(
SettingRequestMessageSize,
serde_json::to_string(&self.setting_request_message_size)?.into(),
),
])
}
@@ -1466,6 +1490,7 @@ impl UpsertModelInfo for WebsocketRequest {
WebsocketRequestIden::SettingSendCookies,
WebsocketRequestIden::SettingStoreCookies,
WebsocketRequestIden::SettingValidateCertificates,
WebsocketRequestIden::SettingRequestMessageSize,
]
}
@@ -1479,6 +1504,7 @@ impl UpsertModelInfo for WebsocketRequest {
let setting_send_cookies: String = row.get("setting_send_cookies")?;
let setting_store_cookies: String = row.get("setting_store_cookies")?;
let setting_validate_certificates: String = row.get("setting_validate_certificates")?;
let setting_request_message_size: String = row.get("setting_request_message_size")?;
Ok(Self {
id: row.get("id")?,
model: row.get("model")?,
@@ -1499,6 +1525,8 @@ impl UpsertModelInfo for WebsocketRequest {
setting_store_cookies: serde_json::from_str(&setting_store_cookies).unwrap_or_default(),
setting_validate_certificates: serde_json::from_str(&setting_validate_certificates)
.unwrap_or_default(),
setting_request_message_size: serde_json::from_str(&setting_request_message_size)
.unwrap_or_else(|_| default_request_message_size_setting()),
})
}
}
@@ -2039,6 +2067,8 @@ pub struct GrpcRequest {
/// Server URL (http for plaintext or https for secure)
pub url: String,
pub setting_validate_certificates: InheritedBoolSetting,
#[serde(default = "default_request_message_size_setting")]
pub setting_request_message_size: InheritedIntSetting,
}
impl UpsertModelInfo for GrpcRequest {
@@ -2086,6 +2116,10 @@ impl UpsertModelInfo for GrpcRequest {
SettingValidateCertificates,
serde_json::to_string(&self.setting_validate_certificates)?.into(),
),
(
SettingRequestMessageSize,
serde_json::to_string(&self.setting_request_message_size)?.into(),
),
])
}
@@ -2105,6 +2139,7 @@ impl UpsertModelInfo for GrpcRequest {
GrpcRequestIden::Authentication,
GrpcRequestIden::Metadata,
GrpcRequestIden::SettingValidateCertificates,
GrpcRequestIden::SettingRequestMessageSize,
]
}
@@ -2115,6 +2150,7 @@ impl UpsertModelInfo for GrpcRequest {
let authentication: String = row.get("authentication")?;
let metadata: String = row.get("metadata")?;
let setting_validate_certificates: String = row.get("setting_validate_certificates")?;
let setting_request_message_size: String = row.get("setting_request_message_size")?;
Ok(Self {
id: row.get("id")?,
model: row.get("model")?,
@@ -2134,6 +2170,8 @@ impl UpsertModelInfo for GrpcRequest {
metadata: serde_json::from_str(metadata.as_str()).unwrap_or_default(),
setting_validate_certificates: serde_json::from_str(&setting_validate_certificates)
.unwrap_or_default(),
setting_request_message_size: serde_json::from_str(&setting_request_message_size)
.unwrap_or_else(|_| default_request_message_size_setting()),
})
}
}
@@ -2684,6 +2722,14 @@ fn default_true() -> bool {
true
}
fn default_request_message_size() -> i32 {
DEFAULT_REQUEST_MESSAGE_SIZE
}
fn default_request_message_size_setting() -> InheritedIntSetting {
InheritedIntSetting { enabled: false, value: DEFAULT_REQUEST_MESSAGE_SIZE }
}
fn default_http_method() -> String {
"GET".to_string()
}
@@ -180,6 +180,14 @@ impl<'a> ClientDb<'a> {
} else {
parent.request_timeout
},
request_message_size: if folder.setting_request_message_size.enabled {
ResolvedSetting::from_model(
folder.setting_request_message_size.value,
AnyModel::Folder(folder.clone()),
)
} else {
parent.request_message_size
},
send_cookies: if folder.setting_send_cookies.enabled {
ResolvedSetting::from_model(
folder.setting_send_cookies.value,
@@ -129,6 +129,14 @@ impl<'a> ClientDb<'a> {
} else {
parent.validate_certificates
},
request_message_size: if grpc_request.setting_request_message_size.enabled {
ResolvedSetting::from_model(
grpc_request.setting_request_message_size.value,
AnyModel::GrpcRequest(grpc_request.clone()),
)
} else {
parent.request_message_size
},
..parent
})
}
@@ -131,6 +131,7 @@ impl<'a> ClientDb<'a> {
} else {
parent.request_timeout
},
request_message_size: parent.request_message_size,
send_cookies: if http_request.setting_send_cookies.enabled {
ResolvedSetting::from_model(
http_request.setting_send_cookies.value,
@@ -139,6 +139,14 @@ impl<'a> ClientDb<'a> {
} else {
parent.validate_certificates
},
request_message_size: if websocket_request.setting_request_message_size.enabled {
ResolvedSetting::from_model(
websocket_request.setting_request_message_size.value,
AnyModel::WebsocketRequest(websocket_request.clone()),
)
} else {
parent.request_message_size
},
send_cookies: if websocket_request.setting_send_cookies.enabled {
ResolvedSetting::from_model(
websocket_request.setting_send_cookies.value,
@@ -21,6 +21,7 @@ impl<'a> ClientDb<'a> {
&Workspace {
name: "Yaak".to_string(),
setting_follow_redirects: true,
setting_request_message_size: crate::models::DEFAULT_REQUEST_MESSAGE_SIZE,
setting_validate_certificates: true,
..Default::default()
},
@@ -102,6 +103,10 @@ impl<'a> ClientDb<'a> {
workspace.setting_request_timeout,
AnyModel::Workspace(workspace.clone()),
),
request_message_size: ResolvedSetting::from_model(
workspace.setting_request_message_size,
AnyModel::Workspace(workspace.clone()),
),
send_cookies: ResolvedSetting::from_model(
workspace.setting_send_cookies,
AnyModel::Workspace(workspace.clone()),
+4
View File
@@ -108,6 +108,7 @@ export type Folder = {
settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
settingRequestMessageSize: InheritedIntSetting;
};
export type GraphQlIntrospection = {
@@ -183,6 +184,7 @@ export type GrpcRequest = {
*/
url: string;
settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
};
export type HttpRequest = {
@@ -450,6 +452,7 @@ export type WebsocketRequest = {
settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
};
export type Workspace = {
@@ -466,6 +469,7 @@ export type Workspace = {
settingValidateCertificates: boolean;
settingFollowRedirects: boolean;
settingRequestTimeout: number;
settingRequestMessageSize: number;
settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: boolean;
settingStoreCookies: boolean;
+9 -2
View File
@@ -1,6 +1,6 @@
use crate::api::{PluginVersion, download_plugin_archive, get_plugin};
use crate::checksum::compute_checksum;
use crate::error::Error::PluginErr;
use crate::error::Error::{PluginErr, PluginNotFoundErr};
use crate::error::Result;
use crate::events::PluginContext;
use crate::manager::PluginManager;
@@ -29,7 +29,14 @@ pub async fn delete_and_uninstall(
let db = query_manager.connect();
db.delete_plugin_by_id(plugin_id, &update_source)?
};
plugin_manager.uninstall(plugin_context, plugin.directory.as_str()).await?;
if let Err(err) = plugin_manager
.uninstall(plugin_context, plugin.directory.as_str())
.await
{
if !matches!(err, PluginNotFoundErr(_)) {
return Err(err);
}
}
Ok(plugin)
}
+4
View File
@@ -46,6 +46,7 @@ export type Folder = {
settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
settingRequestMessageSize: InheritedIntSetting;
};
export type GrpcRequest = {
@@ -69,6 +70,7 @@ export type GrpcRequest = {
*/
url: string;
settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
};
export type HttpRequest = {
@@ -159,6 +161,7 @@ export type WebsocketRequest = {
settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
};
export type Workspace = {
@@ -175,6 +178,7 @@ export type Workspace = {
settingValidateCertificates: boolean;
settingFollowRedirects: boolean;
settingRequestTimeout: number;
settingRequestMessageSize: number;
settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: boolean;
settingStoreCookies: boolean;
+11 -1
View File
@@ -20,6 +20,7 @@ pub async fn ws_connect(
headers: HeaderMap<HeaderValue>,
validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>,
request_message_size: i32,
) -> Result<(WebSocketStream<MaybeTlsStream<TcpStream>>, Response)> {
info!("Connecting to WS {url}");
let tls_config = get_tls_config(validate_certificates, WITH_ALPN, client_cert.clone())?;
@@ -34,7 +35,7 @@ pub async fn ws_connect(
let (stream, response) = connect_async_tls_with_config(
req,
Some(WebSocketConfig::default()),
Some(websocket_config(request_message_size)),
false,
Some(Connector::Rustls(Arc::new(tls_config))),
)
@@ -48,3 +49,12 @@ pub async fn ws_connect(
Ok((stream, response))
}
fn websocket_config(request_message_size: i32) -> WebSocketConfig {
let max_message_size = message_size_limit(request_message_size);
WebSocketConfig::default().max_message_size(max_message_size).max_frame_size(max_message_size)
}
pub(crate) fn message_size_limit(setting: i32) -> Option<usize> {
setting.try_into().ok().filter(|limit| *limit > 0)
}
+28 -7
View File
@@ -1,4 +1,5 @@
use crate::connect::ws_connect;
use crate::connect::{message_size_limit, ws_connect};
use crate::error::Error::GenericError;
use crate::error::Result;
use futures_util::stream::SplitSink;
use futures_util::{SinkExt, StreamExt};
@@ -15,10 +16,16 @@ use tokio_tungstenite::tungstenite::http::HeaderValue;
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
use yaak_tls::ClientCertificateConfig;
type WebsocketSink = SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>;
struct WebsocketConnection {
max_message_size: Option<usize>,
sink: WebsocketSink,
}
#[derive(Clone)]
pub struct WebsocketManager {
connections:
Arc<Mutex<HashMap<String, SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>>>>,
connections: Arc<Mutex<HashMap<String, WebsocketConnection>>>,
read_tasks: Arc<Mutex<HashMap<String, tokio::task::JoinHandle<()>>>>,
}
@@ -35,14 +42,20 @@ impl WebsocketManager {
receive_tx: mpsc::Sender<Message>,
validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>,
request_message_size: i32,
) -> Result<Response> {
let tx = receive_tx.clone();
let max_message_size = message_size_limit(request_message_size);
let (stream, response) =
ws_connect(url, headers, validate_certificates, client_cert).await?;
ws_connect(url, headers, validate_certificates, client_cert, request_message_size)
.await?;
let (write, mut read) = stream.split();
self.connections.lock().await.insert(id.to_string(), write);
self.connections
.lock()
.await
.insert(id.to_string(), WebsocketConnection { max_message_size, sink: write });
let handle = {
let connection_id = id.to_string();
@@ -76,7 +89,15 @@ impl WebsocketManager {
None => return Ok(()),
Some(c) => c,
};
connection.send(msg).await?;
if let Some(limit) = connection.max_message_size {
let message_size = msg.len();
if message_size > limit {
return Err(GenericError(format!(
"WebSocket message too large: found {message_size} bytes, the limit is {limit} bytes"
)));
}
}
connection.sink.send(msg).await?;
Ok(())
}
@@ -84,7 +105,7 @@ impl WebsocketManager {
info!("Closing websocket");
if let Some(mut connection) = self.connections.lock().await.remove(id) {
// Wait a maximum of 1 second for the connection to close
if let Err(e) = connection.close().await {
if let Err(e) = connection.sink.close().await {
warn!("Failed to close websocket connection {e:?}");
};
}
+1
View File
@@ -12,6 +12,7 @@ serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["sync", "rt"] }
yaak-http = { workspace = true }
yaak-core = { workspace = true }
yaak-crypto = { workspace = true }
yaak-models = { workspace = true }
yaak-plugins = { workspace = true }
+12
View File
@@ -4,6 +4,18 @@ use thiserror::Error;
pub enum Error {
#[error(transparent)]
Send(#[from] crate::send::SendHttpRequestError),
#[error(transparent)]
Model(#[from] yaak_models::error::Error),
#[error(transparent)]
Plugin(#[from] yaak_plugins::error::Error),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
}
pub type Result<T> = std::result::Result<T, Error>;
+29
View File
@@ -0,0 +1,29 @@
use crate::Result;
use std::fs::File;
use std::path::Path;
use yaak_models::query_manager::QueryManager;
use yaak_models::util::get_workspace_export_resources;
pub struct ExportDataParams<'a> {
pub query_manager: &'a QueryManager,
pub yaak_version: &'a str,
pub export_path: &'a Path,
pub workspace_ids: Vec<&'a str>,
pub include_private_environments: bool,
}
pub fn export_data(params: ExportDataParams<'_>) -> Result<()> {
let db = params.query_manager.connect();
let export_data = get_workspace_export_resources(
&db,
params.yaak_version,
params.workspace_ids,
params.include_private_environments,
)?;
let file = File::options().create(true).truncate(true).write(true).open(params.export_path)?;
serde_json::to_writer_pretty(&file, &export_data)?;
file.sync_all()?;
Ok(())
}
+129
View File
@@ -0,0 +1,129 @@
use crate::Result;
use log::info;
use std::collections::BTreeMap;
use yaak_core::WorkspaceContext;
use yaak_models::models::{
Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace,
};
use yaak_models::query_manager::QueryManager;
use yaak_models::util::{BatchUpsertResult, UpdateSource, maybe_gen_id, maybe_gen_id_opt};
use yaak_plugins::events::{ImportResources, PluginContext};
use yaak_plugins::manager::PluginManager;
pub struct ImportDataParams<'a> {
pub query_manager: &'a QueryManager,
pub plugin_manager: &'a PluginManager,
pub plugin_context: &'a PluginContext,
pub workspace_context: WorkspaceContext,
pub contents: &'a str,
}
pub async fn import_data(params: ImportDataParams<'_>) -> Result<BatchUpsertResult> {
let import_result =
params.plugin_manager.import_data(params.plugin_context, params.contents).await?;
import_resources(params.query_manager, params.workspace_context, import_result.resources)
}
pub fn import_resources(
query_manager: &QueryManager,
workspace_context: WorkspaceContext,
resources: ImportResources,
) -> Result<BatchUpsertResult> {
let mut id_map: BTreeMap<String, String> = BTreeMap::new();
let workspaces: Vec<Workspace> = resources
.workspaces
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<Workspace>(&workspace_context, v.id.as_str(), &mut id_map);
v
})
.collect();
let environments: Vec<Environment> = resources
.environments
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<Environment>(&workspace_context, v.id.as_str(), &mut id_map);
v.workspace_id =
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
match (v.parent_model.as_str(), v.parent_id.clone().as_deref()) {
("folder", Some(parent_id)) => {
v.parent_id =
Some(maybe_gen_id::<Folder>(&workspace_context, parent_id, &mut id_map));
}
("", _) => {
v.parent_model = "workspace".to_string();
}
_ => {
v.parent_id = None;
}
};
v
})
.collect();
let folders: Vec<Folder> = resources
.folders
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<Folder>(&workspace_context, v.id.as_str(), &mut id_map);
v.workspace_id =
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
v.folder_id = maybe_gen_id_opt::<Folder>(&workspace_context, v.folder_id, &mut id_map);
v
})
.collect();
let http_requests: Vec<HttpRequest> = resources
.http_requests
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<HttpRequest>(&workspace_context, v.id.as_str(), &mut id_map);
v.workspace_id =
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
v.folder_id = maybe_gen_id_opt::<Folder>(&workspace_context, v.folder_id, &mut id_map);
v
})
.collect();
let grpc_requests: Vec<GrpcRequest> = resources
.grpc_requests
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<GrpcRequest>(&workspace_context, v.id.as_str(), &mut id_map);
v.workspace_id =
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
v.folder_id = maybe_gen_id_opt::<Folder>(&workspace_context, v.folder_id, &mut id_map);
v
})
.collect();
let websocket_requests: Vec<WebsocketRequest> = resources
.websocket_requests
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<WebsocketRequest>(&workspace_context, v.id.as_str(), &mut id_map);
v.workspace_id =
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
v.folder_id = maybe_gen_id_opt::<Folder>(&workspace_context, v.folder_id, &mut id_map);
v
})
.collect();
info!("Importing data");
query_manager.with_tx(|tx| {
tx.batch_upsert(
workspaces,
environments,
folders,
http_requests,
grpc_requests,
websocket_requests,
&UpdateSource::Import,
)
.map_err(crate::Error::from)
})
}
+2
View File
@@ -1,4 +1,6 @@
pub mod error;
pub mod export;
pub mod import;
pub mod plugin_events;
pub mod render;
pub mod send;
+18
View File
@@ -16970,6 +16970,7 @@
"ws": "^8.20.1"
},
"devDependencies": {
"@types/node": "^24.0.13",
"@types/ws": "^8.5.13"
}
},
@@ -16991,6 +16992,23 @@
"undici-types": "~7.16.0"
}
},
"packages/plugin-runtime/node_modules/@types/node": {
"version": "24.13.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"packages/plugin-runtime/node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
},
"packages/tailwind-config": {
"name": "@yaakapp-internal/tailwind-config",
"version": "1.0.0",
+1
View File
@@ -75,6 +75,7 @@
"start": "npm run client:dev",
"client:build": "node scripts/run-build.mjs client",
"client:dev": "node scripts/run-dev.mjs client",
"client:bundle": "node scripts/run-build.mjs client --config crates-tauri/yaak-app-client/tauri.release.conf.json --no-sign",
"proxy:build": "node scripts/run-build.mjs proxy",
"proxy:dev": "node scripts/run-dev.mjs proxy",
"migration": "node scripts/create-migration.cjs",
+80 -80
View File
@@ -18,12 +18,12 @@ export type CallHttpAuthenticationActionRequest = { index: number, pluginRefId:
export type CallHttpAuthenticationRequest = { contextId: string, values: { [key in string]?: JsonPrimitive }, method: string, url: string, headers: Array<HttpHeader>, };
export type CallHttpAuthenticationResponse = {
export type CallHttpAuthenticationResponse = {
/**
* HTTP headers to add to the request. Existing headers will be replaced, while
* new headers will be added.
*/
setHeaders?: Array<HttpHeader>,
setHeaders?: Array<HttpHeader>,
/**
* Query parameters to add to the request. Existing params will be replaced, while
* new params will be added.
@@ -78,7 +78,7 @@ export type ExportHttpRequestRequest = { httpRequest: HttpRequest, };
export type ExportHttpRequestResponse = { content: string, };
export type FileFilter = { name: string,
export type FileFilter = { name: string,
/**
* File extensions to require
*/
@@ -100,149 +100,149 @@ export type FormInputAccordion = { label: string, inputs?: Array<FormInput>, hid
export type FormInputBanner = { inputs?: Array<FormInput>, hidden?: boolean, color?: Color, };
export type FormInputBase = {
export type FormInputBase = {
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
optional?: boolean,
/**
* The label of the input
*/
label?: string,
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
description?: string, };
export type FormInputCheckbox = {
export type FormInputCheckbox = {
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
optional?: boolean,
/**
* The label of the input
*/
label?: string,
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
description?: string, };
export type FormInputEditor = {
export type FormInputEditor = {
/**
* Placeholder for the text input
*/
placeholder?: string | null,
placeholder?: string | null,
/**
* Don't show the editor gutter (line numbers, folds, etc.)
*/
hideGutter?: boolean,
hideGutter?: boolean,
/**
* Language for syntax highlighting
*/
language?: EditorLanguage, readOnly?: boolean,
language?: EditorLanguage, readOnly?: boolean,
/**
* Fixed number of visible rows
*/
rows?: number, completionOptions?: Array<GenericCompletionOption>,
rows?: number, completionOptions?: Array<GenericCompletionOption>,
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
optional?: boolean,
/**
* The label of the input
*/
label?: string,
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
description?: string, };
export type FormInputFile = {
export type FormInputFile = {
/**
* The title of the file selection window
*/
title: string,
title: string,
/**
* Allow selecting multiple files
*/
multiple?: boolean, directory?: boolean, defaultPath?: string, filters?: Array<FileFilter>,
multiple?: boolean, directory?: boolean, defaultPath?: string, filters?: Array<FileFilter>,
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
optional?: boolean,
/**
* The label of the input
*/
label?: string,
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
@@ -250,63 +250,63 @@ description?: string, };
export type FormInputHStack = { inputs?: Array<FormInput>, hidden?: boolean, };
export type FormInputHttpRequest = {
export type FormInputHttpRequest = {
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
optional?: boolean,
/**
* The label of the input
*/
label?: string,
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
description?: string, };
export type FormInputKeyValue = {
export type FormInputKeyValue = {
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
optional?: boolean,
/**
* The label of the input
*/
label?: string,
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
@@ -314,36 +314,36 @@ description?: string, };
export type FormInputMarkdown = { content: string, hidden?: boolean, };
export type FormInputSelect = {
export type FormInputSelect = {
/**
* The options that will be available in the select input
*/
options: Array<FormInputSelectOption>,
options: Array<FormInputSelectOption>,
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
optional?: boolean,
/**
* The label of the input
*/
label?: string,
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
@@ -351,44 +351,44 @@ description?: string, };
export type FormInputSelectOption = { label: string, value: string, };
export type FormInputText = {
export type FormInputText = {
/**
* Placeholder for the text input
*/
placeholder?: string | null,
placeholder?: string | null,
/**
* Placeholder for the text input
*/
password?: boolean,
password?: boolean,
/**
* Whether to allow newlines in the input, like a <textarea/>
*/
multiLine?: boolean, completionOptions?: Array<GenericCompletionOption>,
multiLine?: boolean, completionOptions?: Array<GenericCompletionOption>,
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
optional?: boolean,
/**
* The label of the input
*/
label?: string,
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
@@ -474,7 +474,7 @@ export type ListOpenWorkspacesResponse = { workspaces: Array<WorkspaceInfo>, };
export type OpenExternalUrlRequest = { url: string, };
export type OpenWindowRequest = { url: string,
export type OpenWindowRequest = { url: string,
/**
* Label for the window. If not provided, a random one will be generated.
*/
@@ -486,15 +486,15 @@ export type PromptFormRequest = { id: string, title: string, description?: strin
export type PromptFormResponse = { values: { [key in string]?: JsonPrimitive } | null, done?: boolean, };
export type PromptTextRequest = { id: string, title: string, label: string, description?: string, defaultValue?: string, placeholder?: string,
export type PromptTextRequest = { id: string, title: string, label: string, description?: string, defaultValue?: string, placeholder?: string,
/**
* Text to add to the confirmation button
*/
confirmText?: string, password?: boolean,
confirmText?: string, password?: boolean,
/**
* Text to add to the cancel button
*/
cancelText?: string,
cancelText?: string,
/**
* Require the user to enter a non-empty value
*/
@@ -524,12 +524,12 @@ export type SetKeyValueResponse = {};
export type ShowToastRequest = { message: string, color?: Color, icon?: Icon, timeout?: number, };
export type TemplateFunction = { name: string, previewType?: TemplateFunctionPreviewType, description?: string,
export type TemplateFunction = { name: string, previewType?: TemplateFunctionPreviewType, description?: string,
/**
* Also support alternative names. This is useful for not breaking existing
* tags when changing the `name` property
*/
aliases?: Array<string>, args: Array<TemplateFunctionArg>,
aliases?: Array<string>, args: Array<TemplateFunctionArg>,
/**
* A list of arg names to show in the inline preview. If not provided, none will be shown (for privacy reasons).
*/
@@ -546,23 +546,23 @@ export type TemplateRenderRequest = { data: JsonValue, purpose: RenderPurpose, }
export type TemplateRenderResponse = { data: JsonValue, };
export type Theme = {
export type Theme = {
/**
* How the theme is identified. This should never be changed
*/
id: string,
id: string,
/**
* The friendly name of the theme to be displayed to the user
*/
label: string,
label: string,
/**
* Whether the theme will be used for dark or light appearance
*/
dark: boolean,
dark: boolean,
/**
* The default top-level colors for the theme
*/
base: ThemeComponentColors,
base: ThemeComponentColors,
/**
* Optionally override theme for individual UI components for more control
*/
@@ -108,6 +108,7 @@ export type Folder = {
settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
settingRequestMessageSize: InheritedIntSetting;
};
export type GraphQlIntrospection = {
@@ -183,6 +184,7 @@ export type GrpcRequest = {
*/
url: string;
settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
};
export type HttpRequest = {
@@ -450,6 +452,7 @@ export type WebsocketRequest = {
settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
};
export type Workspace = {
@@ -466,6 +469,7 @@ export type Workspace = {
settingValidateCertificates: boolean;
settingFollowRedirects: boolean;
settingRequestTimeout: number;
settingRequestMessageSize: number;
settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: boolean;
settingStoreCookies: boolean;
+1
View File
@@ -0,0 +1 @@
24.11.1
+1
View File
@@ -9,6 +9,7 @@
"ws": "^8.20.1"
},
"devDependencies": {
"@types/node": "^24.0.13",
"@types/ws": "^8.5.13"
}
}
+190
View File
@@ -0,0 +1,190 @@
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import type { PluginDefinition } from "@yaakapp/api";
type PluginFeatureKey = Exclude<
Extract<keyof PluginDefinition, string>,
"init" | "dispose"
>;
type PluginAPIKey = PluginFeatureKey | "lifecycle";
type MetadataDefinition = {
key: PluginFeatureKey;
label: string;
array: boolean;
};
type MetadataItem =
| string
| number
| boolean
| null
| MetadataItem[]
| { [key: string]: MetadataItem };
type APITypeMetadata = {
label: string;
source: string;
count: number;
items: MetadataItem[];
};
type PluginMetadata = {
schemaVersion: 1;
apiTypes: PluginAPIKey[];
apis: Partial<Record<PluginAPIKey, APITypeMetadata>>;
};
const definitions: MetadataDefinition[] = [
{
key: "authentication",
label: "Authentication",
array: false,
},
{ key: "filter", label: "Filter", array: false },
{
key: "folderActions",
label: "Folder Action",
array: true,
},
{
key: "grpcRequestActions",
label: "gRPC Request Action",
array: true,
},
{
key: "httpRequestActions",
label: "HTTP Request Action",
array: true,
},
{ key: "importer", label: "Importer", array: false },
{
key: "templateFunctions",
label: "Template Tag",
array: true,
},
{ key: "themes", label: "Theme", array: true },
{
key: "websocketRequestActions",
label: "WebSocket Request Action",
array: true,
},
{
key: "workspaceActions",
label: "Workspace Action",
array: true,
},
];
export function generatePluginMetadata(
plugin: PluginDefinition,
): PluginMetadata {
const metadata: PluginMetadata = {
schemaVersion: 1,
apiTypes: [],
apis: {},
};
for (const definition of definitions) {
const value = plugin[definition.key];
const items = definition.array ? value : value ? [value] : [];
if (!Array.isArray(items) || items.length === 0) {
continue;
}
metadata.apiTypes.push(definition.key);
metadata.apis[definition.key] = {
label: definition.label,
source: definition.key,
count: items.length,
items: sanitize(items) as MetadataItem[],
};
}
const lifecycleHooks = ["init", "dispose"].filter(
(key) =>
typeof plugin[key as keyof Pick<PluginDefinition, "init" | "dispose">] ===
"function",
);
if (lifecycleHooks.length > 0) {
metadata.apiTypes.push("lifecycle");
metadata.apis.lifecycle = {
label: "Lifecycle Hook",
source: lifecycleHooks.join(","),
count: lifecycleHooks.length,
items: lifecycleHooks.map((name) => ({ name })),
};
}
return metadata;
}
const entryPath = process.argv[1];
const outputPath = process.argv[2];
if (!entryPath) {
throw new Error("Missing plugin entrypoint path");
}
if (!outputPath) {
throw new Error("Missing plugin metadata output path");
}
const require = createRequire(path.join(process.cwd(), "plugin-metadata.js"));
const moduleExports = require(path.resolve(entryPath)) as PluginDefinition & {
plugin?: PluginDefinition;
default?: PluginDefinition;
};
const plugin = moduleExports.plugin ?? moduleExports.default ?? moduleExports;
if (!plugin || typeof plugin !== "object") {
throw new Error("Plugin entrypoint must export a plugin object");
}
const metadata = generatePluginMetadata(plugin);
fs.writeFileSync(outputPath, `${JSON.stringify(metadata, null, 2)}\n`);
function sanitize(
value: unknown,
seen = new WeakSet<object>(),
): MetadataItem | undefined {
if (value === null) return null;
switch (typeof value) {
case "boolean":
case "number":
case "string":
return value;
case "bigint":
return value.toString();
case "function":
case "symbol":
case "undefined":
return undefined;
}
const objectValue = value as object;
if (seen.has(objectValue)) {
return "[Circular]";
}
seen.add(objectValue);
if (Array.isArray(value)) {
const output = value.map((item) => sanitize(item, seen) ?? null);
seen.delete(objectValue);
return output;
}
const output: Record<string, MetadataItem> = {};
for (const [key, item] of Object.entries(objectValue)) {
const sanitized = sanitize(item, seen);
if (sanitized !== undefined) {
output[key] = sanitized;
}
}
seen.delete(objectValue);
return output;
}
+2 -2
View File
@@ -53,7 +53,7 @@ export const defaultLightTheme: Theme = {
dark: false,
base: {
surface: "hsl(0,0%,100%)",
surfaceHighlight: "hsl(218,24%,87%)",
surfaceHighlight: "hsl(218,24%,92%)",
text: "hsl(217,24%,10%)",
textSubtle: "hsl(217,24%,40%)",
textSubtlest: "hsl(217,24%,58%)",
@@ -70,7 +70,7 @@ export const defaultLightTheme: Theme = {
sidebar: {
surface: "hsl(220,20%,98%)",
border: "hsl(217,22%,88%)",
surfaceHighlight: "hsl(217,25%,90%)",
surfaceHighlight: "hsl(217,25%,94%)",
},
},
};
+3
View File
@@ -12,6 +12,9 @@ export type { DocumentPlatform, YaakColorKey, YaakColors, YaakTheme } from "./wi
export {
addThemeStylesToDocument,
applyThemeToDocument,
completeColorVariables,
completeFullColorVariables,
completePartialColorVariables,
completeTheme,
getThemeCSS,
indent,
+128 -108
View File
@@ -47,18 +47,10 @@ export type YaakTheme = {
export type YaakColorKey = keyof ThemeComponentColors;
export type DocumentPlatform = "linux" | "macos" | "windows" | "unknown";
type ComponentName = keyof NonNullable<YaakTheme["components"]>;
type ComponentName = keyof NonNullable<Theme["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));
export function completeFullColorVariables(theme: Theme, cmp: Partial<CSSVariables>): CSSVariables {
const color = (value: string | undefined) => yc(theme, value);
const vars: CSSVariables = {
surface: cmp.surface,
@@ -66,12 +58,12 @@ function themeVariables(
surfaceActive: cmp.surfaceActive ?? color(cmp.primary)?.lower(0.2).translucify(0.8).css(),
backdrop: cmp.backdrop ?? color(cmp.surface)?.lower(0.2).translucify(0.2).css(),
selection: cmp.selection ?? color(cmp.primary)?.lower(0.1).translucify(0.7).css(),
border: cmp.border ?? color(cmp.surface)?.lift(0.11)?.css(),
borderSubtle: cmp.borderSubtle ?? color(cmp.border)?.lower(0.06)?.css(),
borderFocus: color(cmp.info)?.translucify(0.5)?.css(),
border: cmp.border,
borderSubtle: cmp.borderSubtle,
borderFocus: cmp.borderFocus ?? color(cmp.info)?.translucify(0.5)?.css(),
text: cmp.text,
textSubtle: cmp.textSubtle ?? color(cmp.text)?.lower(0.2)?.css(),
textSubtlest: cmp.textSubtlest ?? color(cmp.text)?.lower(0.3)?.css(),
textSubtle: cmp.textSubtle,
textSubtlest: cmp.textSubtlest,
shadow:
cmp.shadow ??
YaakColor.black()
@@ -86,95 +78,126 @@ function themeVariables(
danger: cmp.danger,
};
for (const [key, value] of Object.entries(vars)) {
if (!value && base?.[key as YaakColorKey]) {
vars[key as YaakColorKey] = base[key as YaakColorKey];
}
}
const themeColor = (value: string) => new YaakColor(value, theme.dark ? "dark" : "light");
const themeSurface = themeColor(theme.dark ? "oklch(23% 0 0)" : "oklch(100% 0 0)");
const surface = themeColor(vars.surface ?? themeSurface.css());
const reference = surface.compositeOver(themeSurface);
const seed = themeColor(vars.surface ?? vars.surfaceHighlight ?? vars.border ?? surface.css());
const textBase = seed.desaturate(0.6).opacify(1);
const borderBase = seed.opacify(1);
const text = vars.text ?? textBase.withContrast(reference, 11).css();
const textColor = themeColor(text);
return vars;
return normalizeColorVariables(theme, {
...vars,
text,
textSubtle: vars.textSubtle ?? textColor.lower(0.2).css(),
textSubtlest: vars.textSubtlest ?? textColor.lower(0.4).css(),
border: vars.border ?? borderBase.desaturate(0.2).withContrast(reference, 3).css(),
borderSubtle:
vars.borderSubtle ?? borderBase.desaturate(0.2).withContrast(reference, 1.2).css(),
});
}
function templateTagColorVariables(color: YaakColor | null): Partial<CSSVariables> {
if (color == null) return {};
export function completePartialColorVariables(
theme: Theme,
cmp: Partial<CSSVariables>,
): CSSVariables {
const color = (value: string | undefined) => yc(theme, value);
const text = color(cmp.text);
return {
text: color.lift(0.7).css(),
textSubtle: color.lift(0.4).css(),
return normalizeColorVariables(theme, {
surface: cmp.surface,
surfaceHighlight: cmp.surfaceHighlight ?? color(cmp.surface)?.lift(0.06).css(),
surfaceActive: cmp.surfaceActive ?? color(cmp.primary)?.lower(0.2).translucify(0.8).css(),
backdrop: cmp.backdrop ?? color(cmp.surface)?.lower(0.2).translucify(0.2).css(),
selection: cmp.selection ?? color(cmp.primary)?.lower(0.1).translucify(0.7).css(),
border: cmp.border ?? color(cmp.surface)?.lift(0.11).css(),
borderSubtle: cmp.borderSubtle ?? color(cmp.border)?.lower(0.06).css(),
borderFocus: cmp.borderFocus ?? color(cmp.info)?.translucify(0.5).css(),
text: cmp.text,
textSubtle: cmp.textSubtle ?? text?.lower(0.3).css(),
textSubtlest: cmp.textSubtlest ?? text?.lower(0.5).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,
});
}
export const completeColorVariables = completeFullColorVariables;
function normalizeColorVariables(theme: Theme, vars: CSSVariables): CSSVariables {
const normalized: CSSVariables = {} as CSSVariables;
for (const [key, value] of Object.entries(vars)) {
normalized[key as YaakColorKey] = value == null ? undefined : yc(theme, value).css();
}
return normalized;
}
function templateTagColorVariables(theme: Theme, color: YaakColor): CSSVariables {
return completeFullColorVariables(theme, {
text: color.liftMax().lower(0.05).css(),
textSubtle: color.liftMax().lower(0.08).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(),
function toastColorVariables(theme: Theme, color: YaakColor): CSSVariables {
return completeFullColorVariables(theme, {
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(),
function bannerColorVariables(theme: Theme, color: YaakColor): CSSVariables {
return completeFullColorVariables(theme, {
surface: color.translucify(0.95).css(),
surfaceHighlight: color.translucify(0.85).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,
theme: Theme,
color: YaakColor,
isDefault = false,
): Partial<CSSVariables> {
if (color == null) return {};
const theme: Partial<ThemeComponentColors> = {
text: "white",
): CSSVariables {
const vars: Partial<CSSVariables> = {
surface: color.lower(0.3).css(),
surfaceHighlight: color.lower(0.1).css(),
border: color.css(),
};
if (isDefault) {
theme.text = undefined;
theme.surface = undefined;
theme.surfaceHighlight = color.lift(0.08).css();
vars.surface = undefined;
vars.surfaceHighlight = color.lift(0.08).css();
}
return theme;
return completeFullColorVariables(theme, vars);
}
function buttonBorderColorVariables(
color: YaakColor | null,
theme: Theme,
color: YaakColor,
isDefault = false,
): Partial<CSSVariables> {
if (color == null) return {};
): CSSVariables {
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(),
text: color.desaturate(0.4).lift(1).css(),
textSubtle: color.desaturate(0.4).lift(0.55).css(),
surfaceHighlight: color.translucify(0.8).css(),
borderSubtle: color.translucify(0.5).css(),
border: color.translucify(0.3).css(),
@@ -185,7 +208,7 @@ function buttonBorderColorVariables(
vars.border = color.lift(0.5).css();
}
return vars;
return completeFullColorVariables(theme, vars);
}
function variablesToCSS(
@@ -202,9 +225,8 @@ function variablesToCSS(
return selector == null ? css : `${selector} {\n${indent(css)}\n}`;
}
function componentCSS(theme: Theme, component: ComponentName): string | null {
if (theme.components == null) return null;
return variablesToCSS(`.x-theme-${component}`, themeVariables(theme, component));
function componentCSS(component: ComponentName, vars: CSSVariables): string | null {
return variablesToCSS(`.x-theme-${component}`, vars);
}
function buttonCSS(
@@ -216,8 +238,11 @@ function buttonCSS(
if (color == null) return null;
return [
variablesToCSS(`.x-theme-button--solid--${colorKey}`, buttonSolidColorVariables(color)),
variablesToCSS(`.x-theme-button--border--${colorKey}`, buttonBorderColorVariables(color)),
variablesToCSS(`.x-theme-button--solid--${colorKey}`, buttonSolidColorVariables(theme, color)),
variablesToCSS(
`.x-theme-button--border--${colorKey}`,
buttonBorderColorVariables(theme, color),
),
].join("\n\n");
}
@@ -229,7 +254,7 @@ function bannerCSS(
const color = yc(theme, colors?.[colorKey]);
if (color == null) return null;
return variablesToCSS(`.x-theme-banner--${colorKey}`, bannerColorVariables(color));
return variablesToCSS(`.x-theme-banner--${colorKey}`, bannerColorVariables(theme, color));
}
function toastCSS(
@@ -240,7 +265,7 @@ function toastCSS(
const color = yc(theme, colors?.[colorKey]);
if (color == null) return null;
return variablesToCSS(`.x-theme-toast--${colorKey}`, toastColorVariables(color));
return variablesToCSS(`.x-theme-toast--${colorKey}`, toastColorVariables(theme, color));
}
function templateTagCSS(
@@ -251,7 +276,10 @@ function templateTagCSS(
const color = yc(theme, colors?.[colorKey]);
if (color == null) return null;
return variablesToCSS(`.x-theme-templateTag--${colorKey}`, templateTagColorVariables(color));
return variablesToCSS(
`.x-theme-templateTag--${colorKey}`,
templateTagColorVariables(theme, color),
);
}
export function getThemeCSS(theme: Theme): string {
@@ -264,18 +292,26 @@ export function getThemeCSS(theme: Theme): string {
let themeCSS = "";
try {
const baseCss = variablesToCSS(null, themeVariables(theme));
const baseCss = variablesToCSS(null, completeFullColorVariables(theme, theme.base));
const baseSurface = yc(theme, theme.base.surface);
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.entries(components).map(([key, value]) =>
componentCSS(key as ComponentName, completePartialColorVariables(theme, value ?? {})),
),
baseSurface == null
? null
: variablesToCSS(
".x-theme-button--solid--default",
buttonSolidColorVariables(theme, baseSurface, true),
),
baseSurface == null
? null
: variablesToCSS(
".x-theme-button--border--default",
buttonBorderColorVariables(theme, baseSurface, true),
),
...Object.keys(colors).map((key) =>
buttonCSS(theme, key as YaakColorKey, theme.components?.button ?? colors),
),
@@ -360,26 +396,10 @@ function yc<T extends string | null | undefined>(
export function completeTheme(theme: Theme): Theme {
const fallback = theme.dark ? defaultDarkTheme.base : defaultLightTheme.base;
const color = (value: string | null | undefined) => yc(theme, value);
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 ??= color(theme.base.surface)?.lift(0.06)?.css();
theme.base.surfaceActive ??= color(theme.base.primary)?.lower(0.2).translucify(0.8).css();
theme.base.border ??= color(theme.base.surface)?.lift(0.12)?.css();
theme.base.borderSubtle ??= color(theme.base.border)?.lower(0.08)?.css();
theme.base.text ??= fallback.text;
theme.base.textSubtle ??= color(theme.base.text)?.lower(0.3)?.css();
theme.base.textSubtlest ??= color(theme.base.text)?.lower(0.5)?.css();
for (const [key, value] of Object.entries(fallback)) {
theme.base[key as YaakColorKey] ??= value;
}
return theme;
}
+254 -17
View File
@@ -3,9 +3,9 @@ import parseColor from "parse-color";
export class YaakColor {
private readonly appearance: "dark" | "light" = "light";
private hue = 0;
private saturation = 0;
private lightness = 0;
private chroma = 0;
private hue = 0;
private alpha = 1;
constructor(cssColor: string, appearance: "dark" | "light" = "light") {
@@ -22,11 +22,11 @@ export class YaakColor {
}
static white(): YaakColor {
return new YaakColor("rgb(0,0,0)", "light").lower(1);
return new YaakColor("rgb(0,0,0)", "light").lower(999);
}
static black(): YaakColor {
return new YaakColor("rgb(0,0,0)", "light").lift(1);
return new YaakColor("rgb(0,0,0)", "light").lift(999);
}
set(cssColor: string): YaakColor {
@@ -35,11 +35,22 @@ export class YaakColor {
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;
const oklch = parseOklch(fixedCssColor);
if (oklch != null) {
this.lightness = oklch.lightness;
this.chroma = oklch.chroma;
this.hue = oklch.hue;
this.alpha = oklch.alpha;
return this;
}
const { rgba } = parseColor(fixedCssColor);
const [lightness, chroma, hue] = rgbToOklch(rgba[0], rgba[1], rgba[2]);
this.lightness = lightness;
this.chroma = chroma;
this.hue = hue;
this.alpha = rgba[3] ?? 1;
return this;
}
@@ -47,6 +58,10 @@ export class YaakColor {
return new YaakColor(this.css(), this.appearance);
}
themeColor(cssColor: string): YaakColor {
return new YaakColor(cssColor, this.appearance);
}
lower(mod: number): YaakColor {
return this.appearance === "dark" ? this._darken(mod) : this._lighten(mod);
}
@@ -55,6 +70,21 @@ export class YaakColor {
return this.appearance === "dark" ? this._lighten(mod) : this._darken(mod);
}
liftMax(): YaakColor {
return this.lift(999);
}
lowerMax(): YaakColor {
return this.lower(999);
}
themeSurface(): YaakColor {
return new YaakColor(
this.appearance === "dark" ? "oklch(23% 0 0)" : "oklch(100% 0 0)",
this.appearance,
);
}
minLightness(n: number): YaakColor {
const color = this.clone();
if (color.lightness < n) {
@@ -69,25 +99,25 @@ export class YaakColor {
translucify(mod: number): YaakColor {
const color = this.clone();
color.alpha = color.alpha - color.alpha * mod;
color.alpha = clamp(color.alpha - color.alpha * mod, 0, 1);
return color;
}
opacify(mod: number): YaakColor {
const color = this.clone();
color.alpha = this.alpha + (100 - this.alpha) * mod;
color.alpha = clamp(this.alpha + (1 - this.alpha) * mod, 0, 1);
return color;
}
desaturate(mod: number): YaakColor {
const color = this.clone();
color.saturation = color.saturation - color.saturation * mod;
color.chroma = color.chroma - color.chroma * mod;
return color;
}
saturate(mod: number): YaakColor {
const color = this.clone();
color.saturation = this.saturation + (100 - this.saturation) * mod;
color.chroma = this.chroma + this.chroma * mod;
return color;
}
@@ -95,29 +125,236 @@ export class YaakColor {
return this.lightness > color.lightness;
}
contrastRatio(background: YaakColor): number {
const foreground = this.alpha < 1 ? this.compositeOver(background) : this;
const foregroundLuminance = foreground.relativeLuminance();
const backgroundLuminance = background.relativeLuminance();
const lighter = Math.max(foregroundLuminance, backgroundLuminance);
const darker = Math.min(foregroundLuminance, backgroundLuminance);
return (lighter + 0.05) / (darker + 0.05);
}
withContrast(background: YaakColor, minContrast: number): YaakColor {
const darker = this.clone();
darker.lightness = 0;
darker.chroma = 0;
darker.hue = 0;
const lighter = this.clone();
lighter.lightness = 100;
lighter.chroma = 0;
lighter.hue = 0;
const darkerContrast = darker.contrastRatio(background);
const lighterContrast = lighter.contrastRatio(background);
let useLighterColor = lighterContrast >= darkerContrast;
// Saturated accent surfaces often read better with white text even when
// black has the higher numeric contrast. Keep yellow-ish light accents dark
// by requiring white to clear a modest contrast floor first.
if (minContrast >= 3 && lighterContrast >= 2.5) {
useLighterColor = true;
}
const selectedContrast = useLighterColor ? lighterContrast : darkerContrast;
if (selectedContrast < minContrast) {
return useLighterColor ? lighter : darker;
}
let minLightness = 0;
let maxLightness = 100;
const color = this.clone();
for (let i = 0; i < 24; i += 1) {
color.lightness = (minLightness + maxLightness) / 2;
const contrast = color.contrastRatio(background);
if (useLighterColor) {
if (contrast >= minContrast) {
maxLightness = color.lightness;
} else {
minLightness = color.lightness;
}
} else if (contrast >= minContrast) {
minLightness = color.lightness;
} else {
maxLightness = color.lightness;
}
}
color.lightness = useLighterColor ? maxLightness : minLightness;
return color;
}
compositeOver(background: YaakColor): YaakColor {
const [fgR, fgG, fgB] = this.rgb();
const [bgR, bgG, bgB] = background.rgb();
const alpha = this.alpha + background.alpha * (1 - this.alpha);
if (alpha <= 0) {
return YaakColor.transparent();
}
const r = (fgR * this.alpha + bgR * background.alpha * (1 - this.alpha)) / alpha;
const g = (fgG * this.alpha + bgG * background.alpha * (1 - this.alpha)) / alpha;
const b = (fgB * this.alpha + bgB * background.alpha * (1 - this.alpha)) / alpha;
return new YaakColor(`rgba(${r},${g},${b},${alpha})`, this.appearance);
}
css(): string {
const [r, g, b] = parseColor(`hsl(${this.hue},${this.saturation}%,${this.lightness}%)`).rgb;
const [r, g, b] = this.rgb();
return rgbaToHex(r, g, b, this.alpha);
}
hexNoAlpha(): string {
const [r, g, b] = parseColor(`hsl(${this.hue},${this.saturation}%,${this.lightness}%)`).rgb;
const [r, g, b] = this.rgb();
return rgbaToHexNoAlpha(r, g, b);
}
private relativeLuminance(): number {
const [r, g, b] = this.rgb();
const red = srgbToLinear(r / 255);
const green = srgbToLinear(g / 255);
const blue = srgbToLinear(b / 255);
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
}
private rgb(): [number, number, number] {
return oklchToRgb(this.lightness, this.chroma, this.hue);
}
private _lighten(mod: number): YaakColor {
const color = this.clone();
color.lightness = this.lightness + (100 - this.lightness) * mod;
color.lightness = clamp(this.lightness + (100 - this.lightness) * mod, 0, 100);
return color;
}
private _darken(mod: number): YaakColor {
const color = this.clone();
color.lightness = this.lightness - this.lightness * mod;
color.lightness = clamp(this.lightness - this.lightness * mod, 0, 100);
return color;
}
}
function parseOklch(
cssColor: string,
): { lightness: number; chroma: number; hue: number; alpha: number } | null {
const match = cssColor
.trim()
.match(
/^oklch\(\s*([^\s,]+)(?:\s+|,\s*)([^\s,]+)(?:\s+|,\s*)([^\s,/]+)(?:\s*\/\s*([^)]+)|(?:\s*,\s*([^)]*))?)\s*\)$/i,
);
if (match == null) return null;
const [, lightnessValue, chromaValue, hueValue, slashAlpha, commaAlpha] = match;
if (lightnessValue == null || chromaValue == null || hueValue == null) return null;
const lightness = parseOklchLightness(lightnessValue);
const chroma = parseCssNumber(chromaValue, 1);
const hue = normalizeHue(parseCssNumber(hueValue.replace(/deg$/i, ""), 1));
const alpha = parseCssNumber(slashAlpha ?? commaAlpha ?? "1", 1);
if (
!Number.isFinite(lightness) ||
!Number.isFinite(chroma) ||
!Number.isFinite(hue) ||
!Number.isFinite(alpha)
) {
return null;
}
return {
lightness: clamp(lightness, 0, 100),
chroma: Math.max(0, chroma),
hue,
alpha: clamp(alpha, 0, 1),
};
}
function parseCssNumber(value: string, percentScale: number): number {
const normalized = value.trim();
if (normalized.endsWith("%")) {
return (Number.parseFloat(normalized) / 100) * percentScale;
}
return Number.parseFloat(normalized);
}
function parseOklchLightness(value: string): number {
const parsed = parseCssNumber(value, 100);
return value.trim().endsWith("%") || parsed > 1 ? parsed : parsed * 100;
}
function rgbToOklch(r: number, g: number, b: number): [number, number, number] {
const red = srgbToLinear(r / 255);
const green = srgbToLinear(g / 255);
const blue = srgbToLinear(b / 255);
const l = 0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue;
const m = 0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue;
const s = 0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue;
const lRoot = Math.cbrt(l);
const mRoot = Math.cbrt(m);
const sRoot = Math.cbrt(s);
const lightness = 0.2104542553 * lRoot + 0.793617785 * mRoot - 0.0040720468 * sRoot;
const a = 1.9779984951 * lRoot - 2.428592205 * mRoot + 0.4505937099 * sRoot;
const okb = 0.0259040371 * lRoot + 0.7827717662 * mRoot - 0.808675766 * sRoot;
return [
lightness * 100,
Math.sqrt(a * a + okb * okb),
normalizeHue(radToDeg(Math.atan2(okb, a))),
];
}
function oklchToRgb(lightness: number, chroma: number, hue: number): [number, number, number] {
const l = clamp(lightness, 0, 100) / 100;
const a = Math.cos(degToRad(hue)) * chroma;
const b = Math.sin(degToRad(hue)) * chroma;
const lRoot = l + 0.3963377774 * a + 0.2158037573 * b;
const mRoot = l - 0.1055613458 * a - 0.0638541728 * b;
const sRoot = l - 0.0894841775 * a - 1.291485548 * b;
const lCube = lRoot * lRoot * lRoot;
const mCube = mRoot * mRoot * mRoot;
const sCube = sRoot * sRoot * sRoot;
const red = 4.0767416621 * lCube - 3.3077115913 * mCube + 0.2309699292 * sCube;
const green = -1.2684380046 * lCube + 2.6097574011 * mCube - 0.3413193965 * sCube;
const blue = -0.0041960863 * lCube - 0.7034186147 * mCube + 1.707614701 * sCube;
return [linearToSrgb(red) * 255, linearToSrgb(green) * 255, linearToSrgb(blue) * 255];
}
function srgbToLinear(value: number): number {
return value <= 0.04045 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4);
}
function linearToSrgb(value: number): number {
const srgb = value <= 0.0031308 ? value * 12.92 : 1.055 * Math.pow(value, 1 / 2.4) - 0.055;
return clamp(srgb, 0, 1);
}
function normalizeHue(value: number): number {
const hue = value % 360;
return hue < 0 ? hue + 360 : hue;
}
function degToRad(value: number): number {
return (value * Math.PI) / 180;
}
function radToDeg(value: number): number {
return (value * 180) / Math.PI;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function rgbaToHex(r: number, g: number, b: number, a: number): string {
const toHex = (n: number): string => {
const hex = Number(Math.round(n)).toString(16);
@@ -364,6 +364,8 @@ function TreeItem_<T extends { id: string }>({
ref={handleEditFocus}
defaultValue={defaultValue}
placeholder={placeholder}
autoCapitalize="off"
autoCorrect="off"
className="bg-transparent outline-none w-full cursor-text"
onBlur={handleEditBlur}
onKeyDown={handleEditKeyDown}
+2 -1
View File
@@ -6,7 +6,8 @@ const Downloader = require("nodejs-file-downloader");
const { rmSync, cpSync, mkdirSync, existsSync } = require("node:fs");
const { execSync } = require("node:child_process");
const NODE_VERSION = "v24.11.1";
const nodeVersionFile = path.join(__dirname, "..", "packages", "plugin-runtime", ".node-version");
const NODE_VERSION = `v${fs.readFileSync(nodeVersionFile, "utf8").trim().replace(/^v/, "")}`;
// `${process.platform}_${process.arch}`
const MAC_ARM = "darwin_arm64";