Template Tag Function Editor (#67)

![CleanShot 2024-08-15 at 16 53
09@2x](https://github.com/user-attachments/assets/8c0eb655-1daf-4dc8-811f-f606c770f7dc)
This commit is contained in:
Gregory Schier
2024-08-16 08:31:19 -07:00
committed by GitHub
parent a7f0fadeae
commit aa85ecb618
62 changed files with 1339 additions and 437 deletions

View File

@@ -24,7 +24,7 @@
}
.cm-placeholder {
@apply text-text-subtlest;
@apply text-placeholder;
}
.cm-scroller {
@@ -60,12 +60,12 @@
}
}
.placeholder {
.template-tag {
/* Colors */
@apply bg-surface text-text-subtle border-border-subtle;
@apply hover:border-border-subtle hover:text-text hover:bg-surface-highlight;
@apply border px-1 mx-[0.5px] rounded cursor-default dark:shadow;
@apply inline border px-1 mx-[0.5px] rounded cursor-default dark:shadow;
-webkit-text-security: none;
}

View File

@@ -1,6 +1,7 @@
import { defaultKeymap } from '@codemirror/commands';
import { Compartment, EditorState, type Extension } from '@codemirror/state';
import { keymap, placeholder as placeholderExt, tooltips } from '@codemirror/view';
import type { EnvironmentVariable } from '@yaakapp/api';
import classNames from 'classnames';
import { EditorView } from 'codemirror';
import type { MutableRefObject, ReactNode } from 'react';
@@ -15,9 +16,13 @@ import {
useMemo,
useRef,
} from 'react';
import { useActiveEnvironment } from '../../../hooks/useActiveEnvironment';
import { useActiveWorkspace } from '../../../hooks/useActiveWorkspace';
import { useActiveEnvironmentVariables } from '../../../hooks/useActiveEnvironmentVariables';
import { parseTemplate } from '../../../hooks/useParseTemplate';
import { useSettings } from '../../../hooks/useSettings';
import { type TemplateFunction, useTemplateFunctions } from '../../../hooks/useTemplateFunctions';
import { useDialog } from '../../DialogContext';
import { TemplateFunctionDialog } from '../../TemplateFunctionDialog';
import { TemplateVariableDialog } from '../../TemplateVariableDialog';
import { IconButton } from '../IconButton';
import { HStack } from '../Stacks';
import './Editor.css';
@@ -58,6 +63,8 @@ export interface EditorProps {
actions?: ReactNode;
}
const emptyVariables: EnvironmentVariable[] = [];
export const Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
{
readOnly,
@@ -87,10 +94,9 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
ref,
) {
const s = useSettings();
const [e] = useActiveEnvironment();
const w = useActiveWorkspace();
const environment = autocompleteVariables ? e : null;
const workspace = autocompleteVariables ? w : null;
const templateFunctions = useTemplateFunctions();
const allEnvironmentVariables = useActiveEnvironmentVariables();
const environmentVariables = autocompleteVariables ? allEnvironmentVariables : emptyVariables;
if (s && wrapLines === undefined) {
wrapLines = s.editorSoftWrap;
@@ -148,19 +154,78 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
cm.current?.view.dispatch({ effects: effect });
}, [wrapLines]);
const dialog = useDialog();
const onClickFunction = useCallback(
async (fn: TemplateFunction, tagValue: string, startPos: number) => {
const initialTokens = await parseTemplate(tagValue);
dialog.show({
id: 'template-function',
size: 'sm',
title: 'Configure Function',
render: ({ hide }) => (
<TemplateFunctionDialog
templateFunction={fn}
hide={hide}
initialTokens={initialTokens}
onChange={(insert) => {
cm.current?.view.dispatch({
changes: [{ from: startPos, to: startPos + tagValue.length, insert }],
});
}}
/>
),
});
},
[dialog],
);
const onClickVariable = useCallback(
async (v: EnvironmentVariable, tagValue: string, startPos: number) => {
const initialTokens = await parseTemplate(tagValue);
dialog.show({
size: 'dynamic',
id: 'template-variable',
title: 'Configure Variable',
render: ({ hide }) => (
<TemplateVariableDialog
definition={v}
hide={hide}
initialTokens={initialTokens}
onChange={(insert) => {
cm.current?.view.dispatch({
changes: [{ from: startPos, to: startPos + tagValue.length, insert }],
});
}}
/>
),
});
},
[dialog],
);
// Update language extension when contentType changes
useEffect(() => {
if (cm.current === null) return;
const { view, languageCompartment } = cm.current;
const ext = getLanguageExtension({
contentType,
environment,
workspace,
environmentVariables,
useTemplating,
autocomplete,
templateFunctions,
onClickFunction,
onClickVariable,
});
view.dispatch({ effects: languageCompartment.reconfigure(ext) });
}, [contentType, autocomplete, useTemplating, environment, workspace]);
}, [
contentType,
autocomplete,
useTemplating,
environmentVariables,
templateFunctions,
onClickFunction,
onClickVariable,
]);
// Initialize the editor when ref mounts
const initEditorRef = useCallback(
@@ -178,8 +243,10 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
contentType,
useTemplating,
autocomplete,
environment,
workspace,
environmentVariables,
templateFunctions,
onClickVariable,
onClickFunction,
});
const state = EditorState.create({

View File

@@ -31,9 +31,10 @@ import {
rectangularSelection,
} from '@codemirror/view';
import { tags as t } from '@lezer/highlight';
import type { EnvironmentVariable } from '@yaakapp/api';
import { graphql, graphqlLanguageSupport } from 'cm6-graphql';
import { EditorView } from 'codemirror';
import type { Environment, Workspace } from '@yaakapp/api';
import type { TemplateFunction } from '../../../hooks/useTemplateFunctions';
import type { EditorProps } from './index';
import { pairs } from './pairs/extension';
import { text } from './text/extension';
@@ -78,13 +79,17 @@ const syntaxExtensions: Record<string, LanguageSupport> = {
export function getLanguageExtension({
contentType,
useTemplating = false,
environment,
workspace,
environmentVariables,
autocomplete,
}: { environment: Environment | null; workspace: Workspace | null } & Pick<
EditorProps,
'contentType' | 'useTemplating' | 'autocomplete'
>) {
templateFunctions,
onClickVariable,
onClickFunction,
}: {
environmentVariables: EnvironmentVariable[];
templateFunctions: TemplateFunction[];
onClickFunction: (option: TemplateFunction, tagValue: string, startPos: number) => void;
onClickVariable: (option: EnvironmentVariable, tagValue: string, startPos: number) => void;
} & Pick<EditorProps, 'contentType' | 'useTemplating' | 'autocomplete'>) {
const justContentType = contentType?.split(';')[0] ?? contentType ?? '';
if (justContentType === 'application/graphql') {
return graphql();
@@ -94,7 +99,14 @@ export function getLanguageExtension({
return base;
}
return twig(base, environment, workspace, autocomplete);
return twig({
base,
environmentVariables,
templateFunctions,
autocomplete,
onClickFunction,
onClickVariable,
});
}
export const baseExtensions = [

View File

@@ -75,21 +75,21 @@ const decorator = function () {
return ViewPlugin.fromClass(
class {
placeholders: DecorationSet;
decorations: DecorationSet;
constructor(view: EditorView) {
this.placeholders = placeholderMatcher.createDeco(view);
this.decorations = placeholderMatcher.createDeco(view);
}
update(update: ViewUpdate) {
this.placeholders = placeholderMatcher.updateDeco(update, this.placeholders);
this.decorations = placeholderMatcher.updateDeco(update, this.decorations);
}
},
{
decorations: (instance) => instance.placeholders,
decorations: (instance) => instance.decorations,
provide: (plugin) =>
EditorView.bidiIsolatedRanges.of((view) => {
return view.plugin(plugin)?.placeholders || Decoration.none;
return view.plugin(plugin)?.decorations || Decoration.none;
}),
},
);

View File

@@ -3,8 +3,12 @@ import type { CompletionContext } from '@codemirror/autocomplete';
const openTag = '${[ ';
const closeTag = ' ]}';
interface TwigCompletionOption {
export interface TwigCompletionOption {
name: string;
label: string;
type: 'function' | 'variable' | 'unknown';
value: string | null;
onClick?: (rawTag: string, startPos: number) => void;
}
export interface TwigCompletionConfig {
@@ -41,12 +45,16 @@ export function twigCompletion({ options }: TwigCompletionConfig) {
from: toMatch.from,
options: options
.filter((v) => v.name.trim())
.map((v) => ({
label: toStartOfVariable ? `${openTag}${v.name}${closeTag}` : v.name,
apply: `${openTag}${v.name}${closeTag}`,
type: 'variable',
matchLen: matchLen,
}))
.map((v) => {
const innerLabel = v.type === 'function' ? `${v.name}()` : v.name;
const tagSyntax = openTag + innerLabel + closeTag;
return {
label: innerLabel,
apply: tagSyntax,
type: v.type === 'variable' ? 'variable' : 'function',
matchLen: matchLen,
};
})
// Filter out exact matches
.filter((o) => o.label !== toMatch.text),
};

View File

@@ -1,29 +1,57 @@
import type { LanguageSupport } from '@codemirror/language';
import { LRLanguage } from '@codemirror/language';
import { parseMixed } from '@lezer/common';
import type { Environment, Workspace } from '@yaakapp/api';
import type { EnvironmentVariable } from '@yaakapp/api';
import type { TemplateFunction } from '../../../../hooks/useTemplateFunctions';
import type { GenericCompletionConfig } from '../genericCompletion';
import { genericCompletion } from '../genericCompletion';
import { textLanguageName } from '../text/extension';
import type { TwigCompletionOption } from './completion';
import { twigCompletion } from './completion';
import { placeholders } from './placeholder';
import { templateTags } from './templateTags';
import { parser as twigParser } from './twig';
export function twig(
base: LanguageSupport,
environment: Environment | null,
workspace: Workspace | null,
autocomplete?: GenericCompletionConfig,
) {
export function twig({
base,
environmentVariables,
templateFunctions,
autocomplete,
onClickFunction,
onClickVariable,
}: {
base: LanguageSupport;
environmentVariables: EnvironmentVariable[];
templateFunctions: TemplateFunction[];
autocomplete?: GenericCompletionConfig;
onClickFunction: (option: TemplateFunction, tagValue: string, startPos: number) => void;
onClickVariable: (option: EnvironmentVariable, tagValue: string, startPos: number) => void;
}) {
const language = mixLanguage(base);
const allVariables = [...(workspace?.variables ?? []), ...(environment?.variables ?? [])];
const variables = allVariables.filter((v) => v.enabled) ?? [];
const completions = twigCompletion({ options: variables });
const variableOptions: TwigCompletionOption[] =
environmentVariables.map((v) => ({
...v,
type: 'variable',
label: v.name,
onClick: (rawTag: string, startPos: number) => onClickVariable(v, rawTag, startPos),
})) ?? [];
const functionOptions: TwigCompletionOption[] =
templateFunctions.map((fn) => ({
name: fn.name,
type: 'function',
value: null,
label: fn.name + '(' + fn.args.length + ')',
onClick: (rawTag: string, startPos: number) => onClickFunction(fn, rawTag, startPos),
})) ?? [];
const options = [...variableOptions, ...functionOptions];
const completions = twigCompletion({ options });
return [
language,
base.support,
placeholders(variables),
templateTags(options),
language.data.of({ autocomplete: completions }),
base.language.data.of({ autocomplete: completions }),
language.data.of({ autocomplete: genericCompletion(autocomplete) }),

View File

@@ -1,91 +0,0 @@
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
import { Decoration, EditorView, ViewPlugin, WidgetType } from '@codemirror/view';
import { BetterMatchDecorator } from '../BetterMatchDecorator';
class PlaceholderWidget extends WidgetType {
constructor(
readonly name: string,
readonly value: string,
readonly exists: boolean,
readonly type: 'function' | 'variable' = 'variable',
) {
super();
}
eq(other: PlaceholderWidget) {
return this.name == other.name && this.exists == other.exists;
}
toDOM() {
const elt = document.createElement('span');
elt.className = `x-theme-placeholder placeholder ${
!this.exists
? 'x-theme-placeholder--danger'
: this.type === 'variable'
? 'x-theme-placeholder--primary'
: 'x-theme-placeholder--info'
}`;
elt.title = !this.exists ? 'Variable not found in active environment' : this.value ?? '';
elt.textContent = this.name;
return elt;
}
ignoreEvent() {
return false;
}
}
export const placeholders = function (variables: { name: string; value?: string }[]) {
const placeholderMatcher = new BetterMatchDecorator({
regexp: /\$\{\[\s*([^\]\s]+)\s*]}/g,
decoration(match, view, matchStartPos) {
const matchEndPos = matchStartPos + match[0].length - 1;
// Don't decorate if the cursor is inside the match
for (const r of view.state.selection.ranges) {
if (r.from > matchStartPos && r.to <= matchEndPos) {
return Decoration.replace({});
}
}
const groupMatch = match[1];
if (groupMatch == null) {
// Should never happen, but make TS happy
console.warn('Group match was empty', match);
return Decoration.replace({});
}
const isFunction = groupMatch.includes('(');
return Decoration.replace({
inclusive: true,
widget: new PlaceholderWidget(
groupMatch,
variables.find((v) => v.name === groupMatch)?.value ?? '',
isFunction ? true : variables.some((v) => v.name === groupMatch),
isFunction ? 'function' : 'variable',
),
});
},
});
return ViewPlugin.fromClass(
class {
placeholders: DecorationSet;
constructor(view: EditorView) {
this.placeholders = placeholderMatcher.createDeco(view);
}
update(update: ViewUpdate) {
this.placeholders = placeholderMatcher.updateDeco(update, this.placeholders);
}
},
{
decorations: (instance) => instance.placeholders,
provide: (plugin) =>
EditorView.atomicRanges.of((view) => {
return view.plugin(plugin)?.placeholders || Decoration.none;
}),
},
);
};

View File

@@ -0,0 +1,115 @@
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
import { Decoration, EditorView, ViewPlugin, WidgetType } from '@codemirror/view';
import { truncate } from '../../../../lib/truncate';
import { BetterMatchDecorator } from '../BetterMatchDecorator';
import type { TwigCompletionOption } from './completion';
const TAG_TRUNCATE_LEN = 30;
class TemplateTagWidget extends WidgetType {
readonly #clickListenerCallback: () => void;
constructor(
readonly option: TwigCompletionOption,
readonly rawTag: string,
readonly startPos: number,
) {
super();
this.#clickListenerCallback = () => {
this.option.onClick?.(this.rawTag, this.startPos);
};
}
eq(other: TemplateTagWidget) {
return (
this.option.name === other.option.name &&
this.option.type === other.option.type &&
this.option.value === other.option.value &&
this.rawTag === other.rawTag
);
}
toDOM() {
const elt = document.createElement('span');
elt.className = `x-theme-templateTag template-tag ${
this.option.type === 'unknown'
? 'x-theme-templateTag--danger'
: this.option.type === 'variable'
? 'x-theme-templateTag--primary'
: 'x-theme-templateTag--info'
}`;
elt.title = this.option.type === 'unknown' ? '__NOT_FOUND__' : this.option.value ?? '';
elt.textContent = truncate(
this.rawTag.replace('${[', '').replace(']}', '').trim(),
TAG_TRUNCATE_LEN,
);
elt.addEventListener('click', this.#clickListenerCallback);
return elt;
}
destroy(dom: HTMLElement) {
dom.removeEventListener('click', this.#clickListenerCallback);
super.destroy(dom);
}
ignoreEvent() {
return false;
}
}
export function templateTags(options: TwigCompletionOption[]) {
const templateTagMatcher = new BetterMatchDecorator({
regexp: /\$\{\[\s*([^\]]+)\s*]}/g,
decoration(match, view, matchStartPos) {
const matchEndPos = matchStartPos + match[0].length - 1;
// Don't decorate if the cursor is inside the match
for (const r of view.state.selection.ranges) {
if (r.from > matchStartPos && r.to <= matchEndPos) {
return Decoration.replace({});
}
}
const innerTagMatch = match[1];
if (innerTagMatch == null) {
// Should never happen, but make TS happy
console.warn('Group match was empty', match);
return Decoration.replace({});
}
// TODO: Replace this hacky match with a proper template parser
const name = innerTagMatch.match(/\s*(\w+)[(\s]*/)?.[1] ?? innerTagMatch;
let option = options.find((v) => v.name === name);
if (option == null) {
option = { type: 'unknown', name: innerTagMatch, value: null, label: innerTagMatch };
}
return Decoration.replace({
inclusive: true,
widget: new TemplateTagWidget(option, match[0], matchStartPos),
});
},
});
return ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = templateTagMatcher.createDeco(view);
}
update(update: ViewUpdate) {
this.decorations = templateTagMatcher.updateDeco(update, this.decorations);
}
},
{
decorations: (instance) => instance.decorations,
provide: (plugin) =>
EditorView.atomicRanges.of((view) => {
return view.plugin(plugin)?.decorations || Decoration.none;
}),
},
);
}

View File

@@ -84,7 +84,7 @@ export function Select<T extends string>({
{options.map((o) => {
if (o.type === 'separator') return null;
return (
<option key={o.label} value={o.value}>
<option key={o.value} value={o.value}>
{o.label}
</option>
);