Placeholder CM tags working

This commit is contained in:
Gregory Schier
2024-09-02 12:35:05 -07:00
parent f8b317e94b
commit 0bfafb284a
13 changed files with 163 additions and 59 deletions

View File

@@ -8,6 +8,7 @@ import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks';
import { StatusTag } from './core/StatusTag';
import { useCopyHttpResponse } from './useCopyHttpResponse';
interface Props {
responses: HttpResponse[];
@@ -25,6 +26,7 @@ export const RecentResponsesDropdown = function ResponsePane({
const deleteAllResponses = useDeleteHttpResponses(activeResponse?.requestId);
const latestResponseId = responses[0]?.id ?? 'n/a';
const saveResponse = useSaveResponse(activeResponse);
const copyResponse = useCopyHttpResponse(activeResponse);
return (
<Dropdown
@@ -37,6 +39,14 @@ export const RecentResponsesDropdown = function ResponsePane({
hidden: responses.length === 0,
disabled: responses.length === 0,
},
{
key: 'copy',
label: 'Copy to Clipboard',
onSelect: copyResponse.mutate,
leftSlot: <Icon icon="copy" />,
hidden: responses.length === 0,
disabled: responses.length === 0,
},
{
key: 'clear-single',
label: 'Delete',

View File

@@ -34,6 +34,7 @@ import { CountBadge } from './core/CountBadge';
import { Editor } from './core/Editor';
import type { GenericCompletionOption } from './core/Editor/genericCompletion';
import { InlineCode } from './core/InlineCode';
import type { Pair } from './core/PairEditor';
import type { TabItem } from './core/Tabs/Tabs';
import { TabContent, Tabs } from './core/Tabs/Tabs';
import { EmptyStateText } from './EmptyStateText';
@@ -89,6 +90,27 @@ export const RequestPane = memo(function RequestPane({
const toast = useToast();
const { urlParameterPairs, urlParametersKey } = useMemo(() => {
const placeholderNames = Array.from(activeRequest.url.matchAll(/\/(:[^/]+)/g)).map(
(m) => m[1] ?? '',
);
const items: Pair[] = [...activeRequest.urlParameters];
for (const name of placeholderNames) {
const index = items.findIndex((p) => p.name === name);
if (index >= 0) {
items[index]!.readOnlyName = true;
} else {
items.push({
name,
value: '',
enabled: true,
readOnlyName: true,
});
}
}
return { urlParameterPairs: items, urlParametersKey: placeholderNames.join(',') };
}, [activeRequest.url, activeRequest.urlParameters]);
const tabs: TabItem[] = useMemo(
() => [
{
@@ -162,7 +184,7 @@ export const RequestPane = memo(function RequestPane({
label: (
<div className="flex items-center">
Params
<CountBadge count={activeRequest.urlParameters.filter((p) => p.name).length} />
<CountBadge count={urlParameterPairs.filter((p) => p.name).length} />
</div>
),
},
@@ -212,11 +234,11 @@ export const RequestPane = memo(function RequestPane({
activeRequest.bodyType,
activeRequest.headers,
activeRequest.method,
activeRequest.urlParameters,
activeRequestId,
handleContentTypeChange,
toast,
updateRequest,
urlParameterPairs,
],
);
@@ -342,9 +364,8 @@ export const RequestPane = memo(function RequestPane({
</TabContent>
<TabContent value="params">
<UrlParametersEditor
forceUpdateKey={forceUpdateKey}
urlParameters={activeRequest.urlParameters}
url={activeRequest.url}
forceUpdateKey={forceUpdateKey + urlParametersKey}
pairs={urlParameterPairs}
onChange={handleUrlParametersChange}
/>
</TabContent>

View File

@@ -1,37 +1,14 @@
import type { HttpRequest } from '@yaakapp/api';
import { useMemo } from 'react';
import type { Pair } from './core/PairEditor';
import { PairOrBulkEditor } from './core/PairOrBulkEditor';
import { VStack } from './core/Stacks';
type Props = {
forceUpdateKey: string;
urlParameters: HttpRequest['headers'];
pairs: HttpRequest['headers'];
onChange: (headers: HttpRequest['urlParameters']) => void;
url: string;
};
export function UrlParametersEditor({ urlParameters, forceUpdateKey, onChange, url }: Props) {
const placeholderNames = Array.from(url.matchAll(/\/(:[^/]+)/g)).map((m) => m[1] ?? '');
const pairs = useMemo(() => {
const items: Pair[] = [...urlParameters];
for (const name of placeholderNames) {
const index = items.findIndex((p) => p.name === name);
if (index >= 0) {
items[index]!.readOnlyName = true;
} else {
items.push({
name,
value: '',
enabled: true,
readOnlyName: true,
});
}
}
return items;
}, [placeholderNames, urlParameters]);
export function UrlParametersEditor({ pairs, forceUpdateKey, onChange }: Props) {
return (
<VStack className="h-full">
<PairOrBulkEditor
@@ -42,7 +19,7 @@ export function UrlParametersEditor({ urlParameters, forceUpdateKey, onChange, u
valuePlaceholder="Value"
pairs={pairs}
onChange={onChange}
forceUpdateKey={forceUpdateKey + placeholderNames.join(':')}
forceUpdateKey={forceUpdateKey}
/>
</VStack>
);

View File

@@ -227,6 +227,10 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
[dialog],
);
const onClickPathParameter = useCallback(async (name: string) => {
console.log('TODO: Focus', name, 'in params tab');
}, []);
// Update the language extension when the language changes
useEffect(() => {
if (cm.current === null) return;
@@ -240,6 +244,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
onClickFunction,
onClickVariable,
onClickMissingVariable,
onClickPathParameter,
});
view.dispatch({ effects: languageCompartment.reconfigure(ext) });
}, [
@@ -251,6 +256,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
onClickFunction,
onClickVariable,
onClickMissingVariable,
onClickPathParameter,
]);
// Initialize the editor when ref mounts
@@ -274,6 +280,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
onClickVariable,
onClickFunction,
onClickMissingVariable,
onClickPathParameter,
});
const state = EditorState.create({

View File

@@ -88,12 +88,14 @@ export function getLanguageExtension({
onClickVariable,
onClickFunction,
onClickMissingVariable,
onClickPathParameter,
}: {
environmentVariables: EnvironmentVariable[];
templateFunctions: TemplateFunction[];
onClickFunction: (option: TemplateFunction, tagValue: string, startPos: number) => void;
onClickVariable: (option: EnvironmentVariable, tagValue: string, startPos: number) => void;
onClickMissingVariable: (name: string, tagValue: string, startPos: number) => void;
onClickPathParameter: (name: string) => void;
} & Pick<EditorProps, 'language' | 'useTemplating' | 'autocomplete'>) {
if (language === 'graphql') {
return graphql();
@@ -112,6 +114,7 @@ export function getLanguageExtension({
onClickFunction,
onClickVariable,
onClickMissingVariable,
onClickPathParameter,
});
}

View File

@@ -18,6 +18,7 @@ export function twig({
onClickFunction,
onClickVariable,
onClickMissingVariable,
onClickPathParameter,
}: {
base: LanguageSupport;
environmentVariables: EnvironmentVariable[];
@@ -26,6 +27,7 @@ export function twig({
onClickFunction: (option: TemplateFunction, tagValue: string, startPos: number) => void;
onClickVariable: (option: EnvironmentVariable, tagValue: string, startPos: number) => void;
onClickMissingVariable: (name: string, tagValue: string, startPos: number) => void;
onClickPathParameter: (name: string) => void;
}) {
const language = mixLanguage(base);
@@ -62,11 +64,11 @@ export function twig({
return [
language,
base.support,
templateTagsPlugin(options, onClickMissingVariable),
language.data.of({ autocomplete: completions }),
base.language.data.of({ autocomplete: completions }),
language.data.of({ autocomplete: genericCompletion(autocomplete) }),
base.language.data.of({ autocomplete: genericCompletion(autocomplete) }),
templateTagsPlugin(options, onClickMissingVariable, onClickPathParameter),
];
}

View File

@@ -2,9 +2,42 @@ import { syntaxTree } from '@codemirror/language';
import type { Range } from '@codemirror/state';
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
import { Decoration, ViewPlugin, WidgetType } from '@codemirror/view';
import type { SyntaxNodeRef } from '@lezer/common';
import { EditorView } from 'codemirror';
import type { TwigCompletionOption } from './completion';
class PathPlaceholderWidget extends WidgetType {
readonly #clickListenerCallback: () => void;
constructor(readonly rawText: string, readonly startPos: number, readonly onClick: () => void) {
super();
this.#clickListenerCallback = () => {
this.onClick?.();
};
}
eq(other: PathPlaceholderWidget) {
return this.startPos === other.startPos && this.rawText === other.rawText;
}
toDOM() {
const elt = document.createElement('span');
elt.className = `x-theme-templateTag x-theme-templateTag--secondary template-tag`;
elt.textContent = this.rawText;
elt.addEventListener('click', this.#clickListenerCallback);
return elt;
}
destroy(dom: HTMLElement) {
dom.removeEventListener('click', this.#clickListenerCallback);
super.destroy(dom);
}
ignoreEvent() {
return false;
}
}
class TemplateTagWidget extends WidgetType {
readonly #clickListenerCallback: () => void;
@@ -62,20 +95,41 @@ function templateTags(
view: EditorView,
options: TwigCompletionOption[],
onClickMissingVariable: (name: string, rawTag: string, startPos: number) => void,
onClickPathParameter: (name: string) => void,
): DecorationSet {
const widgets: Range<Decoration>[] = [];
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
const tree = syntaxTree(view.state);
tree.iterate({
from,
to,
enter(node) {
if (node.name == 'Tag') {
// Don't decorate if the cursor is inside the match
for (const r of view.state.selection.ranges) {
if (r.from > node.from && r.to < node.to) {
return;
if (node.name === 'Text') {
// Find the `url` node and then jump into it to find the placeholders
for (let i = node.from; i < node.to; i++) {
const innerTree = syntaxTree(view.state).resolveInner(i);
if (innerTree.node.name === 'url') {
innerTree.toTree().iterate({
enter(node) {
if (node.name !== 'Placeholder') return;
if (isSelectionInsideNode(view, node)) return;
const globalFrom = innerTree.node.from + node.from;
const globalTo = innerTree.node.from + node.to;
const rawText = view.state.doc.sliceString(globalFrom, globalTo);
const onClick = () => onClickPathParameter(rawText);
const widget = new PathPlaceholderWidget(rawText, globalFrom, onClick);
const deco = Decoration.replace({ widget, inclusive: false });
console.log('ADDED WIDGET', globalFrom, node, rawText);
widgets.push(deco.range(globalFrom, globalTo));
},
});
break;
}
}
} else if (node.name === 'Tag') {
// Don't decorate if the cursor is inside the match
if (isSelectionInsideNode(view, node)) return;
const rawTag = view.state.doc.sliceString(node.from, node.to);
@@ -114,17 +168,28 @@ function templateTags(
export function templateTagsPlugin(
options: TwigCompletionOption[],
onClickMissingVariable: (name: string, tagValue: string, startPos: number) => void,
onClickPathParameter: (name: string) => void,
) {
return ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = templateTags(view, options, onClickMissingVariable);
this.decorations = templateTags(
view,
options,
onClickMissingVariable,
onClickPathParameter,
);
}
update(update: ViewUpdate) {
this.decorations = templateTags(update.view, options, onClickMissingVariable);
this.decorations = templateTags(
update.view,
options,
onClickMissingVariable,
onClickPathParameter,
);
}
},
{
@@ -146,3 +211,10 @@ export function templateTagsPlugin(
},
);
}
function isSelectionInsideNode(view: EditorView, node: SyntaxNodeRef) {
for (const r of view.state.selection.ranges) {
if (r.from > node.from && r.to < node.to) return true;
}
return false;
}

View File

@@ -3,9 +3,9 @@ import { styleTags, tags as t } from '@lezer/highlight';
export const highlight = styleTags({
Protocol: t.comment,
Placeholder: t.emphasis,
// PathSegment: t.tagName,
// Port: t.attributeName,
// Host: t.variableName,
// Path: t.bool,
// Query: t.string,
PathSegment: t.tagName,
Port: t.attributeName,
Host: t.variableName,
Path: t.bool,
Query: t.string,
});

View File

@@ -1,6 +1,4 @@
@top Program { url }
url { Protocol? Host Port? Path? Query? }
@top url { Protocol? Host Port? Path? Query? }
Path { ("/" (Placeholder | PathSegment))+ }

View File

@@ -1,6 +1,6 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
export const
Program = 1,
url = 1,
Protocol = 2,
Host = 3,
Port = 4,

View File

@@ -3,17 +3,17 @@ import {LRParser} from "@lezer/lr"
import {highlight} from "./highlight"
export const parser = LRParser.deserialize({
version: 14,
states: "$UOQOPOOOYOPO'#ChOhOPO'#ChQOOOOOOmOQO'#CeOuOPO'#CaO!QOSO'#CdOOOO,59S,59SO!VOPO,59SO!_OPO,59SO!jOPO,59SOOOO,59P,59POOOO-E6c-E6cO!xOPO,59OOOOO1G.n1G.nO#QOPO1G.nO#YOPO1G.nO#eOSO'#CfO#jOPO1G.jOOOO7+$Y7+$YO#rOPO7+$YOOOO,59Q,59QOOOO-E6d-E6dOOOO<<Gt<<Gt",
stateData: "$Q~OQQORPO~OSXO]SO^UOZ[X~ORYO~OUZOVZO~O]SOZTX^TX~O_]O~O^UOZ[a~O]SO^UOZ[a~OS`O]SO^UOZ[a~O`aOZWa~O^UOZ[i~O]SO^UOZ[i~O_eO~O`aOZWi~O^UOZ[q~OQRUVU~",
goto: "!Z]PPPPP^PPhw!QP!WQWPS_XYRd`QVPU^WXYSc_`RgdWTPXY`R[TQb]RfbRRO",
nodeNames: "⚠ Program Protocol Host Port Path Placeholder PathSegment Query",
maxTerm: 16,
states: "#SOQOPOOQYOPOOOTOPOOOeOQO'#CeOmOPO'#CaOxOSO'#CdQOOOOOQ`OPOOQ]OPOOOOOO,59P,59POOOO-E6c-E6cO}OPO,59OO!VOSO'#CfO![OPO1G.jOOOO,59Q,59QOOOO-E6d-E6d",
stateData: "!j~OQQORPO~OSWO[RO]TO~OUXOVXO~O[ROZTX]TX~O^ZO~O_[OZWa~O^^O~O_[OZWi~OQRUVU~",
goto: "rZPPPPP[PP`elTVPWVUPVWSSPWRYSQ]ZR_]",
nodeNames: "⚠ url Protocol Host Port Path Placeholder PathSegment Query",
maxTerm: 15,
propSources: [highlight],
skippedNodes: [0],
repeatNodeCount: 2,
tokenData: "+h~RdOs!atv!avw#Ow}!a}!O#i!O!P#i!P!Q$o!Q![$t![!]&|!]!a!a!a!b)Z!b!c!a!c!})`!}#R!a#R#S#i#S#T!a#T#o)`#o;'S!a;'S;=`!x<%lO!aQ!fUVQOs!at!P!a!Q!a!a!b;'S!a;'S;=`!x<%lO!aQ!{P;=`<%l!aR#VU`PVQOs!at!P!a!Q!a!a!b;'S!a;'S;=`!x<%lO!aR#p_RPVQOs!at}!a}!O#i!O!P#i!Q![#i![!a!a!b!c!a!c!}#i!}#R!a#R#S#i#S#T!a#T#o#i#o;'S!a;'S;=`!x<%lO!a~$tO]~V$}a_SRPVQOs!at}!a}!O#i!O!P#i!Q![$t![!_!a!_!`&S!`!a!a!b!c!a!c!}$t!}#R!a#R#S#i#S#T!a#T#o$t#o;'S!a;'S;=`!x<%lO!aU&ZZ_SVQOs!at!P!a!Q![&S![!a!a!b!c!a!c!}&S!}#T!a#T#o&S#o;'S!a;'S;=`!x<%lO!aR'RXVQOs'ntv'nvw!aw!P'n!Q![(e![!a'n!b;'S'n;'S;=`(_<%lO'nQ'uWUQVQOs'ntv'nvw!aw!P'n!Q!a'n!b;'S'n;'S;=`(_<%lO'nQ(bP;=`<%l'nR(nXSPUQVQOs'ntv'nvw!aw!P'n!Q![(e![!a'n!b;'S'n;'S;=`(_<%lO'n~)`O^~V)ib_SRPVQOs!at}!a}!O#i!O!P#i!Q![$t![!]*q!]!_!a!_!`&S!`!a!a!b!c!a!c!})`!}#R!a#R#S#i#S#T!a#T#o)`#o;'S!a;'S;=`!x<%lO!aR*vVVQOs!at!P!a!P!Q+]!Q!a!a!b;'S!a;'S;=`!x<%lO!aP+`P!P!Q+cP+hOQP",
tokenData: "+U~RdOs!atv!avw#Ow}!a}!O#i!O!P#i!P!Q$o!Q![$t![!]&|!]!a!a!a!b(w!b!c!a!c!}(|!}#R!a#R#S#i#S#T!a#T#o(|#o;'S!a;'S;=`!x<%lO!aQ!fUVQOs!at!P!a!Q!a!a!b;'S!a;'S;=`!x<%lO!aQ!{P;=`<%l!aR#VU_PVQOs!at!P!a!Q!a!a!b;'S!a;'S;=`!x<%lO!aR#p_RPVQOs!at}!a}!O#i!O!P#i!Q![#i![!a!a!b!c!a!c!}#i!}#R!a#R#S#i#S#T!a#T#o#i#o;'S!a;'S;=`!x<%lO!a~$tO[~V$}a^SRPVQOs!at}!a}!O#i!O!P#i!Q![$t![!_!a!_!`&S!`!a!a!b!c!a!c!}$t!}#R!a#R#S#i#S#T!a#T#o$t#o;'S!a;'S;=`!x<%lO!aU&ZZ^SVQOs!at!P!a!Q![&S![!a!a!b!c!a!c!}&S!}#T!a#T#o&S#o;'S!a;'S;=`!x<%lO!aR'RVVQOs'ht!P'h!Q![(X![!a'h!b;'S'h;'S;=`(R<%lO'hQ'oUUQVQOs'ht!P'h!Q!a'h!b;'S'h;'S;=`(R<%lO'hQ(UP;=`<%l'hR(bVSPUQVQOs'ht!P'h!Q![(X![!a'h!b;'S'h;'S;=`(R<%lO'h~(|O]~V)Vb^SRPVQOs!at}!a}!O#i!O!P#i!Q![$t![!]*_!]!_!a!_!`&S!`!a!a!b!c!a!c!}(|!}#R!a#R#S#i#S#T!a#T#o(|#o;'S!a;'S;=`!x<%lO!aR*dVVQOs!at!P!a!P!Q*y!Q!a!a!b;'S!a;'S;=`!x<%lO!aP*|P!P!Q+PP+UOQP",
tokenizers: [0, 1, 2],
topRules: {"Program":[0,1]},
tokenPrec: 134,
termNames: {"0":"⚠","1":"@top","2":"Protocol","3":"Host","4":"Port","5":"Path","6":"Placeholder","7":"PathSegment","8":"Query","9":"(\"/\" (Placeholder | PathSegment))+","10":"(\"&\" queryPair)+","11":"␄","12":"url","13":"\"/\"","14":"\"?\"","15":"queryPair","16":"\"&\""}
topRules: {"url":[0,1]},
tokenPrec: 66,
termNames: {"0":"⚠","1":"@top","2":"Protocol","3":"Host","4":"Port","5":"Path","6":"Placeholder","7":"PathSegment","8":"Query","9":"(\"/\" (Placeholder | PathSegment))+","10":"(\"&\" queryPair)+","11":"␄","12":"\"/\"","13":"\"?\"","14":"queryPair","15":"\"&\""}
})

View File

@@ -0,0 +1,15 @@
import { useMutation } from '@tanstack/react-query';
import type { HttpResponse } from '@yaakapp/api';
import { useCopy } from '../hooks/useCopy';
import { getResponseBodyText } from '../lib/responseBody';
export function useCopyHttpResponse(response: HttpResponse) {
const copy = useCopy();
return useMutation({
mutationKey: ['copy_http_response'],
async mutationFn() {
const body = await getResponseBodyText(response);
copy(body);
},
});
}

View File

@@ -1,5 +1,4 @@
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
import { attachConsole } from '@tauri-apps/plugin-log';
import { type } from '@tauri-apps/plugin-os';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';