diff --git a/src-web/components/RequestPane.tsx b/src-web/components/RequestPane.tsx
index 5d3792bd..7f4235a9 100644
--- a/src-web/components/RequestPane.tsx
+++ b/src-web/components/RequestPane.tsx
@@ -158,7 +158,6 @@ export const RequestPane = memo(function RequestPane({
{
value: TAB_DESCRIPTION,
label: 'Info',
- rightSlot: activeRequest.description ? : null,
},
{
value: TAB_BODY,
@@ -272,7 +271,6 @@ export const RequestPane = memo(function RequestPane({
activeRequest.authentication,
activeRequest.authenticationType,
activeRequest.bodyType,
- activeRequest.description,
activeRequest.headers,
activeRequest.method,
activeRequestId,
diff --git a/src-web/components/SidebarItem.tsx b/src-web/components/SidebarItem.tsx
index 95eb03e9..4a148e91 100644
--- a/src-web/components/SidebarItem.tsx
+++ b/src-web/components/SidebarItem.tsx
@@ -1,7 +1,7 @@
import type { AnyModel, GrpcConnection, HttpResponse } from '@yaakapp-internal/models';
import classNames from 'classnames';
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 type { XYCoord } from 'react-dnd';
import { useDrag, useDrop } from 'react-dnd';
@@ -31,12 +31,11 @@ export type SidebarItemProps = {
className?: string;
itemId: string;
itemName: string;
- itemFallbackName: string;
itemModel: AnyModel['model'];
onMove: (id: string, side: 'above' | 'below') => void;
onEnd: (id: string) => void;
onDragStart: (id: string) => void;
- children?: ReactNode;
+ children: ReactElement | null;
child: SidebarTreeNode;
latestHttpResponse: HttpResponse | null;
latestGrpcConnection: GrpcConnection | null;
@@ -57,7 +56,6 @@ export const SidebarItem = memo(function SidebarItem({
onDragStart,
onSelect,
className,
- itemFallbackName,
latestHttpResponse,
latestGrpcConnection,
children,
@@ -210,7 +208,7 @@ export const SidebarItem = memo(function SidebarItem({
return null;
}
});
- }, [itemId, itemModel])
+ }, [itemId, itemModel]);
const item = useAtomValue(itemAtom);
@@ -271,7 +269,7 @@ export const SidebarItem = memo(function SidebarItem({
onKeyDown={handleInputKeyDown}
/>
) : (
- {itemName || itemFallbackName}
+ {itemName}
)}
{latestGrpcConnection ? (
diff --git a/src-web/components/SidebarItems.tsx b/src-web/components/SidebarItems.tsx
index 73ab5ee4..86930c78 100644
--- a/src-web/components/SidebarItems.tsx
+++ b/src-web/components/SidebarItems.tsx
@@ -54,19 +54,16 @@ export const SidebarItems = memo(function SidebarItems({
r.requestId === child.id) ?? null}
- latestGrpcConnection={
- grpcConnections.find((c) => c.requestId === child.id) ?? null
- }
+ latestGrpcConnection={grpcConnections.find((c) => c.requestId === child.id) ?? null}
onMove={handleMove}
onEnd={handleEnd}
onSelect={onSelect}
onDragStart={handleDragStart}
child={child}
>
- {child.model === 'folder' && draggingId !== child.id && (
+ {child.model === 'folder' && draggingId !== child.id ? (
- )}
+ ) : null}
);
})}
- {hoveredIndex === tree.children.length && hoveredTree?.id === tree.id && (
-
- )}
+ {hoveredIndex === tree.children.length && hoveredTree?.id === tree.id && }
);
});
diff --git a/src-web/components/core/Editor/Editor.tsx b/src-web/components/core/Editor/Editor.tsx
index 4cb73050..1eea239f 100644
--- a/src-web/components/core/Editor/Editor.tsx
+++ b/src-web/components/core/Editor/Editor.tsx
@@ -24,7 +24,10 @@ import { useDialog } from '../../../hooks/useDialog';
import { parseTemplate } from '../../../hooks/useParseTemplate';
import { useRequestEditor } from '../../../hooks/useRequestEditor';
import { useSettings } from '../../../hooks/useSettings';
-import { useTemplateFunctions } from '../../../hooks/useTemplateFunctions';
+import {
+ useTemplateFunctions,
+ useTwigCompletionOptions,
+} from '../../../hooks/useTemplateFunctions';
import { TemplateFunctionDialog } from '../../TemplateFunctionDialog';
import { TemplateVariableDialog } from '../../TemplateVariableDialog';
import { IconButton } from '../IconButton';
@@ -160,22 +163,28 @@ export const Editor = forwardRef(function E
// Update placeholder
const placeholderCompartment = useRef(new Compartment());
- useEffect(() => {
- if (cm.current === null) return;
- const effect = placeholderCompartment.current.reconfigure(
- placeholderExt(placeholderElFromText(placeholder ?? '')),
- );
- cm.current?.view.dispatch({ effects: effect });
- }, [placeholder]);
+ useEffect(
+ function configurePlaceholder() {
+ if (cm.current === null) return;
+ const effect = placeholderCompartment.current.reconfigure(
+ placeholderExt(placeholderElFromText(placeholder ?? '')),
+ );
+ cm.current?.view.dispatch({ effects: effect });
+ },
+ [placeholder],
+ );
// Update wrap lines
const wrapLinesCompartment = useRef(new Compartment());
- useEffect(() => {
- if (cm.current === null) return;
- const ext = wrapLines ? [EditorView.lineWrapping] : [];
- const effect = wrapLinesCompartment.current.reconfigure(ext);
- cm.current?.view.dispatch({ effects: effect });
- }, [wrapLines]);
+ useEffect(
+ function configureWrapLines() {
+ if (cm.current === null) return;
+ const ext = wrapLines ? [EditorView.lineWrapping] : [];
+ const effect = wrapLinesCompartment.current.reconfigure(ext);
+ cm.current?.view.dispatch({ effects: effect });
+ },
+ [wrapLines],
+ );
const dialog = useDialog();
const onClickFunction = useCallback(
@@ -257,6 +266,8 @@ export const Editor = forwardRef(function E
[focusParamValue],
);
+ const completionOptions = useTwigCompletionOptions(onClickFunction);
+
// Update the language extension when the language changes
useEffect(() => {
if (cm.current === null) return;
@@ -266,8 +277,7 @@ export const Editor = forwardRef(function E
environmentVariables,
useTemplating,
autocomplete,
- templateFunctions,
- onClickFunction,
+ completionOptions,
onClickVariable,
onClickMissingVariable,
onClickPathParameter,
@@ -283,11 +293,12 @@ export const Editor = forwardRef(function E
onClickVariable,
onClickMissingVariable,
onClickPathParameter,
+ completionOptions,
]);
// Initialize the editor when ref mounts
const initEditorRef = useCallback(
- (container: HTMLDivElement | null) => {
+ function initEditorRef(container: HTMLDivElement | null) {
if (container === null) {
cm.current?.view.destroy();
cm.current = null;
@@ -299,11 +310,10 @@ export const Editor = forwardRef(function E
const langExt = getLanguageExtension({
language,
useTemplating,
+ completionOptions,
autocomplete,
environmentVariables,
- templateFunctions,
onClickVariable,
- onClickFunction,
onClickMissingVariable,
onClickPathParameter,
});
@@ -362,31 +372,34 @@ export const Editor = forwardRef(function E
);
// For read-only mode, update content when `defaultValue` changes
- useEffect(() => {
- if (!readOnly || cm.current?.view == null || defaultValue == null) return;
+ useEffect(
+ function updateReadOnlyEditor() {
+ if (!readOnly || cm.current?.view == null || defaultValue == null) return;
- // Replace codemirror contents
- const currentDoc = cm.current.view.state.doc.toString();
- if (defaultValue.startsWith(currentDoc)) {
- // If we're just appending, append only the changes. This preserves
- // things like scroll position.
- cm.current.view.dispatch({
- changes: cm.current.view.state.changes({
- from: currentDoc.length,
- insert: defaultValue.slice(currentDoc.length),
- }),
- });
- } else {
- // If we're replacing everything, reset the entire content
- cm.current.view.dispatch({
- changes: cm.current.view.state.changes({
- from: 0,
- to: currentDoc.length,
- insert: defaultValue,
- }),
- });
- }
- }, [defaultValue, readOnly]);
+ // Replace codemirror contents
+ const currentDoc = cm.current.view.state.doc.toString();
+ if (defaultValue.startsWith(currentDoc)) {
+ // If we're just appending, append only the changes. This preserves
+ // things like scroll position.
+ cm.current.view.dispatch({
+ changes: cm.current.view.state.changes({
+ from: currentDoc.length,
+ insert: defaultValue.slice(currentDoc.length),
+ }),
+ });
+ } else {
+ // If we're replacing everything, reset the entire content
+ cm.current.view.dispatch({
+ changes: cm.current.view.state.changes({
+ from: 0,
+ to: currentDoc.length,
+ insert: defaultValue,
+ }),
+ });
+ }
+ },
+ [defaultValue, readOnly],
+ );
// Add bg classes to actions, so they appear over the text
const decoratedActions = useMemo(() => {
@@ -557,7 +570,7 @@ function saveCachedEditorState(stateKey: string | null, state: EditorState | nul
function getCachedEditorState(doc: string, stateKey: string | null) {
if (stateKey == null) return;
- const stateStr = sessionStorage.getItem(stateKey)
+ const stateStr = sessionStorage.getItem(stateKey);
if (stateStr == null) return null;
try {
diff --git a/src-web/components/core/Editor/extensions.ts b/src-web/components/core/Editor/extensions.ts
index 9df08c2a..22d5eb30 100644
--- a/src-web/components/core/Editor/extensions.ts
+++ b/src-web/components/core/Editor/extensions.ts
@@ -34,14 +34,15 @@ import {
} from '@codemirror/view';
import { tags as t } from '@lezer/highlight';
import type { EnvironmentVariable } from '@yaakapp-internal/models';
-import type { TemplateFunction } from '@yaakapp-internal/plugin';
import { graphql } from 'cm6-graphql';
import { EditorView } from 'codemirror';
import { pluralizeCount } from '../../../lib/pluralize';
import type { EditorProps } from './Editor';
import { pairs } from './pairs/extension';
import { text } from './text/extension';
+import type { TwigCompletionOption } from './twig/completion';
import { twig } from './twig/extension';
+import { pathParametersPlugin } from './twig/pathParameters';
import { url } from './url/extension';
export const syntaxHighlightStyle = HighlightStyle.define([
@@ -89,18 +90,16 @@ export function getLanguageExtension({
useTemplating = false,
environmentVariables,
autocomplete,
- templateFunctions,
onClickVariable,
- onClickFunction,
onClickMissingVariable,
onClickPathParameter,
+ completionOptions,
}: {
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;
+ completionOptions: TwigCompletionOption[];
} & Pick) {
if (language === 'graphql') {
return graphql();
@@ -111,15 +110,17 @@ export function getLanguageExtension({
return base;
}
+ const extraExtensions = language === 'url' ? [pathParametersPlugin(onClickPathParameter)] : [];
+
return twig({
base,
environmentVariables,
- templateFunctions,
+ completionOptions,
autocomplete,
- onClickFunction,
onClickVariable,
onClickMissingVariable,
onClickPathParameter,
+ extraExtensions,
});
}
diff --git a/src-web/components/core/Editor/twig/extension.ts b/src-web/components/core/Editor/twig/extension.ts
index 7c706f9a..3d4775d1 100644
--- a/src-web/components/core/Editor/twig/extension.ts
+++ b/src-web/components/core/Editor/twig/extension.ts
@@ -1,8 +1,8 @@
import type { LanguageSupport } from '@codemirror/language';
import { LRLanguage } from '@codemirror/language';
+import type { Extension } from '@codemirror/state';
import { parseMixed } from '@lezer/common';
import type { EnvironmentVariable } from '@yaakapp-internal/models';
-import type { TemplateFunction } from '@yaakapp-internal/plugin';
import type { GenericCompletionConfig } from '../genericCompletion';
import { genericCompletion } from '../genericCompletion';
import { textLanguageName } from '../text/extension';
@@ -14,21 +14,20 @@ import { parser as twigParser } from './twig';
export function twig({
base,
environmentVariables,
- templateFunctions,
+ completionOptions,
autocomplete,
- onClickFunction,
onClickVariable,
onClickMissingVariable,
- onClickPathParameter,
+ extraExtensions,
}: {
base: LanguageSupport;
environmentVariables: EnvironmentVariable[];
- templateFunctions: TemplateFunction[];
+ completionOptions: TwigCompletionOption[];
autocomplete?: GenericCompletionConfig;
- 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;
+ extraExtensions: Extension[];
}) {
const language = mixLanguage(base);
@@ -40,28 +39,7 @@ export function twig({
onClick: (rawTag: string, startPos: number) => onClickVariable(v, rawTag, startPos),
})) ?? [];
- const functionOptions: TwigCompletionOption[] =
- 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 options = [...variableOptions, ...completionOptions];
const completions = twigCompletion({ options });
return [
@@ -71,11 +49,20 @@ export function twig({
base.language.data.of({ autocomplete: completions }),
language.data.of({ autocomplete: genericCompletion(autocomplete) }),
base.language.data.of({ autocomplete: genericCompletion(autocomplete) }),
- templateTagsPlugin(options, onClickMissingVariable, onClickPathParameter),
+ templateTagsPlugin(options, onClickMissingVariable),
+ ...extraExtensions,
];
}
+const mixedLanguagesCache: Record = {};
+
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 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;
}
diff --git a/src-web/components/core/Editor/twig/pathParameters.ts b/src-web/components/core/Editor/twig/pathParameters.ts
new file mode 100644
index 00000000..4cd64393
--- /dev/null
+++ b/src-web/components/core/Editor/twig/pathParameters.ts
@@ -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[] = [];
+ 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;
+ });
+ },
+ },
+ );
+}
diff --git a/src-web/components/core/Editor/twig/templateTags.ts b/src-web/components/core/Editor/twig/templateTags.ts
index 6c5871cf..9b7650cb 100644
--- a/src-web/components/core/Editor/twig/templateTags.ts
+++ b/src-web/components/core/Editor/twig/templateTags.ts
@@ -6,42 +6,6 @@ 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;
@@ -99,38 +63,15 @@ function templateTags(
view: EditorView,
options: TwigCompletionOption[],
onClickMissingVariable: (name: string, rawTag: string, startPos: number) => void,
- onClickPathParameter: (name: string) => void,
): DecorationSet {
const widgets: Range[] = [];
+ const tree = syntaxTree(view.state);
for (const { from, to } of view.visibleRanges) {
- const tree = syntaxTree(view.state);
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;
- 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') {
+ if (node.name === 'Tag') {
// Don't decorate if the cursor is inside the match
if (isSelectionInsideNode(view, node)) return;
@@ -177,28 +118,17 @@ 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,
- onClickPathParameter,
- );
+ this.decorations = templateTags(view, options, onClickMissingVariable);
}
update(update: ViewUpdate) {
- this.decorations = templateTags(
- update.view,
- options,
- onClickMissingVariable,
- onClickPathParameter,
- );
+ this.decorations = templateTags(update.view, options, onClickMissingVariable);
}
},
{
diff --git a/src-web/hooks/useSyncModelStores.ts b/src-web/hooks/useSyncModelStores.ts
index d3157fe3..9692744c 100644
--- a/src-web/hooks/useSyncModelStores.ts
+++ b/src-web/hooks/useSyncModelStores.ts
@@ -1,3 +1,4 @@
+import deepEqual from '@gilbarbara/deep-equal';
import { useQueryClient } from '@tanstack/react-query';
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
import type { AnyModel, KeyValue } from '@yaakapp-internal/models';
@@ -121,7 +122,11 @@ export function updateModelList(model: T) {
return (current: T[] | undefined): T[] => {
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)];
} else {
return pushToFront ? [model, ...(current ?? [])] : [...(current ?? []), model];
@@ -130,12 +135,21 @@ export function updateModelList(model: T) {
}
export function removeModelById(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) {
- return (entries: KeyValue[] | undefined) =>
- entries?.filter(
+ return (prevEntries: KeyValue[] | undefined) =>
+ prevEntries?.filter(
(e) =>
!(
e.namespace === model.namespace &&
diff --git a/src-web/hooks/useTemplateFunctions.ts b/src-web/hooks/useTemplateFunctions.ts
index 6e0b0b4d..3067da50 100644
--- a/src-web/hooks/useTemplateFunctions.ts
+++ b/src-web/hooks/useTemplateFunctions.ts
@@ -2,12 +2,41 @@ import { useQuery } from '@tanstack/react-query';
import type { GetTemplateFunctionsResponse, TemplateFunction } from '@yaakapp-internal/plugin';
import { atom, useAtomValue } from 'jotai';
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 { usePluginsKey } from './usePlugins';
const templateFunctionsAtom = atom([]);
+export function useTwigCompletionOptions(
+ onClick: (fn: TemplateFunction, ragTag: string, pos: number) => void,
+) {
+ const templateFunctions = useTemplateFunctions();
+ return useMemo(() => {
+ 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() {
return useAtomValue(templateFunctionsAtom);
}