Official 1Password Template Function (#305)

This commit is contained in:
Gregory Schier
2025-11-22 06:08:13 -08:00
committed by GitHub
parent 43a7132014
commit 2bac610efe
20 changed files with 1440 additions and 142 deletions
+1021 -1
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -28,6 +28,7 @@
"plugins/importer-postman", "plugins/importer-postman",
"plugins/importer-postman-environment", "plugins/importer-postman-environment",
"plugins/importer-yaak", "plugins/importer-yaak",
"plugins/template-function-1password",
"plugins/template-function-cookie", "plugins/template-function-cookie",
"plugins/template-function-ctx", "plugins/template-function-ctx",
"plugins/template-function-encode", "plugins/template-function-encode",
+19 -8
View File
@@ -25,7 +25,11 @@ import { Context, PluginDefinition } from '@yaakapp/api';
import console from 'node:console'; import console from 'node:console';
import { type Stats, statSync, watch } from 'node:fs'; import { type Stats, statSync, watch } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { applyDynamicFormInput, applyFormInputDefaults } from './common'; import {
applyDynamicFormInput,
applyFormInputDefaults,
validateTemplateFunctionArgs,
} from './common';
import { EventChannel } from './EventChannel'; import { EventChannel } from './EventChannel';
import { migrateTemplateFunctionSelectOptions } from './migrations'; import { migrateTemplateFunctionSelectOptions } from './migrations';
@@ -334,15 +338,22 @@ export class PluginInstance {
); );
} else if (typeof fn?.onRender === 'function') { } else if (typeof fn?.onRender === 'function') {
const resolvedArgs = await applyDynamicFormInput(ctx, fn.args, payload.args); const resolvedArgs = await applyDynamicFormInput(ctx, fn.args, payload.args);
payload.args.values = applyFormInputDefaults(resolvedArgs, payload.args.values); const values = applyFormInputDefaults(resolvedArgs, payload.args.values);
try { const error = validateTemplateFunctionArgs(fn.name, resolvedArgs, values);
const result = await fn.onRender(ctx, payload.args); if (error && payload.args.purpose !== 'preview') {
this.#sendPayload( this.#sendPayload(
context, context,
{ { type: 'call_template_function_response', value: null, error },
type: 'call_template_function_response', replyId,
value: result ?? null, );
}, return;
}
try {
const result = await fn.onRender(ctx, { ...payload.args, values });
this.#sendPayload(
context,
{ type: 'call_template_function_response', value: result ?? null },
replyId, replyId,
); );
} catch (err) { } catch (err) {
+28 -1
View File
@@ -48,9 +48,36 @@ export async function applyDynamicFormInput(
...(typeof dynamic === 'function' ? await dynamic(ctx, callArgs as any) : undefined), ...(typeof dynamic === 'function' ? await dynamic(ctx, callArgs as any) : undefined),
}; };
if ('inputs' in newArg && Array.isArray(newArg.inputs)) { if ('inputs' in newArg && Array.isArray(newArg.inputs)) {
newArg.inputs = await applyDynamicFormInput(ctx, newArg.inputs, callArgs as any); try {
newArg.inputs = await applyDynamicFormInput(ctx, newArg.inputs, callArgs as any);
} catch (e) {
console.error('Failed to apply dynamic form input', e);
}
} }
resolvedArgs.push(newArg); resolvedArgs.push(newArg);
} }
return resolvedArgs; return resolvedArgs;
} }
export function validateTemplateFunctionArgs(
fnName: string,
args: TemplateFunctionArg[],
values: CallTemplateFunctionArgs['values'],
): string | null {
for (const arg of args) {
if ('inputs' in arg && arg.inputs) {
// Recurse down
const err = validateTemplateFunctionArgs(fnName, arg.inputs, values);
if (err) return err;
}
if (!('name' in arg)) continue;
if (arg.optional) continue;
if (arg.defaultValue != null) continue;
if (arg.hidden) continue;
if (values[arg.name] != null) continue;
return `Missing required argument "${arg.label || arg.name}" for template function ${fnName}()`;
}
return null;
}
@@ -0,0 +1,20 @@
{
"name": "@yaak/template-function-1password",
"displayName": "1Password Template Functions",
"description": "Template function for accessing 1Password secrets",
"private": true,
"version": "0.1.0",
"scripts": {
"build": "run-p build:*",
"build:1-build": "yaakcli build",
"build:2-cpywasm": "cpx '../../node_modules/@1password/sdk-core/nodejs/core_bg.*' build/",
"dev": "yaakcli dev",
"lint": "tsc --noEmit && eslint . --ext .ts,.tsx"
},
"dependencies": {
"@1password/sdk": "^0.4.0-beta.2"
},
"devDependencies": {
"cpx": "^1.5.0"
}
}
@@ -0,0 +1,123 @@
import type { Client } from '@1password/sdk';
import { createClient } from '@1password/sdk';
import type { CallTemplateFunctionArgs } from '@yaakapp-internal/plugins';
import type { PluginDefinition } from '@yaakapp/api';
import crypto from 'crypto';
const _clients: Record<string, Client> = {};
async function op(args: CallTemplateFunctionArgs): Promise<Client | null> {
const token = args.values.token;
if (typeof token !== 'string') return null;
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
try {
_clients[tokenHash] ??= await createClient({
auth: token,
integrationName: 'Yaak 1Password Plugin',
integrationVersion: 'v1.0.0',
});
} catch {
return null;
}
return _clients[tokenHash];
}
export const plugin: PluginDefinition = {
templateFunctions: [
{
name: '1password.item',
description: 'Get a secret',
args: [
{
name: 'token',
type: 'text',
label: '1Password Service Account Token',
description: '',
defaultValue: '${[ONEPASSWORD_TOKEN]}',
password: true,
},
{
name: 'vault',
label: 'Vault',
type: 'select',
options: [],
async dynamic(_ctx, args) {
const client = await op(args);
if (client == null) return { hidden: true };
// Fetches a secret.
const vaults = await client.vaults.list({ decryptDetails: true });
return {
options: vaults.map((vault) => ({
label: `${vault.title} (${vault.activeItemCount} Items)`,
value: vault.id,
})),
};
},
},
{
name: 'item',
label: 'Item',
type: 'select',
options: [],
async dynamic(_ctx, args) {
const client = await op(args);
if (client == null) return { hidden: true };
const vaultId = args.values.vault;
if (typeof vaultId !== 'string') return { hidden: true };
const items = await client.items.list(vaultId);
return {
options: items.map((item) => ({
label: item.title + ' ' + item.category,
value: item.id,
})),
};
},
},
{
name: 'field',
label: 'Field',
type: 'select',
options: [],
async dynamic(_ctx, args) {
const client = await op(args);
if (client == null) return { hidden: true };
const vaultId = args.values.vault;
const itemId = args.values.item;
if (typeof vaultId !== 'string' || typeof itemId !== 'string') {
return { hidden: true };
}
const item = await client.items.get(vaultId, itemId);
return {
options: item.fields.map((field) => ({ label: field.title, value: field.id })),
};
},
},
],
async onRender(_ctx, args) {
const client = await op(args);
if (client == null) throw new Error('Invalid token');
const vaultId = args.values.vault;
const itemId = args.values.item;
const fieldId = args.values.field;
if (
typeof vaultId !== 'string' ||
typeof itemId !== 'string' ||
typeof fieldId !== 'string'
) {
return null;
}
const item = await client.items.get(vaultId, itemId);
const field = item.fields.find((f) => f.id === fieldId);
if (field == null) {
throw new Error('Field not found: ' + fieldId);
}
return field.value ?? '';
},
},
],
};
@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}
@@ -1,4 +1,5 @@
import type { AnyModel, HttpUrlParameter } from '@yaakapp-internal/models'; import type { AnyModel, HttpUrlParameter } from '@yaakapp-internal/models';
import type { GenericCompletionOption } from '@yaakapp-internal/plugins';
import type { CallTemplateFunctionArgs, Context, PluginDefinition } from '@yaakapp/api'; import type { CallTemplateFunctionArgs, Context, PluginDefinition } from '@yaakapp/api';
export const plugin: PluginDefinition = { export const plugin: PluginDefinition = {
@@ -36,6 +37,21 @@ export const plugin: PluginDefinition = {
name: 'header', name: 'header',
label: 'Header Name', label: 'Header Name',
type: 'text', type: 'text',
async dynamic(ctx, args) {
if (typeof args.values.requestId !== 'string') return null;
const request = await ctx.httpRequest.getById({ id: args.values.requestId });
if (request == null) return null;
const validHeaders = request.headers.filter(h => h.enabled !== false && h.name);
return {
placeholder: validHeaders[0]?.name,
completionOptions: validHeaders.map<GenericCompletionOption>((h) => ({
label: h.name,
type: 'constant',
})),
};
},
}, },
], ],
async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> { async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
@@ -1,3 +1,4 @@
import type { GenericCompletionOption } from '@yaakapp-internal/plugins';
import type { JSONPathResult } from '../../template-function-json'; import type { JSONPathResult } from '../../template-function-json';
import { filterJSONPath } from '../../template-function-json'; import { filterJSONPath } from '../../template-function-json';
import type { XPathResult } from '../../template-function-xml'; import type { XPathResult } from '../../template-function-xml';
@@ -68,7 +69,22 @@ export const plugin: PluginDefinition = {
type: 'text', type: 'text',
name: 'header', name: 'header',
label: 'Header Name', label: 'Header Name',
placeholder: 'Content-Type', async dynamic(ctx, args) {
const response = await getResponse(ctx, {
requestId: String(args.values.request || ''),
purpose: args.purpose,
behavior: args.values.behavior ? String(args.values.behavior) : null,
ttl: String(args.values.ttl || ''),
});
return {
placeholder: response?.headers[0]?.name,
completionOptions: response?.headers.map<GenericCompletionOption>((h) => ({
label: h.name,
type: 'constant',
})),
};
},
}, },
], ],
async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> { async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
+1 -1
View File
@@ -11,5 +11,5 @@ for (const name of readdirSync(pluginsDir)) {
const destDir = path.join(__dirname, '../src-tauri/vendored/plugins/', name); const destDir = path.join(__dirname, '../src-tauri/vendored/plugins/', name);
console.log(`Copying ${name} to ${destDir}`); console.log(`Copying ${name} to ${destDir}`);
cpSync(path.join(dir, 'package.json'), path.join(destDir, 'package.json')); cpSync(path.join(dir, 'package.json'), path.join(destDir, 'package.json'));
cpSync(path.join(dir, 'build/index.js'), path.join(destDir, 'build/index.js')); cpSync(path.join(dir, 'build'), path.join(destDir, 'build'), { recursive: true });
} }
+1 -1
View File
@@ -335,7 +335,7 @@ impl Parser {
while self.pos < self.chars.len() { while self.pos < self.chars.len() {
let ch = self.peek_char(); let ch = self.peek_char();
let is_valid = if start_pos == self.pos { let is_valid = if start_pos == self.pos {
ch.is_alphabetic() || ch == '_' // First is more restrictive ch.is_alphanumeric() || ch == '_' // The first char is more restrictive
} else { } else {
ch.is_alphanumeric() || ch == '_' || ch == '-' ch.is_alphanumeric() || ch == '_' || ch == '-'
}; };
+57 -47
View File
@@ -1,3 +1,4 @@
import type { EditorView } from '@codemirror/view';
import type { import type {
Folder, Folder,
GrpcRequest, GrpcRequest,
@@ -5,10 +6,12 @@ import type {
WebsocketRequest, WebsocketRequest,
Workspace, Workspace,
} from '@yaakapp-internal/models'; } from '@yaakapp-internal/models';
import type { FormInput, TemplateFunction } from '@yaakapp-internal/plugins'; import type { TemplateFunction } from '@yaakapp-internal/plugins';
import type { FnArg, Tokens } from '@yaakapp-internal/templates'; import type { FnArg, Tokens } from '@yaakapp-internal/templates';
import { parseTemplate } from '@yaakapp-internal/templates';
import classNames from 'classnames'; import classNames from 'classnames';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { activeWorkspaceAtom } from '../hooks/useActiveWorkspace';
import { useDebouncedValue } from '../hooks/useDebouncedValue'; import { useDebouncedValue } from '../hooks/useDebouncedValue';
import { useRenderTemplate } from '../hooks/useRenderTemplate'; import { useRenderTemplate } from '../hooks/useRenderTemplate';
import { useTemplateFunctionConfig } from '../hooks/useTemplateFunctionConfig'; import { useTemplateFunctionConfig } from '../hooks/useTemplateFunctionConfig';
@@ -17,14 +20,17 @@ import {
useTemplateTokensToString, useTemplateTokensToString,
} from '../hooks/useTemplateTokensToString'; } from '../hooks/useTemplateTokensToString';
import { useToggle } from '../hooks/useToggle'; import { useToggle } from '../hooks/useToggle';
import { showDialog } from '../lib/dialog';
import { convertTemplateToInsecure } from '../lib/encryption'; import { convertTemplateToInsecure } from '../lib/encryption';
import { jotaiStore } from '../lib/jotai';
import { setupOrConfigureEncryption } from '../lib/setupOrConfigureEncryption'; import { setupOrConfigureEncryption } from '../lib/setupOrConfigureEncryption';
import { Button } from './core/Button'; import { Button } from './core/Button';
import { collectArgumentValues } from './core/Editor/twig/util';
import { IconButton } from './core/IconButton'; import { IconButton } from './core/IconButton';
import { InlineCode } from './core/InlineCode'; import { InlineCode } from './core/InlineCode';
import { LoadingIcon } from './core/LoadingIcon'; import { LoadingIcon } from './core/LoadingIcon';
import { PlainInput } from './core/PlainInput'; import { PlainInput } from './core/PlainInput';
import { HStack, VStack } from './core/Stacks'; import { HStack } from './core/Stacks';
import { DYNAMIC_FORM_NULL_ARG, DynamicForm } from './DynamicForm'; import { DYNAMIC_FORM_NULL_ARG, DynamicForm } from './DynamicForm';
interface Props { interface Props {
@@ -115,7 +121,7 @@ function InitializedTemplateFunctionDialog({
}, [argValues, name]); }, [argValues, name]);
const tagText = useTemplateTokensToString(tokens); const tagText = useTemplateTokensToString(tokens);
const templateFunction = useTemplateFunctionConfig(name, argValues, model).data; const templateFunction = useTemplateFunctionConfig(name, argValues, model);
const handleDone = () => { const handleDone = () => {
if (tagText.data) { if (tagText.data) {
@@ -136,7 +142,7 @@ function InitializedTemplateFunctionDialog({
const tooLarge = rendered.data ? rendered.data.length > 10000 : false; const tooLarge = rendered.data ? rendered.data.length > 10000 : false;
const dataContainsSecrets = useMemo(() => { const dataContainsSecrets = useMemo(() => {
for (const [name, value] of Object.entries(argValues)) { for (const [name, value] of Object.entries(argValues)) {
const arg = templateFunction?.args.find((a) => 'name' in a && a.name === name); const arg = templateFunction.data?.args.find((a) => 'name' in a && a.name === name);
const isTextPassword = arg?.type === 'text' && arg.password; const isTextPassword = arg?.type === 'text' && arg.password;
if (isTextPassword && typeof value === 'string' && value && rendered.data?.includes(value)) { if (isTextPassword && typeof value === 'string' && value && rendered.data?.includes(value)) {
return true; return true;
@@ -147,7 +153,13 @@ function InitializedTemplateFunctionDialog({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [rendered.data]); }, [rendered.data]);
if (templateFunction == null) return null; 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 ( return (
<form <form
@@ -172,16 +184,16 @@ function InitializedTemplateFunctionDialog({
<DynamicForm <DynamicForm
autocompleteVariables autocompleteVariables
autocompleteFunctions autocompleteFunctions
inputs={templateFunction.args} inputs={templateFunction.data.args}
data={argValues} data={argValues}
onChange={setArgValues} onChange={setArgValues}
stateKey={`template_function.${templateFunction.name}`} stateKey={`template_function.${templateFunction.data.name}`}
/> />
)} )}
</div> </div>
<div className="px-6 border-t border-t-border py-3 bg-surface-highlight w-full flex flex-col gap-4"> <div className="px-6 border-t border-t-border py-3 bg-surface-highlight w-full flex flex-col gap-4">
{previewType !== 'none' ? ( {previewType !== 'none' ? (
<VStack className="w-full"> <div className="w-full grid grid-cols-1 grid-rows-[auto_auto]">
<HStack space={0.5}> <HStack space={0.5}>
<HStack className="text-sm text-text-subtle" space={1.5}> <HStack className="text-sm text-text-subtle" space={1.5}>
Rendered Preview Rendered Preview
@@ -202,7 +214,7 @@ function InitializedTemplateFunctionDialog({
<InlineCode <InlineCode
className={classNames( className={classNames(
'relative', 'relative',
'whitespace-pre-wrap !select-text cursor-text max-h-[10rem] overflow-y-auto hide-scrollbars !border-text-subtlest', 'whitespace-pre-wrap !select-text cursor-text max-h-[10rem] overflow-auto hide-scrollbars !border-text-subtlest',
tooLarge && 'italic text-danger', tooLarge && 'italic text-danger',
)} )}
> >
@@ -219,25 +231,25 @@ function InitializedTemplateFunctionDialog({
) : ( ) : (
rendered.data || <>&nbsp;</> rendered.data || <>&nbsp;</>
)} )}
<div className="absolute right-0 top-0 bottom-0 flex items-center"> <div className="absolute right-0 top-0 flex items-center">
<IconButton <IconButton
size="xs" size="xs"
icon="refresh" icon="refresh"
className="text-text-subtle" className="text-text-subtle"
title="Refresh preview" title="Refresh preview"
spin={rendered.isLoading} spin={rendered.isPending}
onClick={() => { onClick={() => {
setRenderKey(new Date().toISOString()); setRenderKey(new Date().toISOString());
}} }}
/> />
</div> </div>
</InlineCode> </InlineCode>
</VStack> </div>
) : ( ) : (
<span /> <span />
)} )}
<div className="flex justify-stretch w-full flex-grow gap-2 [&>*]:flex-1"> <div className="flex justify-stretch w-full flex-grow gap-2 [&>*]:flex-1">
{templateFunction.name === 'secure' && ( {templateFunction.data.name === 'secure' && (
<Button variant="border" color="secondary" onClick={setupOrConfigureEncryption}> <Button variant="border" color="secondary" onClick={setupOrConfigureEncryption}>
Reveal Encryption Key Reveal Encryption Key
</Button> </Button>
@@ -251,37 +263,35 @@ function InitializedTemplateFunctionDialog({
); );
} }
/** TemplateFunctionDialog.show = function (
* Process the initial tokens from the template and merge those with the default values pulled from fn: TemplateFunction,
* the template function definition. tagValue: string,
*/ startPos: number,
function collectArgumentValues(initialTokens: Tokens, templateFunction: TemplateFunction) { view: EditorView,
const initial: Record<string, string | boolean> = {}; ) {
const initialArgs = const initialTokens = parseTemplate(tagValue);
initialTokens.tokens[0]?.type === 'tag' && initialTokens.tokens[0]?.val.type === 'fn' showDialog({
? initialTokens.tokens[0]?.val.args id: 'template-function-' + Math.random(), // Allow multiple at once
: []; size: 'md',
className: 'h-[60rem]',
const processArg = (arg: FormInput) => { noPadding: true,
if ('inputs' in arg && arg.inputs) { title: <InlineCode>{fn.name}()</InlineCode>,
arg.inputs.forEach(processArg); description: fn.description,
} render: ({ hide }) => {
if (!('name' in arg)) return; const model = jotaiStore.get(activeWorkspaceAtom)!;
return (
const initialArg = initialArgs.find((a) => a.name === arg.name); <TemplateFunctionDialog
const initialArgValue = templateFunction={fn}
initialArg?.value.type === 'str' model={model}
? initialArg?.value.text hide={hide}
: initialArg?.value.type === 'bool' initialTokens={initialTokens}
? initialArg.value.value onChange={(insert) => {
: undefined; view.dispatch({
const value = initialArgValue ?? arg.defaultValue; changes: [{ from: startPos, to: startPos + tagValue.length, insert }],
if (value != null) { });
initial[arg.name] = value; }}
} />
}; );
},
templateFunction.args.forEach(processArg); });
};
return initial;
}
+4 -30
View File
@@ -10,7 +10,6 @@ import { vscodeKeymap } from '@replit/codemirror-vscode-keymap';
import type { EditorKeymap } from '@yaakapp-internal/models'; import type { EditorKeymap } from '@yaakapp-internal/models';
import { settingsAtom } from '@yaakapp-internal/models'; import { settingsAtom } from '@yaakapp-internal/models';
import type { EditorLanguage, TemplateFunction } from '@yaakapp-internal/plugins'; import type { EditorLanguage, TemplateFunction } from '@yaakapp-internal/plugins';
import { parseTemplate } from '@yaakapp-internal/templates';
import classNames from 'classnames'; import classNames from 'classnames';
import type { GraphQLSchema } from 'graphql'; import type { GraphQLSchema } from 'graphql';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
@@ -27,20 +26,17 @@ import {
useRef, useRef,
} from 'react'; } from 'react';
import { activeEnvironmentAtom } from '../../../hooks/useActiveEnvironment'; import { activeEnvironmentAtom } from '../../../hooks/useActiveEnvironment';
import { activeWorkspaceAtom } from '../../../hooks/useActiveWorkspace';
import type { WrappedEnvironmentVariable } from '../../../hooks/useEnvironmentVariables'; import type { WrappedEnvironmentVariable } from '../../../hooks/useEnvironmentVariables';
import { useEnvironmentVariables } from '../../../hooks/useEnvironmentVariables'; import { useEnvironmentVariables } from '../../../hooks/useEnvironmentVariables';
import { useRandomKey } from '../../../hooks/useRandomKey'; import { useRandomKey } from '../../../hooks/useRandomKey';
import { useRequestEditor } from '../../../hooks/useRequestEditor'; import { useRequestEditor } from '../../../hooks/useRequestEditor';
import { useTemplateFunctionCompletionOptions } from '../../../hooks/useTemplateFunctions'; import { useTemplateFunctionCompletionOptions } from '../../../hooks/useTemplateFunctions';
import { showDialog } from '../../../lib/dialog';
import { editEnvironment } from '../../../lib/editEnvironment'; import { editEnvironment } from '../../../lib/editEnvironment';
import { tryFormatJson, tryFormatXml } from '../../../lib/formatters'; import { tryFormatJson, tryFormatXml } from '../../../lib/formatters';
import { jotaiStore } from '../../../lib/jotai'; import { jotaiStore } from '../../../lib/jotai';
import { withEncryptionEnabled } from '../../../lib/setupOrConfigureEncryption'; import { withEncryptionEnabled } from '../../../lib/setupOrConfigureEncryption';
import { TemplateFunctionDialog } from '../../TemplateFunctionDialog'; import { TemplateFunctionDialog } from '../../TemplateFunctionDialog';
import { IconButton } from '../IconButton'; import { IconButton } from '../IconButton';
import { InlineCode } from '../InlineCode';
import { HStack } from '../Stacks'; import { HStack } from '../Stacks';
import './Editor.css'; import './Editor.css';
import { import {
@@ -285,32 +281,10 @@ export function Editor({
const onClickFunction = useCallback( const onClickFunction = useCallback(
async (fn: TemplateFunction, tagValue: string, startPos: number) => { async (fn: TemplateFunction, tagValue: string, startPos: number) => {
const initialTokens = parseTemplate(tagValue); const show = () => {
const show = () => if (cm.current === null) return;
showDialog({ TemplateFunctionDialog.show(fn, tagValue, startPos, cm.current.view);
id: 'template-function-' + Math.random(), // Allow multiple at once };
size: 'md',
className: 'h-[90vh] max-h-[60rem]',
noPadding: true,
title: <InlineCode>{fn.name}()</InlineCode>,
description: fn.description,
render: ({ hide }) => {
const model = jotaiStore.get(activeWorkspaceAtom)!;
return (
<TemplateFunctionDialog
templateFunction={fn}
model={model}
hide={hide}
initialTokens={initialTokens}
onChange={(insert) => {
cm.current?.view.dispatch({
changes: [{ from: startPos, to: startPos + tagValue.length, insert }],
});
}}
/>
);
},
});
if (fn.name === 'secure') { if (fn.name === 'secure') {
withEncryptionEnabled(show); withEncryptionEnabled(show);
@@ -1,5 +1,6 @@
import type { CompletionContext } from '@codemirror/autocomplete'; import type { CompletionContext } from '@codemirror/autocomplete';
import type { GenericCompletionOption } from '@yaakapp-internal/plugins'; import type { GenericCompletionOption } from '@yaakapp-internal/plugins';
import { defaultBoost } from './twig/completion';
export interface GenericCompletionConfig { export interface GenericCompletionConfig {
minMatch?: number; minMatch?: number;
@@ -23,7 +24,12 @@ export function genericCompletion(config?: GenericCompletionConfig) {
const matchedMinimumLength = toMatch.to - toMatch.from >= minMatch; const matchedMinimumLength = toMatch.to - toMatch.from >= minMatch;
if (!matchedMinimumLength && !context.explicit) return null; if (!matchedMinimumLength && !context.explicit) return null;
const optionsWithoutExactMatches = options.filter((o) => o.label !== toMatch.text); const optionsWithoutExactMatches = options
.filter((o) => o.label !== toMatch.text)
.map((o) => ({
...o,
boost: defaultBoost(o),
}));
return { return {
validFor: () => true, // Not really sure why this is all it needs validFor: () => true, // Not really sure why this is all it needs
from: toMatch.from, from: toMatch.from,
@@ -1,4 +1,6 @@
import type { Completion, CompletionContext } from '@codemirror/autocomplete'; import type { Completion, CompletionContext } from '@codemirror/autocomplete';
import { startCompletion } from '@codemirror/autocomplete';
import type { TemplateFunction } from '@yaakapp-internal/plugins';
const openTag = '${[ '; const openTag = '${[ ';
const closeTag = ' ]}'; const closeTag = ' ]}';
@@ -11,9 +13,7 @@ export type TwigCompletionOptionNamespace = {
type: 'namespace'; type: 'namespace';
}; };
export type TwigCompletionOptionFunction = { export type TwigCompletionOptionFunction = TemplateFunction & {
args: { name: string }[];
aliases?: string[];
type: 'function'; type: 'function';
}; };
@@ -34,32 +34,24 @@ export interface TwigCompletionConfig {
options: TwigCompletionOption[]; options: TwigCompletionOption[];
} }
const MIN_MATCH_VAR = 1; const MIN_MATCH_NAME = 2;
const MIN_MATCH_NAME = 1;
export function twigCompletion({ options }: TwigCompletionConfig) { export function twigCompletion({ options }: TwigCompletionConfig) {
return function completions(context: CompletionContext) { return function completions(context: CompletionContext) {
const toStartOfName = context.matchBefore(/[\w_.]*/); const toStartOfName = context.matchBefore(/[\w_.]*/);
const toStartOfVariable = context.matchBefore(/\$\{?\[?\s*[\w_]*/); const toMatch = toStartOfName ?? null;
const toMatch = toStartOfVariable ?? toStartOfName ?? null;
if (toMatch === null) return null; if (toMatch === null) return null;
const matchLen = toMatch.to - toMatch.from; const matchLen = toMatch.to - toMatch.from;
const failedVarLen = toStartOfVariable !== null && matchLen < MIN_MATCH_VAR; if (toMatch.from >0 && matchLen < MIN_MATCH_NAME) {
if (failedVarLen && !context.explicit) {
return null;
}
const failedNameLen = toStartOfVariable === null && matchLen < MIN_MATCH_NAME;
if (failedNameLen && !context.explicit) {
return null; return null;
} }
const completions: Completion[] = options const completions: Completion[] = options
.flatMap((o): Completion[] => { .flatMap((o): Completion[] => {
const matchSegments = toStartOfName!.text.split('.'); const matchSegments = toMatch!.text.replace(/^\$/, '').split('.');
const optionSegments = o.name.split('.'); const optionSegments = o.name.split('.');
// If not on the last segment, only complete the namespace // If not on the last segment, only complete the namespace
@@ -68,8 +60,17 @@ export function twigCompletion({ options }: TwigCompletionConfig) {
return [ return [
{ {
label: prefix + '.*', label: prefix + '.*',
apply: prefix,
type: 'namespace', type: 'namespace',
detail: 'namespace',
apply: (view, _completion, from, to) => {
const insert = `${prefix}.`;
view.dispatch({
changes: { from, to, insert: insert },
selection: { anchor: from + insert.length },
});
// Leave the autocomplete open so the user can continue typing the rest of the namespace
startCompletion(view);
},
}, },
]; ];
} }
@@ -79,24 +80,49 @@ export function twigCompletion({ options }: TwigCompletionConfig) {
return [ return [
{ {
label: o.name, label: o.name,
apply: openTag + inner + closeTag,
info: o.description, info: o.description,
detail: o.type, detail: o.type,
type: o.type === 'variable' ? 'variable' : 'function', type: o.type === 'variable' ? 'variable' : 'function',
apply: (view, _completion, from, to) => {
const insert = openTag + inner + closeTag;
view.dispatch({
changes: { from, to, insert: insert },
selection: { anchor: from + insert.length },
});
},
}, },
]; ];
}) })
.filter((v) => v != null); .filter((v) => v != null);
// TODO: Figure out how to make autocomplete stay open if opened explicitly. It sucks when you explicitly const uniqueCompletions = uniqueBy(completions, 'label');
// open it, then it closes when you type the next character. const sortedCompletions = uniqueCompletions.sort((a, b) => {
const boostDiff = defaultBoost(b) - defaultBoost(a);
if (boostDiff !== 0) return boostDiff;
return a.label.localeCompare(b.label);
});
return { return {
matchLen,
validFor: () => true, // Not really sure why this is all it needs validFor: () => true, // Not really sure why this is all it needs
from: toMatch.from, from: toMatch.from,
matchLen, options: sortedCompletions,
options: completions
// Filter out exact matches
.filter((o) => o.label !== toMatch.text),
}; };
}; };
} }
export function uniqueBy<T, K extends keyof T>(arr: T[], key: K): T[] {
const map = new Map<T[K], T>();
for (const item of arr) {
map.set(item[key], item); // overwrites → keeps last
}
return [...map.values()];
}
export function defaultBoost(o: Completion) {
if (o.type === 'variable') return 4;
if (o.type === 'constant') return 3;
if (o.type === 'function') return 2;
if (o.type === 'namespace') return 1;
return 0;
}
@@ -3,7 +3,9 @@ import type { Range } from '@codemirror/state';
import type { DecorationSet, ViewUpdate } from '@codemirror/view'; import type { DecorationSet, ViewUpdate } from '@codemirror/view';
import { Decoration, ViewPlugin, WidgetType, EditorView } from '@codemirror/view'; import { Decoration, ViewPlugin, WidgetType, EditorView } from '@codemirror/view';
import type { SyntaxNodeRef } from '@lezer/common'; import type { SyntaxNodeRef } from '@lezer/common';
import { parseTemplate } from '@yaakapp-internal/templates';
import type { TwigCompletionOption } from './completion'; import type { TwigCompletionOption } from './completion';
import { collectArgumentValues } from './util';
class TemplateTagWidget extends WidgetType { class TemplateTagWidget extends WidgetType {
readonly #clickListenerCallback: () => void; readonly #clickListenerCallback: () => void;
@@ -40,10 +42,7 @@ class TemplateTagWidget extends WidgetType {
}`; }`;
elt.title = this.option.invalid ? 'Not Found' : (this.option.value ?? ''); elt.title = this.option.invalid ? 'Not Found' : (this.option.value ?? '');
elt.setAttribute('data-tag-type', this.option.type); elt.setAttribute('data-tag-type', this.option.type);
elt.textContent = elt.textContent = this.option.label;
this.option.type === 'function'
? `${this.option.name}(${this.option.args.length ? '…' : ''})`
: this.option.name;
elt.addEventListener('click', this.#clickListenerCallback); elt.addEventListener('click', this.#clickListenerCallback);
return elt; return elt;
} }
@@ -107,7 +106,20 @@ function templateTags(
}; };
} }
const widget = new TemplateTagWidget(option, rawTag, node.from); let invalid = false;
if (option.type === 'function') {
const tokens = parseTemplate(rawTag);
const values = collectArgumentValues(tokens, option);
for (const arg of option.args) {
if (!('optional' in arg)) continue;
if (!arg.optional && values[arg.name] == null) {
invalid = true;
break;
}
}
}
const widget = new TemplateTagWidget({ ...option, invalid }, rawTag, node.from);
const deco = Decoration.replace({ widget, inclusive: true }); const deco = Decoration.replace({ widget, inclusive: true });
widgets.push(deco.range(node.from, node.to)); widgets.push(deco.range(node.from, node.to));
} }
@@ -0,0 +1,37 @@
import type { FormInput, TemplateFunction } from '@yaakapp-internal/plugins';
import type { Tokens } from '@yaakapp-internal/templates';
/**
* Process the initial tokens from the template and merge those with the default values pulled from
* the template function definition.
*/
export function collectArgumentValues(initialTokens: Tokens, templateFunction: TemplateFunction) {
const initial: Record<string, string | boolean> = {};
const initialArgs =
initialTokens.tokens[0]?.type === 'tag' && initialTokens.tokens[0]?.val.type === 'fn'
? initialTokens.tokens[0]?.val.args
: [];
const processArg = (arg: FormInput) => {
if ('inputs' in arg && arg.inputs) {
arg.inputs.forEach(processArg);
}
if (!('name' in arg)) return;
const initialArg = initialArgs.find((a) => a.name === arg.name);
const initialArgValue =
initialArg?.value.type === 'str'
? initialArg?.value.text
: initialArg?.value.type === 'bool'
? initialArg.value.value
: undefined;
const value = initialArgValue ?? arg.defaultValue;
if (value != null) {
initial[arg.name] = value;
}
};
templateFunction.args.forEach(processArg);
return initial;
}
+3 -1
View File
@@ -24,6 +24,7 @@ export interface SelectProps<T extends string> {
size?: ButtonProps['size']; size?: ButtonProps['size'];
className?: string; className?: string;
disabled?: boolean; disabled?: boolean;
filterable?: boolean;
} }
export function Select<T extends string>({ export function Select<T extends string>({
@@ -40,6 +41,7 @@ export function Select<T extends string>({
onChange, onChange,
className, className,
defaultValue, defaultValue,
filterable,
size = 'md', size = 'md',
}: SelectProps<T>) { }: SelectProps<T>) {
const [focused, setFocused] = useState<boolean>(false); const [focused, setFocused] = useState<boolean>(false);
@@ -64,7 +66,7 @@ export function Select<T extends string>({
<Label htmlFor={id} visuallyHidden={hideLabel} className={labelClassName} help={help}> <Label htmlFor={id} visuallyHidden={hideLabel} className={labelClassName} help={help}>
{label} {label}
</Label> </Label>
{type() === 'macos' ? ( {type() === 'macos' && !filterable ? (
<HStack <HStack
space={2} space={2}
className={classNames( className={classNames(
+4 -1
View File
@@ -6,7 +6,7 @@ import type {
WebsocketRequest, WebsocketRequest,
Workspace, Workspace,
} from '@yaakapp-internal/models'; } from '@yaakapp-internal/models';
import { httpResponsesAtom } from '@yaakapp-internal/models'; import { environmentsAtom, httpResponsesAtom } from '@yaakapp-internal/models';
import type { GetTemplateFunctionConfigResponse, JsonPrimitive } from '@yaakapp-internal/plugins'; import type { GetTemplateFunctionConfigResponse, JsonPrimitive } from '@yaakapp-internal/plugins';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { md5 } from 'js-md5'; import { md5 } from 'js-md5';
@@ -22,6 +22,8 @@ export function useTemplateFunctionConfig(
const workspaceId = useAtomValue(activeWorkspaceIdAtom); const workspaceId = useAtomValue(activeWorkspaceIdAtom);
const environmentId = useAtomValue(activeEnvironmentIdAtom); const environmentId = useAtomValue(activeEnvironmentIdAtom);
const responses = useAtomValue(httpResponsesAtom); const responses = useAtomValue(httpResponsesAtom);
const environments = useAtomValue(environmentsAtom);
const environmentsKey = environments.map((e) => e.id + e.updatedAt).join(':');
// Some auth handlers like OAuth 2.0 show the current token after a successful request. To // Some auth handlers like OAuth 2.0 show the current token after a successful request. To
// handle that, we'll force the auth to re-fetch after each new response closes // handle that, we'll force the auth to re-fetch after each new response closes
@@ -41,6 +43,7 @@ export function useTemplateFunctionConfig(
responseKey, responseKey,
workspaceId, workspaceId,
environmentId, environmentId,
environmentsKey,
], ],
placeholderData: (prev) => prev, // Keep previous data on refetch placeholderData: (prev) => prev, // Keep previous data on refetch
queryFn: async () => { queryFn: async () => {
+12 -21
View File
@@ -20,27 +20,18 @@ export function useTemplateFunctionCompletionOptions(
if (!enabled) { if (!enabled) {
return []; return [];
} }
return ( return templateFunctions.map((fn) => {
templateFunctions.map((fn) => { const argsLabel = fn.args.length > 0 ? '…' : '';
const NUM_ARGS = 2; const fn2: TwigCompletionOption = {
const argsWithName = fn.args.filter((a) => 'name' in a); type: 'function',
const shortArgs = onClick: (rawTag: string, startPos: number) => onClick(fn, rawTag, startPos),
argsWithName label: `${fn.name}(${argsLabel})`,
.slice(0, NUM_ARGS) invalid: false,
.map((a) => a.name) value: null,
.join(', ') + (fn.args.length > NUM_ARGS ? ', …' : ''); ...fn,
return { };
name: fn.name, return fn2;
aliases: fn.aliases, });
type: 'function',
description: fn.description,
args: argsWithName.map((a) => ({ name: a.name })),
value: null,
label: `${fn.name}(${shortArgs})`,
onClick: (rawTag: string, startPos: number) => onClick(fn, rawTag, startPos),
};
}) ?? []
);
}, [enabled, onClick, templateFunctions]); }, [enabled, onClick, templateFunctions]);
} }