Hacky implementation of variable autocomplete

This commit is contained in:
Gregory Schier
2023-10-23 10:31:21 -07:00
parent 4ad5d7f291
commit 83e2cab1b6
7 changed files with 83 additions and 61 deletions

View File

@@ -20,7 +20,6 @@ interface Props {
export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Props) { export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Props) {
const environments = useEnvironments(); const environments = useEnvironments();
const updateEnvironment = useUpdateEnvironment();
const activeRequest = useActiveRequest(); const activeRequest = useActiveRequest();
const dialog = useDialog(); const dialog = useDialog();
@@ -82,7 +81,11 @@ const EnvironmentList = function({ environment }: EnvironmentListProps) {
className='w-full h-[400px] !bg-gray-50' className='w-full h-[400px] !bg-gray-50'
defaultValue={JSON.stringify(environment.data, null, 2)} defaultValue={JSON.stringify(environment.data, null, 2)}
onChange={data => { onChange={data => {
updateEnvironment.mutate({ data: JSON.parse(data) }); try {
updateEnvironment.mutate({ data: JSON.parse(data) });
} catch (err) {
// That's okay
}
}} }}
/> />
</div> </div>

View File

@@ -12,6 +12,7 @@ import './Editor.css';
import { baseExtensions, getLanguageExtension, multiLineExtensions } from './extensions'; import { baseExtensions, getLanguageExtension, multiLineExtensions } from './extensions';
import type { GenericCompletionConfig } from './genericCompletion'; import type { GenericCompletionConfig } from './genericCompletion';
import { singleLineExt } from './singleLine'; import { singleLineExt } from './singleLine';
import { useEnvironments } from '../../../hooks/useEnvironments';
// Export some things so all the code-split parts are in this file // Export some things so all the code-split parts are in this file
export { buildClientSchema, getIntrospectionQuery } from 'graphql/utilities'; export { buildClientSchema, getIntrospectionQuery } from 'graphql/utilities';
@@ -64,6 +65,9 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
}: EditorProps, }: EditorProps,
ref, ref,
) { ) {
const environments = useEnvironments();
const environment = environments[0] ?? null;
const cm = useRef<{ view: EditorView; languageCompartment: Compartment } | null>(null); const cm = useRef<{ view: EditorView; languageCompartment: Compartment } | null>(null);
useImperativeHandle(ref, () => cm.current?.view); useImperativeHandle(ref, () => cm.current?.view);
@@ -108,9 +112,9 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
useEffect(() => { useEffect(() => {
if (cm.current === null) return; if (cm.current === null) return;
const { view, languageCompartment } = cm.current; const { view, languageCompartment } = cm.current;
const ext = getLanguageExtension({ contentType, useTemplating, autocomplete }); const ext = getLanguageExtension({ contentType, environment, useTemplating, autocomplete });
view.dispatch({ effects: languageCompartment.reconfigure(ext) }); view.dispatch({ effects: languageCompartment.reconfigure(ext) });
}, [contentType, autocomplete, useTemplating]); }, [contentType, autocomplete, useTemplating, environment]);
useEffect(() => { useEffect(() => {
if (cm.current === null) return; if (cm.current === null) return;
@@ -131,7 +135,7 @@ const _Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
let view: EditorView; let view: EditorView;
try { try {
const languageCompartment = new Compartment(); const languageCompartment = new Compartment();
const langExt = getLanguageExtension({ contentType, useTemplating, autocomplete }); const langExt = getLanguageExtension({ contentType, useTemplating, autocomplete, environment });
const state = EditorState.create({ const state = EditorState.create({
doc: `${defaultValue ?? ''}`, doc: `${defaultValue ?? ''}`,
@@ -238,21 +242,21 @@ function getExtensions({
: []), : []),
...(singleLine ...(singleLine
? [ ? [
EditorView.domEventHandlers({ EditorView.domEventHandlers({
focus: (e, view) => { focus: (_, view) => {
// select all text on focus, like a regular input does // select all text on focus, like a regular input does
view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } }); view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } });
}, },
keydown: (e) => { keydown: (e) => {
// Submit nearest form on enter if there is one // Submit nearest form on enter if there is one
if (e.key === 'Enter') { if (e.key === 'Enter') {
const el = e.currentTarget as HTMLElement; const el = e.currentTarget as HTMLElement;
const form = el.closest('form'); const form = el.closest('form');
form?.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true })); form?.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
} }
}, },
}), }),
] ]
: []), : []),
// Handle onFocus // Handle onFocus

View File

@@ -37,6 +37,7 @@ import type { EditorProps } from './index';
import { text } from './text/extension'; import { text } from './text/extension';
import { twig } from './twig/extension'; import { twig } from './twig/extension';
import { url } from './url/extension'; import { url } from './url/extension';
import type { Environment } from '../../../lib/models';
export const myHighlightStyle = HighlightStyle.define([ export const myHighlightStyle = HighlightStyle.define([
{ {
@@ -95,8 +96,9 @@ const syntaxExtensions: Record<string, LanguageSupport> = {
export function getLanguageExtension({ export function getLanguageExtension({
contentType, contentType,
useTemplating = false, useTemplating = false,
environment,
autocomplete, autocomplete,
}: Pick<EditorProps, 'contentType' | 'useTemplating' | 'autocomplete'>) { }: { environment: Environment | null } & Pick<EditorProps, 'contentType' | 'useTemplating' | 'autocomplete'>) {
if (contentType === 'application/graphql') { if (contentType === 'application/graphql') {
return graphql(); return graphql();
} }
@@ -106,7 +108,7 @@ export function getLanguageExtension({
return base ? base : []; return base ? base : [];
} }
return twig(base, autocomplete); return twig(base, environment, autocomplete);
} }
export const baseExtensions = [ export const baseExtensions = [

View File

@@ -24,6 +24,6 @@ export function genericCompletion({ options, minMatch = 1 }: GenericCompletionCo
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);
return { from: toMatch.from, options: optionsWithoutExactMatches, info: 'hello' }; return { from: toMatch.from, options: optionsWithoutExactMatches, info: 'hello', };
}; };
} }

View File

@@ -6,8 +6,8 @@ export function singleLineExt() {
(tr: Transaction): TransactionSpec | TransactionSpec[] => { (tr: Transaction): TransactionSpec | TransactionSpec[] => {
if (!tr.isUserEvent('input')) return tr; if (!tr.isUserEvent('input')) return tr;
const trs: TransactionSpec[] = []; const specs: TransactionSpec[] = [];
tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => { tr.changes.iterChanges((_, toA, fromB, toB, inserted) => {
let insert = ''; let insert = '';
let newlinesRemoved = 0; let newlinesRemoved = 0;
for (const line of inserted) { for (const line of inserted) {
@@ -21,9 +21,10 @@ export function singleLineExt() {
const selection = EditorSelection.create([cursor], 0); const selection = EditorSelection.create([cursor], 0);
const changes = [{ from: fromB, to: toA, insert }]; const changes = [{ from: fromB, to: toA, insert }];
trs.push({ ...tr, selection, changes }); specs.push({ ...tr, selection, changes });
}); });
return trs;
return specs;
}, },
); );
} }

View File

@@ -3,44 +3,50 @@ import type { CompletionContext } from '@codemirror/autocomplete';
const openTag = '${[ '; const openTag = '${[ ';
const closeTag = ' ]}'; const closeTag = ' ]}';
const variables: { name: string }[] = [ interface TwigCompletionOption {
{ name: 'foo' } name: string;
]; }
export interface TwigCompletionConfig {
options: TwigCompletionOption[];
}
const MIN_MATCH_VAR = 2; const MIN_MATCH_VAR = 2;
const MIN_MATCH_NAME = 3; const MIN_MATCH_NAME = 3;
export function completions(context: CompletionContext) { export function twigCompletion({ options }: TwigCompletionConfig) {
const toStartOfName = context.matchBefore(/\w*/); return function completions(context: CompletionContext) {
const toStartOfVariable = context.matchBefore(/\$\{?\[?\s*\w*/); const toStartOfName = context.matchBefore(/\w*/);
const toMatch = toStartOfVariable ?? toStartOfName ?? null; const toStartOfVariable = context.matchBefore(/\$\{?\[?\s*\w*/);
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; const failedVarLen = toStartOfVariable !== null && matchLen < MIN_MATCH_VAR;
if (failedVarLen && !context.explicit) { if (failedVarLen && !context.explicit) {
return null; return null;
}
const failedNameLen = toStartOfVariable === null && matchLen < MIN_MATCH_NAME;
if (failedNameLen && !context.explicit) {
return 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.
return {
from: toMatch.from,
options: options
.map((v) => ({
label: toStartOfVariable ? `${openTag}${v.name}${closeTag}` : v.name,
apply: `${openTag}${v.name}${closeTag}`,
type: 'variable',
matchLen,
}))
// Filter out exact matches
.filter((o) => o.label !== toMatch.text),
};
} }
const failedNameLen = toStartOfVariable === null && matchLen < MIN_MATCH_NAME;
if (failedNameLen && !context.explicit) {
return 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.
return {
from: toMatch.from,
options: variables
.map((v) => ({
label: toStartOfVariable ? `${openTag}${v.name}${closeTag}` : v.name,
apply: `${openTag}${v.name}${closeTag}`,
type: 'variable',
matchLen,
}))
// Filter out exact matches
.filter((o) => o.label !== toMatch.text),
};
} }

View File

@@ -5,10 +5,16 @@ import type { GenericCompletionConfig } from '../genericCompletion';
import { genericCompletion } from '../genericCompletion'; import { genericCompletion } from '../genericCompletion';
import { placeholders } from '../placeholder'; import { placeholders } from '../placeholder';
import { textLanguageName } from '../text/extension'; import { textLanguageName } from '../text/extension';
import { completions } from './completion'; import { twigCompletion } from './completion';
import { parser as twigParser } from './twig'; import { parser as twigParser } from './twig';
import type { Environment } from '../../../../lib/models';
export function twig(base: LanguageSupport, environment: Environment | null, autocomplete?: GenericCompletionConfig) {
// TODO: fill variables here
const data = environment?.data ?? {};
const options = Object.keys(data).map(key => ({ name: key }));
const completions = twigCompletion({ options });
export function twig(base: LanguageSupport, autocomplete?: GenericCompletionConfig) {
const language = mixLanguage(base); const language = mixLanguage(base);
const completion = language.data.of({ autocomplete: completions }); const completion = language.data.of({ autocomplete: completions });
const completionBase = base.language.data.of({ autocomplete: completions }); const completionBase = base.language.data.of({ autocomplete: completions });