Skip to content

Commit

Permalink
Improve performance by only sending changes to worker
Browse files Browse the repository at this point in the history
  • Loading branch information
elsmr committed Jan 7, 2025
1 parent ebad9db commit fb4d3c0
Show file tree
Hide file tree
Showing 10 changed files with 195 additions and 90 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export const AUTOCOMPLETABLE_BUILT_IN_MODULES_JS = [

export const DEFAULT_LINTER_SEVERITY: Diagnostic['severity'] = 'error';

export const DEFAULT_LINTER_DELAY_IN_MS = 300;
export const DEFAULT_LINTER_DELAY_IN_MS = 500;

/**
* Length of the start of the script wrapper, used as offset for the linter to find a location in source text.
Expand Down
4 changes: 3 additions & 1 deletion packages/editor-ui/src/composables/useCodeEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export const useCodeEditor = <L extends CodeEditorLanguage>({
const params = toValue(languageParams);
return params && 'mode' in params ? params.mode : 'runOnceForAllItems';
});
const { createWorker: createTsWorker } = useTypescript(editor, mode, toValue(id));

function getInitialLanguageExtensions(lang: CodeEditorLanguage): Extension[] {
switch (lang) {
Expand All @@ -117,7 +118,8 @@ export const useCodeEditor = <L extends CodeEditorLanguage>({

switch (lang) {
case 'javaScript': {
langExtensions.push(await useTypescript(editor.value, mode, toValue(id)));
const tsExtension = await createTsWorker();
langExtensions.push(tsExtension);
break;
}
case 'python': {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,17 @@ import { typescriptWorkerFacet } from './facet';
import { blockCommentSnippet, snippets } from './snippets';

const START_CHARACTERS = ['"', "'", '(', '.', '@'];
const START_CHARACTERS_REGEX = /[\.\(\'\"\@]/;

export const typescriptCompletionSource: CompletionSource = async (context) => {
const { worker } = context.state.facet(typescriptWorkerFacet);

let word = context.matchBefore(/[\$\w]+/);
let word = context.matchBefore(START_CHARACTERS_REGEX);
if (!word?.text) {
word = context.matchBefore(/[\.\(\'\"\@]/);
word = context.matchBefore(/[\"\'].*/);
}
if (!word?.text) {
word = context.matchBefore(/[\$\w]+/);
}

