Split out slow pathParameters extension and skip unnecessary model updates

This commit is contained in:
Gregory Schier
2025-01-01 16:42:53 -08:00
parent add39bda6e
commit 42cd4a5f0f
10 changed files with 253 additions and 177 deletions

View File

@@ -158,7 +158,6 @@ export const RequestPane = memo(function RequestPane({
{ {
value: TAB_DESCRIPTION, value: TAB_DESCRIPTION,
label: 'Info', label: 'Info',
rightSlot: activeRequest.description ? <CountBadge count={true} /> : null,
}, },
{ {
value: TAB_BODY, value: TAB_BODY,
@@ -272,7 +271,6 @@ export const RequestPane = memo(function RequestPane({
activeRequest.authentication, activeRequest.authentication,
activeRequest.authenticationType, activeRequest.authenticationType,
activeRequest.bodyType, activeRequest.bodyType,
activeRequest.description,
activeRequest.headers, activeRequest.headers,
activeRequest.method, activeRequest.method,
activeRequestId, activeRequestId,

View File

@@ -1,7 +1,7 @@
import type { AnyModel, GrpcConnection, HttpResponse } from '@yaakapp-internal/models'; import type { AnyModel, GrpcConnection, HttpResponse } from '@yaakapp-internal/models';
import classNames from 'classnames'; import classNames from 'classnames';
import { atom, useAtomValue } from 'jotai'; import { atom, useAtomValue } from 'jotai';
import type { ReactNode } from 'react'; import type { ReactElement } from 'react';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { XYCoord } from 'react-dnd'; import type { XYCoord } from 'react-dnd';
import { useDrag, useDrop } from 'react-dnd'; import { useDrag, useDrop } from 'react-dnd';
@@ -31,12 +31,11 @@ export type SidebarItemProps = {
className?: string; className?: string;
itemId: string; itemId: string;
itemName: string; itemName: string;
itemFallbackName: string;
itemModel: AnyModel['model']; itemModel: AnyModel['model'];
onMove: (id: string, side: 'above' | 'below') => void; onMove: (id: string, side: 'above' | 'below') => void;
onEnd: (id: string) => void; onEnd: (id: string) => void;
onDragStart: (id: string) => void; onDragStart: (id: string) => void;
children?: ReactNode; children: ReactElement<typeof SidebarItem> | null;
child: SidebarTreeNode; child: SidebarTreeNode;
latestHttpResponse: HttpResponse | null; latestHttpResponse: HttpResponse | null;
latestGrpcConnection: GrpcConnection | null; latestGrpcConnection: GrpcConnection | null;
@@ -57,7 +56,6 @@ export const SidebarItem = memo(function SidebarItem({
onDragStart, onDragStart,
onSelect, onSelect,
className, className,
itemFallbackName,
latestHttpResponse, latestHttpResponse,
latestGrpcConnection, latestGrpcConnection,
children, children,
@@ -210,7 +208,7 @@ export const SidebarItem = memo(function SidebarItem({
return null; return null;
} }
}); });
}, [itemId, itemModel]) }, [itemId, itemModel]);
const item = useAtomValue(itemAtom); const item = useAtomValue(itemAtom);
@@ -271,7 +269,7 @@ export const SidebarItem = memo(function SidebarItem({
onKeyDown={handleInputKeyDown} onKeyDown={handleInputKeyDown}
/> />
) : ( ) : (
<span className="truncate">{itemName || itemFallbackName}</span> <span className="truncate">{itemName}</span>
)} )}
</div> </div>
{latestGrpcConnection ? ( {latestGrpcConnection ? (

View File

@@ -54,19 +54,16 @@ export const SidebarItems = memo(function SidebarItems({
<SidebarItem <SidebarItem
itemId={child.id} itemId={child.id}
itemName={child.name} itemName={child.name}
itemFallbackName="TODO"
itemModel={child.model} itemModel={child.model}
latestHttpResponse={httpResponses.find((r) => r.requestId === child.id) ?? null} latestHttpResponse={httpResponses.find((r) => r.requestId === child.id) ?? null}
latestGrpcConnection={ latestGrpcConnection={grpcConnections.find((c) => c.requestId === child.id) ?? null}
grpcConnections.find((c) => c.requestId === child.id) ?? null
}
onMove={handleMove} onMove={handleMove}
onEnd={handleEnd} onEnd={handleEnd}
onSelect={onSelect} onSelect={onSelect}
onDragStart={handleDragStart} onDragStart={handleDragStart}
child={child} child={child}
> >
{child.model === 'folder' && draggingId !== child.id && ( {child.model === 'folder' && draggingId !== child.id ? (
<SidebarItems <SidebarItems
draggingId={draggingId} draggingId={draggingId}
handleDragStart={handleDragStart} handleDragStart={handleDragStart}
@@ -81,14 +78,12 @@ export const SidebarItems = memo(function SidebarItems({
tree={child} tree={child}
treeParentMap={treeParentMap} treeParentMap={treeParentMap}
/> />
)} ) : null}
</SidebarItem> </SidebarItem>
</Fragment> </Fragment>
); );
})} })}
{hoveredIndex === tree.children.length && hoveredTree?.id === tree.id && ( {hoveredIndex === tree.children.length && hoveredTree?.id === tree.id && <DropMarker />}
<DropMarker />
)}
</VStack> </VStack>
); );
}); });

