From cbe0d27a5e4c7bbcce1338b8bdd25ce941d47366 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Fri, 17 Mar 2023 16:51:20 -0700 Subject: [PATCH] Beginnings of autocomplete for headers --- src-web/components/Sidebar.tsx | 8 +++++- src-web/components/UrlBar.tsx | 6 ++++- src-web/components/Workspace.tsx | 25 ++++++++++++++++-- src-web/components/core/Editor/Editor.css | 4 +-- src-web/components/core/Editor/Editor.tsx | 9 ++++--- src-web/components/core/Editor/extensions.ts | 12 ++++++--- .../core/Editor/genericCompletion.ts | 25 ++++++++++++++++++ .../components/core/Editor/text/extension.ts | 11 ++++++++ .../components/core/Editor/text/text.grammar | 5 ++++ .../components/core/Editor/text/text.terms.ts | 4 +++ src-web/components/core/Editor/text/text.ts | 16 ++++++++++++ .../components/core/Editor/twig/completion.ts | 3 ++- .../components/core/Editor/twig/extension.ts | 25 ++++++++++-------- .../components/core/Editor/url/completion.ts | 26 ++++++------------- src-web/components/core/Input.tsx | 2 +- src-web/components/core/PairEditor.tsx | 21 ++++++++++++--- src-web/hooks/useWorkspaces.ts | 1 - 17 files changed, 155 insertions(+), 48 deletions(-) create mode 100644 src-web/components/core/Editor/genericCompletion.ts create mode 100644 src-web/components/core/Editor/text/extension.ts create mode 100644 src-web/components/core/Editor/text/text.grammar create mode 100644 src-web/components/core/Editor/text/text.terms.ts create mode 100644 src-web/components/core/Editor/text/text.ts diff --git a/src-web/components/Sidebar.tsx b/src-web/components/Sidebar.tsx index eb9550a9..816ba251 100644 --- a/src-web/components/Sidebar.tsx +++ b/src-web/components/Sidebar.tsx @@ -21,11 +21,12 @@ interface Props { } const MIN_WIDTH = 110; +const INITIAL_WIDTH = 200; const MAX_WIDTH = 500; export function Sidebar({ className }: Props) { const [isDragging, setIsDragging] = useState(false); - const width = useKeyValue({ key: 'sidebar_width', initialValue: 200 }); + const width = useKeyValue({ key: 'sidebar_width', initialValue: INITIAL_WIDTH }); const requests = useRequests(); const activeRequest = useActiveRequest(); const createRequest = useCreateRequest({ navigateAfter: true }); @@ -39,6 +40,10 @@ export function Sidebar({ className }: Props) { } }; + const handleResizeReset = () => { + width.set(INITIAL_WIDTH); + }; + const handleResizeStart = (e: React.MouseEvent) => { unsub(); const mouseStartX = e.clientX; @@ -72,6 +77,7 @@ export function Sidebar({ className }: Props) { aria-hidden className="group absolute -right-2 top-0 bottom-0 w-4 cursor-ew-resize flex justify-center" onMouseDown={handleResizeStart} + onDoubleClick={handleResizeReset} >
900; @@ -54,7 +57,25 @@ export default function Workspace() {
- + null, + leftSlot: , + }, + '-----', + { + label: 'Delete Request', + onSelect: deleteRequest.mutate, + leftSlot: , + }, + ]} + > + + + +
ul { - @apply p-1 max-h-[40vh]; + @apply p-1 max-h-[20rem]; } & > ul > li { diff --git a/src-web/components/core/Editor/Editor.tsx b/src-web/components/core/Editor/Editor.tsx index 8e8d4a45..9e1f9d26 100644 --- a/src-web/components/core/Editor/Editor.tsx +++ b/src-web/components/core/Editor/Editor.tsx @@ -9,6 +9,7 @@ import { useUnmount } from 'react-use'; import { IconButton } from '../IconButton'; import './Editor.css'; import { baseExtensions, getLanguageExtension, multiLineExtensions } from './extensions'; +import type { GenericCompletionOption } from './genericCompletion'; import { singleLineExt } from './singleLine'; export interface _EditorProps { @@ -26,6 +27,7 @@ export interface _EditorProps { onFocus?: () => void; singleLine?: boolean; format?: (v: string) => string; + autocompleteOptions?: GenericCompletionOption[]; } export function _Editor({ @@ -41,6 +43,7 @@ export function _Editor({ className, singleLine, format, + autocompleteOptions, }: _EditorProps) { const cm = useRef<{ view: EditorView; languageCompartment: Compartment } | null>(null); const wrapperRef = useRef(null); @@ -77,16 +80,16 @@ export function _Editor({ useEffect(() => { if (cm.current === null) return; const { view, languageCompartment } = cm.current; - const ext = getLanguageExtension({ contentType, useTemplating }); + const ext = getLanguageExtension({ contentType, useTemplating, autocompleteOptions }); view.dispatch({ effects: languageCompartment.reconfigure(ext) }); - }, [contentType]); + }, [contentType, JSON.stringify(autocompleteOptions)]); // Initialize the editor when ref mounts useEffect(() => { if (wrapperRef.current === null || cm.current !== null) return; try { const languageCompartment = new Compartment(); - const langExt = getLanguageExtension({ contentType, useTemplating }); + const langExt = getLanguageExtension({ contentType, useTemplating, autocompleteOptions }); const state = EditorState.create({ doc: `${defaultValue ?? ''}`, extensions: [ diff --git a/src-web/components/core/Editor/extensions.ts b/src-web/components/core/Editor/extensions.ts index d3a239ca..a45943e7 100644 --- a/src-web/components/core/Editor/extensions.ts +++ b/src-web/components/core/Editor/extensions.ts @@ -33,6 +33,8 @@ import { } from '@codemirror/view'; import { tags as t } from '@lezer/highlight'; import { graphqlLanguageSupport } from 'cm6-graphql'; +import type { GenericCompletionOption } from './genericCompletion'; +import { text } from './text/extension'; import { twig } from './twig/extension'; import { url } from './url/extension'; @@ -93,17 +95,19 @@ const syntaxExtensions: Record = { export function getLanguageExtension({ contentType, useTemplating = false, + autocompleteOptions, }: { contentType?: string; useTemplating?: boolean; + autocompleteOptions?: GenericCompletionOption[]; }) { const justContentType = contentType?.split(';')[0] ?? contentType ?? ''; - const base = syntaxExtensions[justContentType] ?? json(); + const base = syntaxExtensions[justContentType] ?? text(); if (!useTemplating) { - return [base]; + return base ? base : []; } - return twig(base); + return twig(base, autocompleteOptions); } export const baseExtensions = [ @@ -115,7 +119,7 @@ export const baseExtensions = [ // TODO: Figure out how to debounce showing of autocomplete in a good way // debouncedAutocompletionDisplay({ millis: 1000 }), // autocompletion({ closeOnBlur: true, interactionDelay: 200, activateOnTyping: false }), - autocompletion({ closeOnBlur: true, interactionDelay: 300 }), + autocompletion({ closeOnBlur: true, interactionDelay: 200 }), syntaxHighlighting(myHighlightStyle), EditorState.allowMultipleSelections.of(true), ]; diff --git a/src-web/components/core/Editor/genericCompletion.ts b/src-web/components/core/Editor/genericCompletion.ts new file mode 100644 index 00000000..9641b548 --- /dev/null +++ b/src-web/components/core/Editor/genericCompletion.ts @@ -0,0 +1,25 @@ +import type { CompletionContext } from '@codemirror/autocomplete'; + +export interface GenericCompletionOption { + label: string; + type: 'constant' | 'variable'; +} + +export function genericCompletion({ + options, + minMatch = 1, +}: { + options: GenericCompletionOption[]; + minMatch?: number; +}) { + return function completions(context: CompletionContext) { + const toMatch = context.matchBefore(/^[\w:/]*/); + if (toMatch === null) return null; + + const matchedMinimumLength = toMatch.to - toMatch.from >= minMatch; + if (!matchedMinimumLength && !context.explicit) return null; + + const optionsWithoutExactMatches = options.filter((o) => o.label !== toMatch.text); + return { from: toMatch.from, options: optionsWithoutExactMatches }; + }; +} diff --git a/src-web/components/core/Editor/text/extension.ts b/src-web/components/core/Editor/text/extension.ts new file mode 100644 index 00000000..efda9ef4 --- /dev/null +++ b/src-web/components/core/Editor/text/extension.ts @@ -0,0 +1,11 @@ +import { LanguageSupport, LRLanguage } from '@codemirror/language'; +import { parser } from './text'; + +const textLanguage = LRLanguage.define({ + parser, + languageData: {}, +}); + +export function text() { + return new LanguageSupport(textLanguage); +} diff --git a/src-web/components/core/Editor/text/text.grammar b/src-web/components/core/Editor/text/text.grammar new file mode 100644 index 00000000..57dc0552 --- /dev/null +++ b/src-web/components/core/Editor/text/text.grammar @@ -0,0 +1,5 @@ +@top Template { Text } + +@tokens { + Text { ![]+ } +} diff --git a/src-web/components/core/Editor/text/text.terms.ts b/src-web/components/core/Editor/text/text.terms.ts new file mode 100644 index 00000000..06c0faa2 --- /dev/null +++ b/src-web/components/core/Editor/text/text.terms.ts @@ -0,0 +1,4 @@ +// This file was generated by lezer-generator. You probably shouldn't edit it. +export const + Template = 1, + Text = 2 diff --git a/src-web/components/core/Editor/text/text.ts b/src-web/components/core/Editor/text/text.ts new file mode 100644 index 00000000..a187c51b --- /dev/null +++ b/src-web/components/core/Editor/text/text.ts @@ -0,0 +1,16 @@ +// This file was generated by lezer-generator. You probably shouldn't edit it. +import {LRParser} from "@lezer/lr" +export const parser = LRParser.deserialize({ + version: 14, + states: "[OQOPOOQOOOOO", + stateData: "V~OQPO~O", + goto: "QPP", + nodeNames: "⚠ Template Text", + maxTerm: 3, + skippedNodes: [0], + repeatNodeCount: 0, + tokenData: "p~RRO;'S[;'S;=`j<%lO[~aRQ~O;'S[;'S;=`j<%lO[~mP;=`<%l[", + tokenizers: [0], + topRules: {"Template":[0,1]}, + tokenPrec: 0 +}) diff --git a/src-web/components/core/Editor/twig/completion.ts b/src-web/components/core/Editor/twig/completion.ts index 9807f1d0..88f03b00 100644 --- a/src-web/components/core/Editor/twig/completion.ts +++ b/src-web/components/core/Editor/twig/completion.ts @@ -6,6 +6,7 @@ const closeTag = ' ]}'; const variables = [ { name: 'DOMAIN' }, { name: 'BASE_URL' }, + { name: 'CONTENT_THINGY' }, { name: 'TOKEN' }, { name: 'PROJECT_ID' }, { name: 'DUMMY' }, @@ -17,7 +18,7 @@ const variables = [ ]; const MIN_MATCH_VAR = 2; -const MIN_MATCH_NAME = 4; +const MIN_MATCH_NAME = 3; export function completions(context: CompletionContext) { const toStartOfName = context.matchBefore(/\w*/); diff --git a/src-web/components/core/Editor/twig/extension.ts b/src-web/components/core/Editor/twig/extension.ts index 3e9999bf..ecfbfeb1 100644 --- a/src-web/components/core/Editor/twig/extension.ts +++ b/src-web/components/core/Editor/twig/extension.ts @@ -1,15 +1,21 @@ import { LanguageSupport, LRLanguage } from '@codemirror/language'; import { parseMixed } from '@lezer/common'; -import { completions } from './completion'; +import type { GenericCompletionOption } from '../genericCompletion'; +import { genericCompletion } from '../genericCompletion'; import { placeholders } from '../widgets'; +import { completions } from './completion'; import { parser as twigParser } from './twig'; -export function twig(base?: LanguageSupport) { +export function twig(base?: LanguageSupport, autocompleteOptions?: GenericCompletionOption[]) { const language = mixedOrPlainLanguage(base); + const additionalCompletion = + autocompleteOptions && base + ? [language.data.of({ autocomplete: genericCompletion({ options: autocompleteOptions }) })] + : []; const completion = language.data.of({ autocomplete: completions, }); - const languageSupport = new LanguageSupport(language, [completion]); + const languageSupport = new LanguageSupport(language, [completion, ...additionalCompletion]); if (base) { const completion2 = base.language.data.of({ autocomplete: completions }); @@ -23,18 +29,15 @@ export function twig(base?: LanguageSupport) { function mixedOrPlainLanguage(base?: LanguageSupport): LRLanguage { const name = 'twig'; - if (base == null) { + if (!base) { return LRLanguage.define({ name, parser: twigParser }); } const parser = twigParser.configure({ - wrap: parseMixed((node) => { - if (!node.type.isTop) return null; - return { - parser: base.language.parser, - overlay: (node) => node.type.name === 'Text', - }; - }), + wrap: parseMixed(() => ({ + parser: base.language.parser, + overlay: (node) => node.type.name === 'Text' || node.type.name === 'Template', + })), }); return LRLanguage.define({ name, parser }); diff --git a/src-web/components/core/Editor/url/completion.ts b/src-web/components/core/Editor/url/completion.ts index c81c34bd..40acefa3 100644 --- a/src-web/components/core/Editor/url/completion.ts +++ b/src-web/components/core/Editor/url/completion.ts @@ -1,19 +1,9 @@ -import type { CompletionContext } from '@codemirror/autocomplete'; +import { genericCompletion } from '../genericCompletion'; -const options = [ - { label: 'http://', type: 'constant' }, - { label: 'https://', type: 'constant' }, -]; - -const MIN_MATCH = 1; - -export function completions(context: CompletionContext) { - const toMatch = context.matchBefore(/^[\w:/]*/); - if (toMatch === null) return null; - - const matchedMinimumLength = toMatch.to - toMatch.from >= MIN_MATCH; - if (!matchedMinimumLength && !context.explicit) return null; - - const optionsWithoutExactMatches = options.filter((o) => o.label !== toMatch.text); - return { from: toMatch.from, options: optionsWithoutExactMatches }; -} +export const completions = genericCompletion({ + options: [ + { label: 'http://', type: 'constant' }, + { label: 'https://', type: 'constant' }, + ], + minMatch: 1, +}); diff --git a/src-web/components/core/Input.tsx b/src-web/components/core/Input.tsx index 8b0a3fcf..8f1d6866 100644 --- a/src-web/components/core/Input.tsx +++ b/src-web/components/core/Input.tsx @@ -12,7 +12,7 @@ type Props = Omit, 'onChange' | 'onFocus'> & { containerClassName?: string; onChange?: (value: string) => void; onFocus?: () => void; - useEditor?: Pick; + useEditor?: Pick; defaultValue?: string; leftSlot?: ReactNode; rightSlot?: ReactNode; diff --git a/src-web/components/core/PairEditor.tsx b/src-web/components/core/PairEditor.tsx index 3e30c703..94a0d14d 100644 --- a/src-web/components/core/PairEditor.tsx +++ b/src-web/components/core/PairEditor.tsx @@ -1,5 +1,6 @@ import classnames from 'classnames'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import type { GenericCompletionOption } from './Editor/genericCompletion'; import { IconButton } from './IconButton'; import { Input } from './Input'; import { VStack } from './Stacks'; @@ -94,6 +95,17 @@ function FormRow({ isLast?: boolean; }) { const { id } = pairContainer; + const valueOptions = useMemo(() => { + if (pairContainer.pair.name.toLowerCase() === 'content-type') { + return [ + { label: 'application/json', type: 'constant' }, + { label: 'text/xml', type: 'constant' }, + { label: 'text/html', type: 'constant' }, + ]; + } + return undefined; + }, [pairContainer.pair.value]); + return (
onChange({ id, pair: { name, value: pairContainer.pair.value } })} onFocus={onFocus} placeholder={isLast ? 'new name' : 'name'} - useEditor={{ useTemplating: true, contentType: 'text/plain' }} + useEditor={{ + useTemplating: true, + autocompleteOptions: [{ label: 'Content-Type', type: 'constant' }], + }} /> onChange({ id, pair: { name: pairContainer.pair.name, value } })} onFocus={onFocus} placeholder={isLast ? 'new value' : 'value'} - useEditor={{ useTemplating: true, contentType: 'text/plain' }} + useEditor={{ useTemplating: true, autocompleteOptions: valueOptions }} /> {onDelete ? ( { - console.log('INVOKING WORKSPACES'); const workspaces = (await invoke('workspaces')) as Workspace[]; return workspaces.map(convertDates); }).data ?? []