mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-25 10:51:57 +01:00
Add .oxfmtignore to skip generated bindings and wasm-pack output. Add npm format script, update DEVELOPMENT.md for Vite+ toolchain, and format all non-generated files with oxfmt. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
300 lines
10 KiB
TypeScript
300 lines
10 KiB
TypeScript
import type { EditorView } from "@codemirror/view";
|
|
import type {
|
|
Folder,
|
|
GrpcRequest,
|
|
HttpRequest,
|
|
WebsocketRequest,
|
|
Workspace,
|
|
} from "@yaakapp-internal/models";
|
|
import type { TemplateFunction } from "@yaakapp-internal/plugins";
|
|
import type { FnArg, Tokens } from "@yaakapp-internal/templates";
|
|
import { parseTemplate } from "@yaakapp-internal/templates";
|
|
import classNames from "classnames";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
|
import { useDebouncedValue } from "../hooks/useDebouncedValue";
|
|
import { useRenderTemplate } from "../hooks/useRenderTemplate";
|
|
import { useTemplateFunctionConfig } from "../hooks/useTemplateFunctionConfig";
|
|
import {
|
|
templateTokensToString,
|
|
useTemplateTokensToString,
|
|
} from "../hooks/useTemplateTokensToString";
|
|
import { useToggle } from "../hooks/useToggle";
|
|
import { showDialog } from "../lib/dialog";
|
|
import { convertTemplateToInsecure } from "../lib/encryption";
|
|
import { jotaiStore } from "../lib/jotai";
|
|
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
|
|
import { Button } from "./core/Button";
|
|
import { collectArgumentValues } from "./core/Editor/twig/util";
|
|
import { IconButton } from "./core/IconButton";
|
|
import { InlineCode } from "./core/InlineCode";
|
|
import { LoadingIcon } from "./core/LoadingIcon";
|
|
import { PlainInput } from "./core/PlainInput";
|
|
import { HStack } from "./core/Stacks";
|
|
import { DYNAMIC_FORM_NULL_ARG, DynamicForm } from "./DynamicForm";
|
|
|
|
interface Props {
|
|
templateFunction: TemplateFunction;
|
|
initialTokens: Tokens;
|
|
hide: () => void;
|
|
onChange: (insert: string) => void;
|
|
model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace;
|
|
}
|
|
|
|
export function TemplateFunctionDialog({ initialTokens, templateFunction, ...props }: Props) {
|
|
const [initialArgValues, setInitialArgValues] = useState<Record<string, string | boolean> | null>(
|
|
null,
|
|
);
|
|
useEffect(() => {
|
|
if (initialArgValues != null) {
|
|
return;
|
|
}
|
|
|
|
(async () => {
|
|
const initial = collectArgumentValues(initialTokens, templateFunction);
|
|
|
|
// HACK: Replace the secure() function's encrypted `value` arg with the decrypted version so
|
|
// we can display it in the editor input.
|
|
if (templateFunction.name === "secure") {
|
|
const template = await templateTokensToString(initialTokens);
|
|
initial.value = await convertTemplateToInsecure(template);
|
|
}
|
|
|
|
setInitialArgValues(initial);
|
|
})().catch(console.error);
|
|
}, [
|
|
initialArgValues,
|
|
initialTokens,
|
|
initialTokens.tokens,
|
|
templateFunction,
|
|
templateFunction.args,
|
|
templateFunction.name,
|
|
]);
|
|
|
|
if (initialArgValues == null) return null;
|
|
|
|
return (
|
|
<InitializedTemplateFunctionDialog
|
|
{...props}
|
|
templateFunction={templateFunction}
|
|
initialArgValues={initialArgValues}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function InitializedTemplateFunctionDialog({
|
|
templateFunction: { name, previewType: ogPreviewType },
|
|
initialArgValues,
|
|
hide,
|
|
onChange,
|
|
model,
|
|
}: Omit<Props, "initialTokens"> & {
|
|
initialArgValues: Record<string, string | boolean>;
|
|
}) {
|
|
const previewType = ogPreviewType == null ? "live" : ogPreviewType;
|
|
const [showSecretsInPreview, toggleShowSecretsInPreview] = useToggle(false);
|
|
const [argValues, setArgValues] = useState<Record<string, string | boolean>>(initialArgValues);
|
|
|
|
const tokens: Tokens = useMemo(() => {
|
|
const argTokens: FnArg[] = Object.keys(argValues).map((name) => ({
|
|
name,
|
|
value:
|
|
argValues[name] === DYNAMIC_FORM_NULL_ARG
|
|
? { type: "null" }
|
|
: typeof argValues[name] === "boolean"
|
|
? { type: "bool", value: argValues[name] === true }
|
|
: { type: "str", text: String(argValues[name] ?? "") },
|
|
}));
|
|
|
|
return {
|
|
tokens: [
|
|
{
|
|
type: "tag",
|
|
val: {
|
|
type: "fn",
|
|
name,
|
|
args: argTokens,
|
|
},
|
|
},
|
|
],
|
|
};
|
|
}, [argValues, name]);
|
|
|
|
const tagText = useTemplateTokensToString(tokens);
|
|
const templateFunction = useTemplateFunctionConfig(name, argValues, model);
|
|
|
|
const handleDone = () => {
|
|
if (tagText.data) {
|
|
onChange(tagText.data);
|
|
}
|
|
hide();
|
|
};
|
|
|
|
const debouncedTagText = useDebouncedValue(tagText.data ?? "", 400);
|
|
const [renderKey, setRenderKey] = useState<string | null>(null);
|
|
const rendered = useRenderTemplate({
|
|
template: debouncedTagText,
|
|
enabled: previewType !== "none",
|
|
purpose: previewType === "click" ? "send" : "preview",
|
|
refreshKey: previewType === "live" ? renderKey + debouncedTagText : renderKey,
|
|
ignoreError: false,
|
|
});
|
|
|
|
const tooLarge = rendered.data ? rendered.data.length > 10000 : false;
|
|
// oxlint-disable-next-line react-hooks/exhaustive-deps -- Only update this on rendered data change to keep secrets hidden on input change
|
|
const dataContainsSecrets = useMemo(() => {
|
|
for (const [name, value] of Object.entries(argValues)) {
|
|
const arg = templateFunction.data?.args.find((a) => "name" in a && a.name === name);
|
|
const isTextPassword = arg?.type === "text" && arg.password;
|
|
if (isTextPassword && typeof value === "string" && value && rendered.data?.includes(value)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}, [rendered.data]);
|
|
|
|
if (templateFunction.data == null || templateFunction.isPending) {
|
|
return (
|
|
<div className="h-full w-full flex items-center justify-center">
|
|
<LoadingIcon size="xl" className="text-text-subtlest" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<form
|
|
className="grid grid-rows-[minmax(0,1fr)_auto_auto] h-full max-h-[90vh]"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
handleDone();
|
|
}}
|
|
>
|
|
<div className="overflow-y-auto h-full px-6">
|
|
{name === "secure" ? (
|
|
<PlainInput
|
|
required
|
|
label="Value"
|
|
name="value"
|
|
type="password"
|
|
placeholder="••••••••••••"
|
|
defaultValue={String(argValues.value ?? "")}
|
|
onChange={(value) => setArgValues({ ...argValues, value })}
|
|
/>
|
|
) : (
|
|
<DynamicForm
|
|
autocompleteVariables
|
|
autocompleteFunctions
|
|
inputs={templateFunction.data.args}
|
|
data={argValues}
|
|
onChange={setArgValues}
|
|
stateKey={`template_function.${templateFunction.data.name}`}
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className="px-6 border-t border-t-border pt-3 pb-6 bg-surface-highlight w-full flex flex-col gap-4">
|
|
{previewType !== "none" ? (
|
|
<div className="w-full grid grid-cols-1 grid-rows-[auto_auto]">
|
|
<HStack space={0.5}>
|
|
<HStack className="text-sm text-text-subtle" space={1.5}>
|
|
Rendered Preview
|
|
{rendered.isLoading && <LoadingIcon size="xs" />}
|
|
</HStack>
|
|
<IconButton
|
|
size="xs"
|
|
iconSize="sm"
|
|
icon={showSecretsInPreview ? "lock" : "lock_open"}
|
|
title={showSecretsInPreview ? "Show preview" : "Hide preview"}
|
|
onClick={toggleShowSecretsInPreview}
|
|
className={classNames(
|
|
"ml-auto text-text-subtlest",
|
|
!dataContainsSecrets && "invisible",
|
|
)}
|
|
/>
|
|
</HStack>
|
|
<div className="relative w-full max-h-[10rem]">
|
|
<InlineCode
|
|
className={classNames(
|
|
"block whitespace-pre-wrap !select-text cursor-text max-h-[10rem] overflow-auto hide-scrollbars !border-text-subtlest",
|
|
tooLarge && "italic text-danger",
|
|
)}
|
|
>
|
|
{rendered.error || tagText.error ? (
|
|
<em className="text-danger">
|
|
{`${rendered.error || tagText.error}`.replace(/^Render Error: /, "")}
|
|
</em>
|
|
) : dataContainsSecrets && !showSecretsInPreview ? (
|
|
<span className="italic text-text-subtle">
|
|
------ sensitive values hidden ------
|
|
</span>
|
|
) : tooLarge ? (
|
|
"too large to preview"
|
|
) : (
|
|
rendered.data || <> </>
|
|
)}
|
|
</InlineCode>
|
|
<div className="absolute right-0.5 top-0 bottom-0 flex items-center">
|
|
<IconButton
|
|
size="xs"
|
|
icon="refresh"
|
|
className="text-text-subtle"
|
|
title="Refresh preview"
|
|
spin={rendered.isPending}
|
|
onClick={() => {
|
|
setRenderKey(new Date().toISOString());
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<span />
|
|
)}
|
|
<div className="flex justify-stretch w-full flex-grow gap-2 [&>*]:flex-1">
|
|
{templateFunction.data.name === "secure" && (
|
|
<Button variant="border" color="secondary" onClick={setupOrConfigureEncryption}>
|
|
Reveal Encryption Key
|
|
</Button>
|
|
)}
|
|
<Button type="submit" color="primary">
|
|
Save
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
TemplateFunctionDialog.show = (
|
|
fn: TemplateFunction,
|
|
tagValue: string,
|
|
startPos: number,
|
|
view: EditorView,
|
|
) => {
|
|
const initialTokens = parseTemplate(tagValue);
|
|
showDialog({
|
|
id: `template-function-${Math.random()}`, // Allow multiple at once
|
|
size: "md",
|
|
className: "h-[60rem]",
|
|
noPadding: true,
|
|
title: <InlineCode>{fn.name}(…)</InlineCode>,
|
|
description: fn.description,
|
|
render: ({ hide }) => {
|
|
const model = jotaiStore.get(activeWorkspaceAtom);
|
|
if (model == null) return null;
|
|
return (
|
|
<TemplateFunctionDialog
|
|
templateFunction={fn}
|
|
model={model}
|
|
hide={hide}
|
|
initialTokens={initialTokens}
|
|
onChange={(insert) => {
|
|
view.dispatch({
|
|
changes: [{ from: startPos, to: startPos + tagValue.length, insert }],
|
|
});
|
|
}}
|
|
/>
|
|
);
|
|
},
|
|
});
|
|
};
|