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,
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,

View File

@@ -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 ? (

View File

@@ -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>
);
});

View File

@@ -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 {

View File

@@ -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,
});
}

View File

@@ -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;
}

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 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);
}
},
{

View File

@@ -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 &&

View File

@@ -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);
}