Compare commits

...

7 Commits

Author SHA1 Message Date
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
48 changed files with 1580 additions and 341 deletions
Generated
+2
View File
@@ -10052,6 +10052,7 @@ dependencies = [
"tempfile", "tempfile",
"thiserror 2.0.17", "thiserror 2.0.17",
"tokio", "tokio",
"yaak-core",
"yaak-crypto", "yaak-crypto",
"yaak-http", "yaak-http",
"yaak-models", "yaak-models",
@@ -10182,6 +10183,7 @@ dependencies = [
"webbrowser", "webbrowser",
"yaak", "yaak",
"yaak-api", "yaak-api",
"yaak-core",
"yaak-crypto", "yaak-crypto",
"yaak-http", "yaak-http",
"yaak-models", "yaak-models",
+7 -3
View File
@@ -130,7 +130,7 @@ export const CookieDialog = ({ cookieJarId }: Props) => {
return key !== nextCookieKey; return key !== nextCookieKey;
}); });
patchModel(cookieJar, { cookies: [...nextCookies, nextCookie] }); void patchModel(cookieJar, { cookies: [...nextCookies, nextCookie] });
setSelectedCookieKey(nextCookieKey); setSelectedCookieKey(nextCookieKey);
setEditingCookieKey(null); setEditingCookieKey(null);
setDraftCookie(null); setDraftCookie(null);
@@ -210,7 +210,7 @@ export const CookieDialog = ({ cookieJarId }: Props) => {
setEditingCookieKey(null); setEditingCookieKey(null);
setDraftCookie(null); setDraftCookie(null);
setDraftExpiresInput(""); setDraftExpiresInput("");
patchModel(cookieJar, { cookies: [] }); void patchModel(cookieJar, { cookies: [] });
}} }}
/> />
</TableHeaderCell> </TableHeaderCell>
@@ -276,7 +276,7 @@ export const CookieDialog = ({ cookieJarId }: Props) => {
setDraftCookie(null); setDraftCookie(null);
setDraftExpiresInput(""); setDraftExpiresInput("");
} }
patchModel(cookieJar, { void patchModel(cookieJar, {
cookies: cookieJar.cookies.filter( cookies: cookieJar.cookies.filter(
(c2: Cookie) => cookieKey(c2) !== key, (c2: Cookie) => cookieKey(c2) !== key,
), ),
@@ -570,6 +570,8 @@ function CookieTextInput({
return ( return (
<input <input
autoFocus={autoFocus} autoFocus={autoFocus}
autoCapitalize="off"
autoCorrect="off"
className={cookieInputClassName} className={cookieInputClassName}
disabled={disabled} disabled={disabled}
onChange={(event) => onChange(event.target.value)} onChange={(event) => onChange(event.target.value)}
@@ -585,6 +587,8 @@ function CookieTextInput({
function CookieTextarea({ onChange, value }: { onChange: (value: string) => void; value: string }) { function CookieTextarea({ onChange, value }: { onChange: (value: string) => void; value: string }) {
return ( return (
<textarea <textarea
autoCapitalize="off"
autoCorrect="off"
className={classNames(cookieInputClassName, "min-h-[5rem] resize-y")} className={classNames(cookieInputClassName, "min-h-[5rem] resize-y")}
onChange={(event) => onChange(event.target.value)} onChange={(event) => onChange(event.target.value)}
value={value} value={value}
@@ -1,5 +1,5 @@
import { patchModel, workspaceMetasAtom, workspacesAtom } from "@yaakapp-internal/models"; 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 { useAtomValue } from "jotai";
import { useAuthTab } from "../hooks/useAuthTab"; import { useAuthTab } from "../hooks/useAuthTab";
import { useHeadersTab } from "../hooks/useHeadersTab"; 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(() => { const pairsText = useMemo(() => {
return pairs return pairs
.filter((p) => !(p.name.trim() === "" && p.value.trim() === "")) .filter((p) => !(p.name.trim() === "" && p.value.trim() === ""))
.map(pairToLine) .map(formatBulkPairLine)
.join("\n"); .join("\n");
}, [pairs]); }, [pairs]);
@@ -26,7 +26,7 @@ export function BulkPairEditor({
const pairs = text const pairs = text
.split("\n") .split("\n")
.filter((l: string) => l.trim()) .filter((l: string) => l.trim())
.map(lineToPair); .map(parseBulkPairLine);
onChange(pairs); onChange(pairs);
}, },
[onChange], [onChange],
@@ -47,16 +47,16 @@ export function BulkPairEditor({
); );
} }
function pairToLine(pair: Pair) { export function formatBulkPairLine(pair: Pair) {
const value = pair.value.replaceAll("\n", "\\n"); const value = pair.value.replaceAll("\n", "\\n");
return `${pair.name}: ${value}`; return `${pair.name}: ${value}`;
} }
function lineToPair(line: string): PairWithId { export function parseBulkPairLine(line: string): PairWithId {
const [, name, value] = line.match(/^(:?[^:]+):\s+(.*)$/) ?? []; const [, name, value] = line.match(/^([^:]+):\s+(.*)$/) ?? [];
return { return {
enabled: true, enabled: true,
name: (name ?? "").trim(), name: (name ?? line).trim(),
value: (value ?? "").replaceAll("\\n", "\n").trim(), value: (value ?? "").replaceAll("\\n", "\n").trim(),
id: generateId(), id: generateId(),
}; };
@@ -580,6 +580,10 @@ function getExtensions({
return [ return [
...baseExtensions, // Must be first ...baseExtensions, // Must be first
EditorView.contentAttributes.of({
autocapitalize: "off",
autocorrect: "off",
}),
EditorView.domEventHandlers({ EditorView.domEventHandlers({
focus: () => { focus: () => {
onFocus.current?.(); onFocus.current?.();
@@ -1,7 +1,7 @@
@top pairs { (Key Sep Value "\n")* } @top pairs { (Key Sep Value "\n")* }
@tokens { @tokens {
Sep { ":" } Sep { ":" $[ \t]+ }
Key { ":"? ![:]+ } Key { ":"? ![:]+ }
Value { ![\n]+ } 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], skippedNodes: [0],
repeatNodeCount: 1, repeatNodeCount: 1,
tokenData: 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], tokenizers: [0, 1, 2],
topRules: { pairs: [0, 1] }, topRules: { pairs: [0, 1] },
tokenPrec: 0, tokenPrec: 0,
@@ -55,6 +55,8 @@ export function KeyValueRow({
const textToCopy = const textToCopy =
copyText ?? copyText ??
(typeof children === "string" || typeof children === "number" ? `${children}` : null); (typeof children === "string" || typeof children === "number" ? `${children}` : null);
const copyTitle =
typeof label === "string" || typeof label === "number" ? `Copy ${label}` : "Copy value";
const resolvedRightSlot = const resolvedRightSlot =
rightSlot ?? rightSlot ??
(enableCopy && textToCopy != null ? ( (enableCopy && textToCopy != null ? (
@@ -62,7 +64,7 @@ export function KeyValueRow({
text={textToCopy} text={textToCopy}
className="text-text-subtle" className="text-text-subtle"
size="2xs" size="2xs"
title={`Copy ${label}`} title={copyTitle}
iconSize="sm" iconSize="sm"
/> />
) : null); ) : null);
@@ -1,6 +1,6 @@
import { useGitFileDiffForCommit, useGitLog, useGitMutations } from "@yaakapp-internal/git"; import { useGitFileDiffForCommit, useGitLog, useGitMutations } from "@yaakapp-internal/git";
import type { GitCommit } 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 classNames from "classnames";
import { formatDistanceToNowStrict } from "date-fns"; import { formatDistanceToNowStrict } from "date-fns";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
@@ -8,7 +8,7 @@ import type {
WebsocketRequest, WebsocketRequest,
Workspace, Workspace,
} from "@yaakapp-internal/models"; } 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 classNames from "classnames";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { modelToYaml } from "../../lib/diffYaml"; import { modelToYaml } from "../../lib/diffYaml";
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { Appearance } from "../lib/theme/appearance"; import type { Appearance } from "@yaakapp-internal/theme";
import { getCSSAppearance, subscribeToPreferredAppearance } from "../lib/theme/appearance"; import { getCSSAppearance, subscribeToPreferredAppearance } from "@yaakapp-internal/theme";
export function usePreferredAppearance() { export function usePreferredAppearance() {
const [preferredAppearance, setPreferredAppearance] = useState<Appearance>(getCSSAppearance()); const [preferredAppearance, setPreferredAppearance] = useState<Appearance>(getCSSAppearance());
@@ -1,6 +1,6 @@
import { settingsAtom } from "@yaakapp-internal/models"; import { settingsAtom } from "@yaakapp-internal/models";
import { resolveAppearance } from "@yaakapp-internal/theme";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { resolveAppearance } from "../lib/theme/appearance";
import { usePreferredAppearance } from "./usePreferredAppearance"; import { usePreferredAppearance } from "./usePreferredAppearance";
export function useResolvedAppearance() { export function useResolvedAppearance() {
+1 -1
View File
@@ -1,7 +1,7 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { settingsAtom } from "@yaakapp-internal/models"; import { settingsAtom } from "@yaakapp-internal/models";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { getResolvedTheme, getThemes } from "../lib/theme/themes"; import { getResolvedTheme, getThemes } from "../lib/themes";
import { usePluginsKey } from "./usePlugins"; import { usePluginsKey } from "./usePlugins";
import { usePreferredAppearance } from "./usePreferredAppearance"; import { usePreferredAppearance } from "./usePreferredAppearance";
+13 -21
View File
@@ -1,40 +1,32 @@
import type { HttpResponse } from "@yaakapp-internal/models"; 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 { invokeCmd } from "../lib/tauri";
import { getActiveCookieJar } from "./useActiveCookieJar"; import { getActiveCookieJar } from "./useActiveCookieJar";
import { getActiveEnvironment } from "./useActiveEnvironment"; import { getActiveEnvironment } from "./useActiveEnvironment";
import { createFastMutation, useFastMutation } from "./useFastMutation"; import { createFastMutation, useFastMutation } from "./useFastMutation";
export function useSendAnyHttpRequest() { async function sendAnyHttpRequestById(id: string | null): Promise<HttpResponse | null> {
return useFastMutation<HttpResponse | null, string, string | null>({ if (id == null) {
mutationKey: ["send_any_request"],
mutationFn: async (id) => {
const request = getModel("http_request", id ?? "n/a");
if (request == null) {
return null; return null;
} }
await flushAllModelWrites();
return invokeCmd("cmd_send_http_request", { return invokeCmd("cmd_send_http_request", {
request, requestId: id,
environmentId: getActiveEnvironment()?.id, environmentId: getActiveEnvironment()?.id,
cookieJarId: getActiveCookieJar()?.id, cookieJarId: getActiveCookieJar()?.id,
}); });
}, }
export function useSendAnyHttpRequest() {
return useFastMutation<HttpResponse | null, string, string | null>({
mutationKey: ["send_any_request"],
mutationFn: sendAnyHttpRequestById,
}); });
} }
export const sendAnyHttpRequest = createFastMutation<HttpResponse | null, string, string | null>({ export const sendAnyHttpRequest = createFastMutation<HttpResponse | null, string, string | null>({
mutationKey: ["send_any_request"], mutationKey: ["send_any_request"],
mutationFn: async (id) => { mutationFn: sendAnyHttpRequestById,
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,
});
},
}); });
@@ -44,6 +44,19 @@ export function initGlobalListeners() {
color: "danger", color: "danger",
timeout: null, timeout: null,
message: `Failed to load plugin "${name}": ${err}`, 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>
),
}); });
} }
}); });
-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 type { GetThemesResponse } from "@yaakapp-internal/plugins";
import { defaultDarkTheme, defaultLightTheme } from "@yaakapp-internal/theme"; import {
import { invokeCmd } from "../tauri"; defaultDarkTheme,
import type { Appearance } from "./appearance"; defaultLightTheme,
import { resolveAppearance } from "./appearance"; resolveAppearance,
type Appearance,
export { defaultDarkTheme, defaultLightTheme } from "@yaakapp-internal/theme"; } from "@yaakapp-internal/theme";
import { invokeCmd } from "./tauri";
export async function getThemes() { export async function getThemes() {
const themes = (await invokeCmd<GetThemesResponse[]>("cmd_get_themes")).flatMap((t) => t.themes); 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 { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { setWindowTheme } from "@yaakapp-internal/mac-window"; import { setWindowTheme } from "@yaakapp-internal/mac-window";
import type { ModelPayload } from "@yaakapp-internal/models"; 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 { getSettings } from "./lib/settings";
import type { Appearance } from "./lib/theme/appearance"; import { getResolvedTheme } from "./lib/themes";
import { getCSSAppearance, subscribeToPreferredAppearance } from "./lib/theme/appearance";
import { getResolvedTheme } from "./lib/theme/themes";
import { applyThemeToDocument } from "@yaakapp-internal/theme";
// NOTE: CSS appearance isn't as accurate as getting it async from the window (next step), but we want // 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 // a good appearance guess so we're not waiting too long
+1
View File
@@ -42,6 +42,7 @@ webbrowser = "1"
zip = "4" zip = "4"
yaak = { workspace = true } yaak = { workspace = true }
yaak-api = { workspace = true } yaak-api = { workspace = true }
yaak-core = { workspace = true }
yaak-crypto = { workspace = true } yaak-crypto = { workspace = true }
yaak-http = { workspace = true } yaak-http = { workspace = true }
yaak-models = { workspace = true } yaak-models = { workspace = true }
+38
View File
@@ -42,6 +42,12 @@ pub enum Commands {
/// Authentication commands /// Authentication commands
Auth(AuthArgs), 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 development and publishing commands
Plugin(PluginArgs), Plugin(PluginArgs),
@@ -92,6 +98,34 @@ pub struct SendArgs {
pub fail_fast: bool, 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)] #[derive(Args)]
#[command(disable_help_subcommand = true)] #[command(disable_help_subcommand = true)]
pub struct CookieJarArgs { pub struct CookieJarArgs {
@@ -447,6 +481,10 @@ pub enum PluginCommands {
/// Install a plugin from a local directory or from the registry /// Install a plugin from a local directory or from the registry
Install(InstallPluginArgs), Install(InstallPluginArgs),
/// Generate plugin metadata for the registry
#[command(hide = true)]
Metadata(PluginPathArg),
/// Publish a Yaak plugin version to the plugin registry /// Publish a Yaak plugin version to the plugin registry
Publish(PluginPathArg), 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 cookie_jar;
pub mod environment; pub mod environment;
pub mod folder; pub mod folder;
pub mod import_export;
pub mod plugin; pub mod plugin;
pub mod request; pub mod request;
pub mod send; pub mod send;
+184 -2
View File
@@ -13,6 +13,7 @@ use std::collections::HashSet;
use std::fs; use std::fs;
use std::io::{self, IsTerminal, Read, Write}; use std::io::{self, IsTerminal, Read, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use walkdir::WalkDir; use walkdir::WalkDir;
@@ -27,6 +28,11 @@ use zip::write::SimpleFileOptions;
type CommandResult<T = ()> = std::result::Result<T, String>; type CommandResult<T = ()> = std::result::Result<T, String>;
const KEYRING_USER: &str = "yaak"; 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)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Environment { 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 { async fn build(args: PluginPathArg) -> CommandResult {
let plugin_dir = resolve_plugin_dir(args.path)?; let plugin_dir = resolve_plugin_dir(args.path)?;
ensure_plugin_build_inputs(&plugin_dir)?; ensure_plugin_build_inputs(&plugin_dir)?;
@@ -112,10 +128,21 @@ async fn build(args: PluginPathArg) -> CommandResult {
for warning in warnings { for warning in warnings {
ui::warning(&warning); ui::warning(&warning);
} }
generate_plugin_metadata(&plugin_dir)?;
ui::success(&format!("Built plugin bundle at {}", plugin_dir.join("build/index.js").display())); ui::success(&format!("Built plugin bundle at {}", plugin_dir.join("build/index.js").display()));
Ok(()) 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 { async fn dev(args: PluginPathArg) -> CommandResult {
let plugin_dir = resolve_plugin_dir(args.path)?; let plugin_dir = resolve_plugin_dir(args.path)?;
ensure_plugin_build_inputs(&plugin_dir)?; ensure_plugin_build_inputs(&plugin_dir)?;
@@ -153,7 +180,15 @@ async fn dev(args: PluginPathArg) -> CommandResult {
}); });
ui::info(&format!("Rebuilding plugin {display_path}")); 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)) => { WatcherEvent::Event(BundleEvent::Error(event)) => {
if event.error.diagnostics.is_empty() { if event.error.diagnostics.is_empty() {
ui::error("Plugin build failed"); ui::error("Plugin build failed");
@@ -228,6 +263,7 @@ async fn publish(args: PluginPathArg) -> CommandResult {
for warning in warnings { for warning in warnings {
ui::warning(&warning); ui::warning(&warning);
} }
generate_plugin_metadata(&plugin_dir)?;
ui::info("Archiving plugin"); ui::info("Archiving plugin");
let archive = create_publish_archive(&plugin_dir)?; 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()) 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 { fn prepare_build_output_dir(plugin_dir: &Path) -> CommandResult {
let build_dir = plugin_dir.join("build"); let build_dir = plugin_dir.join("build");
if build_dir.exists() { 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#"{ const TEMPLATE_TSCONFIG: &str = r#"{
"compilerOptions": { "compilerOptions": {
"target": "es2021", "target": "es2021",
@@ -636,7 +750,8 @@ describe("Example Plugin", () => {
#[cfg(test)] #[cfg(test)]
mod tests { 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::collections::HashSet;
use std::fs; use std::fs;
use std::io::Cursor; use std::io::Cursor;
@@ -659,6 +774,7 @@ mod tests {
.expect("write src/index.ts"); .expect("write src/index.ts");
fs::write(root.join("build/index.js"), "exports.plugin = {};\n") fs::write(root.join("build/index.js"), "exports.plugin = {};\n")
.expect("write build/index.js"); .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"); fs::write(root.join("ignored/secret.txt"), "do-not-ship").expect("write ignored file");
let archive = create_publish_archive(root).expect("create archive"); let archive = create_publish_archive(root).expect("create archive");
@@ -673,8 +789,74 @@ mod tests {
assert!(names.contains("README.md")); assert!(names.contains("README.md"));
assert!(names.contains("package.json")); assert!(names.contains("package.json"));
assert!(names.contains("package-lock.json")); assert!(names.contains("package-lock.json"));
assert!(names.contains("build/metadata.json"));
assert!(names.contains("src/index.ts")); assert!(names.contains("src/index.ts"));
assert!(names.contains("build/index.js")); assert!(names.contains("build/index.js"));
assert!(!names.contains("ignored/secret.txt")); 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 { let exit_code = match command {
Commands::Auth(args) => commands::auth::run(args).await, 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 { Commands::Plugin(args) => match args.command {
PluginCommands::Build(args) => commands::plugin::run_build(args).await, PluginCommands::Build(args) => commands::plugin::run_build(args).await,
PluginCommands::Dev(args) => commands::plugin::run_dev(args).await, PluginCommands::Dev(args) => commands::plugin::run_dev(args).await,
PluginCommands::Generate(args) => commands::plugin::run_generate(args).await, PluginCommands::Generate(args) => commands::plugin::run_generate(args).await,
PluginCommands::Publish(args) => commands::plugin::run_publish(args).await, PluginCommands::Publish(args) => commands::plugin::run_publish(args).await,
PluginCommands::Metadata(args) => commands::plugin::run_metadata(args).await,
PluginCommands::Install(install_args) => { PluginCommands::Install(install_args) => {
let mut context = CliContext::new(data_dir.clone(), app_id); let mut context = CliContext::new(data_dir.clone(), app_id);
context.init_plugins(CliExecutionContext::default()).await; 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)] #[error(transparent)]
ApiError(#[from] yaak_api::Error), ApiError(#[from] yaak_api::Error),
#[error(transparent)]
YaakError(#[from] yaak::Error),
#[error(transparent)] #[error(transparent)]
ClipboardError(#[from] tauri_plugin_clipboard_manager::Error), ClipboardError(#[from] tauri_plugin_clipboard_manager::Error),
+12 -105
View File
@@ -1,16 +1,12 @@
use crate::PluginContextExt; use crate::PluginContextExt;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::models_ext::QueryManagerExt; use crate::models_ext::QueryManagerExt;
use log::info;
use std::collections::BTreeMap;
use std::fs::read_to_string; use std::fs::read_to_string;
use std::io::ErrorKind; use std::io::ErrorKind;
use tauri::{Manager, Runtime, WebviewWindow}; use tauri::{Manager, Runtime, WebviewWindow};
use yaak::import::{self, ImportDataParams};
use yaak_core::WorkspaceContext; use yaak_core::WorkspaceContext;
use yaak_models::models::{ use yaak_models::util::BatchUpsertResult;
Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace,
};
use yaak_models::util::{BatchUpsertResult, UpdateSource, maybe_gen_id, maybe_gen_id_opt};
use yaak_plugins::manager::PluginManager; use yaak_plugins::manager::PluginManager;
use yaak_tauri_utils::window::WorkspaceWindowTrait; use yaak_tauri_utils::window::WorkspaceWindowTrait;
@@ -19,113 +15,24 @@ pub(crate) async fn import_data<R: Runtime>(
file_path: &str, file_path: &str,
) -> Result<BatchUpsertResult> { ) -> Result<BatchUpsertResult> {
let plugin_manager = window.state::<PluginManager>(); let plugin_manager = window.state::<PluginManager>();
let query_manager = window.db_manager();
let file = read_import_file(file_path)?; let file = read_import_file(file_path)?;
let file_contents = file.as_str(); let plugin_context = window.plugin_context();
let import_result = plugin_manager.import_data(&window.plugin_context(), file_contents).await?; let workspace_context = WorkspaceContext {
let mut id_map: BTreeMap<String, String> = BTreeMap::new();
// Create WorkspaceContext from window
let ctx = WorkspaceContext {
workspace_id: window.workspace_id(), workspace_id: window.workspace_id(),
environment_id: window.environment_id(), environment_id: window.environment_id(),
cookie_jar_id: window.cookie_jar_id(), cookie_jar_id: window.cookie_jar_id(),
request_id: None, request_id: None,
}; };
let resources = import_result.resources; Ok(import::import_data(ImportDataParams {
query_manager: &query_manager,
let workspaces: Vec<Workspace> = resources plugin_manager: &plugin_manager,
.workspaces plugin_context: &plugin_context,
.into_iter() workspace_context,
.map(|mut v| { contents: &file,
v.id = maybe_gen_id::<Workspace>(&ctx, v.id.as_str(), &mut id_map);
v
}) })
.collect(); .await?)
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)
} }
fn read_import_file(file_path: &str) -> Result<String> { fn read_import_file(file_path: &str) -> Result<String> {
+42 -28
View File
@@ -14,8 +14,7 @@ use error::Result as YaakResult;
use eventsource_client::{EventParser, SSE}; use eventsource_client::{EventParser, SSE};
use log::{debug, error, info, warn}; use log::{debug, error, info, warn};
use std::collections::HashMap; use std::collections::HashMap;
use std::fs::File; use std::path::{Path, PathBuf};
use std::path::PathBuf;
use std::str::FromStr; use std::str::FromStr;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
@@ -31,6 +30,7 @@ use tauri_plugin_window_state::{AppHandleExt, StateFlags};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio::task::block_in_place; use tokio::task::block_in_place;
use tokio::time; use tokio::time;
use yaak::export::{self, ExportDataParams};
use yaak_common::command::new_checked_command; use yaak_common::command::new_checked_command;
use yaak_crypto::manager::EncryptionManager; use yaak_crypto::manager::EncryptionManager;
use yaak_grpc::manager::{GrpcConfig, GrpcHandle}; use yaak_grpc::manager::{GrpcConfig, GrpcHandle};
@@ -41,7 +41,7 @@ use yaak_models::models::{
GrpcEventType, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState, Workspace, GrpcEventType, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState, Workspace,
WorkspaceMeta, WorkspaceMeta,
}; };
use yaak_models::util::{BatchUpsertResult, UpdateSource, get_workspace_export_resources}; use yaak_models::util::{BatchUpsertResult, UpdateSource};
use yaak_plugins::events::{ use yaak_plugins::events::{
CallFolderActionArgs, CallFolderActionRequest, CallGrpcRequestActionArgs, CallFolderActionArgs, CallFolderActionRequest, CallGrpcRequestActionArgs,
CallGrpcRequestActionRequest, CallHttpRequestActionArgs, CallHttpRequestActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionArgs, CallHttpRequestActionRequest,
@@ -54,7 +54,7 @@ use yaak_plugins::events::{
InternalEventPayload, JsonPrimitive, PluginContext, RenderPurpose, ShowToastRequest, InternalEventPayload, JsonPrimitive, PluginContext, RenderPurpose, ShowToastRequest,
}; };
use yaak_plugins::manager::PluginManager; 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_plugins::template_callback::PluginTemplateCallback;
use yaak_sse::sse::ServerSentEvent; use yaak_sse::sse::ServerSentEvent;
use yaak_tauri_utils::window::WorkspaceWindowTrait; use yaak_tauri_utils::window::WorkspaceWindowTrait;
@@ -1384,24 +1384,14 @@ async fn cmd_export_data<R: Runtime>(
workspace_ids: Vec<&str>, workspace_ids: Vec<&str>,
include_private_environments: bool, include_private_environments: bool,
) -> YaakResult<()> { ) -> YaakResult<()> {
let db = app_handle.db();
let version = app_handle.package_info().version.to_string(); let version = app_handle.package_info().version.to_string();
let export_data = Ok(export::export_data(ExportDataParams {
get_workspace_export_resources(&db, &version, workspace_ids, include_private_environments)?; query_manager: &app_handle.db_manager(),
let f = File::options() yaak_version: &version,
.create(true) export_path: Path::new(export_path),
.truncate(true) workspace_ids,
.write(true) include_private_environments,
.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(())
} }
#[tauri::command] #[tauri::command]
@@ -1425,11 +1415,10 @@ async fn cmd_send_http_request<R: Runtime>(
window: WebviewWindow<R>, window: WebviewWindow<R>,
environment_id: Option<&str>, environment_id: Option<&str>,
cookie_jar_id: Option<&str>, cookie_jar_id: Option<&str>,
// NOTE: We receive the entire request because to account for the race request_id: String,
// condition where the user may have just edited a field before sending
// that has not yet been saved in the DB.
request: HttpRequest,
) -> YaakResult<HttpResponse> { ) -> YaakResult<HttpResponse> {
let request = app_handle.db().get_http_request(&request_id)?;
let blobs = app_handle.blob_manager(); let blobs = app_handle.blob_manager();
let response = app_handle.db().upsert_http_response( let response = app_handle.db().upsert_http_response(
&HttpResponse { &HttpResponse {
@@ -1512,11 +1501,36 @@ async fn cmd_plugin_info<R: Runtime>(
plugin_manager: State<'_, PluginManager>, plugin_manager: State<'_, PluginManager>,
) -> YaakResult<PluginMetadata> { ) -> YaakResult<PluginMetadata> {
let plugin = app_handle.db().get_plugin(id)?; 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()) .get_plugin_by_dir(plugin.directory.as_str())
.await .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] #[tauri::command]
+24 -5
View File
@@ -8,6 +8,8 @@ import { newStoreData } from "./util";
let _store: JotaiStore | null = null; let _store: JotaiStore | null = null;
const pendingModelWrites = new Set<Promise<unknown>>();
export function initModelStore(store: JotaiStore) { export function initModelStore(store: JotaiStore) {
_store = store; _store = store;
@@ -42,6 +44,23 @@ function mustStore(): JotaiStore {
return _store; 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; let _activeWorkspaceId: string | null = null;
export async function changeModelStoreWorkspace(workspaceId: string | 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>>( export async function updateModel<M extends AnyModel["model"], T extends ExtractModel<AnyModel, M>>(
model: T, model: T,
): Promise<string> { ): Promise<string> {
return invoke<string>("models_upsert", { model }); return trackModelWrite(invoke<string>("models_upsert", { model }));
} }
export async function deleteModelById< export async function deleteModelById<
@@ -134,7 +153,7 @@ export async function deleteModel<M extends AnyModel["model"], T extends Extract
if (model == null) { if (model == null) {
throw new Error("Failed to delete null model"); 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>>( 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 }>>( export async function createGlobalModel<T extends Exclude<AnyModel, { workspaceId: string }>>(
patch: Partial<T> & Pick<T, "model">, patch: Partial<T> & Pick<T, "model">,
): Promise<string> { ): 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 }>>( export async function createWorkspaceModel<T extends Extract<AnyModel, { workspaceId: string }>>(
patch: Partial<T> & Pick<T, "model" | "workspaceId">, patch: Partial<T> & Pick<T, "model" | "workspaceId">,
): Promise<string> { ): Promise<string> {
return invoke<string>("models_upsert", { model: patch }); return trackModelWrite(invoke<string>("models_upsert", { model: patch }));
} }
export function replaceModelsInStore< export function replaceModelsInStore<
+9 -2
View File
@@ -1,6 +1,6 @@
use crate::api::{PluginVersion, download_plugin_archive, get_plugin}; use crate::api::{PluginVersion, download_plugin_archive, get_plugin};
use crate::checksum::compute_checksum; use crate::checksum::compute_checksum;
use crate::error::Error::PluginErr; use crate::error::Error::{PluginErr, PluginNotFoundErr};
use crate::error::Result; use crate::error::Result;
use crate::events::PluginContext; use crate::events::PluginContext;
use crate::manager::PluginManager; use crate::manager::PluginManager;
@@ -29,7 +29,14 @@ pub async fn delete_and_uninstall(
let db = query_manager.connect(); let db = query_manager.connect();
db.delete_plugin_by_id(plugin_id, &update_source)? 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) Ok(plugin)
} }
+1
View File
@@ -12,6 +12,7 @@ serde_json = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
tokio = { workspace = true, features = ["sync", "rt"] } tokio = { workspace = true, features = ["sync", "rt"] }
yaak-http = { workspace = true } yaak-http = { workspace = true }
yaak-core = { workspace = true }
yaak-crypto = { workspace = true } yaak-crypto = { workspace = true }
yaak-models = { workspace = true } yaak-models = { workspace = true }
yaak-plugins = { workspace = true } yaak-plugins = { workspace = true }
+12
View File
@@ -4,6 +4,18 @@ use thiserror::Error;
pub enum Error { pub enum Error {
#[error(transparent)] #[error(transparent)]
Send(#[from] crate::send::SendHttpRequestError), 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>; 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 error;
pub mod export;
pub mod import;
pub mod plugin_events; pub mod plugin_events;
pub mod render; pub mod render;
pub mod send; pub mod send;
+18
View File
@@ -16970,6 +16970,7 @@
"ws": "^8.20.1" "ws": "^8.20.1"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.0.13",
"@types/ws": "^8.5.13" "@types/ws": "^8.5.13"
} }
}, },
@@ -16991,6 +16992,23 @@
"undici-types": "~7.16.0" "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": { "packages/tailwind-config": {
"name": "@yaakapp-internal/tailwind-config", "name": "@yaakapp-internal/tailwind-config",
"version": "1.0.0", "version": "1.0.0",
+1
View File
@@ -0,0 +1 @@
24.11.1
+1
View File
@@ -9,6 +9,7 @@
"ws": "^8.20.1" "ws": "^8.20.1"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.0.13",
"@types/ws": "^8.5.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;
}
+3
View File
@@ -12,6 +12,9 @@ export type { DocumentPlatform, YaakColorKey, YaakColors, YaakTheme } from "./wi
export { export {
addThemeStylesToDocument, addThemeStylesToDocument,
applyThemeToDocument, applyThemeToDocument,
completeColorVariables,
completeFullColorVariables,
completePartialColorVariables,
completeTheme, completeTheme,
getThemeCSS, getThemeCSS,
indent, indent,
+126 -106
View File
@@ -47,18 +47,10 @@ export type YaakTheme = {
export type YaakColorKey = keyof ThemeComponentColors; export type YaakColorKey = keyof ThemeComponentColors;
export type DocumentPlatform = "linux" | "macos" | "windows" | "unknown"; 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>; type CSSVariables = Record<YaakColorKey, string | undefined>;
function themeVariables( export function completeFullColorVariables(theme: Theme, cmp: Partial<CSSVariables>): CSSVariables {
theme: Theme,
component?: ComponentName,
base?: CSSVariables,
): CSSVariables | null {
const cmp =
component == null
? theme.base
: (theme.components?.[component] ?? ({} as ThemeComponentColors));
const color = (value: string | undefined) => yc(theme, value); const color = (value: string | undefined) => yc(theme, value);
const vars: CSSVariables = { const vars: CSSVariables = {
surface: cmp.surface, surface: cmp.surface,
@@ -66,12 +58,12 @@ function themeVariables(
surfaceActive: cmp.surfaceActive ?? color(cmp.primary)?.lower(0.2).translucify(0.8).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(), 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(), selection: cmp.selection ?? color(cmp.primary)?.lower(0.1).translucify(0.7).css(),
border: cmp.border ?? color(cmp.surface)?.lift(0.11)?.css(), border: cmp.border,
borderSubtle: cmp.borderSubtle ?? color(cmp.border)?.lower(0.06)?.css(), borderSubtle: cmp.borderSubtle,
borderFocus: color(cmp.info)?.translucify(0.5)?.css(), borderFocus: cmp.borderFocus ?? color(cmp.info)?.translucify(0.5)?.css(),
text: cmp.text, text: cmp.text,
textSubtle: cmp.textSubtle ?? color(cmp.text)?.lower(0.2)?.css(), textSubtle: cmp.textSubtle,
textSubtlest: cmp.textSubtlest ?? color(cmp.text)?.lower(0.3)?.css(), textSubtlest: cmp.textSubtlest,
shadow: shadow:
cmp.shadow ?? cmp.shadow ??
YaakColor.black() YaakColor.black()
@@ -86,95 +78,126 @@ function themeVariables(
danger: cmp.danger, danger: cmp.danger,
}; };
for (const [key, value] of Object.entries(vars)) { const themeColor = (value: string) => new YaakColor(value, theme.dark ? "dark" : "light");
if (!value && base?.[key as YaakColorKey]) { const themeSurface = themeColor(theme.dark ? "oklch(23% 0 0)" : "oklch(100% 0 0)");
vars[key as YaakColorKey] = base[key as YaakColorKey]; 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> { export function completePartialColorVariables(
if (color == null) return {}; theme: Theme,
cmp: Partial<CSSVariables>,
): CSSVariables {
const color = (value: string | undefined) => yc(theme, value);
const text = color(cmp.text);
return { return normalizeColorVariables(theme, {
text: color.lift(0.7).css(), surface: cmp.surface,
textSubtle: color.lift(0.4).css(), 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(), textSubtlest: color.css(),
surface: color.lower(0.2).translucify(0.8).css(), surface: color.lower(0.2).translucify(0.8).css(),
border: color.translucify(0.6).css(), border: color.translucify(0.6).css(),
borderSubtle: color.translucify(0.8).css(), borderSubtle: color.translucify(0.8).css(),
surfaceHighlight: color.lower(0.1).translucify(0.7).css(), surfaceHighlight: color.lower(0.1).translucify(0.7).css(),
}; });
} }
function toastColorVariables(color: YaakColor | null): Partial<CSSVariables> { function toastColorVariables(theme: Theme, color: YaakColor): CSSVariables {
if (color == null) return {}; return completeFullColorVariables(theme, {
return {
text: color.lift(0.8).css(),
textSubtle: color.lift(0.8).translucify(0.3).css(),
surface: color.translucify(0.9).css(), surface: color.translucify(0.9).css(),
surfaceHighlight: color.translucify(0.8).css(), surfaceHighlight: color.translucify(0.8).css(),
border: color.lift(0.3).translucify(0.6).css(), });
};
} }
function bannerColorVariables(color: YaakColor | null): Partial<CSSVariables> { function bannerColorVariables(theme: Theme, color: YaakColor): CSSVariables {
if (color == null) return {}; return completeFullColorVariables(theme, {
return {
text: color.lift(0.8).css(),
textSubtle: color.translucify(0.3).css(),
textSubtlest: color.translucify(0.6).css(),
surface: color.translucify(0.95).css(), surface: color.translucify(0.95).css(),
surfaceHighlight: color.translucify(0.85).css(),
border: color.lift(0.3).translucify(0.8).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( function buttonSolidColorVariables(
color: YaakColor | null, theme: Theme,
color: YaakColor,
isDefault = false, isDefault = false,
): Partial<CSSVariables> { ): CSSVariables {
if (color == null) return {}; const vars: Partial<CSSVariables> = {
const theme: Partial<ThemeComponentColors> = {
text: "white",
surface: color.lower(0.3).css(), surface: color.lower(0.3).css(),
surfaceHighlight: color.lower(0.1).css(), surfaceHighlight: color.lower(0.1).css(),
border: color.css(),
}; };
if (isDefault) { if (isDefault) {
theme.text = undefined; vars.surface = undefined;
theme.surface = undefined; vars.surfaceHighlight = color.lift(0.08).css();
theme.surfaceHighlight = color.lift(0.08).css();
} }
return theme; return completeFullColorVariables(theme, vars);
} }
function buttonBorderColorVariables( function buttonBorderColorVariables(
color: YaakColor | null, theme: Theme,
color: YaakColor,
isDefault = false, isDefault = false,
): Partial<CSSVariables> { ): CSSVariables {
if (color == null) return {};
const vars: Partial<CSSVariables> = { const vars: Partial<CSSVariables> = {
text: color.lift(0.8).css(), text: color.desaturate(0.4).lift(1).css(),
textSubtle: color.lift(0.55).css(), textSubtle: color.desaturate(0.4).lift(0.55).css(),
textSubtlest: color.lift(0.4).translucify(0.6).css(),
surfaceHighlight: color.translucify(0.8).css(), surfaceHighlight: color.translucify(0.8).css(),
borderSubtle: color.translucify(0.5).css(), borderSubtle: color.translucify(0.5).css(),
border: color.translucify(0.3).css(), border: color.translucify(0.3).css(),
@@ -185,7 +208,7 @@ function buttonBorderColorVariables(
vars.border = color.lift(0.5).css(); vars.border = color.lift(0.5).css();
} }
return vars; return completeFullColorVariables(theme, vars);
} }
function variablesToCSS( function variablesToCSS(
@@ -202,9 +225,8 @@ function variablesToCSS(
return selector == null ? css : `${selector} {\n${indent(css)}\n}`; return selector == null ? css : `${selector} {\n${indent(css)}\n}`;
} }
function componentCSS(theme: Theme, component: ComponentName): string | null { function componentCSS(component: ComponentName, vars: CSSVariables): string | null {
if (theme.components == null) return null; return variablesToCSS(`.x-theme-${component}`, vars);
return variablesToCSS(`.x-theme-${component}`, themeVariables(theme, component));
} }
function buttonCSS( function buttonCSS(
@@ -216,8 +238,11 @@ function buttonCSS(
if (color == null) return null; if (color == null) return null;
return [ return [
variablesToCSS(`.x-theme-button--solid--${colorKey}`, buttonSolidColorVariables(color)), variablesToCSS(`.x-theme-button--solid--${colorKey}`, buttonSolidColorVariables(theme, color)),
variablesToCSS(`.x-theme-button--border--${colorKey}`, buttonBorderColorVariables(color)), variablesToCSS(
`.x-theme-button--border--${colorKey}`,
buttonBorderColorVariables(theme, color),
),
].join("\n\n"); ].join("\n\n");
} }
@@ -229,7 +254,7 @@ function bannerCSS(
const color = yc(theme, colors?.[colorKey]); const color = yc(theme, colors?.[colorKey]);
if (color == null) return null; if (color == null) return null;
return variablesToCSS(`.x-theme-banner--${colorKey}`, bannerColorVariables(color)); return variablesToCSS(`.x-theme-banner--${colorKey}`, bannerColorVariables(theme, color));
} }
function toastCSS( function toastCSS(
@@ -240,7 +265,7 @@ function toastCSS(
const color = yc(theme, colors?.[colorKey]); const color = yc(theme, colors?.[colorKey]);
if (color == null) return null; if (color == null) return null;
return variablesToCSS(`.x-theme-toast--${colorKey}`, toastColorVariables(color)); return variablesToCSS(`.x-theme-toast--${colorKey}`, toastColorVariables(theme, color));
} }
function templateTagCSS( function templateTagCSS(
@@ -251,7 +276,10 @@ function templateTagCSS(
const color = yc(theme, colors?.[colorKey]); const color = yc(theme, colors?.[colorKey]);
if (color == null) return null; 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 { export function getThemeCSS(theme: Theme): string {
@@ -264,17 +292,25 @@ export function getThemeCSS(theme: Theme): string {
let themeCSS = ""; let themeCSS = "";
try { try {
const baseCss = variablesToCSS(null, themeVariables(theme)); const baseCss = variablesToCSS(null, completeFullColorVariables(theme, theme.base));
const baseSurface = yc(theme, theme.base.surface);
themeCSS = [ themeCSS = [
baseCss, baseCss,
...Object.keys(components).map((key) => componentCSS(theme, key as ComponentName)), ...Object.entries(components).map(([key, value]) =>
variablesToCSS( componentCSS(key as ComponentName, completePartialColorVariables(theme, value ?? {})),
".x-theme-button--solid--default",
buttonSolidColorVariables(yc(theme, theme.base.surface), true),
), ),
variablesToCSS( baseSurface == null
? null
: variablesToCSS(
".x-theme-button--solid--default",
buttonSolidColorVariables(theme, baseSurface, true),
),
baseSurface == null
? null
: variablesToCSS(
".x-theme-button--border--default", ".x-theme-button--border--default",
buttonBorderColorVariables(yc(theme, theme.base.surface), true), buttonBorderColorVariables(theme, baseSurface, true),
), ),
...Object.keys(colors).map((key) => ...Object.keys(colors).map((key) =>
buttonCSS(theme, key as YaakColorKey, theme.components?.button ?? colors), 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 { export function completeTheme(theme: Theme): Theme {
const fallback = theme.dark ? defaultDarkTheme.base : defaultLightTheme.base; const fallback = theme.dark ? defaultDarkTheme.base : defaultLightTheme.base;
const color = (value: string | null | undefined) => yc(theme, value);
theme.base.primary ??= fallback.primary; for (const [key, value] of Object.entries(fallback)) {
theme.base.secondary ??= fallback.secondary; theme.base[key as YaakColorKey] ??= value;
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();
return theme; return theme;
} }
+254 -17
View File
@@ -3,9 +3,9 @@ import parseColor from "parse-color";
export class YaakColor { export class YaakColor {
private readonly appearance: "dark" | "light" = "light"; private readonly appearance: "dark" | "light" = "light";
private hue = 0;
private saturation = 0;
private lightness = 0; private lightness = 0;
private chroma = 0;
private hue = 0;
private alpha = 1; private alpha = 1;
constructor(cssColor: string, appearance: "dark" | "light" = "light") { constructor(cssColor: string, appearance: "dark" | "light" = "light") {
@@ -22,11 +22,11 @@ export class YaakColor {
} }
static white(): 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 { 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 { set(cssColor: string): YaakColor {
@@ -35,11 +35,22 @@ export class YaakColor {
const [r, g, b, a] = hexToRgba(cssColor); const [r, g, b, a] = hexToRgba(cssColor);
fixedCssColor = `rgba(${r},${g},${b},${a})`; fixedCssColor = `rgba(${r},${g},${b},${a})`;
} }
const { hsla } = parseColor(fixedCssColor);
this.hue = hsla[0]; const oklch = parseOklch(fixedCssColor);
this.saturation = hsla[1]; if (oklch != null) {
this.lightness = hsla[2]; this.lightness = oklch.lightness;
this.alpha = hsla[3] ?? 1; 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; return this;
} }
@@ -47,6 +58,10 @@ export class YaakColor {
return new YaakColor(this.css(), this.appearance); return new YaakColor(this.css(), this.appearance);
} }
themeColor(cssColor: string): YaakColor {
return new YaakColor(cssColor, this.appearance);
}
lower(mod: number): YaakColor { lower(mod: number): YaakColor {
return this.appearance === "dark" ? this._darken(mod) : this._lighten(mod); 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); 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 { minLightness(n: number): YaakColor {
const color = this.clone(); const color = this.clone();
if (color.lightness < n) { if (color.lightness < n) {
@@ -69,25 +99,25 @@ export class YaakColor {
translucify(mod: number): YaakColor { translucify(mod: number): YaakColor {
const color = this.clone(); const color = this.clone();
color.alpha = color.alpha - color.alpha * mod; color.alpha = clamp(color.alpha - color.alpha * mod, 0, 1);
return color; return color;
} }
opacify(mod: number): YaakColor { opacify(mod: number): YaakColor {
const color = this.clone(); 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; return color;
} }
desaturate(mod: number): YaakColor { desaturate(mod: number): YaakColor {
const color = this.clone(); const color = this.clone();
color.saturation = color.saturation - color.saturation * mod; color.chroma = color.chroma - color.chroma * mod;
return color; return color;
} }
saturate(mod: number): YaakColor { saturate(mod: number): YaakColor {
const color = this.clone(); const color = this.clone();
color.saturation = this.saturation + (100 - this.saturation) * mod; color.chroma = this.chroma + this.chroma * mod;
return color; return color;
} }
@@ -95,29 +125,236 @@ export class YaakColor {
return this.lightness > color.lightness; 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 { 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); return rgbaToHex(r, g, b, this.alpha);
} }
hexNoAlpha(): string { 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); 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 { private _lighten(mod: number): YaakColor {
const color = this.clone(); 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; return color;
} }
private _darken(mod: number): YaakColor { private _darken(mod: number): YaakColor {
const color = this.clone(); const color = this.clone();
color.lightness = this.lightness - this.lightness * mod; color.lightness = clamp(this.lightness - this.lightness * mod, 0, 100);
return color; 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 { function rgbaToHex(r: number, g: number, b: number, a: number): string {
const toHex = (n: number): string => { const toHex = (n: number): string => {
const hex = Number(Math.round(n)).toString(16); const hex = Number(Math.round(n)).toString(16);
@@ -364,6 +364,8 @@ function TreeItem_<T extends { id: string }>({
ref={handleEditFocus} ref={handleEditFocus}
defaultValue={defaultValue} defaultValue={defaultValue}
placeholder={placeholder} placeholder={placeholder}
autoCapitalize="off"
autoCorrect="off"
className="bg-transparent outline-none w-full cursor-text" className="bg-transparent outline-none w-full cursor-text"
onBlur={handleEditBlur} onBlur={handleEditBlur}
onKeyDown={handleEditKeyDown} 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 { rmSync, cpSync, mkdirSync, existsSync } = require("node:fs");
const { execSync } = require("node:child_process"); 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}` // `${process.platform}_${process.arch}`
const MAC_ARM = "darwin_arm64"; const MAC_ARM = "darwin_arm64";