const blockComment = context.matchBefore(/\/\*?\*?/);
Expand Down Expand Up @@ -68,7 +72,10 @@ export const typescriptCompletionSource: CompletionSource = async (context) => {
(option) =>
word.text === '' ||
START_CHARACTERS.includes(word.text) ||
prefixMatch(option.label, word.text),
prefixMatch(
option.label.replace(START_CHARACTERS_REGEX, ''),
word.text.replace(START_CHARACTERS_REGEX, ''),
),
)
.map((completion) => {
if (completion.label.endsWith('()')) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,101 +10,122 @@ import { executionDataToJson } from '@/utils/nodeTypesUtils';
import { autocompletion } from '@codemirror/autocomplete';
import { javascriptLanguage } from '@codemirror/lang-javascript';
import { LanguageSupport } from '@codemirror/language';
import { linter } from '@codemirror/lint';
import { Text, type Extension } from '@codemirror/state';
import { EditorView, hoverTooltip } from '@codemirror/view';
import * as Comlink from 'comlink';
import { NodeConnectionType, type CodeExecutionMode, type INodeExecutionData } from 'n8n-workflow';
import { toRef, toValue, watch, type MaybeRefOrGetter } from 'vue';
import type { RemoteLanguageServiceWorkerInit } from '../types';
import { ref, toRef, toValue, watch, type MaybeRefOrGetter } from 'vue';
import type { LanguageServiceWorker, RemoteLanguageServiceWorkerInit } from '../types';
import { typescriptCompletionSource } from './completions';
import { typescriptWorkerFacet } from './facet';
import { typescriptHoverTooltips } from './hoverTooltip';
import { linter } from '@codemirror/lint';
import { typescriptLintSource } from './linter';

export async function useTypescript(
view: MaybeRefOrGetter<EditorView>,
export function useTypescript(
view: MaybeRefOrGetter<EditorView | undefined>,
mode: MaybeRefOrGetter<CodeExecutionMode>,
id: MaybeRefOrGetter<string>,
) {
const { init } = Comlink.wrap<RemoteLanguageServiceWorkerInit>(
new Worker(new URL('../worker/typescript.worker.ts', import.meta.url), { type: 'module' }),
);
const { getInputDataWithPinned, getSchemaForExecutionData } = useDataSchema();
const ndvStore = useNDVStore();
const workflowsStore = useWorkflowsStore();
const { debounce } = useDebounce();
const activeNodeName = ndvStore.activeNodeName;
const worker = ref<Comlink.Remote<LanguageServiceWorker>>();

watch(
[() => workflowsStore.getWorkflowExecution, () => workflowsStore.getWorkflowRunData],
debounce(
async () => {
await worker.updateNodeTypes();
forceParse(toValue(view));
async function createWorker(): Promise<Extension> {
const { init } = Comlink.wrap<RemoteLanguageServiceWorkerInit>(
new Worker(new URL('../worker/typescript.worker.ts', import.meta.url), { type: 'module' }),
);
worker.value = await init(
{
id: toValue(id),
content: Comlink.proxy((toValue(view)?.state.doc ?? Text.empty).toJSON()),
allNodeNames: autocompletableNodeNames(),
variables: useEnvironmentsStore().variables.map((v) => v.key),
inputNodeNames: activeNodeName
? workflowsStore
.getCurrentWorkflow()
.getParentNodes(activeNodeName, NodeConnectionType.Main, 1)
: [],
mode: toValue(mode),
},
{ debounceTime: 200, trailing: true },
),
);
Comlink.proxy(async (nodeName) => {
const node = workflowsStore.getNodeByName(nodeName);

watch(toRef(mode), async (newMode) => {
await worker.updateMode(newMode);
forceParse(toValue(view));
});
if (node) {
const inputData: INodeExecutionData[] = getInputDataWithPinned(node);
const schema = getSchemaForExecutionData(executionDataToJson(inputData), true);
const execution = workflowsStore.getWorkflowExecution;
const binaryData = useNodeHelpers()
.getBinaryData(
execution?.data?.resultData?.runData ?? null,
node.name,
ndvStore.ndvInputRunIndex ?? 0,
0,
)
.filter((data) => Boolean(data && Object.keys(data).length));

return {
json: schema,
binary: Object.keys(binaryData.reduce((acc, obj) => ({ ...acc, ...obj }), {})),
params: getSchemaForExecutionData([node.parameters]),
};
}

return undefined;
}),
);

const worker = await init(
{
id: toValue(id),
content: toValue(view).state.doc.toString(),
allNodeNames: autocompletableNodeNames(),
variables: useEnvironmentsStore().variables.map((v) => v.key),
inputNodeNames: activeNodeName
? workflowsStore
.getCurrentWorkflow()
.getParentNodes(activeNodeName, NodeConnectionType.Main, 1)
: [],
mode: toValue(mode),
},
Comlink.proxy(async (nodeName) => {
const node = workflowsStore.getNodeByName(nodeName);
const editor = toValue(view);

if (node) {
const inputData: INodeExecutionData[] = getInputDataWithPinned(node);
const schema = getSchemaForExecutionData(executionDataToJson(inputData), true);
const execution = workflowsStore.getWorkflowExecution;
const binaryData = useNodeHelpers()
.getBinaryData(
execution?.data?.resultData?.runData ?? null,
node.name,
ndvStore.ndvInputRunIndex ?? 0,
0,
)
.filter((data) => Boolean(data && Object.keys(data).length));
if (editor) {
forceParse(editor);
}

return {
json: schema,
binary: Object.keys(binaryData.reduce((acc, obj) => ({ ...acc, ...obj }), {})),
params: getSchemaForExecutionData([node.parameters]),
};
}
return [
typescriptWorkerFacet.of({ worker: worker.value }),
new LanguageSupport(javascriptLanguage, [
javascriptLanguage.data.of({ autocomplete: typescriptCompletionSource }),
]),
autocompletion({ icons: false, aboveCursor: true }),
linter(typescriptLintSource),
hoverTooltip(typescriptHoverTooltips, {
hideOnChange: true,
hoverTime: 500,
}),
EditorView.updateListener.of(async (update) => {
if (update.docChanged) {
void worker.value?.updateFile(update.changes.toJSON());
}
}),
];
}

return undefined;
}),
async function onWorkflowDataChange() {
const editor = toValue(view);
if (!editor || !worker.value) return;

await worker.value.updateNodeTypes();

forceParse(editor);
}

watch(
[() => workflowsStore.getWorkflowExecution, () => workflowsStore.getWorkflowRunData],
debounce(onWorkflowDataChange, { debounceTime: 200, trailing: true }),
);

return [
typescriptWorkerFacet.of({ worker }),
new LanguageSupport(javascriptLanguage, [
javascriptLanguage.data.of({ autocomplete: typescriptCompletionSource }),
]),
autocompletion({ icons: false, aboveCursor: true }),
linter(typescriptLintSource),
hoverTooltip(typescriptHoverTooltips, {
hideOnChange: true,
hoverTime: 500,
}),
EditorView.updateListener.of(async (update) => {
if (!update.docChanged) return;
await worker.updateFile(update.state.doc.toString());
}),
];
watch(toRef(mode), async (newMode) => {
const editor = toValue(view);
if (!editor || !worker.value) return;

await worker.value.updateMode(newMode);
forceParse(editor);
});

return {
createWorker,
};
}
5 changes: 3 additions & 2 deletions packages/editor-ui/src/plugins/codemirror/typescript/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Diagnostic } from '@codemirror/lint';
import type { CodeExecutionMode } from 'n8n-workflow';
import type ts from 'typescript';
import type * as Comlink from 'comlink';
import type { ChangeSet } from '@codemirror/state';

export interface HoverInfo {
start: number;
Expand All @@ -14,7 +15,7 @@ export interface HoverInfo {

export type WorkerInitOptions = {
id: string;
content: string;
content: string[];
allNodeNames: string[];
inputNodeNames: string[];
variables: string[];
Expand All @@ -25,7 +26,7 @@ export type NodeData = { json: Schema | undefined; binary: string[]; params: Sch
export type NodeDataFetcher = (nodeName: string) => Promise<NodeData | undefined>;

export type LanguageServiceWorker = {
updateFile(content: string): void;
updateFile(changes: ChangeSet): void;
updateMode(mode: CodeExecutionMode): void;
updateNodeTypes(): void;
getCompletionsAtPos(pos: number): Promise<{ result: CompletionResult; isGlobal: boolean } | null>;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export {};

declare global {
interface NodeData<C, J extends N8nJson, B extends string, P> {
interface NodeData<C = any, J extends N8nJson = any, B extends string = string, P = any> {
context: C;
params: P;
all(branchIndex?: number, runIndex?: number): Array<N8nItem<J, B>>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ declare global {
function $min(...numbers: number[]): number;
function $max(...numbers: number[]): number;

// @ts-expect-error NodeName and NodeDataMap are created dynamically
function $<K extends NodeName>(nodeName: K): NodeDataMap[K];
type SomeOtherString = string & NonNullable<unknown>;
// @ts-expect-error NodeName is created dynamically
function $<K extends NodeName>(
nodeName: K | SomeOtherString,
// @ts-expect-error NodeDataMap is created dynamically
): K extends keyof NodeDataMap ? NodeDataMap[K] : NodeData;
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as Comlink from 'comlink';
import type { LanguageServiceWorker, LanguageServiceWorkerInit } from '../types';
import { indexedDbCache } from './cache';
import { fnPrefix, wrapInFunction } from './utils';
import { bufferChangeSets, fnPrefix } from './utils';

import type { CodeExecutionMode } from 'n8n-workflow';

Expand All @@ -23,6 +23,8 @@ import { getUsedNodeNames } from './typescriptAst';
import runOnceForAllItemsTypes from './type-declarations/n8n-once-for-all-items.d.ts?raw';
import runOnceForEachItemTypes from './type-declarations/n8n-once-for-each-item.d.ts?raw';
import { loadTypes } from './npmTypesLoader';
import { ChangeSet, Text } from '@codemirror/state';
import { until } from '@vueuse/core';

self.process = { env: {} } as NodeJS.Process;

Expand All @@ -34,12 +36,13 @@ const worker: LanguageServiceWorkerInit = {
const allNodeNames = options.allNodeNames;
const codeFileName = `${options.id}.js`;
const mode = ref<CodeExecutionMode>(options.mode);
const busyApplyingChangesToCode = ref(false);

const cache = await indexedDbCache('typescript-cache', 'fs-map');
const env = await setupTypescriptEnv({
cache,
mode: mode.value,
code: { content: options.content, fileName: codeFileName },
code: { content: Text.of(options.content).toString(), fileName: codeFileName },
});

const prefix = computed(() => fnPrefix(mode.value));
Expand Down Expand Up @@ -149,12 +152,37 @@ const worker: LanguageServiceWorkerInit = {
{ immediate: true },
);

watch(prefix, (newPrefix, oldPrefix) => {
env.updateFile(codeFileName, newPrefix, { start: 0, length: oldPrefix.length });
});

const applyChangesToCode = bufferChangeSets((bufferedChanges) => {
bufferedChanges.iterChanges((start, end, _fromNew, _toNew, text) => {
const length = end - start;

env.updateFile(codeFileName, text.toString(), {
start: editorPositionToTypescript(start),
length,
});
});

void loadTypesIfNeeded();
});

const waitForChangesAppliedToCode = async () => {
await until(busyApplyingChangesToCode).toBe(false, { timeout: 500 });
};

return Comlink.proxy<LanguageServiceWorker>({
updateFile: async (content) => {
updateFile(codeFileName, wrapInFunction(content, mode.value));
await loadTypesIfNeeded();
updateFile: async (changes) => {
busyApplyingChangesToCode.value = true;
void applyChangesToCode(ChangeSet.fromJSON(changes)).then(() => {
busyApplyingChangesToCode.value = false;
});
},
async getCompletionsAtPos(pos) {
await waitForChangesAppliedToCode();

return await getCompletionsAtPos({
pos: editorPositionToTypescript(pos),
fileName: codeFileName,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ export async function getUsedNodeNames(file: ts.SourceFile) {

if (callExpressions.length === 0) return [];

const nodeNames = (callExpressions as ts.CallExpression[]).map(
(e) => (e.arguments.at(0) as ts.StringLiteral)?.text,
);
const nodeNames = (callExpressions as ts.CallExpression[])
.map((e) => (e.arguments.at(0) as ts.StringLiteral)?.text)
.filter(Boolean);

return nodeNames;
}
Loading

0 comments on commit fb4d3c0

Please sign in to comment.