mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-12 02:26:30 -05:00
Beginnings of autocomplete for headers
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
|
||||
25
src-web/components/core/Editor/genericCompletion.ts
Normal file
25
src-web/components/core/Editor/genericCompletion.ts
Normal 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 };
|
||||
};
|
||||
}
|
||||
11
src-web/components/core/Editor/text/extension.ts
Normal file
11
src-web/components/core/Editor/text/extension.ts
Normal 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);
|
||||
}
|
||||
5
src-web/components/core/Editor/text/text.grammar
Normal file
5
src-web/components/core/Editor/text/text.grammar
Normal file
@@ -0,0 +1,5 @@
|
||||
@top Template { Text }
|
||||
|
||||
@tokens {
|
||||
Text { ![]+ }
|
||||
}
|
||||
4
src-web/components/core/Editor/text/text.terms.ts
Normal file
4
src-web/components/core/Editor/text/text.terms.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
export const
|
||||
Template = 1,
|
||||
Text = 2
|
||||
16
src-web/components/core/Editor/text/text.ts
Normal file
16
src-web/components/core/Editor/text/text.ts
Normal 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
|
||||
})
|
||||
@@ -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*/);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ?? []
|
||||
|
||||
Reference in New Issue
Block a user