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

View File

@@ -1,4 +1,6 @@
import type { Completion, CompletionContext } from '@codemirror/autocomplete';
import { startCompletion } from '@codemirror/autocomplete';
import type { TemplateFunction } from '@yaakapp-internal/plugins';
const openTag = '${[ ';
const closeTag = ' ]}';
@@ -11,9 +13,7 @@ export type TwigCompletionOptionNamespace = {
type: 'namespace';
};
export type TwigCompletionOptionFunction = {
args: { name: string }[];
aliases?: string[];
export type TwigCompletionOptionFunction = TemplateFunction & {
type: 'function';
};
@@ -34,32 +34,24 @@ export interface TwigCompletionConfig {
options: TwigCompletionOption[];
}
const MIN_MATCH_VAR = 1;
const MIN_MATCH_NAME = 1;
const MIN_MATCH_NAME = 2;
export function twigCompletion({ options }: TwigCompletionConfig) {
return function completions(context: CompletionContext) {
const toStartOfName = context.matchBefore(/[\w_.]*/);
const toStartOfVariable = context.matchBefore(/\$\{?\[?\s*[\w_]*/);
const toMatch = toStartOfVariable ?? toStartOfName ?? null;
const toMatch = toStartOfName ?? null;
if (toMatch === null) return null;
const matchLen = toMatch.to - toMatch.from;
const failedVarLen = toStartOfVariable !== null && matchLen < MIN_MATCH_VAR;
if (failedVarLen && !context.explicit) {
return null;
}
const failedNameLen = toStartOfVariable === null && matchLen < MIN_MATCH_NAME;
if (failedNameLen && !context.explicit) {
if (toMatch.from >0 && matchLen < MIN_MATCH_NAME) {
return null;
}
const completions: Completion[] = options
.flatMap((o): Completion[] => {
const matchSegments = toStartOfName!.text.split('.');
const matchSegments = toMatch!.text.replace(/^\$/, '').split('.');
const optionSegments = o.name.split('.');
// If not on the last segment, only complete the namespace
@@ -68,8 +60,17 @@ export function twigCompletion({ options }: TwigCompletionConfig) {
return [
{
label: prefix + '.*',
apply: prefix,
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 [
{
label: o.name,
apply: openTag + inner + closeTag,
info: o.description,
detail: o.type,
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);
// TODO: Figure out how to make autocomplete stay open if opened explicitly. It sucks when you explicitly
// open it, then it closes when you type the next character.
const uniqueCompletions = uniqueBy(completions, 'label');
const sortedCompletions = uniqueCompletions.sort((a, b) => {
const boostDiff = defaultBoost(b) - defaultBoost(a);
if (boostDiff !== 0) return boostDiff;
return a.label.localeCompare(b.label);
});
return {
matchLen,
validFor: () => true, // Not really sure why this is all it needs
from: toMatch.from,
matchLen,
options: completions
// Filter out exact matches
.filter((o) => o.label !== toMatch.text),
options: sortedCompletions,
};
};
}
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;
}

View File

@@ -3,7 +3,9 @@ import type { Range } from '@codemirror/state';
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
import { Decoration, ViewPlugin, WidgetType, EditorView } from '@codemirror/view';
import type { SyntaxNodeRef } from '@lezer/common';
import { parseTemplate } from '@yaakapp-internal/templates';
import type { TwigCompletionOption } from './completion';
import { collectArgumentValues } from './util';
class TemplateTagWidget extends WidgetType {
readonly #clickListenerCallback: () => void;
@@ -40,10 +42,7 @@ class TemplateTagWidget extends WidgetType {
}`;
elt.title = this.option.invalid ? 'Not Found' : (this.option.value ?? '');
elt.setAttribute('data-tag-type', this.option.type);
elt.textContent =
this.option.type === 'function'
? `${this.option.name}(${this.option.args.length ? '…' : ''})`
: this.option.name;
elt.textContent = this.option.label;
elt.addEventListener('click', this.#clickListenerCallback);
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 });
widgets.push(deco.range(node.from, node.to));
}

View File

@@ -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;
}