mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-23 09:08:32 +02: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,
|
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,
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
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 { 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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 &&
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user