mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-17 23:13:51 +01:00
Split out slow pathParameters extension and skip unnecessary model updates
This commit is contained in:
@@ -158,7 +158,6 @@ export const RequestPane = memo(function RequestPane({
|
||||
{
|
||||
value: TAB_DESCRIPTION,
|
||||
label: 'Info',
|
||||
rightSlot: activeRequest.description ? <CountBadge count={true} /> : 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,
|
||||
|
||||
@@ -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<typeof SidebarItem> | 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}
|
||||
/>
|
||||
) : (
|
||||
<span className="truncate">{itemName || itemFallbackName}</span>
|
||||
<span className="truncate">{itemName}</span>
|
||||
)}
|
||||
</div>
|
||||
{latestGrpcConnection ? (
|
||||
|
||||
@@ -54,19 +54,16 @@ export const SidebarItems = memo(function SidebarItems({
|
||||
<SidebarItem
|
||||
itemId={child.id}
|
||||
itemName={child.name}
|
||||
itemFallbackName="TODO"
|
||||
itemModel={child.model}
|
||||
latestHttpResponse={httpResponses.find((r) => 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 ? (
|
||||
<SidebarItems
|
||||
draggingId={draggingId}
|
||||
handleDragStart={handleDragStart}
|
||||
@@ -81,14 +78,12 @@ export const SidebarItems = memo(function SidebarItems({
|
||||
tree={child}
|
||||
treeParentMap={treeParentMap}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
</SidebarItem>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{hoveredIndex === tree.children.length && hoveredTree?.id === tree.id && (
|
||||
<DropMarker />
|
||||
)}
|
||||
{hoveredIndex === tree.children.length && hoveredTree?.id === tree.id && <DropMarker />}
|
||||
</VStack>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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<EditorView | undefined, EditorProps>(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<EditorView | undefined, EditorProps>(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<EditorView | undefined, EditorProps>(function E
|
||||
environmentVariables,
|
||||
useTemplating,
|
||||
autocomplete,
|
||||
templateFunctions,
|
||||
onClickFunction,
|
||||
completionOptions,
|
||||
onClickVariable,
|
||||
onClickMissingVariable,
|
||||
onClickPathParameter,
|
||||
@@ -283,11 +293,12 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(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<EditorView | undefined, EditorProps>(function E
|
||||
const langExt = getLanguageExtension({
|
||||
language,
|
||||
useTemplating,
|
||||
completionOptions,
|
||||
autocomplete,
|
||||
environmentVariables,
|
||||
templateFunctions,
|
||||
onClickVariable,
|
||||
onClickFunction,
|
||||
onClickMissingVariable,
|
||||
onClickPathParameter,
|
||||
});
|
||||
@@ -362,31 +372,34 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(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 {
|
||||
|
||||
@@ -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<EditorProps, 'language' | 'useTemplating' | 'autocomplete'>) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, 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 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;
|
||||
}
|
||||
|
||||
109
src-web/components/core/Editor/twig/pathParameters.ts
Normal file
109
src-web/components/core/Editor/twig/pathParameters.ts
Normal 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;
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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<Decoration>[] = [];
|
||||
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);
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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<T extends AnyModel>(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<T extends AnyModel>(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) {
|
||||
return (entries: KeyValue[] | undefined) =>
|
||||
entries?.filter(
|
||||
return (prevEntries: KeyValue[] | undefined) =>
|
||||
prevEntries?.filter(
|
||||
(e) =>
|
||||
!(
|
||||
e.namespace === model.namespace &&
|
||||
|
||||
@@ -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<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() {
|
||||
return useAtomValue(templateFunctionsAtom);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user