View File

@@ -24,7 +24,10 @@ import { useDialog } from '../../../hooks/useDialog';
import { parseTemplate } from '../../../hooks/useParseTemplate'; import { parseTemplate } from '../../../hooks/useParseTemplate';
import { useRequestEditor } from '../../../hooks/useRequestEditor'; import { useRequestEditor } from '../../../hooks/useRequestEditor';
import { useSettings } from '../../../hooks/useSettings'; import { useSettings } from '../../../hooks/useSettings';
import { useTemplateFunctions } from '../../../hooks/useTemplateFunctions'; import {
useTemplateFunctions,
useTwigCompletionOptions,
} from '../../../hooks/useTemplateFunctions';
import { TemplateFunctionDialog } from '../../TemplateFunctionDialog'; import { TemplateFunctionDialog } from '../../TemplateFunctionDialog';
import { TemplateVariableDialog } from '../../TemplateVariableDialog'; import { TemplateVariableDialog } from '../../TemplateVariableDialog';
import { IconButton } from '../IconButton'; import { IconButton } from '../IconButton';
@@ -160,22 +163,28 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
// Update placeholder // Update placeholder
const placeholderCompartment = useRef(new Compartment()); const placeholderCompartment = useRef(new Compartment());
useEffect(() => { useEffect(
if (cm.current === null) return; function configurePlaceholder() {
const effect = placeholderCompartment.current.reconfigure( if (cm.current === null) return;
placeholderExt(placeholderElFromText(placeholder ?? '')), const effect = placeholderCompartment.current.reconfigure(
); placeholderExt(placeholderElFromText(placeholder ?? '')),
cm.current?.view.dispatch({ effects: effect }); );
}, [placeholder]); cm.current?.view.dispatch({ effects: effect });
},
[placeholder],
);
// Update wrap lines // Update wrap lines
const wrapLinesCompartment = useRef(new Compartment()); const wrapLinesCompartment = useRef(new Compartment());
useEffect(() => { useEffect(
if (cm.current === null) return; function configureWrapLines() {
const ext = wrapLines ? [EditorView.lineWrapping] : []; if (cm.current === null) return;
const effect = wrapLinesCompartment.current.reconfigure(ext); const ext = wrapLines ? [EditorView.lineWrapping] : [];
cm.current?.view.dispatch({ effects: effect }); const effect = wrapLinesCompartment.current.reconfigure(ext);
}, [wrapLines]); cm.current?.view.dispatch({ effects: effect });
},
[wrapLines],
);
const dialog = useDialog(); const dialog = useDialog();
const onClickFunction = useCallback( const onClickFunction = useCallback(
@@ -257,6 +266,8 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
[focusParamValue], [focusParamValue],
); );
const completionOptions = useTwigCompletionOptions(onClickFunction);
// Update the language extension when the language changes // Update the language extension when the language changes
useEffect(() => { useEffect(() => {
if (cm.current === null) return; if (cm.current === null) return;
@@ -266,8 +277,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
environmentVariables, environmentVariables,
useTemplating, useTemplating,
autocomplete, autocomplete,
templateFunctions, completionOptions,
onClickFunction,
onClickVariable, onClickVariable,
onClickMissingVariable, onClickMissingVariable,
onClickPathParameter, onClickPathParameter,
@@ -283,11 +293,12 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
onClickVariable, onClickVariable,
onClickMissingVariable, onClickMissingVariable,
onClickPathParameter, onClickPathParameter,
completionOptions,
]); ]);
// Initialize the editor when ref mounts // Initialize the editor when ref mounts
const initEditorRef = useCallback( const initEditorRef = useCallback(
(container: HTMLDivElement | null) => { function initEditorRef(container: HTMLDivElement | null) {
if (container === null) { if (container === null) {
cm.current?.view.destroy(); cm.current?.view.destroy();
cm.current = null; cm.current = null;
@@ -299,11 +310,10 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
const langExt = getLanguageExtension({ const langExt = getLanguageExtension({
language, language,
useTemplating, useTemplating,
completionOptions,
autocomplete, autocomplete,
environmentVariables, environmentVariables,
templateFunctions,
onClickVariable, onClickVariable,
onClickFunction,
onClickMissingVariable, onClickMissingVariable,
onClickPathParameter, onClickPathParameter,
}); });
@@ -362,31 +372,34 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
); );
// For read-only mode, update content when `defaultValue` changes // For read-only mode, update content when `defaultValue` changes
useEffect(() => { useEffect(
if (!readOnly || cm.current?.view == null || defaultValue == null) return; function updateReadOnlyEditor() {
if (!readOnly || cm.current?.view == null || defaultValue == null) return;
// Replace codemirror contents // Replace codemirror contents
const currentDoc = cm.current.view.state.doc.toString(); const currentDoc = cm.current.view.state.doc.toString();
if (defaultValue.startsWith(currentDoc)) { if (defaultValue.startsWith(currentDoc)) {
// If we're just appending, append only the changes. This preserves // If we're just appending, append only the changes. This preserves
// things like scroll position. // things like scroll position.
cm.current.view.dispatch({ cm.current.view.dispatch({
changes: cm.current.view.state.changes({ changes: cm.current.view.state.changes({
from: currentDoc.length, from: currentDoc.length,
insert: defaultValue.slice(currentDoc.length), insert: defaultValue.slice(currentDoc.length),
}), }),
}); });
} else { } else {
// If we're replacing everything, reset the entire content // If we're replacing everything, reset the entire content
cm.current.view.dispatch({ cm.current.view.dispatch({
changes: cm.current.view.state.changes({ changes: cm.current.view.state.changes({
from: 0, from: 0,
to: currentDoc.length, to: currentDoc.length,
insert: defaultValue, insert: defaultValue,
}), }),
}); });
} }
}, [defaultValue, readOnly]); },
[defaultValue, readOnly],
);
// Add bg classes to actions, so they appear over the text // Add bg classes to actions, so they appear over the text
const decoratedActions = useMemo(() => { const decoratedActions = useMemo(() => {
@@ -557,7 +570,7 @@ function saveCachedEditorState(stateKey: string | null, state: EditorState | nul
function getCachedEditorState(doc: string, stateKey: string | null) { function getCachedEditorState(doc: string, stateKey: string | null) {
if (stateKey == null) return; if (stateKey == null) return;
const stateStr = sessionStorage.getItem(stateKey) const stateStr = sessionStorage.getItem(stateKey);
if (stateStr == null) return null; if (stateStr == null) return null;
try { try {

View File

@@ -34,14 +34,15 @@ import {
} from '@codemirror/view'; } from '@codemirror/view';
import { tags as t } from '@lezer/highlight'; import { tags as t } from '@lezer/highlight';
import type { EnvironmentVariable } from '@yaakapp-internal/models'; import type { EnvironmentVariable } from '@yaakapp-internal/models';
import type { TemplateFunction } from '@yaakapp-internal/plugin';
import { graphql } from 'cm6-graphql'; import { graphql } from 'cm6-graphql';
import { EditorView } from 'codemirror'; import { EditorView } from 'codemirror';
import { pluralizeCount } from '../../../lib/pluralize'; import { pluralizeCount } from '../../../lib/pluralize';
import type { EditorProps } from './Editor'; import type { EditorProps } from './Editor';
import { pairs } from './pairs/extension'; import { pairs } from './pairs/extension';
import { text } from './text/extension'; import { text } from './text/extension';
import type { TwigCompletionOption } from './twig/completion';
import { twig } from './twig/extension'; import { twig } from './twig/extension';
import { pathParametersPlugin } from './twig/pathParameters';
import { url } from './url/extension'; import { url } from './url/extension';
export const syntaxHighlightStyle = HighlightStyle.define([ export const syntaxHighlightStyle = HighlightStyle.define([
@@ -89,18 +90,16 @@ export function getLanguageExtension({
useTemplating = false, useTemplating = false,
environmentVariables, environmentVariables,
autocomplete, autocomplete,
templateFunctions,
onClickVariable, onClickVariable,
onClickFunction,
onClickMissingVariable, onClickMissingVariable,
onClickPathParameter, onClickPathParameter,
completionOptions,
}: { }: {
environmentVariables: EnvironmentVariable[]; environmentVariables: EnvironmentVariable[];
templateFunctions: TemplateFunction[];
onClickFunction: (option: TemplateFunction, tagValue: string, startPos: number) => void;
onClickVariable: (option: EnvironmentVariable, tagValue: string, startPos: number) => void; onClickVariable: (option: EnvironmentVariable, tagValue: string, startPos: number) => void;
onClickMissingVariable: (name: string, tagValue: string, startPos: number) => void; onClickMissingVariable: (name: string, tagValue: string, startPos: number) => void;
onClickPathParameter: (name: string) => void; onClickPathParameter: (name: string) => void;
completionOptions: TwigCompletionOption[];
} & Pick<EditorProps, 'language' | 'useTemplating' | 'autocomplete'>) { } & Pick<EditorProps, 'language' | 'useTemplating' | 'autocomplete'>) {
if (language === 'graphql') { if (language === 'graphql') {
return graphql(); return graphql();
@@ -111,15 +110,17 @@ export function getLanguageExtension({
return base; return base;
} }
const extraExtensions = language === 'url' ? [pathParametersPlugin(onClickPathParameter)] : [];
return twig({ return twig({
base, base,
environmentVariables, environmentVariables,
templateFunctions, completionOptions,
autocomplete, autocomplete,
onClickFunction,
onClickVariable, onClickVariable,
onClickMissingVariable, onClickMissingVariable,
onClickPathParameter, onClickPathParameter,
extraExtensions,
}); });
} }

View File

@@ -1,8 +1,8 @@
import type { LanguageSupport } from '@codemirror/language'; import type { LanguageSupport } from '@codemirror/language';
import { LRLanguage } from '@codemirror/language'; import { LRLanguage } from '@codemirror/language';
import type { Extension } from '@codemirror/state';
import { parseMixed } from '@lezer/common'; import { parseMixed } from '@lezer/common';
import type { EnvironmentVariable } from '@yaakapp-internal/models'; import type { EnvironmentVariable } from '@yaakapp-internal/models';
import type { TemplateFunction } from '@yaakapp-internal/plugin';
import type { GenericCompletionConfig } from '../genericCompletion'; import type { GenericCompletionConfig } from '../genericCompletion';
import { genericCompletion } from '../genericCompletion'; import { genericCompletion } from '../genericCompletion';
import { textLanguageName } from '../text/extension'; import { textLanguageName } from '../text/extension';
@@ -14,21 +14,20 @@ import { parser as twigParser } from './twig';
export function twig({ export function twig({
base, base,
environmentVariables, environmentVariables,
templateFunctions, completionOptions,
autocomplete, autocomplete,
onClickFunction,
onClickVariable, onClickVariable,
onClickMissingVariable, onClickMissingVariable,
onClickPathParameter, extraExtensions,
}: { }: {
base: LanguageSupport; base: LanguageSupport;
environmentVariables: EnvironmentVariable[]; environmentVariables: EnvironmentVariable[];
templateFunctions: TemplateFunction[]; completionOptions: TwigCompletionOption[];
autocomplete?: GenericCompletionConfig; autocomplete?: GenericCompletionConfig;
onClickFunction: (option: TemplateFunction, tagValue: string, startPos: number) => void;
onClickVariable: (option: EnvironmentVariable, tagValue: string, startPos: number) => void; onClickVariable: (option: EnvironmentVariable, tagValue: string, startPos: number) => void;
onClickMissingVariable: (name: string, tagValue: string, startPos: number) => void; onClickMissingVariable: (name: string, tagValue: string, startPos: number) => void;
onClickPathParameter: (name: string) => void; onClickPathParameter: (name: string) => void;
extraExtensions: Extension[];
}) { }) {
const language = mixLanguage(base); const language = mixLanguage(base);
@@ -40,28 +39,7 @@ export function twig({
onClick: (rawTag: string, startPos: number) => onClickVariable(v, rawTag, startPos), onClick: (rawTag: string, startPos: number) => onClickVariable(v, rawTag, startPos),
})) ?? []; })) ?? [];
const functionOptions: TwigCompletionOption[] = const options = [...variableOptions, ...completionOptions];
templateFunctions.map((fn) => {
const NUM_ARGS = 2;
const shortArgs =
fn.args
.slice(0, NUM_ARGS)
.map((a) => a.name)
.join(', ') + (fn.args.length > NUM_ARGS ? ', …' : '');
return {
name: fn.name,
aliases: fn.aliases,
type: 'function',
description: fn.description,
args: fn.args.map((a) => ({ name: a.name })),
value: null,
label: `${fn.name}(${shortArgs})`,
onClick: (rawTag: string, startPos: number) => onClickFunction(fn, rawTag, startPos),
};
}) ?? [];
const options = [...variableOptions, ...functionOptions];
const completions = twigCompletion({ options }); const completions = twigCompletion({ options });
return [ return [
@@ -71,11 +49,20 @@ export function twig({
base.language.data.of({ autocomplete: completions }), base.language.data.of({ autocomplete: completions }),
language.data.of({ autocomplete: genericCompletion(autocomplete) }), language.data.of({ autocomplete: genericCompletion(autocomplete) }),
base.language.data.of({ autocomplete: genericCompletion(autocomplete) }), base.language.data.of({ autocomplete: genericCompletion(autocomplete) }),
templateTagsPlugin(options, onClickMissingVariable, onClickPathParameter), templateTagsPlugin(options, onClickMissingVariable),
...extraExtensions,
]; ];
} }
const mixedLanguagesCache: Record<string, LRLanguage> = {};
function mixLanguage(base: LanguageSupport): LRLanguage { function mixLanguage(base: LanguageSupport): LRLanguage {
// It can be slow to mix languages when there are hundreds of editors, so we'll cache them to speed it up
const cached = mixedLanguagesCache[base.language.name];
if (cached != null) {
return cached;
}
const name = 'twig'; const name = 'twig';
const parser = twigParser.configure({ const parser = twigParser.configure({
@@ -92,5 +79,7 @@ function mixLanguage(base: LanguageSupport): LRLanguage {
}), }),
}); });
return LRLanguage.define({ name, parser }); const language = LRLanguage.define({ name, parser });
mixedLanguagesCache[base.language.name] = language;
return language;
} }

View File

@@ -0,0 +1,109 @@
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 { EditorView } from 'codemirror';
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;
}
}
function pathParameters(
view: EditorView,
onClickPathParameter: (name: string) => void,
): DecorationSet {
const widgets: Range<Decoration>[] = [];
const tree = syntaxTree(view.state);
for (const { from, to } of view.visibleRanges) {
tree.iterate({
from,
to,
enter(node) {
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;
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 });
widgets.push(deco.range(globalFrom, globalTo));
},
});
break;
}
}
}
},
});
}
// Widgets must be sorted start to end
widgets.sort((a, b) => a.from - b.from);
return Decoration.set(widgets);
}
export function pathParametersPlugin(onClickPathParameter: (name: string) => void) {
return ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = pathParameters(view, onClickPathParameter);
}
update(update: ViewUpdate) {
this.decorations = pathParameters(update.view, onClickPathParameter);
}
},
{
decorations(v) {
return v.decorations;
},
provide(plugin) {
return EditorView.atomicRanges.of((view) => {
return view.plugin(plugin)?.decorations || Decoration.none;
});
},
},
);
}

