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<