Beginnings of autocomplete for headers

This commit is contained in:
Gregory Schier
2023-03-17 16:51:20 -07:00
parent cd39699467
commit cbe0d27a5e
17 changed files with 155 additions and 48 deletions

View File

@@ -21,11 +21,12 @@ interface Props {
} }
const MIN_WIDTH = 110; const MIN_WIDTH = 110;
const INITIAL_WIDTH = 200;
const MAX_WIDTH = 500; const MAX_WIDTH = 500;
export function Sidebar({ className }: Props) { export function Sidebar({ className }: Props) {
const [isDragging, setIsDragging] = useState<boolean>(false); const [isDragging, setIsDragging] = useState<boolean>(false);
const width = useKeyValue<number>({ key: 'sidebar_width', initialValue: 200 }); const width = useKeyValue<number>({ key: 'sidebar_width', initialValue: INITIAL_WIDTH });
const requests = useRequests(); const requests = useRequests();
const activeRequest = useActiveRequest(); const activeRequest = useActiveRequest();
const createRequest = useCreateRequest({ navigateAfter: true }); 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<HTMLDivElement>) => { const handleResizeStart = (e: React.MouseEvent<HTMLDivElement>) => {
unsub(); unsub();
const mouseStartX = e.clientX; const mouseStartX = e.clientX;
@@ -72,6 +77,7 @@ export function Sidebar({ className }: Props) {
aria-hidden aria-hidden
className="group absolute -right-2 top-0 bottom-0 w-4 cursor-ew-resize flex justify-center" className="group absolute -right-2 top-0 bottom-0 w-4 cursor-ew-resize flex justify-center"
onMouseDown={handleResizeStart} onMouseDown={handleResizeStart}
onDoubleClick={handleResizeReset}
> >
<div <div
className={classnames( className={classnames(

View File

@@ -24,7 +24,11 @@ export function UrlBar({ sendRequest, loading, onMethodChange, method, onUrlChan
> >
<Input <Input
hideLabel hideLabel
useEditor={{ useTemplating: true, contentType: 'url' }} useEditor={{
useTemplating: true,
contentType: 'url',
autocompleteOptions: [{ label: 'FOO', type: 'constant' }],
}}
className="px-0" className="px-0"
name="url" name="url"
label="Enter URL" label="Enter URL"

View File

@@ -3,9 +3,11 @@ import { useNavigate } from 'react-router-dom';
import { useWindowSize } from 'react-use'; import { useWindowSize } from 'react-use';
import { useActiveRequest } from '../hooks/useActiveRequest'; import { useActiveRequest } from '../hooks/useActiveRequest';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace'; import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useDeleteRequest } from '../hooks/useDeleteRequest';
import { useWorkspaces } from '../hooks/useWorkspaces'; import { useWorkspaces } from '../hooks/useWorkspaces';
import { Button } from './core/Button'; import { Button } from './core/Button';
import { DropdownMenuRadio, DropdownMenuTrigger } from './core/Dropdown'; import { Dropdown, DropdownMenuRadio, DropdownMenuTrigger } from './core/Dropdown';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton'; import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks'; import { HStack } from './core/Stacks';
import { WindowDragRegion } from './core/WindowDragRegion'; import { WindowDragRegion } from './core/WindowDragRegion';
@@ -17,6 +19,7 @@ export default function Workspace() {
const navigate = useNavigate(); const navigate = useNavigate();
const activeRequest = useActiveRequest(); const activeRequest = useActiveRequest();
const activeWorkspace = useActiveWorkspace(); const activeWorkspace = useActiveWorkspace();
const deleteRequest = useDeleteRequest(activeRequest);
const workspaces = useWorkspaces(); const workspaces = useWorkspaces();
const { width } = useWindowSize(); const { width } = useWindowSize();
const isSideBySide = width > 900; const isSideBySide = width > 900;
@@ -54,7 +57,25 @@ export default function Workspace() {
</div> </div>
<div className="flex-1 flex justify-end -mr-2"> <div className="flex-1 flex justify-end -mr-2">
<IconButton size="sm" title="" icon="magnifyingGlass" /> <IconButton size="sm" title="" icon="magnifyingGlass" />
<IconButton size="sm" title="" icon="gear" /> <Dropdown
items={[
{
label: 'Something Else',
onSelect: () => null,
leftSlot: <Icon icon="camera" />,
},
'-----',
{
label: 'Delete Request',
onSelect: deleteRequest.mutate,
leftSlot: <Icon icon="trash" />,
},
]}
>
<DropdownMenuTrigger>
<IconButton size="sm" title="Request Options" icon="gear" />
</DropdownMenuTrigger>
</Dropdown>
</div> </div>
</HStack> </HStack>
<div <div

View File

@@ -29,7 +29,7 @@
@apply bg-transparent; @apply bg-transparent;
} }
&.cm-focused .cm-selectionBackground { &.cm-focused .cm-selectionBackground {
@apply bg-gray-400; @apply bg-violet-500/20;
} }
/* Style gutters */ /* Style gutters */
@@ -155,7 +155,7 @@
&.cm-tooltip-autocomplete { &.cm-tooltip-autocomplete {
& > ul { & > ul {
@apply p-1 max-h-[40vh]; @apply p-1 max-h-[20rem];
} }
& > ul > li { & > ul > li {

View File

@@ -9,6 +9,7 @@ import { useUnmount } from 'react-use';
import { IconButton } from '../IconButton'; import { IconButton } from '../IconButton';
import './Editor.css'; import './Editor.css';
import { baseExtensions, getLanguageExtension, multiLineExtensions } from './extensions'; import { baseExtensions, getLanguageExtension, multiLineExtensions } from './extensions';
import type { GenericCompletionOption } from './genericCompletion';
import { singleLineExt } from './singleLine'; import { singleLineExt } from './singleLine';
export interface _EditorProps { export interface _EditorProps {
@@ -26,6 +27,7 @@ export interface _EditorProps {
onFocus?: () => void; onFocus?: () => void;
singleLine?: boolean; singleLine?: boolean;
format?: (v: string) => string; format?: (v: string) => string;
autocompleteOptions?: GenericCompletionOption[];
} }
export function _Editor({ export function _Editor({
@@ -41,6 +43,7 @@ export function _Editor({
className, className,
singleLine, singleLine,
format, format,
autocompleteOptions,
}: _EditorProps) { }: _EditorProps) {
const cm = useRef<{ view: EditorView; languageCompartment: Compartment } | null>(null); const cm = useRef<{ view: EditorView; languageCompartment: Compartment } | null>(null);
const wrapperRef = useRef<HTMLDivElement | null>(null); const wrapperRef = useRef<HTMLDivElement | null>(null);
@@ -77,16 +80,16 @@ export 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 }); const ext = getLanguageExtension({ contentType, useTemplating, autocompleteOptions });
view.dispatch({ effects: languageCompartment.reconfigure(ext) }); view.dispatch({ effects: languageCompartment.reconfigure(ext) });
}, [contentType]); }, [contentType, JSON.stringify(autocompleteOptions)]);
// Initialize the editor when ref mounts // Initialize the editor when ref mounts
useEffect(() => { useEffect(() => {
if (wrapperRef.current === null || cm.current !== null) return; if (wrapperRef.current === null || cm.current !== null) return;
try { try {
const languageCompartment = new Compartment(); const languageCompartment = new Compartment();
const langExt = getLanguageExtension({ contentType, useTemplating }); const langExt = getLanguageExtension({ contentType, useTemplating, autocompleteOptions });
const state = EditorState.create({ const state = EditorState.create({
doc: `${defaultValue ?? ''}`, doc: `${defaultValue ?? ''}`,
extensions: [ extensions: [

View File

@@ -33,6 +33,8 @@ import {
} from '@codemirror/view'; } from '@codemirror/view';
import { tags as t } from '@lezer/highlight'; import { tags as t } from '@lezer/highlight';
import { graphqlLanguageSupport } from 'cm6-graphql'; import { graphqlLanguageSupport } from 'cm6-graphql';
import type { GenericCompletionOption } from './genericCompletion';
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';
@@ -93,17 +95,19 @@ const syntaxExtensions: Record<string, LanguageSupport> = {
export function getLanguageExtension({ export function getLanguageExtension({
contentType, contentType,
useTemplating = false, useTemplating = false,
autocompleteOptions,
}: { }: {
contentType?: string; contentType?: string;
useTemplating?: boolean; useTemplating?: boolean;
autocompleteOptions?: GenericCompletionOption[];
}) { }) {
const justContentType = contentType?.split(';')[0] ?? contentType ?? ''; const justContentType = contentType?.split(';')[0] ?? contentType ?? '';
const base = syntaxExtensions[justContentType] ?? json(); const base = syntaxExtensions[justContentType] ?? text();
if (!useTemplating) { if (!useTemplating) {
return [base]; return base ? base : [];
} }
return twig(base); return twig(base, autocompleteOptions);
} }
export const baseExtensions = [ export const baseExtensions = [
@@ -115,7 +119,7 @@ export const baseExtensions = [
// TODO: Figure out how to debounce showing of autocomplete in a good way // TODO: Figure out how to debounce showing of autocomplete in a good way
// debouncedAutocompletionDisplay({ millis: 1000 }), // debouncedAutocompletionDisplay({ millis: 1000 }),
// autocompletion({ closeOnBlur: true, interactionDelay: 200, activateOnTyping: false }), // autocompletion({ closeOnBlur: true, interactionDelay: 200, activateOnTyping: false }),
autocompletion({ closeOnBlur: true, interactionDelay: 300 }), autocompletion({ closeOnBlur: true, interactionDelay: 200 }),
syntaxHighlighting(myHighlightStyle), syntaxHighlighting(myHighlightStyle),
EditorState.allowMultipleSelections.of(true), EditorState.allowMultipleSelections.of(true),
]; ];

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
@top Template { Text }
@tokens {
Text { ![]+ }
}

View File

@@ -0,0 +1,4 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
export const
Template = 1,
Text = 2

View File

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

View File

@@ -6,6 +6,7 @@ const closeTag = ' ]}';
const variables = [ const variables = [
{ name: 'DOMAIN' }, { name: 'DOMAIN' },
{ name: 'BASE_URL' }, { name: 'BASE_URL' },
{ name: 'CONTENT_THINGY' },
{ name: 'TOKEN' }, { name: 'TOKEN' },
{ name: 'PROJECT_ID' }, { name: 'PROJECT_ID' },
{ name: 'DUMMY' }, { name: 'DUMMY' },
@@ -17,7 +18,7 @@ const variables = [
]; ];
const MIN_MATCH_VAR = 2; const MIN_MATCH_VAR = 2;
const MIN_MATCH_NAME = 4; const MIN_MATCH_NAME = 3;
export function completions(context: CompletionContext) { export function completions(context: CompletionContext) {
const toStartOfName = context.matchBefore(/\w*/); const toStartOfName = context.matchBefore(/\w*/);

View File

@@ -1,15 +1,21 @@
import { LanguageSupport, LRLanguage } from '@codemirror/language'; import { LanguageSupport, LRLanguage } from '@codemirror/language';
import { parseMixed } from '@lezer/common'; import { parseMixed } from '@lezer/common';
import { completions } from './completion'; import type { GenericCompletionOption } from '../genericCompletion';
import { genericCompletion } from '../genericCompletion';
import { placeholders } from '../widgets'; import { placeholders } from '../widgets';
import { completions } from './completion';
import { parser as twigParser } from './twig'; import { parser as twigParser } from './twig';
export function twig(base?: LanguageSupport) { export function twig(base?: LanguageSupport, autocompleteOptions?: GenericCompletionOption[]) {
const language = mixedOrPlainLanguage(base); const language = mixedOrPlainLanguage(base);
const additionalCompletion =
autocompleteOptions && base
? [language.data.of({ autocomplete: genericCompletion({ options: autocompleteOptions }) })]
: [];
const completion = language.data.of({ const completion = language.data.of({
autocomplete: completions, autocomplete: completions,
}); });
const languageSupport = new LanguageSupport(language, [completion]); const languageSupport = new LanguageSupport(language, [completion, ...additionalCompletion]);
if (base) { if (base) {
const completion2 = base.language.data.of({ autocomplete: completions }); const completion2 = base.language.data.of({ autocomplete: completions });
@@ -23,18 +29,15 @@ export function twig(base?: LanguageSupport) {
function mixedOrPlainLanguage(base?: LanguageSupport): LRLanguage { function mixedOrPlainLanguage(base?: LanguageSupport): LRLanguage {
const name = 'twig'; const name = 'twig';
if (base == null) { if (!base) {
return LRLanguage.define({ name, parser: twigParser }); return LRLanguage.define({ name, parser: twigParser });
} }
const parser = twigParser.configure({ const parser = twigParser.configure({
wrap: parseMixed((node) => { wrap: parseMixed(() => ({
if (!node.type.isTop) return null; parser: base.language.parser,
return { overlay: (node) => node.type.name === 'Text' || node.type.name === 'Template',
parser: base.language.parser, })),
overlay: (node) => node.type.name === 'Text',
};
}),
}); });
return LRLanguage.define({ name, parser }); return LRLanguage.define({ name, parser });

View File

@@ -1,19 +1,9 @@
import type { CompletionContext } from '@codemirror/autocomplete'; import { genericCompletion } from '../genericCompletion';
const options = [ export const completions = genericCompletion({
{ label: 'http://', type: 'constant' }, options: [
{ label: 'https://', type: 'constant' }, { label: 'http://', type: 'constant' },
]; { label: 'https://', type: 'constant' },
],
const MIN_MATCH = 1; minMatch: 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 };
}

View File

@@ -12,7 +12,7 @@ type Props = Omit<HTMLAttributes<HTMLInputElement>, 'onChange' | 'onFocus'> & {
containerClassName?: string; containerClassName?: string;
onChange?: (value: string) => void; onChange?: (value: string) => void;
onFocus?: () => void; onFocus?: () => void;
useEditor?: Pick<EditorProps, 'contentType' | 'useTemplating'>; useEditor?: Pick<EditorProps, 'contentType' | 'useTemplating' | 'autocompleteOptions'>;
defaultValue?: string; defaultValue?: string;
leftSlot?: ReactNode; leftSlot?: ReactNode;
rightSlot?: ReactNode; rightSlot?: ReactNode;

View File

@@ -1,5 +1,6 @@
import classnames from 'classnames'; 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 { IconButton } from './IconButton';
import { Input } from './Input'; import { Input } from './Input';
import { VStack } from './Stacks'; import { VStack } from './Stacks';
@@ -94,6 +95,17 @@ function FormRow({
isLast?: boolean; isLast?: boolean;
}) { }) {
const { id } = pairContainer; const { id } = pairContainer;
const valueOptions = useMemo<GenericCompletionOption[] | undefined>(() => {
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 ( return (
<div className="group grid grid-cols-[1fr_1fr_auto] grid-rows-1 gap-2 items-center"> <div className="group grid grid-cols-[1fr_1fr_auto] grid-rows-1 gap-2 items-center">
<Input <Input
@@ -105,7 +117,10 @@ function FormRow({
onChange={(name) => onChange({ id, pair: { name, value: pairContainer.pair.value } })} onChange={(name) => onChange({ id, pair: { name, value: pairContainer.pair.value } })}
onFocus={onFocus} onFocus={onFocus}
placeholder={isLast ? 'new name' : 'name'} placeholder={isLast ? 'new name' : 'name'}
useEditor={{ useTemplating: true, contentType: 'text/plain' }} useEditor={{
useTemplating: true,
autocompleteOptions: [{ label: 'Content-Type', type: 'constant' }],
}}
/> />
<Input <Input
hideLabel hideLabel
@@ -116,7 +131,7 @@ function FormRow({
onChange={(value) => onChange({ id, pair: { name: pairContainer.pair.name, value } })} onChange={(value) => onChange({ id, pair: { name: pairContainer.pair.name, value } })}
onFocus={onFocus} onFocus={onFocus}
placeholder={isLast ? 'new value' : 'value'} placeholder={isLast ? 'new value' : 'value'}
useEditor={{ useTemplating: true, contentType: 'text/plain' }} useEditor={{ useTemplating: true, autocompleteOptions: valueOptions }}
/> />
{onDelete ? ( {onDelete ? (
<IconButton <IconButton

View File

@@ -6,7 +6,6 @@ import { useQuery } from '@tanstack/react-query';
export function useWorkspaces() { export function useWorkspaces() {
return ( return (
useQuery(['workspaces'], async () => { useQuery(['workspaces'], async () => {
console.log('INVOKING WORKSPACES');
const workspaces = (await invoke('workspaces')) as Workspace[]; const workspaces = (await invoke('workspaces')) as Workspace[];
return workspaces.map(convertDates); return workspaces.map(convertDates);
}).data ?? [] }).data ?? []