View File

@@ -6,42 +6,6 @@ import type { SyntaxNodeRef } from '@lezer/common';
import { EditorView } from 'codemirror'; import { EditorView } from 'codemirror';
import type { TwigCompletionOption } from './completion'; 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 { class TemplateTagWidget extends WidgetType {
readonly #clickListenerCallback: () => void; readonly #clickListenerCallback: () => void;
@@ -99,38 +63,15 @@ function templateTags(
view: EditorView, view: EditorView,
options: TwigCompletionOption[], options: TwigCompletionOption[],
onClickMissingVariable: (name: string, rawTag: string, startPos: number) => void, onClickMissingVariable: (name: string, rawTag: string, startPos: number) => void,
onClickPathParameter: (name: string) => void,
): DecorationSet { ): DecorationSet {
const widgets: Range<Decoration>[] = []; const widgets: Range<Decoration>[] = [];
const tree = syntaxTree(view.state);
for (const { from, to } of view.visibleRanges) { for (const { from, to } of view.visibleRanges) {
const tree = syntaxTree(view.state);
tree.iterate({ tree.iterate({
from, from,
to, to,
enter(node) { enter(node) {
if (node.name === 'Text') { if (node.name === 'Tag') {
// 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 });
widgets.push(deco.range(globalFrom, globalTo));
},
});
break;
}
}
} else if (node.name === 'Tag') {
// Don't decorate if the cursor is inside the match // Don't decorate if the cursor is inside the match
if (isSelectionInsideNode(view, node)) return; if (isSelectionInsideNode(view, node)) return;
@@ -177,28 +118,17 @@ function templateTags(
export function templateTagsPlugin( export function templateTagsPlugin(
options: TwigCompletionOption[], options: TwigCompletionOption[],
onClickMissingVariable: (name: string, tagValue: string, startPos: number) => void, onClickMissingVariable: (name: string, tagValue: string, startPos: number) => void,
onClickPathParameter: (name: string) => void,
) { ) {
return ViewPlugin.fromClass( return ViewPlugin.fromClass(
class { class {
decorations: DecorationSet; decorations: DecorationSet;
constructor(view: EditorView) { constructor(view: EditorView) {
this.decorations = templateTags( this.decorations = templateTags(view, options, onClickMissingVariable);
view,
options,
onClickMissingVariable,
onClickPathParameter,
);
} }
update(update: ViewUpdate) { update(update: ViewUpdate) {
this.decorations = templateTags( this.decorations = templateTags(update.view, options, onClickMissingVariable);
update.view,
options,
onClickMissingVariable,
onClickPathParameter,
);
} }
}, },
{ {

View File

@@ -1,3 +1,4 @@
import deepEqual from '@gilbarbara/deep-equal';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'; import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
import type { AnyModel, KeyValue } from '@yaakapp-internal/models'; import type { AnyModel, KeyValue } from '@yaakapp-internal/models';
@@ -121,7 +122,11 @@ export function updateModelList<T extends AnyModel>(model: T) {
return (current: T[] | undefined): T[] => { return (current: T[] | undefined): T[] => {
const index = current?.findIndex((v) => modelsEq(v, model)) ?? -1; const index = current?.findIndex((v) => modelsEq(v, model)) ?? -1;
if (index >= 0) { const existingModel = current?.[index];
if (existingModel && deepEqual(existingModel, model)) {
// We already have the exact model, so do nothing
return current;
} else if (existingModel) {
return [...(current ?? []).slice(0, index), model, ...(current ?? []).slice(index + 1)]; return [...(current ?? []).slice(0, index), model, ...(current ?? []).slice(index + 1)];
} else { } else {
return pushToFront ? [model, ...(current ?? [])] : [...(current ?? []), model]; return pushToFront ? [model, ...(current ?? [])] : [...(current ?? []), model];
@@ -130,12 +135,21 @@ export function updateModelList<T extends AnyModel>(model: T) {
} }
export function removeModelById<T extends { id: string }>(model: T) { export function removeModelById<T extends { id: string }>(model: T) {
return (entries: T[] | undefined) => entries?.filter((e) => e.id !== model.id) ?? []; return (prevEntries: T[] | undefined) => {
const entries = prevEntries?.filter((e) => e.id !== model.id) ?? [];
// Don't trigger an update if we didn't actually remove anything
if (entries.length === (prevEntries ?? []).length) {
return prevEntries ?? [];
}
return entries;
};
} }
export function removeModelByKeyValue(model: KeyValue) { export function removeModelByKeyValue(model: KeyValue) {
return (entries: KeyValue[] | undefined) => return (prevEntries: KeyValue[] | undefined) =>
entries?.filter( prevEntries?.filter(
(e) => (e) =>
!( !(
e.namespace === model.namespace && e.namespace === model.namespace &&

View File

@@ -2,12 +2,41 @@ import { useQuery } from '@tanstack/react-query';
import type { GetTemplateFunctionsResponse, TemplateFunction } from '@yaakapp-internal/plugin'; import type { GetTemplateFunctionsResponse, TemplateFunction } from '@yaakapp-internal/plugin';
import { atom, useAtomValue } from 'jotai'; import { atom, useAtomValue } from 'jotai';
import { useSetAtom } from 'jotai/index'; import { useSetAtom } from 'jotai/index';
import { useState } from 'react'; import { useMemo, useState } from 'react';
import type {TwigCompletionOption} from "../components/core/Editor/twig/completion";
import { invokeCmd } from '../lib/tauri'; import { invokeCmd } from '../lib/tauri';
import { usePluginsKey } from './usePlugins'; import { usePluginsKey } from './usePlugins';
const templateFunctionsAtom = atom<TemplateFunction[]>([]); const templateFunctionsAtom = atom<TemplateFunction[]>([]);
export function useTwigCompletionOptions(
onClick: (fn: TemplateFunction, ragTag: string, pos: number) => void,
) {
const templateFunctions = useTemplateFunctions();
return useMemo<TwigCompletionOption[]>(() => {
return (
templateFunctions.map((fn) => {
const NUM_ARGS = 2;
const shortArgs =
fn.args
.slice(0, NUM_ARGS)
.map((a) => a.name)
.join(', ') + (fn.args.length > NUM_ARGS ? ', …' : '');
return {
name: fn.name,
aliases: fn.aliases,
type: 'function',
description: fn.description,
args: fn.args.map((a) => ({ name: a.name })),
value: null,
label: `${fn.name}(${shortArgs})`,
onClick: (rawTag: string, startPos: number) => onClick(fn, rawTag, startPos),
};
}) ?? []
);
}, [onClick, templateFunctions]);
}
export function useTemplateFunctions() { export function useTemplateFunctions() {
return useAtomValue(templateFunctionsAtom); return useAtomValue(templateFunctionsAtom);
} }