From 0bfafb284aa015cb44fa9ee0d94016d4536130c3 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Mon, 2 Sep 2024 12:35:05 -0700 Subject: [PATCH] Placeholder CM tags working --- .../components/RecentResponsesDropdown.tsx | 10 +++ src-web/components/RequestPane.tsx | 31 +++++-- src-web/components/UrlParameterEditor.tsx | 29 +----- src-web/components/core/Editor/Editor.tsx | 7 ++ src-web/components/core/Editor/extensions.ts | 3 + .../components/core/Editor/twig/extension.ts | 4 +- .../core/Editor/twig/templateTags.ts | 88 +++++++++++++++++-- .../components/core/Editor/url/highlight.ts | 10 +-- .../components/core/Editor/url/url.grammar | 4 +- .../components/core/Editor/url/url.terms.ts | 2 +- src-web/components/core/Editor/url/url.ts | 18 ++-- src-web/components/useCopyHttpResponse.ts | 15 ++++ src-web/main.tsx | 1 - 13 files changed, 163 insertions(+), 59 deletions(-) create mode 100644 src-web/components/useCopyHttpResponse.ts diff --git a/src-web/components/RecentResponsesDropdown.tsx b/src-web/components/RecentResponsesDropdown.tsx index 5043f323..fa88b6d6 100644 --- a/src-web/components/RecentResponsesDropdown.tsx +++ b/src-web/components/RecentResponsesDropdown.tsx @@ -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 ( , + hidden: responses.length === 0, + disabled: responses.length === 0, + }, { key: 'clear-single', label: 'Delete', diff --git a/src-web/components/RequestPane.tsx b/src-web/components/RequestPane.tsx index 5a00c5e3..6d44ecab 100644 --- a/src-web/components/RequestPane.tsx +++ b/src-web/components/RequestPane.tsx @@ -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: (
Params - p.name).length} /> + p.name).length} />
), }, @@ -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({ diff --git a/src-web/components/UrlParameterEditor.tsx b/src-web/components/UrlParameterEditor.tsx index 5588d7d4..14c4604e 100644 --- a/src-web/components/UrlParameterEditor.tsx +++ b/src-web/components/UrlParameterEditor.tsx @@ -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 ( ); diff --git a/src-web/components/core/Editor/Editor.tsx b/src-web/components/core/Editor/Editor.tsx index 41aabcb8..db33409b 100644 --- a/src-web/components/core/Editor/Editor.tsx +++ b/src-web/components/core/Editor/Editor.tsx @@ -227,6 +227,10 @@ export const Editor = forwardRef(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(function E onClickFunction, onClickVariable, onClickMissingVariable, + onClickPathParameter, }); view.dispatch({ effects: languageCompartment.reconfigure(ext) }); }, [ @@ -251,6 +256,7 @@ export const Editor = forwardRef(function E onClickFunction, onClickVariable, onClickMissingVariable, + onClickPathParameter, ]); // Initialize the editor when ref mounts @@ -274,6 +280,7 @@ export const Editor = forwardRef(function E onClickVariable, onClickFunction, onClickMissingVariable, + onClickPathParameter, }); const state = EditorState.create({ diff --git a/src-web/components/core/Editor/extensions.ts b/src-web/components/core/Editor/extensions.ts index 0325ac4b..0546f99c 100644 --- a/src-web/components/core/Editor/extensions.ts +++ b/src-web/components/core/Editor/extensions.ts @@ -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) { if (language === 'graphql') { return graphql(); @@ -112,6 +114,7 @@ export function getLanguageExtension({ onClickFunction, onClickVariable, onClickMissingVariable, + onClickPathParameter, }); } diff --git a/src-web/components/core/Editor/twig/extension.ts b/src-web/components/core/Editor/twig/extension.ts index 67643cdb..8536ef70 100644 --- a/src-web/components/core/Editor/twig/extension.ts +++ b/src-web/components/core/Editor/twig/extension.ts @@ -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), ]; } diff --git a/src-web/components/core/Editor/twig/templateTags.ts b/src-web/components/core/Editor/twig/templateTags.ts index aa95e85d..d5a348a0 100644 --- a/src-web/components/core/Editor/twig/templateTags.ts +++ b/src-web/components/core/Editor/twig/templateTags.ts @@ -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[] = []; 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; +} diff --git a/src-web/components/core/Editor/url/highlight.ts b/src-web/components/core/Editor/url/highlight.ts index e908323f..8b96f6de 100644 --- a/src-web/components/core/Editor/url/highlight.ts +++ b/src-web/components/core/Editor/url/highlight.ts @@ -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, }); diff --git a/src-web/components/core/Editor/url/url.grammar b/src-web/components/core/Editor/url/url.grammar index cb81cd73..1fd9e797 100644 --- a/src-web/components/core/Editor/url/url.grammar +++ b/src-web/components/core/Editor/url/url.grammar @@ -1,6 +1,4 @@ -@top Program { url } - -url { Protocol? Host Port? Path? Query? } +@top url { Protocol? Host Port? Path? Query? } Path { ("/" (Placeholder | PathSegment))+ } diff --git a/src-web/components/core/Editor/url/url.terms.ts b/src-web/components/core/Editor/url/url.terms.ts index 82a226dc..36ddee27 100644 --- a/src-web/components/core/Editor/url/url.terms.ts +++ b/src-web/components/core/Editor/url/url.terms.ts @@ -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, diff --git a/src-web/components/core/Editor/url/url.ts b/src-web/components/core/Editor/url/url.ts index c1c3c351..02d395a3 100644 --- a/src-web/components/core/Editor/url/url.ts +++ b/src-web/components/core/Editor/url/url.ts @@ -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<