Beginnings of autocomplete for headers

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

View File

@@ -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<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 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<HTMLDivElement>) => {
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}
>
<div
className={classnames(

View File

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

View File

@@ -3,9 +3,11 @@ import { useNavigate } from 'react-router-dom';
import { useWindowSize } from 'react-use';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useDeleteRequest } from '../hooks/useDeleteRequest';
import { useWorkspaces } from '../hooks/useWorkspaces';
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 { HStack } from './core/Stacks';
import { WindowDragRegion } from './core/WindowDragRegion';
@@ -17,6 +19,7 @@ export default function Workspace() {
const navigate = useNavigate();
const activeRequest = useActiveRequest();
const activeWorkspace = useActiveWorkspace();
const deleteRequest = useDeleteRequest(activeRequest);
const workspaces = useWorkspaces();
const { width } = useWindowSize();
const isSideBySide = width > 900;
@@ -54,7 +57,25 @@ export default function Workspace() {
</div>
<div className="flex-1 flex justify-end -mr-2">
<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>
</HStack>
<div

View File

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

View File

@@ -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<HTMLDivElement | null>(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: [

View File

@@ -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<string, LanguageSupport> = {
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),
];

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 = [
{ 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*/);

View File

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

View File

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

View File

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

View File

@@ -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<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 (
<div className="group grid grid-cols-[1fr_1fr_auto] grid-rows-1 gap-2 items-center">
<Input
@@ -105,7 +117,10 @@ function FormRow({
onChange={(name) => 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' }],
}}
/>
<Input
hideLabel
@@ -116,7 +131,7 @@ function FormRow({
onChange={(value) => 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 ? (
<IconButton

View File

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