feat(editor): New Code editor based on the TypeScript language service (#12285)

This commit is contained in:
Elias Meire
2025-01-08 11:28:56 +01:00
committed by GitHub
parent ac497c8a67
commit 52ae02abaa
58 changed files with 2861 additions and 758 deletions

View File

@@ -57,7 +57,7 @@ for (const item of $input.all()) {
return
`);
getParameter().get('.cm-lint-marker-error').should('have.length', 6);
getParameter().get('.cm-lintRange-error').should('have.length', 6);
getParameter().contains('itemMatching').realHover();
cy.get('.cm-tooltip-lint').should(
'have.text',
@@ -81,7 +81,7 @@ $input.item()
return []
`);
getParameter().get('.cm-lint-marker-error').should('have.length', 5);
getParameter().get('.cm-lintRange-error').should('have.length', 5);
getParameter().contains('all').realHover();
cy.get('.cm-tooltip-lint').should(
'have.text',

View File

@@ -16,6 +16,7 @@
--prim-gray-490: hsl(var(--prim-gray-h), 3%, 51%);
--prim-gray-420: hsl(var(--prim-gray-h), 4%, 58%);
--prim-gray-320: hsl(var(--prim-gray-h), 10%, 68%);
--prim-gray-320-alpha-010: hsla(var(--prim-gray-h), 10%, 68%, 0.1);
--prim-gray-200: hsl(var(--prim-gray-h), 18%, 80%);
--prim-gray-120: hsl(var(--prim-gray-h), 25%, 88%);
--prim-gray-70: hsl(var(--prim-gray-h), 32%, 93%);

View File

@@ -139,12 +139,20 @@
--color-infobox-examples-border-color: var(--prim-gray-670);
// Code
--color-code-tags-string: var(--prim-color-alt-f-tint-150);
--color-code-tags-primitive: var(--prim-color-alt-b-shade-100);
--color-code-tags-keyword: var(--prim-color-alt-g-tint-150);
--color-code-tags-operator: var(--prim-color-alt-h);
--color-code-tags-variable: var(--prim-color-primary-tint-100);
--color-code-tags-definition: var(--prim-color-alt-e);
--color-code-tags-string: #9ecbff;
--color-code-tags-regex: #9ecbff;
--color-code-tags-primitive: #79b8ff;
--color-code-tags-keyword: #f97583;
--color-code-tags-variable: #79b8ff;
--color-code-tags-parameter: #e1e4e8;
--color-code-tags-function: #b392f0;
--color-code-tags-constant: #79b8ff;
--color-code-tags-property: #79b8ff;
--color-code-tags-type: #b392f0;
--color-code-tags-class: #b392f0;
--color-code-tags-heading: #79b8ff;
--color-code-tags-invalid: #f97583;
--color-code-tags-comment: #6a737d;
--color-json-default: var(--prim-color-secondary-tint-200);
--color-json-null: var(--color-danger);
--color-json-boolean: var(--prim-color-alt-a);
@@ -155,15 +163,18 @@
--color-json-brackets-hover: var(--prim-color-alt-e);
--color-json-line: var(--prim-gray-200);
--color-json-highlight: var(--color-background-base);
--color-code-background: var(--prim-gray-800);
--color-code-background: var(--prim-gray-820);
--color-code-background-readonly: var(--prim-gray-740);
--color-code-lineHighlight: var(--prim-gray-740);
--color-code-lineHighlight: var(--prim-gray-320-alpha-010);
--color-code-foreground: var(--prim-gray-70);
--color-code-caret: var(--prim-gray-10);
--color-code-selection: var(--prim-color-alt-e-alpha-04);
--color-code-gutterBackground: var(--prim-gray-670);
--color-code-gutterForeground: var(--prim-gray-320);
--color-code-tags-comment: var(--prim-gray-200);
--color-code-selection: #3392ff44;
--color-code-selection-highlight: #17e5e633;
--color-code-gutter-background: var(--prim-gray-820);
--color-code-gutter-foreground: var(--prim-gray-320);
--color-code-gutter-foreground-active: var(--prim-gray-10);
--color-code-indentation-marker: var(--prim-gray-740);
--color-code-indentation-marker-active: var(--prim-gray-670);
--color-line-break: var(--prim-gray-420);
--color-code-line-break: var(--prim-color-secondary-tint-100);

View File

@@ -183,12 +183,20 @@
--color-infobox-examples-border-color: var(--color-foreground-light);
// Code
--color-code-tags-string: var(--prim-color-alt-f);
--color-code-tags-primitive: var(--prim-color-alt-b-shade-100);
--color-code-tags-keyword: var(--prim-color-alt-g);
--color-code-tags-operator: var(--prim-color-alt-h);
--color-code-tags-variable: var(--prim-color-alt-c-shade-100);
--color-code-tags-definition: var(--prim-color-alt-e-shade-150);
--color-code-tags-string: #032f62;
--color-code-tags-regex: #032f62;
--color-code-tags-primitive: #005cc5;
--color-code-tags-keyword: #d73a49;
--color-code-tags-variable: #005cc5;
--color-code-tags-parameter: #24292e;
--color-code-tags-function: #6f42c1;
--color-code-tags-constant: #005cc5;
--color-code-tags-property: #005cc5;
--color-code-tags-type: #005cc5;
--color-code-tags-class: #6f42c1;
--color-code-tags-heading: #005cc5;
--color-code-tags-invalid: #cb2431;
--color-code-tags-comment: #6a737d;
--color-json-default: var(--prim-color-secondary-shade-100);
--color-json-null: var(--prim-color-alt-c);
--color-json-boolean: var(--prim-color-alt-a);
@@ -201,13 +209,16 @@
--color-json-highlight: var(--prim-gray-70);
--color-code-background: var(--prim-gray-0);
--color-code-background-readonly: var(--prim-gray-40);
--color-code-lineHighlight: var(--prim-gray-40);
--color-code-lineHighlight: var(--prim-gray-320-alpha-010);
--color-code-foreground: var(--prim-gray-670);
--color-code-caret: var(--prim-gray-420);
--color-code-selection: var(--prim-gray-120);
--color-code-gutterBackground: var(--prim-gray-0);
--color-code-gutterForeground: var(--prim-gray-320);
--color-code-tags-comment: var(--prim-gray-420);
--color-code-caret: var(--prim-gray-820);
--color-code-selection: #0366d625;
--color-code-selection-highlight: #34d05840;
--color-code-gutter-background: var(--prim-gray-0);
--color-code-gutter-foreground: var(--prim-gray-320);
--color-code-gutter-foreground-active: var(--prim-gray-670);
--color-code-indentation-marker: var(--prim-gray-70);
--color-code-indentation-marker-active: var(--prim-gray-200);
--color-line-break: var(--prim-gray-320);
--color-code-line-break: var(--prim-color-secondary-tint-200);

View File

@@ -25,6 +25,7 @@
"@codemirror/lang-python": "^6.1.6",
"@codemirror/language": "^6.10.1",
"@codemirror/lint": "^6.8.0",
"@codemirror/search": "^6.5.6",
"@codemirror/state": "^6.4.1",
"@codemirror/view": "^6.26.3",
"@fontsource/open-sans": "^4.5.0",
@@ -39,6 +40,8 @@
"@n8n/codemirror-lang": "workspace:*",
"@n8n/codemirror-lang-sql": "^1.0.2",
"@n8n/permissions": "workspace:*",
"@replit/codemirror-indentation-markers": "^6.5.3",
"@typescript/vfs": "^1.6.0",
"@sentry/vue": "catalog:frontend",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.2",
@@ -52,6 +55,7 @@
"change-case": "^5.4.4",
"chart.js": "^4.4.0",
"codemirror-lang-html-n8n": "^1.0.0",
"comlink": "^4.4.1",
"dateformat": "^3.0.3",
"email-providers": "^2.0.1",
"esprima-next": "5.8.4",
@@ -70,6 +74,7 @@
"qrcode.vue": "^3.3.4",
"stream-browserify": "^3.0.0",
"timeago.js": "^4.0.2",
"typescript": "^5.5.2",
"uuid": "catalog:",
"v3-infinite-loading": "^1.2.2",
"vue": "catalog:frontend",

View File

@@ -1,32 +1,24 @@
<script setup lang="ts">
import { javascript } from '@codemirror/lang-javascript';
import { python } from '@codemirror/lang-python';
import type { LanguageSupport } from '@codemirror/language';
import type { Extension, Line } from '@codemirror/state';
import { Compartment, EditorState } from '@codemirror/state';
import type { ViewUpdate } from '@codemirror/view';
import { EditorView } from '@codemirror/view';
import type { CodeExecutionMode, CodeNodeEditorLanguage } from 'n8n-workflow';
import { format } from 'prettier';
import jsParser from 'prettier/plugins/babel';
import * as estree from 'prettier/plugins/estree';
import { type Ref, computed, nextTick, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue';
import { CODE_NODE_TYPE } from '@/constants';
import { codeNodeEditorEventBus } from '@/event-bus';
import { useRootStore } from '@/stores/root.store';
import { useCodeEditor } from '@/composables/useCodeEditor';
import { useI18n } from '@/composables/useI18n';
import { useMessage } from '@/composables/useMessage';
import { useTelemetry } from '@/composables/useTelemetry';
import AskAI from './AskAI/AskAI.vue';
import { readOnlyEditorExtensions, writableEditorExtensions } from './baseExtensions';
import { useCompleter } from './completer';
import { CODE_PLACEHOLDERS } from './constants';
import { useLinter } from './linter';
import { codeNodeEditorTheme } from './theme';
import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { dropInCodeEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
import { useSettingsStore } from '@/stores/settings.store';
import { dropInCodeEditor } from '@/plugins/codemirror/dragAndDrop';
type Props = {
mode: CodeExecutionMode;
@@ -36,6 +28,7 @@ type Props = {
language?: CodeNodeEditorLanguage;
isReadOnly?: boolean;
rows?: number;
id?: string;
};
const props = withDefaults(defineProps<Props>(), {
@@ -44,99 +37,57 @@ const props = withDefaults(defineProps<Props>(), {
language: 'javaScript',
isReadOnly: false,
rows: 4,
id: crypto.randomUUID(),
});
const emit = defineEmits<{
'update:modelValue': [value: string];
}>();
const message = useMessage();
const editor = ref(null) as Ref<EditorView | null>;
const languageCompartment = ref(new Compartment());
const dragAndDropCompartment = ref(new Compartment());
const linterCompartment = ref(new Compartment());
const isEditorHovered = ref(false);
const isEditorFocused = ref(false);
const tabs = ref(['code', 'ask-ai']);
const activeTab = ref('code');
const hasChanges = ref(false);
const isLoadingAIResponse = ref(false);
const codeNodeEditorRef = ref<HTMLDivElement>();
const codeNodeEditorContainerRef = ref<HTMLDivElement>();
const { autocompletionExtension } = useCompleter(() => props.mode, editor);
const { createLinter } = useLinter(() => props.mode, editor);
const hasManualChanges = ref(false);
const rootStore = useRootStore();
const i18n = useI18n();
const telemetry = useTelemetry();
const settingsStore = useSettingsStore();
const linter = useLinter(
() => props.mode,
() => props.language,
);
const extensions = computed(() => [linter.value]);
const placeholder = computed(() => CODE_PLACEHOLDERS[props.language]?.[props.mode] ?? '');
const dragAndDropEnabled = computed(() => {
return !props.isReadOnly;
});
const { highlightLine, readEditorValue, editor } = useCodeEditor({
id: props.id,
editorRef: codeNodeEditorRef,
language: () => props.language,
languageParams: () => ({ mode: props.mode }),
editorValue: () => props.modelValue,
placeholder,
extensions,
isReadOnly: () => props.isReadOnly,
theme: {
maxHeight: props.fillParent ? '100%' : '40vh',
minHeight: '20vh',
rows: props.rows,
},
onChange: onEditorUpdate,
});
onMounted(() => {
if (!props.isReadOnly) codeNodeEditorEventBus.on('highlightLine', highlightLine);
codeNodeEditorEventBus.on('codeDiffApplied', diffApplied);
const { isReadOnly, language } = props;
const extensions: Extension[] = [
...readOnlyEditorExtensions,
EditorState.readOnly.of(isReadOnly),
EditorView.editable.of(!isReadOnly),
codeNodeEditorTheme({
isReadOnly,
maxHeight: props.fillParent ? '100%' : '40vh',
minHeight: '20vh',
rows: props.rows,
}),
];
if (!isReadOnly) {
const linter = createLinter(language);
if (linter) {
extensions.push(linterCompartment.value.of(linter));
}
extensions.push(
...writableEditorExtensions,
dragAndDropCompartment.value.of(dragAndDropExtension.value),
EditorView.domEventHandlers({
focus: () => {
isEditorFocused.value = true;
},
blur: () => {
isEditorFocused.value = false;
},
}),
EditorView.updateListener.of((viewUpdate) => {
if (!viewUpdate.docChanged) return;
trackCompletion(viewUpdate);
const value = editor.value?.state.doc.toString();
if (value) {
emit('update:modelValue', value);
}
hasChanges.value = true;
}),
);
}
const [languageSupport, ...otherExtensions] = languageExtensions.value;
extensions.push(languageCompartment.value.of(languageSupport), ...otherExtensions);
const state = EditorState.create({
doc: props.modelValue ?? placeholder.value,
extensions,
});
editor.value = new EditorView({
parent: codeNodeEditorRef.value,
state,
});
// empty on first load, default param value
if (!props.modelValue) {
refreshPlaceholder();
emit('update:modelValue', placeholder.value);
}
});
@@ -150,89 +101,12 @@ const askAiEnabled = computed(() => {
return settingsStore.isAskAiEnabled && props.language === 'javaScript';
});
const placeholder = computed(() => {
return CODE_PLACEHOLDERS[props.language]?.[props.mode] ?? '';
});
const dragAndDropEnabled = computed(() => {
return !props.isReadOnly && props.mode === 'runOnceForEachItem';
});
const dragAndDropExtension = computed(() => (dragAndDropEnabled.value ? mappingDropCursor() : []));
// eslint-disable-next-line vue/return-in-computed-property
const languageExtensions = computed<[LanguageSupport, ...Extension[]]>(() => {
switch (props.language) {
case 'javaScript':
return [javascript(), autocompletionExtension('javaScript')];
case 'python':
return [python(), autocompletionExtension('python')];
watch([() => props.language, () => props.mode], (_, [prevLanguage, prevMode]) => {
if (readEditorValue().trim() === CODE_PLACEHOLDERS[prevLanguage]?.[prevMode]) {
emit('update:modelValue', placeholder.value);
}
});
watch(
() => props.modelValue,
(newValue) => {
if (!editor.value) {
return;
}
const current = editor.value.state.doc.toString();
if (current === newValue) {
return;
}
editor.value.dispatch({
changes: { from: 0, to: getCurrentEditorContent().length, insert: newValue },
});
},
);
watch(
() => props.mode,
(_newMode, previousMode: CodeExecutionMode) => {
reloadLinter();
if (getCurrentEditorContent().trim() === CODE_PLACEHOLDERS[props.language]?.[previousMode]) {
refreshPlaceholder();
}
},
);
watch(dragAndDropExtension, (extension) => {
editor.value?.dispatch({
effects: dragAndDropCompartment.value.reconfigure(extension),
});
});
watch(
() => props.language,
(_newLanguage, previousLanguage: CodeNodeEditorLanguage) => {
if (getCurrentEditorContent().trim() === CODE_PLACEHOLDERS[previousLanguage]?.[props.mode]) {
refreshPlaceholder();
}
const [languageSupport] = languageExtensions.value;
editor.value?.dispatch({
effects: languageCompartment.value.reconfigure(languageSupport),
});
reloadLinter();
},
);
watch(
askAiEnabled,
async (isEnabled) => {
if (isEnabled && !props.modelValue) {
emit('update:modelValue', placeholder.value);
}
await nextTick();
hasChanges.value = props.modelValue !== placeholder.value;
},
{ immediate: true },
);
function getCurrentEditorContent() {
return editor.value?.state.doc.toString() ?? '';
}
async function onBeforeTabLeave(_activeName: string, oldActiveName: string) {
// Confirm dialog if leaving ask-ai tab during loading
if (oldActiveName === 'ask-ai' && isLoadingAIResponse.value) {
@@ -243,69 +117,28 @@ async function onBeforeTabLeave(_activeName: string, oldActiveName: string) {
showCancelButton: true,
});
if (confirmModal === 'confirm') {
return true;
}
return false;
return confirmModal === 'confirm';
}
return true;
}
async function onReplaceCode(code: string) {
async function onAiReplaceCode(code: string) {
const formattedCode = await format(code, {
parser: 'babel',
plugins: [jsParser, estree],
});
editor.value?.dispatch({
changes: { from: 0, to: getCurrentEditorContent().length, insert: formattedCode },
});
emit('update:modelValue', formattedCode);
activeTab.value = 'code';
hasChanges.value = false;
hasManualChanges.value = false;
}
function onMouseOver(event: MouseEvent) {
const fromElement = event.relatedTarget as HTMLElement;
const containerRef = codeNodeEditorContainerRef.value;
if (!containerRef?.contains(fromElement)) isEditorHovered.value = true;
}
function onMouseOut(event: MouseEvent) {
const fromElement = event.relatedTarget as HTMLElement;
const containerRef = codeNodeEditorContainerRef.value;
if (!containerRef?.contains(fromElement)) isEditorHovered.value = false;
}
function reloadLinter() {
if (!editor.value) return;
const linter = createLinter(props.language);
if (linter) {
editor.value.dispatch({
effects: linterCompartment.value.reconfigure(linter),
});
}
}
function refreshPlaceholder() {
if (!editor.value) return;
editor.value.dispatch({
changes: { from: 0, to: getCurrentEditorContent().length, insert: placeholder.value },
});
}
function getLine(lineNumber: number): Line | null {
try {
return editor.value?.state.doc.line(lineNumber) ?? null;
} catch {
return null;
}
function onEditorUpdate(viewUpdate: ViewUpdate) {
trackCompletion(viewUpdate);
hasManualChanges.value = true;
emit('update:modelValue', readEditorValue());
}
function diffApplied() {
@@ -315,25 +148,6 @@ function diffApplied() {
});
}
function highlightLine(lineNumber: number | 'final') {
if (!editor.value) return;
if (lineNumber === 'final') {
editor.value.dispatch({
selection: { anchor: (props.modelValue ?? getCurrentEditorContent()).length },
});
return;
}
const line = getLine(lineNumber);
if (!line) return;
editor.value.dispatch({
selection: { anchor: line.from },
});
}
function trackCompletion(viewUpdate: ViewUpdate) {
const completionTx = viewUpdate.transactions.find((tx) => tx.isUserEvent('input.complete'));
@@ -342,7 +156,7 @@ function trackCompletion(viewUpdate: ViewUpdate) {
try {
// @ts-expect-error - undocumented fields
const { fromA, toB } = viewUpdate?.changedRanges[0];
const full = getCurrentEditorContent().slice(fromA, toB);
const full = viewUpdate.state.doc.slice(fromA, toB).toString();
const lastDotIndex = full.lastIndexOf('.');
let context = null;
@@ -379,16 +193,19 @@ function onAiLoadEnd() {
async function onDrop(value: string, event: MouseEvent) {
if (!editor.value) return;
await dropInCodeEditor(toRaw(editor.value), event, value);
const valueToInsert =
props.mode === 'runOnceForAllItems'
? value.replace('$json', '$input.first().json').replace(/\$\((.*)\)\.item/, '$($1).first()')
: value;
await dropInCodeEditor(toRaw(editor.value), event, valueToInsert);
}
</script>
<template>
<div
ref="codeNodeEditorContainerRef"
:class="['code-node-editor', $style['code-node-editor-container'], language]"
@mouseover="onMouseOver"
@mouseout="onMouseOut"
:class="['code-node-editor', $style['code-node-editor-container']]"
>
<el-tabs
v-if="askAiEnabled"
@@ -433,8 +250,8 @@ async function onDrop(value: string, event: MouseEvent) {
<!-- Key the AskAI tab to make sure it re-mounts when changing tabs -->
<AskAI
:key="activeTab"
:has-changes="hasChanges"
@replace-code="onReplaceCode"
:has-changes="hasManualChanges"
@replace-code="onAiReplaceCode"
@started-loading="onAiLoadStart"
@finished-loading="onAiLoadEnd"
/>

View File

@@ -1,49 +0,0 @@
import {
dropCursor,
EditorView,
highlightActiveLine,
highlightActiveLineGutter,
highlightSpecialChars,
keymap,
lineNumbers,
} from '@codemirror/view';
import { bracketMatching, foldGutter, indentOnInput } from '@codemirror/language';
import { history, toggleComment, deleteCharBackward } from '@codemirror/commands';
import { lintGutter } from '@codemirror/lint';
import { type Extension, Prec } from '@codemirror/state';
import { codeInputHandler } from '@/plugins/codemirror/inputHandlers/code.inputHandler';
import {
autocompleteKeyMap,
enterKeyMap,
historyKeyMap,
tabKeyMap,
} from '@/plugins/codemirror/keymap';
export const readOnlyEditorExtensions: readonly Extension[] = [
lineNumbers(),
EditorView.lineWrapping,
highlightSpecialChars(),
];
export const writableEditorExtensions: readonly Extension[] = [
history(),
lintGutter(),
foldGutter(),
codeInputHandler(),
dropCursor(),
indentOnInput(),
bracketMatching(),
highlightActiveLine(),
highlightActiveLineGutter(),
Prec.highest(
keymap.of([
...tabKeyMap(),
...enterKeyMap,
...autocompleteKeyMap,
...historyKeyMap,
{ key: 'Mod-/', run: toggleComment },
{ key: 'Backspace', run: deleteCharBackward, shift: deleteCharBackward },
]),
),
];

View File

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

View File

@@ -1,10 +1,10 @@
import type { Diagnostic } from '@codemirror/lint';
import { linter } from '@codemirror/lint';
import { linter as codeMirrorLinter } from '@codemirror/lint';
import type { EditorView } from '@codemirror/view';
import * as esprima from 'esprima-next';
import type { Node, MemberExpression } from 'estree';
import type { CodeExecutionMode, CodeNodeEditorLanguage } from 'n8n-workflow';
import { toValue, type MaybeRefOrGetter } from 'vue';
import { computed, toValue, type MaybeRefOrGetter } from 'vue';
import { useI18n } from '@/composables/useI18n';
import {
@@ -17,17 +17,17 @@ import { walk } from './utils';
export const useLinter = (
mode: MaybeRefOrGetter<CodeExecutionMode>,
editor: MaybeRefOrGetter<EditorView | null>,
language: MaybeRefOrGetter<CodeNodeEditorLanguage>,
) => {
const i18n = useI18n();
function createLinter(language: CodeNodeEditorLanguage) {
switch (language) {
const linter = computed(() => {
switch (toValue(language)) {
case 'javaScript':
return linter(lintSource, { delay: DEFAULT_LINTER_DELAY_IN_MS });
return codeMirrorLinter(lintSource, { delay: DEFAULT_LINTER_DELAY_IN_MS });
}
return undefined;
}
return [];
});
function lintSource(editorView: EditorView): Diagnostic[] {
const doc = editorView.state.doc.toString();
@@ -38,34 +38,7 @@ export const useLinter = (
try {
ast = esprima.parseScript(script, { range: true });
} catch (syntaxError) {
let line;
try {
const lineAtError = editorView.state.doc.line(syntaxError.lineNumber - 1).text;
// optional chaining operators currently unsupported by esprima-next
if (['?.', ']?'].some((operator) => lineAtError.includes(operator))) return [];
} catch {
return [];
}
try {
line = editorView.state.doc.line(syntaxError.lineNumber);
return [
{
from: line.from,
to: line.to,
severity: DEFAULT_LINTER_SEVERITY,
message: i18n.baseText('codeNodeEditor.linter.bothModes.syntaxError'),
},
];
} catch {
/**
* For invalid (e.g. half-written) n8n syntax, esprima errors with an off-by-one line number for the final line. In future, we should add full linting for n8n syntax before parsing JS.
*/
return [];
}
return [];
}
if (ast === null) return [];
@@ -118,7 +91,7 @@ export const useLinter = (
walk(ast, isUnavailableVarInAllItems).forEach((node) => {
const [start, end] = getRange(node);
const varName = getText(node);
const varName = getText(editorView, node);
if (!varName) return;
@@ -250,7 +223,7 @@ export const useLinter = (
walk<TargetNode>(ast, isUnavailableMethodinEachItem).forEach((node) => {
const [start, end] = getRange(node.property);
const method = getText(node.property);
const method = getText(editorView, node.property);
if (!method) return;
@@ -444,7 +417,7 @@ export const useLinter = (
if (shadowStart && start > shadowStart) return; // skip shadow item
const varName = getText(node);
const varName = getText(editorView, node);
if (!varName) return;
@@ -489,7 +462,7 @@ export const useLinter = (
!['json', 'binary'].includes(node.property.name);
walk<TargetNode>(ast, isDirectAccessToItemSubproperty).forEach((node) => {
const varName = getText(node);
const varName = getText(editorView, node);
if (!varName) return;
@@ -636,19 +609,15 @@ export const useLinter = (
// helpers
// ----------------------------------
function getText(node: RangeNode) {
const editorValue = toValue(editor);
if (!editorValue) return null;
function getText(editorView: EditorView, node: RangeNode) {
const [start, end] = getRange(node);
return editorValue.state.doc.toString().slice(start, end);
return editorView.state.doc.toString().slice(start, end);
}
function getRange(node: RangeNode) {
return node.range.map((loc) => loc - OFFSET_FOR_SCRIPT_WRAPPER);
}
return { createLinter };
return linter;
};

View File

@@ -32,16 +32,62 @@ interface ThemeSettings {
maxHeight?: string;
minHeight?: string;
rows?: number;
highlightColors?: 'default' | 'html';
}
export const codeNodeEditorTheme = ({
isReadOnly,
minHeight,
maxHeight,
rows,
highlightColors,
}: ThemeSettings) => [
const codeEditorSyntaxHighlighting = syntaxHighlighting(
HighlightStyle.define([
{ tag: tags.keyword, color: 'var(--color-code-tags-keyword)' },
{
tag: [
tags.deleted,
tags.character,
tags.macroName,
tags.definition(tags.name),
tags.definition(tags.variableName),
tags.atom,
tags.bool,
],
color: 'var(--color-code-tags-variable)',
},
{ tag: [tags.name, tags.propertyName], color: 'var(--color-code-tags-property)' },
{
tag: [tags.processingInstruction, tags.string, tags.inserted, tags.special(tags.string)],
color: 'var(--color-code-tags-string)',
},
{
tag: [tags.function(tags.variableName), tags.labelName],
color: 'var(--color-code-tags-function)',
},
{
tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)],
color: 'var(--color-code-tags-constant)',
},
{ tag: [tags.className], color: 'var(--color-code-tags-class)' },
{
tag: [tags.number, tags.changed, tags.annotation, tags.modifier, tags.self, tags.namespace],
color: 'var(--color-code-tags-primitive)',
},
{ tag: [tags.typeName], color: 'var(--color-code-tags-type)' },
{ tag: [tags.operator, tags.operatorKeyword], color: 'var(--color-code-tags-keyword)' },
{
tag: [tags.url, tags.escape, tags.regexp, tags.link],
color: 'var(--color-code-tags-keyword)',
},
{ tag: [tags.meta, tags.comment, tags.lineComment], color: 'var(--color-code-tags-comment)' },
{ tag: tags.strong, fontWeight: 'bold' },
{ tag: tags.emphasis, fontStyle: 'italic' },
{ tag: tags.link, textDecoration: 'underline' },
{ tag: tags.heading, fontWeight: 'bold', color: 'var(--color-code-tags-heading)' },
{ tag: tags.invalid, color: 'var(--color-code-tags-invalid)' },
{ tag: tags.strikethrough, textDecoration: 'line-through' },
{
tag: [tags.derefOperator, tags.special(tags.variableName), tags.variableName, tags.separator],
color: 'var(--color-code-foreground)',
},
]),
);
export const codeEditorTheme = ({ isReadOnly, minHeight, maxHeight, rows }: ThemeSettings) => [
EditorView.theme({
'&': {
'font-size': BASE_STYLING.fontSize,
@@ -54,13 +100,17 @@ export const codeNodeEditorTheme = ({
'.cm-content': {
fontFamily: BASE_STYLING.fontFamily,
caretColor: isReadOnly ? 'transparent' : 'var(--color-code-caret)',
lineHeight: 'var(--font-line-height-xloose)',
paddingTop: 'var(--spacing-2xs)',
paddingBottom: 'var(--spacing-s)',
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'var(--color-code-caret)',
},
'&.cm-focused .cm-selectionBackgroundm .cm-selectionBackground, .cm-content ::selection': {
backgroundColor: 'var(--color-code-selection)',
},
'&.cm-focused > .cm-scroller .cm-selectionLayer > .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection':
{
background: 'var(--color-code-selection)',
},
'&.cm-editor': {
...(isReadOnly ? { backgroundColor: 'var(--color-code-background-readonly)' } : {}),
borderColor: 'var(--border-color-base)',
@@ -75,13 +125,19 @@ export const codeNodeEditorTheme = ({
'.cm-activeLineGutter': {
backgroundColor: 'var(--color-code-lineHighlight)',
},
'.cm-lineNumbers .cm-activeLineGutter': {
color: 'var(--color-code-gutter-foreground-active)',
},
'.cm-gutters': {
backgroundColor: isReadOnly
? 'var(--color-code-background-readonly)'
: 'var(--color-code-gutterBackground)',
color: 'var(--color-code-gutterForeground)',
: 'var(--color-code-gutter-background)',
color: 'var(--color-code-gutter-foreground)',
border: '0',
borderRadius: 'var(--border-radius-base)',
borderRightColor: 'var(--border-color-base)',
},
'.cm-gutterElement': {
padding: 0,
},
'.cm-tooltip': {
maxWidth: BASE_STYLING.tooltip.maxWidth,
@@ -92,11 +148,30 @@ export const codeNodeEditorTheme = ({
maxHeight: maxHeight ?? '100%',
...(isReadOnly
? {}
: { minHeight: rows && rows !== -1 ? `${Number(rows + 1) * 1.3}em` : 'auto' }),
: {
minHeight: rows && rows !== -1 ? `${Number(rows + 1) * 1.3}em` : 'auto',
}),
},
'.cm-lineNumbers .cm-gutterElement': {
padding: '0 var(--spacing-5xs) 0 var(--spacing-2xs)',
},
'.cm-gutter,.cm-content': {
minHeight: rows && rows !== -1 ? 'auto' : (minHeight ?? 'calc(35vh - var(--spacing-2xl))'),
},
'.cm-foldGutter': {
width: '16px',
},
'.cm-fold-marker': {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
opacity: 0,
transition: 'opacity 0.3s ease',
},
'.cm-activeLineGutter .cm-fold-marker, .cm-gutters:hover .cm-fold-marker': {
opacity: 1,
},
'.cm-diagnosticAction': {
backgroundColor: BASE_STYLING.diagnosticButton.backgroundColor,
color: 'var(--color-primary)',
@@ -106,103 +181,81 @@ export const codeNodeEditorTheme = ({
cursor: BASE_STYLING.diagnosticButton.cursor,
},
'.cm-diagnostic-error': {
backgroundColor: 'var(--color-background-base)',
backgroundColor: 'var(--color-infobox-background)',
},
'.cm-diagnosticText': {
fontSize: 'var(--font-size-xs)',
color: 'var(--color-text-base)',
},
'.cm-diagnosticDocs': {
fontSize: 'var(--font-size-2xs)',
},
'.cm-foldPlaceholder': {
color: 'var(--color-text-base)',
backgroundColor: 'var(--color-background-base)',
border: 'var(--border-base)',
},
'.cm-selectionMatch': {
background: 'var(--color-code-selection-highlight)',
},
'.cm-selectionMatch-main': {
background: 'var(--color-code-selection-highlight)',
},
'.cm-matchingBracket': {
background: 'var(--color-code-selection)',
},
'.cm-completionMatchedText': {
textDecoration: 'none',
fontWeight: '600',
color: 'var(--color-autocomplete-item-selected)',
},
'.cm-faded > span': {
opacity: 0.6,
},
'.cm-panel.cm-search': {
padding: 'var(--spacing-4xs) var(--spacing-2xs)',
},
'.cm-panels': {
background: 'var(--color-background-light)',
color: 'var(--color-text-base)',
},
'.cm-panels-bottom': {
borderTop: 'var(--border-base)',
},
'.cm-textfield': {
color: 'var(--color-text-dark)',
background: 'var(--color-foreground-xlight)',
borderRadius: 'var(--border-radius-base)',
border: 'var(--border-base)',
fontSize: '90%',
},
'.cm-textfield:focus': {
outline: 'none',
borderColor: 'var(--color-secondary)',
},
'.cm-panel button': {
color: 'var(--color-text-base)',
},
'.cm-panel input[type="checkbox"]': {
border: 'var(--border-base)',
outline: 'none',
},
'.cm-panel input[type="checkbox"]:hover': {
border: 'var(--border-base)',
outline: 'none',
},
'.cm-panel.cm-search label': {
fontSize: '90%',
},
'.cm-button': {
outline: 'none',
border: 'var(--border-base)',
color: 'var(--color-text-dark)',
backgroundColor: 'var(--color-foreground-xlight)',
backgroundImage: 'none',
borderRadius: 'var(--border-radius-base)',
fontSize: '90%',
},
}),
highlightColors === 'html'
? syntaxHighlighting(
HighlightStyle.define([
{ tag: tags.keyword, color: '#c678dd' },
{
tag: [tags.name, tags.deleted, tags.character, tags.propertyName, tags.macroName],
color: '#e06c75',
},
{ tag: [tags.function(tags.variableName), tags.labelName], color: '#61afef' },
{
tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)],
color: '#d19a66',
},
{ tag: [tags.definition(tags.name), tags.separator], color: '#abb2bf' },
{
tag: [
tags.typeName,
tags.className,
tags.number,
tags.changed,
tags.annotation,
tags.modifier,
tags.self,
tags.namespace,
],
color: '#e06c75',
},
{
tag: [
tags.operator,
tags.operatorKeyword,
tags.url,
tags.escape,
tags.regexp,
tags.link,
tags.special(tags.string),
],
color: '#56b6c2',
},
{ tag: [tags.meta, tags.comment], color: '#7d8799' },
{ tag: tags.strong, fontWeight: 'bold' },
{ tag: tags.emphasis, fontStyle: 'italic' },
{ tag: tags.strikethrough, textDecoration: 'line-through' },
{ tag: tags.link, color: '#7d8799', textDecoration: 'underline' },
{ tag: tags.heading, fontWeight: 'bold', color: '#e06c75' },
{ tag: [tags.atom, tags.bool, tags.special(tags.variableName)], color: '#d19a66' },
{ tag: [tags.processingInstruction, tags.string, tags.inserted], color: '#98c379' },
{ tag: tags.invalid, color: 'red', 'font-weight': 'bold' },
]),
)
: syntaxHighlighting(
HighlightStyle.define([
{
tag: tags.comment,
color: 'var(--color-code-tags-comment)',
},
{
tag: [tags.string, tags.special(tags.brace)],
color: 'var(--color-code-tags-string)',
},
{
tag: [tags.number, tags.self, tags.bool, tags.null],
color: 'var(--color-code-tags-primitive)',
},
{
tag: tags.keyword,
color: 'var(--color-code-tags-keyword)',
},
{
tag: tags.operator,
color: 'var(--color-code-tags-operator)',
},
{
tag: [
tags.variableName,
tags.propertyName,
tags.attributeName,
tags.regexp,
tags.className,
tags.typeName,
],
color: 'var(--color-code-tags-variable)',
},
{
tag: [
tags.definition(tags.typeName),
tags.definition(tags.propertyName),
tags.function(tags.variableName),
],
color: 'var(--color-code-tags-definition)',
},
]),
),
codeEditorSyntaxHighlighting,
];

View File

@@ -245,7 +245,6 @@ const onResizeThrottle = useThrottleFn(onResize, 10);
margin-bottom: 0;
:global(.el-dialog__body) {
background-color: var(--color-expression-editor-modal-background);
height: 100%;
padding: var(--spacing-s);
}

View File

@@ -7,20 +7,14 @@ import { computed, onMounted, ref, watch } from 'vue';
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
import { n8nAutocompletion, n8nLang } from '@/plugins/codemirror/n8nLang';
import { forceParse } from '@/utils/forceParse';
import { completionStatus } from '@codemirror/autocomplete';
import { inputTheme } from './theme';
import { useExpressionEditor } from '@/composables/useExpressionEditor';
import {
autocompleteKeyMap,
enterKeyMap,
historyKeyMap,
tabKeyMap,
} from '@/plugins/codemirror/keymap';
import { infoBoxTooltips } from '@/plugins/codemirror/tooltips/InfoBoxTooltip';
import type { Segment } from '@/types/expressions';
import { removeExpressionPrefix } from '@/utils/expressions';
import { mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
import { editorKeymap } from '@/plugins/codemirror/keymap';
type Props = {
modelValue: string;
@@ -41,24 +35,7 @@ const emit = defineEmits<{
const root = ref<HTMLElement>();
const extensions = computed(() => [
inputTheme(props.isReadOnly),
Prec.highest(
keymap.of([
...tabKeyMap(),
...historyKeyMap,
...enterKeyMap,
...autocompleteKeyMap,
{
any: (view, event) => {
if (event.key === 'Escape' && completionStatus(view.state) === null) {
event.stopPropagation();
emit('close');
}
return false;
},
},
]),
),
Prec.highest(keymap.of(editorKeymap)),
n8nLang(),
n8nAutocompletion(),
mappingDropCursor(),
@@ -66,7 +43,7 @@ const extensions = computed(() => [
history(),
expressionInputHandler(),
EditorView.lineWrapping,
EditorView.domEventHandlers({ scroll: forceParse }),
EditorView.domEventHandlers({ scroll: (_, view) => forceParse(view) }),
infoBoxTooltips(),
]);
const editorValue = ref<string>(removeExpressionPrefix(props.modelValue));

View File

@@ -22,19 +22,14 @@ import htmlParser from 'prettier/plugins/html';
import cssParser from 'prettier/plugins/postcss';
import { computed, onBeforeUnmount, onMounted, ref, toRaw, toValue, watch } from 'vue';
import { htmlEditorEventBus } from '@/event-bus';
import { useExpressionEditor } from '@/composables/useExpressionEditor';
import { htmlEditorEventBus } from '@/event-bus';
import { n8nCompletionSources } from '@/plugins/codemirror/completions/addCompletions';
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
import {
autocompleteKeyMap,
enterKeyMap,
historyKeyMap,
tabKeyMap,
} from '@/plugins/codemirror/keymap';
import { editorKeymap } from '@/plugins/codemirror/keymap';
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
import { autoCloseTags, htmlLanguage } from 'codemirror-lang-html-n8n';
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
import { codeEditorTheme } from '../CodeNodeEditor/theme';
import type { Range, Section } from './types';
import { nonTakenRanges } from './utils';
import { dropInExpressionEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
@@ -67,16 +62,13 @@ const extensions = computed(() => [
),
autoCloseTags,
expressionInputHandler(),
Prec.highest(
keymap.of([...tabKeyMap(), ...enterKeyMap, ...historyKeyMap, ...autocompleteKeyMap]),
),
Prec.highest(keymap.of(editorKeymap)),
indentOnInput(),
codeNodeEditorTheme({
codeEditorTheme({
isReadOnly: props.isReadOnly,
maxHeight: props.fullscreen ? '100%' : '40vh',
minHeight: '20vh',
rows: props.rows,
highlightColors: 'html',
}),
lineNumbers(),
highlightActiveLineGutter(),

View File

@@ -97,7 +97,7 @@ onMounted(() => {
extensions: [
EditorState.readOnly.of(true),
EditorView.lineWrapping,
EditorView.domEventHandlers({ scroll: forceParse }),
EditorView.domEventHandlers({ scroll: (_, view) => forceParse(view) }),
...props.extensions,
],
}),

View File

@@ -7,12 +7,7 @@ import { computed, ref, watch } from 'vue';
import { useExpressionEditor } from '@/composables/useExpressionEditor';
import { mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
import {
autocompleteKeyMap,
enterKeyMap,
historyKeyMap,
tabKeyMap,
} from '@/plugins/codemirror/keymap';
import { editorKeymap } from '@/plugins/codemirror/keymap';
import { n8nAutocompletion, n8nLang } from '@/plugins/codemirror/n8nLang';
import { infoBoxTooltips } from '@/plugins/codemirror/tooltips/InfoBoxTooltip';
import type { Segment } from '@/types/expressions';
@@ -42,9 +37,7 @@ const emit = defineEmits<{
const root = ref<HTMLElement>();
const extensions = computed(() => [
Prec.highest(
keymap.of([...tabKeyMap(false), ...enterKeyMap, ...autocompleteKeyMap, ...historyKeyMap]),
),
Prec.highest(keymap.of(editorKeymap)),
n8nLang(),
n8nAutocompletion(),
inputTheme({ isReadOnly: props.isReadOnly, rows: props.rows }),

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { history, toggleComment } from '@codemirror/commands';
import { history } from '@codemirror/commands';
import { javascript } from '@codemirror/lang-javascript';
import { foldGutter, indentOnInput } from '@codemirror/language';
import { lintGutter } from '@codemirror/lint';
@@ -16,14 +16,9 @@ import {
} from '@codemirror/view';
import { computed, onMounted, ref, watch } from 'vue';
import {
autocompleteKeyMap,
enterKeyMap,
historyKeyMap,
tabKeyMap,
} from '@/plugins/codemirror/keymap';
import { editorKeymap } from '@/plugins/codemirror/keymap';
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
import { codeEditorTheme } from '../CodeNodeEditor/theme';
type Props = {
modelValue: string;
@@ -85,7 +80,7 @@ const extensions = computed(() => {
lineNumbers(),
EditorView.lineWrapping,
EditorState.readOnly.of(props.isReadOnly),
codeNodeEditorTheme({
codeEditorTheme({
isReadOnly: props.isReadOnly,
maxHeight: props.fillParent ? '100%' : '40vh',
minHeight: '20vh',
@@ -96,15 +91,7 @@ const extensions = computed(() => {
if (!props.isReadOnly) {
extensionsToApply.push(
history(),
Prec.highest(
keymap.of([
...tabKeyMap(),
...enterKeyMap,
...historyKeyMap,
...autocompleteKeyMap,
{ key: 'Mod-/', run: toggleComment },
]),
),
Prec.highest(keymap.of(editorKeymap)),
lintGutter(),
n8nAutocompletion(),
indentOnInput(),

View File

@@ -15,15 +15,10 @@ import {
lineNumbers,
} from '@codemirror/view';
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
import {
autocompleteKeyMap,
enterKeyMap,
historyKeyMap,
tabKeyMap,
} from '@/plugins/codemirror/keymap';
import { editorKeymap } from '@/plugins/codemirror/keymap';
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
import { computed, onMounted, ref, watch } from 'vue';
import { codeEditorTheme } from '../CodeNodeEditor/theme';
import { mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
type Props = {
@@ -48,7 +43,7 @@ const extensions = computed(() => {
lineNumbers(),
EditorView.lineWrapping,
EditorState.readOnly.of(props.isReadOnly),
codeNodeEditorTheme({
codeEditorTheme({
isReadOnly: props.isReadOnly,
maxHeight: props.fillParent ? '100%' : '40vh',
minHeight: '20vh',
@@ -58,9 +53,7 @@ const extensions = computed(() => {
if (!props.isReadOnly) {
extensionsToApply.push(
history(),
Prec.highest(
keymap.of([...tabKeyMap(), ...enterKeyMap, ...historyKeyMap, ...autocompleteKeyMap]),
),
Prec.highest(keymap.of(editorKeymap)),
createLinter(jsonParseLinter()),
lintGutter(),
n8nAutocompletion(),

View File

@@ -476,6 +476,10 @@ const shortPath = computed<string>(() => {
return short.join('.');
});
const parameterId = computed(() => {
return `${node.value?.id ?? crypto.randomUUID()}${props.path}`;
});
const isResourceLocatorParameter = computed<boolean>(() => {
return props.parameter.type === 'resourceLocator' || props.parameter.type === 'workflowSelector';
});
@@ -1092,21 +1096,20 @@ onUpdated(async () => {
"
>
<el-dialog
width="calc(100% - var(--spacing-3xl))"
:class="$style.modal"
:model-value="codeEditDialogVisible"
:append-to="`#${APP_MODALS_ELEMENT_ID}`"
width="80%"
:title="`${i18n.baseText('codeEdit.edit')} ${i18n
.nodeText()
.inputLabelDisplayName(parameter, path)}`"
:before-close="closeCodeEditDialog"
data-test-id="code-editor-fullscreen"
>
<div
:key="codeEditDialogVisible.toString()"
class="ignore-key-press-canvas code-edit-dialog"
>
<div class="ignore-key-press-canvas code-edit-dialog">
<CodeNodeEditor
v-if="editorType === 'codeNodeEditor'"
:id="parameterId"
:mode="codeEditorMode"
:model-value="modelValueString"
:default-value="parameter.default"
@@ -1116,7 +1119,7 @@ onUpdated(async () => {
@update:model-value="valueChangedDebounced"
/>
<HtmlEditor
v-else-if="editorType === 'htmlEditor'"
v-else-if="editorType === 'htmlEditor' && !codeEditDialogVisible"
:model-value="modelValueString"
:is-read-only="isReadOnly"
:rows="editorRows"
@@ -1126,7 +1129,7 @@ onUpdated(async () => {
@update:model-value="valueChangedDebounced"
/>
<SqlEditor
v-else-if="editorType === 'sqlEditor'"
v-else-if="editorType === 'sqlEditor' && !codeEditDialogVisible"
:model-value="modelValueString"
:dialect="getArgument('sqlDialect')"
:is-read-only="isReadOnly"
@@ -1135,7 +1138,7 @@ onUpdated(async () => {
@update:model-value="valueChangedDebounced"
/>
<JsEditor
v-else-if="editorType === 'jsEditor'"
v-else-if="editorType === 'jsEditor' && !codeEditDialogVisible"
:model-value="modelValueString"
:is-read-only="isReadOnly"
:rows="editorRows"
@@ -1145,7 +1148,7 @@ onUpdated(async () => {
/>
<JsonEditor
v-else-if="parameter.type === 'json'"
v-else-if="parameter.type === 'json' && !codeEditDialogVisible"
:model-value="modelValueString"
:is-read-only="isReadOnly"
:rows="editorRows"
@@ -1166,8 +1169,8 @@ onUpdated(async () => {
></TextEdit>
<CodeNodeEditor
v-if="editorType === 'codeNodeEditor' && isCodeNode"
:key="'code-' + codeEditDialogVisible.toString()"
v-if="editorType === 'codeNodeEditor' && isCodeNode && !codeEditDialogVisible"
:id="parameterId"
:mode="codeEditorMode"
:model-value="modelValueString"
:default-value="parameter.default"
@@ -1191,8 +1194,7 @@ onUpdated(async () => {
</CodeNodeEditor>
<HtmlEditor
v-else-if="editorType === 'htmlEditor'"
:key="'html-' + codeEditDialogVisible.toString()"
v-else-if="editorType === 'htmlEditor' && !codeEditDialogVisible"
:model-value="modelValueString"
:is-read-only="isReadOnly"
:rows="editorRows"
@@ -1214,7 +1216,6 @@ onUpdated(async () => {
<SqlEditor
v-else-if="editorType === 'sqlEditor'"
:key="'sql-' + codeEditDialogVisible.toString()"
:model-value="modelValueString"
:dialect="getArgument('sqlDialect')"
:is-read-only="isReadOnly"
@@ -1235,7 +1236,6 @@ onUpdated(async () => {
<JsEditor
v-else-if="editorType === 'jsEditor'"
:key="'js-' + codeEditDialogVisible.toString()"
:model-value="modelValueString"
:is-read-only="isReadOnly || editorIsReadOnly"
:rows="editorRows"
@@ -1257,7 +1257,6 @@ onUpdated(async () => {
<JsonEditor
v-else-if="parameter.type === 'json'"
:key="'json-' + codeEditDialogVisible.toString()"
:model-value="modelValueString"
:is-read-only="isReadOnly"
:rows="editorRows"
@@ -1278,6 +1277,7 @@ onUpdated(async () => {
<div v-else-if="editorType" class="readonly-code clickable" @click="displayEditDialog()">
<CodeNodeEditor
v-if="!codeEditDialogVisible"
:id="parameterId"
:mode="codeEditorMode"
:model-value="modelValueString"
:language="editorLanguage"
@@ -1630,8 +1630,8 @@ onUpdated(async () => {
.textarea-modal-opener {
position: absolute;
right: 0;
bottom: 0;
right: 1px;
bottom: 1px;
background-color: var(--color-code-background);
padding: 3px;
line-height: 9px;
@@ -1639,6 +1639,8 @@ onUpdated(async () => {
border-top-left-radius: var(--border-radius-base);
border-bottom-right-radius: var(--border-radius-base);
cursor: pointer;
border-right: none;
border-bottom: none;
svg {
width: 9px !important;
@@ -1660,7 +1662,7 @@ onUpdated(async () => {
}
.code-edit-dialog {
height: 70vh;
height: 100%;
.code-node-editor {
height: 100%;
@@ -1668,7 +1670,25 @@ onUpdated(async () => {
}
</style>
<style lang="scss" module>
<style lang="css" module>
.modal {
--dialog-close-top: var(--spacing-m);
display: flex;
flex-direction: column;
overflow: clip;
height: calc(100% - var(--spacing-4xl));
margin-bottom: 0;
:global(.el-dialog__header) {
padding-bottom: 0;
}
:global(.el-dialog__body) {
height: calc(100% - var(--spacing-3xl));
padding: var(--spacing-s);
}
}
.tipVisible {
--input-border-bottom-left-radius: 0;
--input-border-bottom-right-radius: 0;

View File

@@ -1,18 +1,14 @@
<script setup lang="ts">
import InlineExpressionEditorOutput from '@/components/InlineExpressionEditor/InlineExpressionEditorOutput.vue';
import { codeNodeEditorEventBus } from '@/event-bus';
import { useExpressionEditor } from '@/composables/useExpressionEditor';
import { codeNodeEditorEventBus } from '@/event-bus';
import { n8nCompletionSources } from '@/plugins/codemirror/completions/addCompletions';
import { dropInExpressionEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
import {
autocompleteKeyMap,
enterKeyMap,
historyKeyMap,
tabKeyMap,
} from '@/plugins/codemirror/keymap';
import { editorKeymap } from '@/plugins/codemirror/keymap';
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
import { ifNotIn } from '@codemirror/autocomplete';
import { history, toggleComment } from '@codemirror/commands';
import { history } from '@codemirror/commands';
import { LanguageSupport, bracketMatching, foldGutter, indentOnInput } from '@codemirror/language';
import { Prec, type Line } from '@codemirror/state';
import {
@@ -34,10 +30,9 @@ import {
StandardSQL,
keywordCompletionSource,
} from '@n8n/codemirror-lang-sql';
import { computed, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue';
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
import { dropInExpressionEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
import { onClickOutside } from '@vueuse/core';
import { computed, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue';
import { codeEditorTheme } from '../CodeNodeEditor/theme';
const SQL_DIALECTS = {
StandardSQL,
@@ -87,7 +82,7 @@ const extensions = computed(() => {
const baseExtensions = [
sqlWithN8nLanguageSupport(),
expressionInputHandler(),
codeNodeEditorTheme({
codeEditorTheme({
isReadOnly: props.isReadOnly,
maxHeight: props.fullscreen ? '100%' : '40vh',
minHeight: '10vh',
@@ -100,15 +95,7 @@ const extensions = computed(() => {
if (!props.isReadOnly) {
return baseExtensions.concat([
history(),
Prec.highest(
keymap.of([
...tabKeyMap(),
...enterKeyMap,
...historyKeyMap,
...autocompleteKeyMap,
{ key: 'Mod-/', run: toggleComment },
]),
),
Prec.highest(keymap.of(editorKeymap)),
n8nAutocompletion(),
indentOnInput(),
highlightActiveLine(),
@@ -185,10 +172,10 @@ function line(lineNumber: number): Line | null {
}
}
function highlightLine(lineNumber: number | 'final') {
function highlightLine(lineNumber: number | 'last') {
if (!editor.value) return;
if (lineNumber === 'final') {
if (lineNumber === 'last') {
editor.value.dispatch({
selection: { anchor: editor.value.state.doc.length },
});

View File

@@ -0,0 +1,448 @@
import { codeEditorTheme } from '@/components/CodeNodeEditor/theme';
import { editorKeymap } from '@/plugins/codemirror/keymap';
import { useTypescript } from '@/plugins/codemirror/typescript/client/useTypescript';
import { closeCursorInfoBox } from '@/plugins/codemirror/tooltips/InfoBoxTooltip';
import { closeBrackets, closeCompletion, completionStatus } from '@codemirror/autocomplete';
import { history, historyField } from '@codemirror/commands';
import { javascript } from '@codemirror/lang-javascript';
import { json } from '@codemirror/lang-json';
import { python } from '@codemirror/lang-python';
import { bracketMatching, foldGutter, foldState, indentOnInput } from '@codemirror/language';
import { highlightSelectionMatches } from '@codemirror/search';
import {
Compartment,
EditorSelection,
EditorState,
type EditorStateConfig,
type Extension,
type SelectionRange,
} from '@codemirror/state';
import {
drawSelection,
dropCursor,
EditorView,
highlightActiveLineGutter,
highlightActiveLine,
highlightSpecialChars,
keymap,
lineNumbers,
type ViewUpdate,
} from '@codemirror/view';
import { indentationMarkers } from '@replit/codemirror-indentation-markers';
import { html } from 'codemirror-lang-html-n8n';
import { debounce } from 'lodash-es';
import { jsonParse, type CodeExecutionMode, type IDataObject } from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
import {
computed,
onBeforeUnmount,
onMounted,
ref,
toRef,
toValue,
watch,
type MaybeRefOrGetter,
type Ref,
} from 'vue';
import { useCompleter } from '../components/CodeNodeEditor/completer';
import { mappingDropCursor } from '../plugins/codemirror/dragAndDrop';
import { languageFacet, type CodeEditorLanguage } from '../plugins/codemirror/format';
export type CodeEditorLanguageParamsMap = {
json: {};
html: {};
javaScript: { mode: CodeExecutionMode };
python: { mode: CodeExecutionMode };
};
export const useCodeEditor = <L extends CodeEditorLanguage>({
editorRef,
editorValue,
language,
languageParams,
placeholder,
extensions = [],
isReadOnly = false,
theme = {},
onChange = () => {},
id = uuid(),
}: {
editorRef: MaybeRefOrGetter<HTMLElement | undefined>;
language: MaybeRefOrGetter<L>;
editorValue?: MaybeRefOrGetter<string>;
placeholder?: MaybeRefOrGetter<string>;
extensions?: MaybeRefOrGetter<Extension[]>;
isReadOnly?: MaybeRefOrGetter<boolean>;
theme?: MaybeRefOrGetter<{
maxHeight?: string;
minHeight?: string;
rows?: number;
}>;
languageParams?: MaybeRefOrGetter<CodeEditorLanguageParamsMap[L]>;
id?: MaybeRefOrGetter<string>;
onChange?: (viewUpdate: ViewUpdate) => void;
}) => {
const editor = ref<EditorView>();
const hasFocus = ref(false);
const hasChanges = ref(false);
const lastChange = ref<ViewUpdate>();
const selection = ref<SelectionRange>(EditorSelection.cursor(0)) as Ref<SelectionRange>;
const customExtensions = ref<Compartment>(new Compartment());
const readOnlyExtensions = ref<Compartment>(new Compartment());
const telemetryExtensions = ref<Compartment>(new Compartment());
const languageExtensions = ref<Compartment>(new Compartment());
const themeExtensions = ref<Compartment>(new Compartment());
const autocompleteStatus = ref<'pending' | 'active' | null>(null);
const dragging = ref(false);
const storedStateFields = { fold: foldState, history: historyField };
const storedStateId = computed(() => `${toValue(id)}.editorState`);
const mode = computed(() => {
const params = toValue(languageParams);
return params && 'mode' in params ? params.mode : 'runOnceForAllItems';
});
const { createWorker: createTsWorker } = useTypescript(editor, mode, id);
function getInitialLanguageExtensions(lang: CodeEditorLanguage): Extension[] {
switch (lang) {
case 'javaScript':
return [javascript()];
default:
return [];
}
}
async function getFullLanguageExtensions(): Promise<Extension[]> {
if (!editor.value) return [];
const lang = toValue(language);
const langExtensions: Extension[] = [languageFacet.of(lang)];
switch (lang) {
case 'javaScript': {
const tsExtension = await createTsWorker();
langExtensions.push(tsExtension);
break;
}
case 'python': {
const pythonAutocomplete = useCompleter(mode, editor.value ?? null).autocompletionExtension(
'python',
);
langExtensions.push([python(), pythonAutocomplete]);
break;
}
case 'json':
langExtensions.push(json());
break;
case 'html':
langExtensions.push(html());
break;
}
return langExtensions;
}
function readEditorValue(): string {
return editor.value?.state.doc.toString() ?? '';
}
function updateSelection(update: ViewUpdate) {
const currentSelection = selection.value;
const newSelection = update.state.selection.ranges[0];
if (!currentSelection?.eq(newSelection)) {
selection.value = newSelection;
}
}
const emitChanges = debounce((update: ViewUpdate) => {
onChange(update);
}, 300);
function onEditorUpdate(update: ViewUpdate) {
autocompleteStatus.value = completionStatus(update.view.state);
updateSelection(update);
if (update.docChanged) {
hasChanges.value = true;
lastChange.value = update;
emitChanges(update);
}
}
function blur() {
if (editor.value) {
editor.value.contentDOM.blur();
closeCompletion(editor.value);
closeCursorInfoBox(editor.value);
}
}
function blurOnClickOutside(event: MouseEvent) {
if (event.target && !dragging.value && !editor.value?.dom.contains(event.target as Node)) {
blur();
}
dragging.value = false;
}
async function setLanguageExtensions() {
if (!editor.value) return;
const initialExtensions = getInitialLanguageExtensions(toValue(language));
if (initialExtensions.length > 0) {
editor.value.dispatch({
effects: languageExtensions.value.reconfigure(initialExtensions),
});
}
editor.value.dispatch({
effects: languageExtensions.value.reconfigure(await getFullLanguageExtensions()),
});
}
function getReadOnlyExtensions() {
return [
EditorState.readOnly.of(toValue(isReadOnly)),
EditorView.editable.of(!toValue(isReadOnly)),
highlightSpecialChars(),
];
}
function setReadOnlyExtensions() {
if (!editor.value) return;
editor.value.dispatch({
effects: readOnlyExtensions.value.reconfigure([getReadOnlyExtensions()]),
});
}
watch(toRef(editorRef), async () => {
const parent = toValue(editorRef);
if (!parent) return;
const initialValue = toValue(editorValue) ? toValue(editorValue) : toValue(placeholder);
const allExtensions = [
customExtensions.value.of(toValue(extensions)),
readOnlyExtensions.value.of(getReadOnlyExtensions()),
telemetryExtensions.value.of([]),
languageExtensions.value.of(getInitialLanguageExtensions(toValue(language))),
themeExtensions.value.of(codeEditorTheme(toValue(theme))),
EditorView.updateListener.of(onEditorUpdate),
EditorView.focusChangeEffect.of((_, newHasFocus) => {
hasFocus.value = newHasFocus;
selection.value = state.selection.ranges[0];
if (!newHasFocus) {
autocompleteStatus.value = null;
}
return null;
}),
EditorState.allowMultipleSelections.of(true),
EditorView.clickAddsSelectionRange.of(
(event) => event.altKey && !event.metaKey && !event.shiftKey,
),
EditorView.contentAttributes.of({ 'data-gramm': 'false' }), // disable grammarly
EditorView.domEventHandlers({
mousedown: () => {
dragging.value = true;
},
}),
highlightSelectionMatches({ minSelectionLength: 2 }),
lineNumbers(),
drawSelection(),
foldGutter({
markerDOM: (open) => {
const svgNS = 'http://www.w3.org/2000/svg';
const wrapper = document.createElement('div');
wrapper.classList.add('cm-fold-marker');
const svgElement = document.createElementNS(svgNS, 'svg');
svgElement.setAttribute('viewBox', '0 0 10 10');
svgElement.setAttribute('width', '10');
svgElement.setAttribute('height', '10');
const pathElement = document.createElementNS(svgNS, 'path');
const d = open ? 'M1 3 L5 7 L9 3' : 'M3 1 L7 5 L3 9'; // Chevron paths
pathElement.setAttribute('d', d);
pathElement.setAttribute('fill', 'none');
pathElement.setAttribute('stroke', 'currentColor');
pathElement.setAttribute('stroke-width', '1.5');
pathElement.setAttribute('stroke-linecap', 'round');
svgElement.appendChild(pathElement);
wrapper.appendChild(svgElement);
return wrapper;
},
}),
EditorView.lineWrapping,
history(),
dropCursor(),
indentOnInput(),
bracketMatching(),
closeBrackets(),
highlightActiveLine(),
highlightActiveLineGutter(),
mappingDropCursor(),
indentationMarkers({
highlightActiveBlock: true,
markerType: 'fullScope',
colors: {
activeDark: 'var(--color-code-indentation-marker-active)',
activeLight: 'var(--color-code-indentation-marker-active)',
dark: 'var(--color-code-indentation-marker)',
light: 'var(--color-code-indentation-marker)',
},
}),
keymap.of(editorKeymap),
];
const parsedStoredState = jsonParse<IDataObject | null>(
localStorage.getItem(storedStateId.value) ?? '',
{
fallbackValue: null,
},
);
const config: EditorStateConfig = {
doc: initialValue,
extensions: allExtensions,
};
const state =
// Only restore from localstorage when code did not change
parsedStoredState && parsedStoredState.doc === initialValue
? EditorState.fromJSON(parsedStoredState, config, storedStateFields)
: EditorState.create(config);
if (editor.value) {
editor.value.destroy();
}
editor.value = new EditorView({
parent,
state,
scrollTo: EditorView.scrollIntoView(state.selection.main, { y: 'center' }),
});
editor.value.dispatch({
effects: languageExtensions.value.reconfigure(await getFullLanguageExtensions()),
});
});
watch(extensions, () => {
if (editor.value) {
editor.value.dispatch({
effects: customExtensions.value.reconfigure(toValue(extensions)),
});
}
});
watch(toRef(language), setLanguageExtensions);
watch(toRef(isReadOnly), setReadOnlyExtensions);
watch(toRef(theme), () => {
if (editor.value) {
editor.value.dispatch({
effects: themeExtensions.value.reconfigure(codeEditorTheme(toValue(theme))),
});
}
});
watch(toRef(editorValue), () => {
if (!editor.value || hasFocus.value) return;
const newValue = toValue(editorValue);
const currentValue = readEditorValue();
if (newValue === undefined || newValue === currentValue) return;
editor.value.dispatch({
changes: { from: 0, to: currentValue.length, insert: newValue },
});
});
onMounted(() => {
document.addEventListener('click', blurOnClickOutside);
});
onBeforeUnmount(() => {
document.removeEventListener('click', blurOnClickOutside);
if (editor.value) {
const stateToStore = editor.value.state.toJSON(storedStateFields);
try {
localStorage.setItem(storedStateId.value, JSON.stringify(stateToStore));
} catch (error) {
// Code is too large, localStorage quota exceeded
localStorage.removeItem(storedStateId.value);
}
if (lastChange.value) onChange(lastChange.value);
editor.value.destroy();
}
});
function setCursorPosition(pos: number | 'end'): void {
const finalPos = pos === 'end' ? (editor.value?.state.doc.length ?? 0) : pos;
editor.value?.dispatch({ selection: { head: finalPos, anchor: finalPos } });
}
function select(anchor: number, head: number | 'end' = 'end'): void {
editor.value?.dispatch({
selection: {
anchor,
head: head === 'end' ? (editor.value?.state.doc.length ?? 0) : head,
},
});
}
function getLine(lineNumber: number | 'last' | 'first') {
if (!editor.value) return;
const { doc } = editor.value.state;
switch (lineNumber) {
case 'first':
return doc.lineAt(0);
case 'last':
return doc.lineAt(doc.length - 1);
default:
return doc.line(lineNumber);
}
}
function selectLine(lineNumber: number | 'last' | 'first'): void {
if (!editor.value) return;
const line = getLine(lineNumber);
if (!line) return;
editor.value.dispatch({
selection: EditorSelection.range(line.from, line.to),
});
}
function highlightLine(lineNumber: number | 'last' | 'first'): void {
if (!editor.value) return;
const line = getLine(lineNumber);
if (!line) return;
editor.value.dispatch({ selection: EditorSelection.cursor(line.from) });
}
const selectAll = () => select(0, 'end');
function focus(): void {
if (hasFocus.value) return;
editor.value?.focus();
}
return {
editor,
hasFocus,
hasChanges,
selection,
readEditorValue,
setCursorPosition,
select,
selectLine,
selectAll,
highlightLine,
focus,
blur,
};
};

View File

@@ -1,17 +1,16 @@
import { renderComponent } from '@/__tests__/render';
import * as workflowHelpers from '@/composables/useWorkflowHelpers';
import { EditorView, keymap } from '@codemirror/view';
import { n8nLang } from '@/plugins/codemirror/n8nLang';
import { EditorSelection } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { createTestingPinia } from '@pinia/testing';
import { waitFor, fireEvent } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { fireEvent, waitFor } from '@testing-library/vue';
import { setActivePinia } from 'pinia';
import { beforeEach, describe, vi } from 'vitest';
import { defineComponent, h, ref, toValue } from 'vue';
import { n8nLang } from '@/plugins/codemirror/n8nLang';
import { useExpressionEditor } from './useExpressionEditor';
import { useRouter } from 'vue-router';
import { EditorSelection } from '@codemirror/state';
import userEvent from '@testing-library/user-event';
import { renderComponent } from '@/__tests__/render';
import { tabKeyMap } from '@/plugins/codemirror/keymap';
import { useExpressionEditor } from './useExpressionEditor';
vi.mock('@/composables/useAutocompleteTelemetry', () => ({
useAutocompleteTelemetry: vi.fn(),
@@ -358,32 +357,4 @@ describe('useExpressionEditor', () => {
expect(expressionEditor.editor.value?.hasFocus).toBe(true);
});
});
describe('keymap', () => {
const TEST_EDITOR_VALUE = '{{ { "foo": "bar" } }}';
test('should indent on tab if blurOnTab is false', async () => {
const { renderResult, expressionEditor } = await renderExpressionEditor({
editorValue: TEST_EDITOR_VALUE,
extensions: [keymap.of([...tabKeyMap(false)])],
});
const root = renderResult.getByTestId('editor-root');
const input = root.querySelector('.cm-line') as HTMLDivElement;
await userEvent.type(input, '{tab}');
expect(expressionEditor.editor.value?.state.doc.toString()).toEqual(` ${TEST_EDITOR_VALUE}`);
});
test('should NOT indent on tab if blurOnTab is true', async () => {
const { renderResult, expressionEditor } = await renderExpressionEditor({
editorValue: TEST_EDITOR_VALUE,
extensions: [keymap.of([...tabKeyMap(true)])],
});
const root = renderResult.getByTestId('editor-root');
const input = root.querySelector('.cm-line') as HTMLDivElement;
await userEvent.type(input, '{tab}');
expect(expressionEditor.editor.value?.state.doc.toString()).toEqual(TEST_EDITOR_VALUE);
});
});
});

View File

@@ -3,6 +3,7 @@ import {
onBeforeUnmount,
onMounted,
ref,
toRef,
toValue,
watch,
watchEffect,
@@ -47,7 +48,7 @@ export const useExpressionEditor = ({
autocompleteTelemetry,
isReadOnly = false,
}: {
editorRef: Ref<HTMLElement | undefined>;
editorRef: MaybeRefOrGetter<HTMLElement | undefined>;
editorValue?: MaybeRefOrGetter<string>;
extensions?: MaybeRefOrGetter<Extension[]>;
additionalData?: MaybeRefOrGetter<IDataObject>;
@@ -178,7 +179,7 @@ export const useExpressionEditor = ({
dragging.value = false;
}
watch(editorRef, () => {
watch(toRef(editorRef), () => {
const parent = toValue(editorRef);
if (!parent) return;

View File

@@ -281,7 +281,7 @@ export function usePushConnection({ router }: { router: ReturnType<typeof useRou
const lineNumber = iRunExecutionData.resultData?.error?.lineNumber;
codeNodeEditorEventBus.emit('highlightLine', lineNumber ?? 'final');
codeNodeEditorEventBus.emit('highlightLine', lineNumber ?? 'last');
const workflow = workflowHelpers.getCurrentWorkflow();
if (executionData.data?.waitTill !== undefined) {

View File

@@ -1,6 +1,6 @@
import { createEventBus } from 'n8n-design-system/utils';
export type HighlightLineEvent = number | 'final';
export type HighlightLineEvent = number | 'last';
export interface CodeNodeEditorEventBusEvents {
/** Event that a diff have been applied to the code node editor */

View File

@@ -112,23 +112,19 @@ export function expressionWithFirstItem(syntaxTree: Tree, expression: string): s
}
export function longestCommonPrefix(...strings: string[]) {
if (strings.length < 2) {
throw new Error('Expected at least two strings');
}
if (strings.length < 2) return '';
return strings.reduce((acc, next) => {
let i = 0;
while (acc[i] && next[i] && acc[i] === next[i]) {
i++;
return strings.reduce((prefix, str) => {
while (!str.startsWith(prefix)) {
prefix = prefix.slice(0, -1);
if (prefix === '') return '';
}
return acc.slice(0, i);
}, '');
return prefix;
}, strings[0]);
}
export const prefixMatch = (first: string, second: string) =>
first.toLocaleLowerCase().startsWith(second.toLocaleLowerCase()) && first !== second;
first.toLocaleLowerCase().startsWith(second.toLocaleLowerCase());
export const isPseudoParam = (candidate: string) => {
const PSEUDO_PARAMS = ['notice']; // user input disallowed

View File

@@ -0,0 +1,47 @@
import { EditorSelection, Facet } from '@codemirror/state';
import type { EditorView } from '@codemirror/view';
import { formatWithCursor, type BuiltInParserName } from 'prettier';
import babelPlugin from 'prettier/plugins/babel';
import estreePlugin from 'prettier/plugins/estree';
export type CodeEditorLanguage = 'json' | 'html' | 'javaScript' | 'python';
export const languageFacet = Facet.define<CodeEditorLanguage, CodeEditorLanguage>({
combine: (values) => values[0] ?? 'javaScript',
});
export function formatDocument(view: EditorView) {
function format(parser: BuiltInParserName) {
void formatWithCursor(view.state.doc.toString(), {
cursorOffset: view.state.selection.main.anchor,
parser,
plugins: [babelPlugin, estreePlugin],
}).then(({ formatted, cursorOffset }) => {
view.dispatch({
changes: {
from: 0,
to: view.state.doc.length,
insert: formatted,
},
selection: EditorSelection.single(cursorOffset),
});
});
}
const langauge = view.state.facet(languageFacet);
switch (langauge) {
case 'javaScript':
format('babel');
break;
case 'html':
format('html');
break;
case 'json':
format('json');
break;
default:
return false;
}
return true;
}

View File

@@ -1,51 +1,83 @@
import {
acceptCompletion,
closeCompletion,
completionStatus,
deleteBracketPair,
moveCompletionSelection,
selectedCompletion,
startCompletion,
} from '@codemirror/autocomplete';
import { indentLess, indentMore, insertNewlineAndIndent, redo, undo } from '@codemirror/commands';
import type { EditorView, KeyBinding } from '@codemirror/view';
export const tabKeyMap = (blurOnTab = false): KeyBinding[] => [
{
any(view, event) {
if (
event.key === 'Tab' ||
(event.key === 'Escape' && completionStatus(view.state) !== null)
) {
event.stopPropagation();
}
return false;
},
},
{
key: 'Tab',
run: (view) => {
if (selectedCompletion(view.state)) {
return acceptCompletion(view);
}
if (!blurOnTab) return indentMore(view);
return false;
},
},
{ key: 'Shift-Tab', run: indentLess },
];
export const enterKeyMap: KeyBinding[] = [
{
key: 'Enter',
run: (view) => {
if (selectedCompletion(view.state)) {
return acceptCompletion(view);
}
return insertNewlineAndIndent(view);
},
},
];
import {
insertNewlineAndIndent,
cursorCharLeft,
selectCharLeft,
deleteLine,
moveLineDown,
moveLineUp,
copyLineDown,
copyLineUp,
selectLine,
cursorMatchingBracket,
indentMore,
indentLess,
cursorLineBoundaryBackward,
selectLineBoundaryBackward,
cursorDocStart,
selectDocStart,
cursorLineBoundaryForward,
selectLineBoundaryForward,
cursorDocEnd,
selectDocEnd,
cursorGroupLeft,
selectGroupLeft,
cursorPageDown,
cursorPageUp,
deleteCharBackward,
deleteCharForward,
deleteGroupBackward,
deleteGroupForward,
deleteToLineEnd,
deleteToLineStart,
selectAll,
selectPageDown,
selectPageUp,
cursorCharRight,
cursorGroupRight,
selectCharRight,
selectGroupRight,
cursorLineUp,
selectLineUp,
cursorLineDown,
selectLineDown,
cursorLineEnd,
cursorLineStart,
selectLineEnd,
selectLineStart,
splitLine,
transposeChars,
redo,
undo,
undoSelection,
toggleComment,
lineComment,
lineUncomment,
toggleBlockComment,
} from '@codemirror/commands';
import {
closeSearchPanel,
gotoLine,
openSearchPanel,
replaceAll,
selectMatches,
selectNextOccurrence,
selectSelectionMatches,
} from '@codemirror/search';
import { addCursorAtEachSelectionLine, addCursorDown, addCursorUp } from './multiCursor';
import { foldAll, foldCode, unfoldAll, unfoldCode } from '@codemirror/language';
import { nextDiagnostic, previousDiagnostic, openLintPanel } from '@codemirror/lint';
import { EditorSelection } from '@codemirror/state';
import { formatDocument } from './format';
const SELECTED_AUTOCOMPLETE_OPTION_SELECTOR = '.cm-tooltip-autocomplete li[aria-selected]';
const onAutocompleteNavigate = (dir: 'up' | 'down') => (view: EditorView) => {
@@ -59,7 +91,20 @@ const onAutocompleteNavigate = (dir: 'up' | 'down') => (view: EditorView) => {
return false;
};
export const autocompleteKeyMap: KeyBinding[] = [
// Keymap based on VSCode
export const editorKeymap: KeyBinding[] = [
{ key: 'Ctrl-Space', run: startCompletion },
{ key: 'Escape', run: closeCompletion },
{
key: 'Escape',
run: (view) => {
if (view.state.selection.ranges.length > 1) {
view.dispatch({ selection: EditorSelection.single(view.state.selection.main.head) });
return true;
}
return false;
},
},
{
key: 'ArrowDown',
run: onAutocompleteNavigate('down'),
@@ -68,9 +113,185 @@ export const autocompleteKeyMap: KeyBinding[] = [
key: 'ArrowUp',
run: onAutocompleteNavigate('up'),
},
];
{ key: 'PageDown', run: moveCompletionSelection(true, 'page') },
{ key: 'PageUp', run: moveCompletionSelection(false, 'page') },
{ key: 'Enter', run: acceptCompletion },
{ key: 'Tab', run: acceptCompletion },
export const historyKeyMap: KeyBinding[] = [
{ key: 'Mod-z', run: undo },
{ key: 'Mod-Shift-z', run: redo },
{ key: 'Mod-f', run: openSearchPanel, scope: 'editor search-panel' },
{ key: 'Escape', run: closeSearchPanel, scope: 'editor search-panel' },
{ key: 'Alt-Enter', run: selectMatches, scope: 'editor search-panel' },
{ key: 'Mod-Alt-Enter', run: replaceAll, scope: 'editor search-panel' },
{ key: 'Ctrl-g', run: gotoLine },
{ key: 'Mod-d', run: selectNextOccurrence, preventDefault: true },
{ key: 'Shift-Mod-l', run: selectSelectionMatches },
{ key: 'Enter', run: insertNewlineAndIndent, shift: insertNewlineAndIndent },
{
key: 'ArrowLeft',
run: cursorCharLeft,
shift: selectCharLeft,
preventDefault: true,
},
{
key: 'Mod-ArrowLeft',
mac: 'Alt-ArrowLeft',
run: cursorGroupLeft,
shift: selectGroupLeft,
},
{
key: 'ArrowRight',
run: cursorCharRight,
shift: selectCharRight,
preventDefault: true,
},
{
key: 'Mod-ArrowRight',
mac: 'Alt-ArrowRight',
run: cursorGroupRight,
shift: selectGroupRight,
},
{
key: 'ArrowUp',
run: cursorLineUp,
shift: selectLineUp,
preventDefault: true,
},
{
key: 'ArrowDown',
run: cursorLineDown,
shift: selectLineDown,
preventDefault: true,
},
{
key: 'Home',
run: cursorLineBoundaryBackward,
shift: selectLineBoundaryBackward,
},
{
mac: 'Cmd-ArrowLeft',
run: cursorLineBoundaryBackward,
shift: selectLineBoundaryBackward,
},
{ key: 'Mod-Home', run: cursorDocStart, shift: selectDocStart },
{ mac: 'Cmd-ArrowUp', run: cursorDocStart, shift: selectDocStart },
{ key: 'PageUp', run: cursorPageUp, shift: selectPageUp },
{ mac: 'Ctrl-ArrowUp', run: cursorPageUp, shift: selectPageUp },
{ key: 'PageDown', run: cursorPageDown, shift: selectPageDown },
{ mac: 'Ctrl-ArrowDown', run: cursorPageDown, shift: selectPageDown },
{
key: 'End',
run: cursorLineBoundaryForward,
shift: selectLineBoundaryForward,
},
{
mac: 'Cmd-ArrowRight',
run: cursorLineBoundaryForward,
shift: selectLineBoundaryForward,
},
{
key: 'Mod-Alt-ArrowUp',
linux: 'Shift-Alt-ArrowUp',
run: addCursorUp,
preventDefault: true,
},
{
key: 'Mod-Alt-ArrowDown',
linux: 'Shift-Alt-ArrowDown',
run: addCursorDown,
preventDefault: true,
},
{
key: 'Shift-Alt-i',
run: addCursorAtEachSelectionLine,
},
{ key: 'Mod-End', run: cursorDocEnd, shift: selectDocEnd },
{ mac: 'Cmd-ArrowDown', run: cursorDocEnd, shift: selectDocEnd },
{ key: 'Mod-a', run: selectAll },
{ key: 'Backspace', run: deleteBracketPair },
{ key: 'Backspace', run: deleteCharBackward, shift: deleteCharBackward },
{ key: 'Delete', run: deleteCharForward },
{ key: 'Mod-Backspace', mac: 'Alt-Backspace', run: deleteGroupBackward },
{ key: 'Mod-Delete', mac: 'Alt-Delete', run: deleteGroupForward },
{ mac: 'Mod-Backspace', run: deleteToLineStart },
{ mac: 'Mod-Delete', run: deleteToLineEnd },
{
mac: 'Ctrl-b',
run: cursorCharLeft,
shift: selectCharLeft,
preventDefault: true,
},
{ mac: 'Ctrl-f', run: cursorCharRight, shift: selectCharRight },
{ mac: 'Ctrl-p', run: cursorLineUp, shift: selectLineUp },
{ mac: 'Ctrl-n', run: cursorLineDown, shift: selectLineDown },
{ mac: 'Ctrl-a', run: cursorLineStart, shift: selectLineStart },
{ mac: 'Ctrl-e', run: cursorLineEnd, shift: selectLineEnd },
{ mac: 'Ctrl-d', run: deleteCharForward },
{ mac: 'Ctrl-h', run: deleteCharBackward },
{ mac: 'Ctrl-k', run: deleteToLineEnd },
{ mac: 'Ctrl-Alt-h', run: deleteGroupBackward },
{ mac: 'Ctrl-o', run: splitLine },
{ mac: 'Ctrl-t', run: transposeChars },
{ mac: 'Ctrl-v', run: cursorPageDown },
{ mac: 'Alt-v', run: cursorPageUp },
{ key: 'Shift-Mod-k', run: deleteLine },
{ key: 'Alt-ArrowDown', run: moveLineDown },
{ key: 'Alt-ArrowUp', run: moveLineUp },
{ win: 'Shift-Alt-ArrowDown', mac: 'Shift-Alt-ArrowDown', run: copyLineDown },
{ win: 'Shift-Alt-ArrowUp', mac: 'Shift-Alt-ArrowUp', run: copyLineUp },
{ key: 'Mod-l', run: selectLine, preventDefault: true },
{ key: 'Shift-Mod-\\', run: cursorMatchingBracket },
{
any(view, event) {
if (
event.key === 'Tab' ||
(event.key === 'Escape' && completionStatus(view.state) !== null)
) {
event.stopPropagation();
}
return false;
},
},
{ key: 'Tab', run: indentMore, shift: indentLess, preventDefault: true },
{ key: 'Mod-[', run: indentLess },
{ key: 'Mod-]', run: indentMore },
{ key: 'Ctrl-Shift-[', mac: 'Cmd-Alt-[', run: foldCode },
{ key: 'Ctrl-Shift-]', mac: 'Cmd-Alt-]', run: unfoldCode },
{ key: 'Mod-k Mod-0', run: foldAll },
{ key: 'Mod-k Mod-j', run: unfoldAll },
{ key: 'Mod-k Mod-c', run: lineComment },
{ key: 'Mod-k Mod-u', run: lineUncomment },
{ key: 'Mod-/', run: toggleComment },
{ key: 'Shift-Alt-a', run: toggleBlockComment },
{ key: 'Mod-z', run: undo, preventDefault: true },
{ key: 'Mod-y', run: redo, preventDefault: true },
{ key: 'Mod-Shift-z', run: redo, preventDefault: true },
{ key: 'Mod-u', run: undoSelection, preventDefault: true },
{ key: 'Mod-Shift-m', run: openLintPanel },
{ key: 'F8', run: nextDiagnostic },
{ key: 'Shift-F8', run: previousDiagnostic },
{ key: 'Shift-Alt-f', linux: 'Ctrl-Shift-i', run: formatDocument },
];

View File

@@ -0,0 +1,52 @@
import { EditorSelection } from '@codemirror/state';
import type { Command } from '@codemirror/view';
const createAddCursor =
(direction: 'up' | 'down'): Command =>
(view) => {
const forward = direction === 'down';
let selection = view.state.selection;
for (const r of selection.ranges) {
selection = selection.addRange(view.moveVertically(r, forward));
}
view.dispatch({ selection });
return true;
};
export const addCursorUp = createAddCursor('up');
export const addCursorDown = createAddCursor('down');
export const addCursorAtEachSelectionLine: Command = (view) => {
let selection: EditorSelection | null = null;
for (const r of view.state.selection.ranges) {
if (r.empty) {
continue;
}
for (let pos = r.from; pos <= r.to; ) {
const line = view.state.doc.lineAt(pos);
const anchor = Math.min(line.to, r.to);
if (selection) {
selection = selection.addRange(EditorSelection.range(anchor, anchor));
} else {
selection = EditorSelection.single(anchor);
}
pos = line.to + 1;
}
}
if (!selection) {
return false;
}
view.dispatch({ selection });
return true;
};

View File

@@ -0,0 +1,94 @@
import { escapeMappingString } from '@/utils/mappingUtils';
import {
insertCompletionText,
pickedCompletion,
type Completion,
type CompletionSource,
} from '@codemirror/autocomplete';
import {
autocompletableNodeNames,
longestCommonPrefix,
prefixMatch,
} from '../../completions/utils';
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(START_CHARACTERS_REGEX);
if (!word?.text) {
word = context.matchBefore(/[\"\'].*/);
}
if (!word?.text) {
word = context.matchBefore(/[\$\w]+/);
}
const blockComment = context.matchBefore(/\/\*?\*?/);
if (blockComment) {
// Autocomplete a block comment snippet
return { from: blockComment?.from, options: [blockCommentSnippet] };
}
if (!word) return null;
const completionResult = await worker.getCompletionsAtPos(context.pos);
if (!completionResult || context.aborted) return null;
const { result, isGlobal } = completionResult;
let options = [...result.options];
if (isGlobal) {
options = options
.flatMap((opt) => {
if (opt.label === '$') {
return [
opt,
...autocompletableNodeNames().map((name) => ({
...opt,
label: `$('${escapeMappingString(name)}')`,
})),
];
}
return opt;
})
.concat(snippets);
}
return {
from: word ? (START_CHARACTERS.includes(word.text) ? word.to : word.from) : context.pos,
filter: false,
getMatch(completion: Completion) {
const lcp = longestCommonPrefix(completion.label, word.text);
return [0, lcp.length];
},
options: options
.filter(
(option) =>
word.text === '' ||
START_CHARACTERS.includes(word.text) ||
prefixMatch(
option.label.replace(START_CHARACTERS_REGEX, ''),
word.text.replace(START_CHARACTERS_REGEX, ''),
),
)
.map((completion) => {
if (completion.label.endsWith('()')) {
completion.apply = (view, _, from, to) => {
const cursorPosition = from + completion.label.length - 1;
view.dispatch({
...insertCompletionText(view.state, completion.label, from, to),
annotations: pickedCompletion.of(completion),
selection: { anchor: cursorPosition, head: cursorPosition },
});
};
}
return completion;
}),
};
};

View File

@@ -0,0 +1,12 @@
import { Facet, combineConfig } from '@codemirror/state';
import type { LanguageServiceWorker } from '../types';
import type * as Comlink from 'comlink';
export const typescriptWorkerFacet = Facet.define<
{ worker: Comlink.Remote<LanguageServiceWorker> },
{ worker: Comlink.Remote<LanguageServiceWorker> }
>({
combine(configs) {
return combineConfig(configs, {});
},
});

View File

@@ -0,0 +1,54 @@
import type { hoverTooltip } from '@codemirror/view';
import { typescriptWorkerFacet } from './facet';
type HoverSource = Parameters<typeof hoverTooltip>[0];
export const typescriptHoverTooltips: HoverSource = async (view, pos) => {
const { worker } = view.state.facet(typescriptWorkerFacet);
const info = await worker.getHoverTooltip(pos);
if (!info) return null;
return {
pos: info.start,
end: info.end,
above: true,
create: () => {
const div = document.createElement('div');
div.classList.add('cm-tooltip-lint');
const wrapper = document.createElement('div');
wrapper.classList.add('cm-diagnostic');
div.appendChild(wrapper);
const text = document.createElement('div');
text.classList.add('cm-diagnosticText');
wrapper.appendChild(text);
if (info.quickInfo?.displayParts) {
for (const part of info.quickInfo.displayParts) {
const span = text.appendChild(document.createElement('span'));
if (
part.kind === 'keyword' &&
['string', 'number', 'boolean', 'object'].includes(part.text)
) {
span.className = 'ts-primitive';
} else if (part.kind === 'punctuation' && ['(', ')'].includes(part.text)) {
span.className = 'ts-text';
} else {
span.className = `ts-${part.kind}`;
}
span.innerText = part.text;
}
}
const documentation = info.quickInfo?.documentation?.find((doc) => doc.kind === 'text')?.text;
if (documentation) {
const docElement = document.createElement('div');
docElement.classList.add('cm-diagnosticDocs');
docElement.textContent = documentation;
wrapper.appendChild(docElement);
}
return { dom: div };
},
};
};

View File

@@ -0,0 +1,11 @@
import type { LintSource } from '@codemirror/lint';
import { typescriptWorkerFacet } from './facet';
export const typescriptLintSource: LintSource = async (view) => {
const { worker } = view.state.facet(typescriptWorkerFacet);
const docLength = view.state.doc.length;
return (await worker.getDiagnostics()).filter((diag) => {
return diag.from < docLength && diag.to <= docLength && diag.from >= 0;
});
};

View File

@@ -0,0 +1,60 @@
import { snippetCompletion } from '@codemirror/autocomplete';
export const blockCommentSnippet = snippetCompletion('/**\n * #{}\n */', {
label: '/**',
detail: 'Block Comment',
});
export const snippets = [
snippetCompletion('console.log(#{})', { label: 'log', detail: 'Log to console' }),
snippetCompletion('for (const #{1:element} of #{2:array}) {\n\t#{}\n}', {
label: 'forof',
detail: 'For-of Loop',
}),
snippetCompletion(
'for (const #{1:key} in #{2:object}) {\n\tif (Object.prototype.hasOwnProperty.call(#{2:object}, #{1:key})) {\n\t\tconst #{3:element} = #{2:object}[#{1:key}];\n\t\t#{}\n\t}\n}',
{
label: 'forin',
detail: 'For-in Loop',
},
),
snippetCompletion(
'for (let #{1:index} = 0; #{1:index} < #{2:array}.length; #{1:index}++) {\n\tconst #{3:element} = #{2:array}[#{1:index}];\n\t#{}\n}',
{
label: 'for',
detail: 'For Loop',
},
),
snippetCompletion('if (#{1:condition}) {\n\t#{}\n}', {
label: 'if',
detail: 'If Statement',
}),
snippetCompletion('if (#{1:condition}) {\n\t#{}\n} else {\n\t\n}', {
label: 'ifelse',
detail: 'If-Else Statement',
}),
snippetCompletion('function #{1:name}(#{2:params}) {\n\t#{}\n}', {
label: 'function',
detail: 'Function Statement',
}),
snippetCompletion('function #{1:name}(#{2:params}) {\n\t#{}\n}', {
label: 'fn',
detail: 'Function Statement',
}),
snippetCompletion(
'switch (#{1:key}) {\n\tcase #{2:value}:\n\t\t#{}\n\t\tbreak;\n\tdefault:\n\t\tbreak;\n}',
{
label: 'switch',
detail: 'Switch Statement',
},
),
snippetCompletion('try {\n\t#{}\n} catch (#{1:error}) {\n\t\n}', {
label: 'trycatch',
detail: 'Try-Catch Statement',
}),
snippetCompletion('while (#{1:condition}) {\n\t#{}\n}', {
label: 'while',
detail: 'While Statement',
}),
blockCommentSnippet,
];

View File

@@ -0,0 +1,131 @@
import { useDataSchema } from '@/composables/useDataSchema';
import { useDebounce } from '@/composables/useDebounce';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { autocompletableNodeNames } from '@/plugins/codemirror/completions/utils';
import useEnvironmentsStore from '@/stores/environments.ee.store';
import { useNDVStore } from '@/stores/ndv.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { forceParse } from '@/utils/forceParse';
import { executionDataToJson } from '@/utils/nodeTypesUtils';
import { autocompletion } from '@codemirror/autocomplete';
import { javascriptLanguage } from '@codemirror/lang-javascript';
import { LanguageSupport } from '@codemirror/language';
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 { 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 function useTypescript(
view: MaybeRefOrGetter<EditorView | undefined>,
mode: MaybeRefOrGetter<CodeExecutionMode>,
id: MaybeRefOrGetter<string>,
) {
const { getInputDataWithPinned, getSchemaForExecutionData } = useDataSchema();
const ndvStore = useNDVStore();
const workflowsStore = useWorkflowsStore();
const { debounce } = useDebounce();
const activeNodeName = ndvStore.activeNodeName;
const worker = ref<Comlink.Remote<LanguageServiceWorker>>();
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),
},
Comlink.proxy(async (nodeName) => {
const node = workflowsStore.getNodeByName(nodeName);
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 editor = toValue(view);
if (editor) {
forceParse(editor);
}
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());
}
}),
];
}
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 }),
);
watch(toRef(mode), async (newMode) => {
const editor = toValue(view);
if (!editor || !worker.value) return;
await worker.value.updateMode(newMode);
forceParse(editor);
});
return {
createWorker,
};
}

View File

@@ -0,0 +1,49 @@
import type { Schema } from '@/Interface';
import type { CompletionResult } from '@codemirror/autocomplete';
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;
end: number;
typeDef?: readonly ts.DefinitionInfo[];
quickInfo: ts.QuickInfo | undefined;
}
export type WorkerInitOptions = {
id: string;
content: string[];
allNodeNames: string[];
inputNodeNames: string[];
variables: string[];
mode: CodeExecutionMode;
};
export type NodeData = { json: Schema | undefined; binary: string[]; params: Schema };
export type NodeDataFetcher = (nodeName: string) => Promise<NodeData | undefined>;
export type LanguageServiceWorker = {
updateFile(changes: ChangeSet): void;
updateMode(mode: CodeExecutionMode): void;
updateNodeTypes(): void;
getCompletionsAtPos(pos: number): Promise<{ result: CompletionResult; isGlobal: boolean } | null>;
getDiagnostics(): Diagnostic[];
getHoverTooltip(pos: number): HoverInfo | null;
};
export type LanguageServiceWorkerInit = {
init(
options: WorkerInitOptions,
nodeDataFetcher: NodeDataFetcher,
): Promise<LanguageServiceWorker>;
};
export type RemoteLanguageServiceWorkerInit = {
init(
options: WorkerInitOptions,
nodeDataFetcher: NodeDataFetcher,
): Comlink.Remote<LanguageServiceWorker>;
};

View File

@@ -0,0 +1,130 @@
export type IndexedDbCache = Awaited<ReturnType<typeof indexedDbCache>>;
export async function indexedDbCache(dbName: string, storeName: string) {
let cache: Record<string, string> = {};
void (await loadCache());
async function loadCache() {
await transaction('readonly', async (store, db) => {
return await new Promise<void>((resolve, reject) => {
const request = store.openCursor();
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result;
if (cursor) {
cache[cursor.key as string] = cursor.value.value;
cursor.continue();
} else {
db.close();
resolve();
}
};
request.onerror = (event) => {
db.close();
reject(event);
};
});
});
}
async function openDb(): Promise<IDBDatabase> {
return await new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, 1);
request.onupgradeneeded = () => {
request.result.createObjectStore(storeName, { keyPath: 'key' });
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function setItem(key: string, value: string): void {
cache[key] = value;
void persistToIndexedDB(key, value);
}
function getItem(key: string): string | null {
return cache[key] ?? null;
}
function removeItem(key: string): void {
delete cache[key];
void deleteFromIndexedDB(key);
}
function clear(): void {
cache = {};
void clearIndexedDB();
}
async function getAllWithPrefix(prefix: string) {
const keyRange = IDBKeyRange.bound(prefix, prefix + '\uffff', false, false);
const results: Record<string, string> = {};
return await transaction('readonly', async (store) => {
return await new Promise<Record<string, string>>((resolve, reject) => {
const request = store.openCursor(keyRange);
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result;
if (cursor) {
results[cursor.key as string] = cursor.value.value;
cursor.continue();
} else {
resolve(results);
}
};
request.onerror = () => {
reject(request.error);
};
});
});
}
async function transaction<T>(
mode: 'readonly' | 'readwrite',
action: (store: IDBObjectStore, db: IDBDatabase) => Promise<T> | T,
): Promise<T> {
const db = await openDb();
const tx = db.transaction(storeName, mode);
const store = tx.objectStore(storeName);
const result = await action(store, db);
return await new Promise<T>((resolve, reject) => {
tx.oncomplete = () => {
db.close();
resolve(result);
};
tx.onerror = () => {
db.close();
reject(tx.error);
};
});
}
async function persistToIndexedDB(key: string, value: string) {
await transaction('readwrite', (store) => {
store.put({ key, value });
});
}
async function deleteFromIndexedDB(key: string) {
await transaction('readwrite', (store) => {
store.delete(key);
});
}
async function clearIndexedDB() {
await transaction('readwrite', (store) => {
store.clear();
});
}
return { getItem, removeItem, setItem, clear, getAllWithPrefix };
}

View File

@@ -0,0 +1,60 @@
import type * as tsvfs from '@typescript/vfs';
import type ts from 'typescript';
import { type Completion } from '@codemirror/autocomplete';
import { TS_COMPLETE_BLOCKLIST, TYPESCRIPT_AUTOCOMPLETE_THRESHOLD } from './constants';
function convertTsKindtoEditorCompletionType(kind: ts.ScriptElementKind) {
if (!kind) return undefined;
const type = String(kind);
if (type === 'member') return 'property';
return type;
}
function typescriptCompletionToEditor(
completionInfo: ts.WithMetadata<ts.CompletionInfo>,
entry: ts.CompletionEntry,
): Completion {
const boost = -Number(entry.sortText) || 0;
const type = convertTsKindtoEditorCompletionType(entry.kind);
return {
label: type && ['method', 'function'].includes(type) ? entry.name + '()' : entry.name,
type: convertTsKindtoEditorCompletionType(entry.kind),
commitCharacters: entry.commitCharacters ?? completionInfo.defaultCommitCharacters,
detail: entry.labelDetails?.detail,
boost,
};
}
function filterTypescriptCompletions(
completionInfo: ts.WithMetadata<ts.CompletionInfo>,
entry: ts.CompletionEntry,
) {
return (
!TS_COMPLETE_BLOCKLIST.includes(entry.kind) &&
(entry.sortText < TYPESCRIPT_AUTOCOMPLETE_THRESHOLD ||
completionInfo.optionalReplacementSpan?.length)
);
}
export async function getCompletionsAtPos({
pos,
fileName,
env,
}: { pos: number; fileName: string; env: tsvfs.VirtualTypeScriptEnvironment }) {
const completionInfo = env.languageService.getCompletionsAtPosition(fileName, pos, {}, {});
if (!completionInfo) return null;
const options = completionInfo.entries
.filter((entry) => filterTypescriptCompletions(completionInfo, entry))
.map((entry) => typescriptCompletionToEditor(completionInfo, entry));
return {
result: { from: pos, options },
isGlobal: completionInfo.isGlobalCompletion,
};
}

View File

@@ -0,0 +1,26 @@
import ts from 'typescript';
export const TS_COMPLETE_BLOCKLIST: ts.ScriptElementKind[] = [ts.ScriptElementKind.warning];
export const COMPILER_OPTIONS: ts.CompilerOptions = {
allowJs: true,
checkJs: true,
target: ts.ScriptTarget.ESNext,
lib: ['es2023'],
module: ts.ModuleKind.ESNext,
strict: true,
noUnusedLocals: true,
noUnusedParameters: true,
importHelpers: false,
skipDefaultLibCheck: true,
noEmit: true,
};
export const TYPESCRIPT_AUTOCOMPLETE_THRESHOLD = '15';
export const TYPESCRIPT_FILES = {
DYNAMIC_TYPES: 'n8n-dynamic.d.ts',
DYNAMIC_INPUT_TYPES: 'n8n-dynamic-input.d.ts',
DYNAMIC_VARIABLES_TYPES: 'n8n-variables.d.ts',
MODE_TYPES: 'n8n-mode-specific.d.ts',
N8N_TYPES: 'n8n.d.ts',
GLOBAL_TYPES: 'globals.d.ts',
};
export const LUXON_VERSION = '3.2.0';

View File

@@ -0,0 +1,66 @@
import { schemaToTypescriptTypes } from './dynamicTypes';
describe('typescript worker dynamicTypes', () => {
describe('schemaToTypescriptTypes', () => {
it('should convert a schema to a typescript type', () => {
expect(
schemaToTypescriptTypes(
{
type: 'object',
value: [
{
key: 'test',
type: 'string',
value: '',
path: '.test',
},
{
type: 'object',
key: 'nested',
path: '.nested',
value: [
{
key: 'amount',
type: 'number',
value: '',
path: '.amount',
},
],
},
{
type: 'array',
key: 'nestedArray',
path: '.nestedArray',
value: [
{
type: 'object',
key: 'nested',
path: '.nestedArray.nested',
value: [
{
key: 'amount',
type: 'number',
value: '',
path: '.amount',
},
],
},
],
},
],
path: '',
},
'NodeName_1',
),
).toEqual(`interface NodeName_1 {
test: string;
nested: {
amount: number;
};
nestedArray: Array<{
amount: number;
}>;
}`);
});
});
});

View File

@@ -0,0 +1,91 @@
import type { Schema } from '@/Interface';
import { pascalCase } from 'change-case';
import { globalTypeDefinition } from './utils';
function processSchema(schema: Schema): string {
switch (schema.type) {
case 'string':
case 'number':
case 'boolean':
case 'bigint':
case 'symbol':
case 'null':
case 'undefined':
return schema.type;
case 'function':
return 'Function';
case 'array':
if (Array.isArray(schema.value)) {
if (schema.value.length > 0) {
return `Array<${processSchema(schema.value[0])}>`;
}
return 'any[]';
}
return `${schema.value}[]`;
case 'object':
if (!Array.isArray(schema.value)) {
return '{}';
}
const properties = schema.value
.map((prop) => {
const key = prop.key ?? 'unknown';
const type = processSchema(prop);
return ` ${key}: ${type};`;
})
.join('\n');
return `{\n${properties}\n}`;
default:
return 'any';
}
}
export function schemaToTypescriptTypes(schema: Schema, interfaceName: string): string {
return `interface ${interfaceName} ${processSchema(schema)}`;
}
export async function getDynamicNodeTypes({
nodeNames,
loadedNodes,
}: { nodeNames: string[]; loadedNodes: Map<string, { type: string; typeName: string }> }) {
return globalTypeDefinition(`
type NodeName = ${nodeNames.map((name) => `'${name}'`).join(' | ')};
${Array.from(loadedNodes.values())
.map(({ type }) => type)
.join(';\n')}
interface NodeDataMap {
${Array.from(loadedNodes.entries())
.map(
([nodeName, { typeName }]) =>
`'${nodeName}': NodeData<${typeName}Context, ${typeName}Json, ${typeName}BinaryKeys, ${typeName}Params>`,
)
.join(';\n')}
}
`);
}
export async function getDynamicInputNodeTypes(inputNodeNames: string[]) {
const typeNames = inputNodeNames.map((nodeName) => pascalCase(nodeName));
return globalTypeDefinition(`
type N8nInputJson = ${typeNames.map((typeName) => `${typeName}Json`).join(' | ')};
type N8nInputBinaryKeys = ${typeNames.map((typeName) => `${typeName}BinaryKeys`).join(' | ')};
type N8nInputContext = ${typeNames.map((typeName) => `${typeName}Context`).join(' | ')};
type N8nInputParams = ${typeNames.map((typeName) => `${typeName}Params`).join(' | ')};
`);
}
export async function getDynamicVariableTypes(variables: string[]) {
return globalTypeDefinition(`
interface N8nVars {
${variables.map((key) => `${key}: string;`).join('\n')}
}`);
}

View File

@@ -0,0 +1,60 @@
import * as tsvfs from '@typescript/vfs';
import { COMPILER_OPTIONS, TYPESCRIPT_FILES } from './constants';
import ts from 'typescript';
import type { IndexedDbCache } from './cache';
import globalTypes from './type-declarations/globals.d.ts?raw';
import n8nTypes from './type-declarations/n8n.d.ts?raw';
import type { CodeExecutionMode } from 'n8n-workflow';
import { wrapInFunction } from './utils';
type EnvOptions = {
code: {
content: string;
fileName: string;
};
mode: CodeExecutionMode;
cache: IndexedDbCache;
};
export function removeUnusedLibs(fsMap: Map<string, string>) {
for (const [name] of fsMap.entries()) {
if (
name === 'lib.d.ts' ||
name.startsWith('/lib.dom') ||
name.startsWith('/lib.webworker') ||
name.startsWith('/lib.scripthost') ||
name.endsWith('.full.d.ts')
) {
fsMap.delete(name);
}
}
}
export async function setupTypescriptEnv({ cache, code, mode }: EnvOptions) {
const fsMap = await tsvfs.createDefaultMapFromCDN(
COMPILER_OPTIONS,
ts.version,
true,
ts,
undefined,
undefined,
cache,
);
removeUnusedLibs(fsMap);
fsMap.set(TYPESCRIPT_FILES.N8N_TYPES, n8nTypes);
fsMap.set(TYPESCRIPT_FILES.GLOBAL_TYPES, globalTypes);
fsMap.set(code.fileName, wrapInFunction(code.content, mode));
const system = tsvfs.createSystem(fsMap);
return tsvfs.createVirtualTypeScriptEnvironment(
system,
Array.from(fsMap.keys()),
ts,
COMPILER_OPTIONS,
);
}

View File

@@ -0,0 +1,24 @@
import type * as tsvfs from '@typescript/vfs';
export function getHoverTooltip({
pos,
fileName,
env,
}: { pos: number; fileName: string; env: tsvfs.VirtualTypeScriptEnvironment }) {
const quickInfo = env.languageService.getQuickInfoAtPosition(fileName, pos);
if (!quickInfo) return null;
const start = quickInfo.textSpan.start;
const typeDef =
env.languageService.getTypeDefinitionAtPosition(fileName, pos) ??
env.languageService.getDefinitionAtPosition(fileName, pos);
return {
start,
end: start + quickInfo.textSpan.length,
typeDef,
quickInfo,
};
}

View File

@@ -0,0 +1,111 @@
import type { Diagnostic } from '@codemirror/lint';
import type * as tsvfs from '@typescript/vfs';
import ts from 'typescript';
import type { DiagnosticWithLocation } from 'typescript';
/**
* TypeScript has a set of diagnostic categories,
* which maps roughly onto CodeMirror's categories.
* Here, we do the mapping.
*/
export function tsCategoryToSeverity(
diagnostic: Pick<ts.DiagnosticWithLocation, 'category' | 'code'>,
): Diagnostic['severity'] {
switch (diagnostic.code) {
case 6133:
// No unused variables
return 'warning';
case 7027:
// Unreachable code detected
return 'warning';
default: {
switch (diagnostic.category) {
case ts.DiagnosticCategory.Error:
return 'error';
case ts.DiagnosticCategory.Message:
return 'info';
case ts.DiagnosticCategory.Warning:
return 'warning';
case ts.DiagnosticCategory.Suggestion:
return 'info';
}
}
}
}
/**
* Not all TypeScript diagnostic relate to specific
* ranges in source code: here we filter for those that
* do.
*/
function isDiagnosticWithLocation(
diagnostic: ts.Diagnostic,
): diagnostic is ts.DiagnosticWithLocation {
return !!(
diagnostic.file &&
typeof diagnostic.start === 'number' &&
typeof diagnostic.length === 'number'
);
}
function isIgnoredDiagnostic(diagnostic: ts.Diagnostic) {
// No implicit any
return diagnostic.code === 7006;
}
/**
* Get the message for a diagnostic. TypeScript
* is kind of weird: messageText might have the message,
* or a pointer to the message. This follows the chain
* to get a string, regardless of which case we're in.
*/
function tsDiagnosticMessage(diagnostic: Pick<ts.Diagnostic, 'messageText'>): string {
if (typeof diagnostic.messageText === 'string') {
return diagnostic.messageText;
}
// TODO: go through linked list
return diagnostic.messageText.messageText;
}
function tsDiagnosticClassName(diagnostic: ts.Diagnostic) {
switch (diagnostic.code) {
case 6133:
// No unused variables
return 'cm-faded';
default:
return undefined;
}
}
function convertTSDiagnosticToCM(d: ts.DiagnosticWithLocation): Diagnostic {
const start = d.start;
const message = tsDiagnosticMessage(d);
return {
from: start,
to: start + d.length,
message,
markClass: tsDiagnosticClassName(d),
severity: tsCategoryToSeverity(d),
};
}
export function getDiagnostics({
env,
fileName,
}: { env: tsvfs.VirtualTypeScriptEnvironment; fileName: string }) {
const exists = env.getSourceFile(fileName);
if (!exists) return [];
const tsDiagnostics = [
...env.languageService.getSemanticDiagnostics(fileName),
...env.languageService.getSyntacticDiagnostics(fileName),
];
const diagnostics = tsDiagnostics.filter(
(diagnostic): diagnostic is DiagnosticWithLocation =>
isDiagnosticWithLocation(diagnostic) && !isIgnoredDiagnostic(diagnostic),
);
return diagnostics.map((d) => convertTSDiagnosticToCM(d));
}

View File

@@ -0,0 +1,60 @@
type NPMTreeMeta = {
default: string;
files: Array<{ name: string }>;
moduleName: string;
version: string;
};
const jsDelivrApi = {
async getFileTree(packageName: string, version = 'latest'): Promise<NPMTreeMeta> {
const url = `https://data.jsdelivr.com/v1/package/npm/${packageName}@${version}/flat`;
const res = await fetch(url);
return await res.json();
},
async getFileContent(packageName: string, fileName: string, version = 'latest'): Promise<string> {
const url = `https://cdn.jsdelivr.net/npm/${packageName}@${version}${fileName}`;
const res = await fetch(url);
return await res.text();
},
};
function isRequiredTypePackageFile(fileName: string) {
return fileName.endsWith('.d.ts') || fileName === '/package.json';
}
function toLocalFilePath(packageName: string, fileName: string) {
return `/node_modules/@types/${packageName}${fileName}`;
}
export const loadTypes = async (
packageName: string,
version: string,
onFileReceived: (path: string, content: string) => void,
): Promise<void> => {
const { files } = await loadTypesFileTree(packageName, version);
await Promise.all(
files
.filter((file) => isRequiredTypePackageFile(file.name))
.map(
async (file) =>
await loadFileContent(packageName, file.name, version).then((content) =>
onFileReceived(toLocalFilePath(packageName, file.name), content),
),
),
);
};
export const loadTypesFileTree = async (
packageName: string,
version: string,
): Promise<NPMTreeMeta> => {
return await jsDelivrApi.getFileTree(`@types/${packageName}`, version);
};
export const loadFileContent = async (
packageName: string,
fileName: string,
version = 'latest',
) => {
return await jsDelivrApi.getFileContent(`@types/${packageName}`, fileName, version);
};

View File

@@ -0,0 +1,16 @@
export {};
import luxon from 'luxon';
declare global {
const DateTime: typeof luxon.DateTime;
type DateTime = luxon.DateTime;
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console) */
interface Console {
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static) */
log(...data: any[]): void;
}
var console: Console;
}

View File

@@ -0,0 +1,15 @@
export {};
declare global {
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>>;
first(branchIndex?: number, runIndex?: number): N8nItem<J, B>;
last(branchIndex?: number, runIndex?: number): N8nItem<J, B>;
itemMatching(itemIndex: number): N8nItem<J, B>;
}
// @ts-expect-error N8nInputJson is populated dynamically
type N8nInput = NodeData<N8nInputContext, N8nInputJson, N8nInputBinaryKeys, N8nInputParams>;
}

View File

@@ -0,0 +1,16 @@
export {};
declare global {
interface NodeData<C, J extends N8nJson, B extends string, P> {
context: C;
item: N8nItem<J, B>;
params: P;
}
// @ts-expect-error N8nInputJson is populated dynamically
type N8nInput = NodeData<{}, N8nInputJson, {}, {}>;
const $itemIndex: number;
const $json: N8nInput['item']['json'];
const $binary: N8nInput['item']['binary'];
}

View File

@@ -0,0 +1,103 @@
import type { DateTime } from 'luxon';
export {};
declare global {
type OutputItemWithoutJsonKey = {
[key: string]: unknown;
} & { json?: never };
type OutputItemWithJsonKey = {
json: {
[key: string]: unknown;
};
};
type MaybePromise<T> = Promise<T> | T;
type OneOutputItem = OutputItemWithJsonKey | OutputItemWithoutJsonKey;
type AllOutputItems = OneOutputItem | Array<OneOutputItem>;
type N8nOutputItem = MaybePromise<OneOutputItem>;
type N8nOutputItems = MaybePromise<AllOutputItems>;
interface N8nJson {
[key: string]: any;
}
interface N8nBinary {
id: string;
fileName: string;
fileExtension: string;
fileType: string;
fileSize: string;
mimeType: string;
}
interface N8nVars {}
// TODO: populate dynamically
interface N8nParameter {}
interface N8nItem<J extends N8nJson = N8nJson, B extends string = string> {
json: J & N8nJson;
binary: Record<B, N8nBinary>;
}
interface N8nCustomData {
set(key: string, value: string): void;
get(key: string): string;
getAll(): Record<string, string>;
setAll(values: Record<string, string>): void;
}
type N8nExecutionMode = 'test' | 'production';
interface N8nExecution {
id: string;
mode: N8nExecutionMode;
resumeUrl?: string;
resumeFormUrl?: string;
customData: N8nCustomData;
}
interface N8nWorkflow {
id: string;
active: boolean;
name: string;
}
interface N8nPrevNode {
name: string;
outputIndex: number;
runIndex: number;
}
const $input: N8nInput;
const $execution: N8nExecution;
const $workflow: N8nWorkflow;
const $prevNode: N8nPrevNode;
const $runIndex: number;
const $now: DateTime;
const $today: DateTime;
const $parameter: N8nInput['params'];
const $vars: N8nVars;
const $nodeVersion: number;
function $jmespath(object: Object | Array<any>, expression: string): any;
function $if<B extends boolean, T, F>(
condition: B,
valueIfTrue: T,
valueIfFalse: F,
): B extends true ? T : T extends false ? F : T | F;
function $ifEmpty<V, E>(value: V, valueIfEmpty: E): V | E;
function $min(...numbers: number[]): number;
function $max(...numbers: number[]): number;
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;
}

View File

@@ -0,0 +1,224 @@
import * as Comlink from 'comlink';
import type { LanguageServiceWorker, LanguageServiceWorkerInit } from '../types';
import { indexedDbCache } from './cache';
import { bufferChangeSets, fnPrefix } from './utils';
import type { CodeExecutionMode } from 'n8n-workflow';
import { pascalCase } from 'change-case';
import { computed, reactive, ref, watch } from 'vue';
import { getCompletionsAtPos } from './completions';
import { LUXON_VERSION, TYPESCRIPT_FILES } from './constants';
import {
getDynamicInputNodeTypes,
getDynamicNodeTypes,
getDynamicVariableTypes,
schemaToTypescriptTypes,
} from './dynamicTypes';
import { setupTypescriptEnv } from './env';
import { getHoverTooltip } from './hoverTooltip';
import { getDiagnostics } from './linter';
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;
const worker: LanguageServiceWorkerInit = {
async init(options, nodeDataFetcher) {
const loadedNodeTypesMap: Map<string, { type: string; typeName: string }> = reactive(new Map());
const inputNodeNames = options.inputNodeNames;
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: Text.of(options.content).toString(), fileName: codeFileName },
});
const prefix = computed(() => fnPrefix(mode.value));
function editorPositionToTypescript(pos: number) {
return pos + prefix.value.length;
}
function typescriptPositionToEditor(pos: number) {
return pos - prefix.value.length;
}
async function loadNodeTypes(nodeName: string) {
const data = await nodeDataFetcher(nodeName);
const typeName = pascalCase(nodeName);
const jsonType = data?.json
? schemaToTypescriptTypes(data.json, `${typeName}Json`)
: `type ${typeName}Json = N8nJson`;
const paramsType = data?.params
? schemaToTypescriptTypes(data.params, `${typeName}Params`)
: `type ${typeName}Params = {}`;
// Using || on purpose to handle empty string
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const binaryType = `type ${typeName}BinaryKeys = ${data?.binary.map((key) => `'${key}'`).join(' | ') || 'string'}`;
const contextType = `type ${typeName}Context = {}`;
const type = [jsonType, binaryType, paramsType, contextType].join('\n');
loadedNodeTypesMap.set(nodeName, { type, typeName });
}
async function loadTypesIfNeeded() {
const file = env.getSourceFile(codeFileName);
if (!file) return;
const nodeNames = await getUsedNodeNames(file);
for (const nodeName of nodeNames) {
if (!loadedNodeTypesMap.has(nodeName)) {
await loadNodeTypes(nodeName);
}
}
}
async function loadLuxonTypes() {
if (cache.getItem('/node_modules/@types/luxon/package.json')) {
const fileMap = await cache.getAllWithPrefix('/node_modules/@types/luxon');
for (const [path, content] of Object.entries(fileMap)) {
updateFile(path, content);
}
} else {
await loadTypes('luxon', LUXON_VERSION, (path, types) => {
cache.setItem(path, types);
updateFile(path, types);
});
}
}
async function setVariableTypes() {
updateFile(
TYPESCRIPT_FILES.DYNAMIC_VARIABLES_TYPES,
await getDynamicVariableTypes(options.variables),
);
}
function updateFile(fileName: string, content: string) {
const exists = env.getSourceFile(fileName);
if (exists) {
env.updateFile(fileName, content);
} else {
env.createFile(fileName, content);
}
}
const loadInputNodes = options.inputNodeNames.map(
async (nodeName) => await loadNodeTypes(nodeName),
);
await Promise.all(
loadInputNodes.concat(loadTypesIfNeeded(), loadLuxonTypes(), setVariableTypes()),
);
watch(
loadedNodeTypesMap,
async (loadedNodes) => {
updateFile(
TYPESCRIPT_FILES.DYNAMIC_INPUT_TYPES,
await getDynamicInputNodeTypes(inputNodeNames),
);
updateFile(
TYPESCRIPT_FILES.DYNAMIC_TYPES,
await getDynamicNodeTypes({ nodeNames: allNodeNames, loadedNodes }),
);
},
{ immediate: true },
);
watch(
mode,
(newMode) => {
updateFile(
TYPESCRIPT_FILES.MODE_TYPES,
newMode === 'runOnceForAllItems' ? runOnceForAllItemsTypes : runOnceForEachItemTypes,
);
},
{ 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 (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,
env,
});
},
getDiagnostics() {
return getDiagnostics({ env, fileName: codeFileName }).map((diagnostic) => ({
...diagnostic,
from: typescriptPositionToEditor(diagnostic.from),
to: typescriptPositionToEditor(diagnostic.to),
}));
},
getHoverTooltip(pos) {
const tooltip = getHoverTooltip({
pos: editorPositionToTypescript(pos),
fileName: codeFileName,
env,
});
if (!tooltip) return null;
tooltip.start = typescriptPositionToEditor(tooltip.start);
tooltip.end = typescriptPositionToEditor(tooltip.end);
return tooltip;
},
async updateMode(newMode) {
mode.value = newMode;
},
async updateNodeTypes() {
const loadedNodeNames = Array.from(loadedNodeTypesMap.keys());
await Promise.all(loadedNodeNames.map(async (nodeName) => await loadNodeTypes(nodeName)));
},
});
},
};
Comlink.expose(worker);

View File

@@ -0,0 +1,38 @@
import ts from 'typescript';
function findNodes(node: ts.Node, check: (node: ts.Node) => boolean): ts.Node[] {
const result: ts.Node[] = [];
// If the current node matches the condition, add it to the result
if (check(node)) {
result.push(node);
}
// Recursively check all child nodes
node.forEachChild((child) => {
result.push(...findNodes(child, check));
});
return result;
}
/**
* Get nodes mentioned in the code
* Check if code includes calls to $('Node A')
*/
export async function getUsedNodeNames(file: ts.SourceFile) {
const callExpressions = findNodes(
file,
(n) =>
n.kind === ts.SyntaxKind.CallExpression &&
(n as ts.CallExpression).expression.getText() === '$',
);
if (callExpressions.length === 0) return [];
const nodeNames = (callExpressions as ts.CallExpression[])
.map((e) => (e.arguments.at(0) as ts.StringLiteral)?.text)
.filter(Boolean);
return nodeNames;
}

View File

@@ -0,0 +1,64 @@
import { ChangeSet } from '@codemirror/state';
import { type CodeExecutionMode } from 'n8n-workflow';
export const fnPrefix = (mode: CodeExecutionMode) => `(
/**
* @returns {${returnTypeForMode(mode)}}
*/
() => {\n`;
export function wrapInFunction(script: string, mode: CodeExecutionMode): string {
return `${fnPrefix(mode)}${script}\n})()`;
}
export function globalTypeDefinition(types: string) {
return `export {};
declare global {
${types}
}`;
}
export function returnTypeForMode(mode: CodeExecutionMode): string {
return mode === 'runOnceForAllItems' ? 'N8nOutputItems' : 'N8nOutputItem';
}
const MAX_CHANGE_BUFFER_CHAR_SIZE = 10_000_000;
const MIN_CHANGE_BUFFER_WINDOW_MS = 50;
const MAX_CHANGE_BUFFER_WINDOW_MS = 500;
// Longer buffer window for large code
function calculateBufferWindowMs(docSize: number, minDelay: number, maxDelay: number): number {
const clampedSize = Math.min(docSize, MAX_CHANGE_BUFFER_CHAR_SIZE);
const normalizedSize = clampedSize / MAX_CHANGE_BUFFER_CHAR_SIZE;
return Math.ceil(minDelay + (maxDelay - minDelay) * normalizedSize);
}
// Create a buffer function to accumulate and compose changesets
export function bufferChangeSets(fn: (changeset: ChangeSet) => void) {
let changeSet = ChangeSet.empty(0);
let timeoutId: NodeJS.Timeout | null = null;
return async (changes: ChangeSet) => {
changeSet = changeSet.compose(changes);
if (timeoutId) {
clearTimeout(timeoutId);
}
return await new Promise<void>((resolve) => {
timeoutId = setTimeout(
() => {
fn(changeSet);
resolve();
changeSet = ChangeSet.empty(0);
},
calculateBufferWindowMs(
changeSet.length,
MIN_CHANGE_BUFFER_WINDOW_MS,
MAX_CHANGE_BUFFER_WINDOW_MS,
),
);
});
};
}

View File

@@ -16,6 +16,11 @@ declare module '*.jpeg';
declare module '*.gif';
declare module '*.webp';
declare module '*?raw' {
const content: string;
export default content;
}
declare module 'v3-infinite-loading' {
import { Plugin, DefineComponent } from 'vue';

View File

@@ -1,3 +1,5 @@
/// <reference types="vite-plugin-comlink/client" />
import type { VNode, ComponentPublicInstance } from 'vue';
import type { PartialDeep } from 'type-fest';
import type { ExternalHooks } from '@/types/externalHooks';

View File

@@ -1,11 +1,3 @@
.code-node-editor .cm-tooltip-autocomplete:after {
padding: var(--spacing-2xs) var(--spacing-s);
border: var(--border-base);
border-bottom-left-radius: var(--border-radius-base);
display: block;
content: 'n8n supports all JavaScript functions, including those not listed.';
}
.code-node-editor .cm-editor .cm-tooltip-autocomplete > ul[role='listbox'] {
border-bottom: none;
border-bottom-left-radius: 0;
@@ -24,9 +16,9 @@
> ul[role='listbox'] {
font-family: var(--font-family-monospace);
height: min(250px, 50vh);
max-height: none;
max-width: 200px;
max-height: min(220px, 20vh);
max-width: 240px;
min-width: 200px;
border: var(--border-base);
border-top-left-radius: var(--border-radius-base);
@@ -41,6 +33,10 @@
border-bottom-right-radius: var(--border-radius-base);
}
&:has(+ .cm-completionInfo) {
height: min(220px, 20vh);
}
li[role='option'] {
color: var(--color-text-base);
display: flex;
@@ -105,6 +101,10 @@
&:has(.cm-tooltip-lint) {
padding: 0;
overflow: hidden;
.cm-diagnostic {
border-left: none;
}
}
.autocomplete-info-container {
@@ -267,6 +267,30 @@
font-size: var(--font-size-3xs);
font-weight: var(--font-weight-bold);
}
// Syntax highlighting
.ts-keyword,
.ts-punctuation {
color: var(--color-code-tags-keyword);
}
.ts-stringLiteral,
.ts-primitive {
color: var(--color-code-tags-primitive);
}
.ts-localName,
.ts-parameterName,
.ts-methodName {
color: var(--color-code-tags-variable);
}
.ts-typeName,
.ts-interfaceName,
.ts-className,
.ts-aliasName {
color: var(--color-code-tags-class);
}
}
.cm-editor .cm-tooltip.cm-completionInfo {
@@ -286,7 +310,7 @@
top: 0 !important;
left: 100% !important;
right: auto !important;
max-width: 320px !important;
max-width: 280px !important;
height: 100%;
&.cm-completionInfo-left-narrow,
@@ -319,10 +343,14 @@
border-radius: var(--border-radius-base);
line-height: var(--font-line-height-loose);
padding: 0;
max-width: 320px;
max-width: 280px;
.cm-tooltip-section:not(:first-child) {
border-top: var(--border-base);
}
.autocomplete-info-container {
height: auto;
max-height: min(250px, 50vh);
max-height: min(220px, 20vh);
}
}

View File

@@ -3,7 +3,7 @@ import type { EditorView } from '@codemirror/view';
/**
* Simulate user action to force parser to catch up during scroll.
*/
export function forceParse(_: Event, view: EditorView) {
export function forceParse(view: EditorView) {
view.dispatch({
changes: { from: view.viewport.to, insert: '_' },
});

View File

@@ -27,5 +27,6 @@
// TODO: remove all options below this line
"useUnknownInCatchVariables": false
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"],
"exclude": ["src/plugins/codemirror/typescript/worker/**/*.d.ts"]
}

View File

@@ -98,6 +98,9 @@ export default mergeConfig(
sourcemap: !!release,
target: browserslistToEsbuild(browsers),
},
worker: {
format: 'es',
},
}),
vitestConfig,
);

147
pnpm-lock.yaml generated
View File

@@ -1369,6 +1369,9 @@ importers:
'@codemirror/lint':
specifier: ^6.8.0
version: 6.8.0
'@codemirror/search':
specifier: ^6.5.6
version: 6.5.6
'@codemirror/state':
specifier: ^6.4.1
version: 6.4.1
@@ -1423,9 +1426,15 @@ importers:
'@n8n/permissions':
specifier: workspace:*
version: link:../@n8n/permissions
'@replit/codemirror-indentation-markers':
specifier: ^6.5.3
version: 6.5.3(@codemirror/language@6.10.1)(@codemirror/state@6.4.1)(@codemirror/view@6.26.3)
'@sentry/vue':
specifier: catalog:frontend
version: 8.33.1(vue@3.5.13(typescript@5.7.2))
'@typescript/vfs':
specifier: ^1.6.0
version: 1.6.0(typescript@5.7.2)
'@vue-flow/background':
specifier: ^1.3.2
version: 1.3.2(@vue-flow/core@1.41.6(vue@3.5.13(typescript@5.7.2)))(vue@3.5.13(typescript@5.7.2))
@@ -1462,6 +1471,9 @@ importers:
codemirror-lang-html-n8n:
specifier: ^1.0.0
version: 1.0.0
comlink:
specifier: ^4.4.1
version: 4.4.1
dateformat:
specifier: ^3.0.3
version: 3.0.3
@@ -1516,6 +1528,9 @@ importers:
timeago.js:
specifier: ^4.0.2
version: 4.0.2
typescript:
specifier: ^5.7.2
version: 5.7.2
uuid:
specifier: 'catalog:'
version: 10.0.0
@@ -3134,6 +3149,9 @@ packages:
'@codemirror/lint@6.8.0':
resolution: {integrity: sha512-lsFofvaw0lnPRJlQylNsC4IRt/1lI4OD/yYslrSGVndOJfStc58v+8p9dgGiD90ktOfL7OhBWns1ZETYgz0EJA==}
'@codemirror/search@6.5.6':
resolution: {integrity: sha512-rpMgcsh7o0GuCDUXKPvww+muLA1pDJaFrpq/CCHtpQJYz8xopu4D1hPcKRoDD0YlF8gZaqTNIRa4VRBWyhyy7Q==}
'@codemirror/state@6.3.3':
resolution: {integrity: sha512-0wufKcTw2dEwEaADajjHf6hBy1sh3M6V0e+q4JKIhLuiMSe5td5HOWpUdvKth1fT1M9VYOboajoBHpkCd7PG7A==}
@@ -4728,6 +4746,13 @@ packages:
resolution: {integrity: sha512-BNgXjqesJu4L5f8F73c2hkkH5IdvjYCKYFgIl+m9oNgqGRIPBJjtiEGOx7jkQ6nElN4311z7Z4aTECtklaaHwg==}
engines: {node: '>=14.19.0', npm: '>=7.0.0'}
'@replit/codemirror-indentation-markers@6.5.3':
resolution: {integrity: sha512-hL5Sfvw3C1vgg7GolLe/uxX5T3tmgOA3ZzqlMv47zjU1ON51pzNWiVbS22oh6crYhtVhv8b3gdXwoYp++2ilHw==}
peerDependencies:
'@codemirror/language': ^6.0.0
'@codemirror/state': ^6.0.0
'@codemirror/view': ^6.0.0
'@rollup/pluginutils@5.1.0':
resolution: {integrity: sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g==}
engines: {node: '>=14.0.0'}
@@ -5528,9 +5553,6 @@ packages:
'@types/concat-stream@2.0.0':
resolution: {integrity: sha512-t3YCerNM7NTVjLuICZo5gYAXYoDvpuuTceCcFQWcDQz26kxUR5uIWolxbIR5jRNIXpMqhOpW/b8imCR1LEmuJw==}
'@types/connect@3.4.35':
resolution: {integrity: sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==}
'@types/connect@3.4.36':
resolution: {integrity: sha512-P63Zd/JUGq+PdrM1lv0Wv5SBYeA2+CORvbrXbngriYY0jzLUWfQMQQxOhjONEz/wlHOAxOdY7CY65rgQdTjq2w==}
@@ -5753,8 +5775,8 @@ packages:
'@types/psl@1.1.0':
resolution: {integrity: sha512-HhZnoLAvI2koev3czVPzBNRYvdrzJGLjQbWZhqFmS9Q6a0yumc5qtfSahBGb5g+6qWvA8iiQktqGkwoIXa/BNQ==}
'@types/qs@6.9.7':
resolution: {integrity: sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==}
'@types/qs@6.9.15':
resolution: {integrity: sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==}
'@types/quoted-printable@1.0.2':
resolution: {integrity: sha512-3B28oB1rRaZNb3N5dlxysm8lH1ujzvReDuYBiIO4jvpTIg9ksrILCNgPxSGVyTWE/qwuxzgHaVehwMK3CVqAtA==}
@@ -6003,6 +6025,11 @@ packages:
resolution: {integrity: sha512-c6EIQRHhcpl6+tO8EMR+kjkkV+ugUNXOmeASA1rlzkd8EPIriavpWoiEz1HR/VLhbVIdhqnV6E7JZm00cBDx2A==}
engines: {node: ^16.0.0 || >=18.0.0}
'@typescript/vfs@1.6.0':
resolution: {integrity: sha512-hvJUjNVeBMp77qPINuUvYXj4FyWeeMMKZkxEATEU3hqBAQ7qdTBCUFT7Sp0Zu0faeEtFf+ldXxMEDr/bk73ISg==}
peerDependencies:
typescript: ^5.7.2
'@ungap/structured-clone@1.2.0':
resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
@@ -7052,6 +7079,9 @@ packages:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
comlink@4.4.1:
resolution: {integrity: sha512-+1dlx0aY5Jo1vHy/tSsIGpSkN4tS9rZSW8FIhG0JH/crs9wwweswIo/POr451r7bZww3hFbPAKnTpimzL/mm4Q==}
commander@10.0.1:
resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==}
engines: {node: '>=14'}
@@ -14492,10 +14522,10 @@ snapshots:
'@babel/helper-compilation-targets': 7.23.6
'@babel/helper-module-transforms': 7.23.3(@babel/core@7.24.0)
'@babel/helpers': 7.24.0
'@babel/parser': 7.26.2
'@babel/parser': 7.26.3
'@babel/template': 7.24.0
'@babel/traverse': 7.24.0
'@babel/types': 7.26.0
'@babel/types': 7.26.3
convert-source-map: 2.0.0
debug: 4.3.7
gensync: 1.0.0-beta.2
@@ -14526,14 +14556,14 @@ snapshots:
'@babel/generator@7.22.9':
dependencies:
'@babel/types': 7.26.0
'@babel/types': 7.26.3
'@jridgewell/gen-mapping': 0.3.5
'@jridgewell/trace-mapping': 0.3.25
jsesc: 2.5.2
'@babel/generator@7.23.6':
dependencies:
'@babel/types': 7.26.0
'@babel/types': 7.26.3
'@jridgewell/gen-mapping': 0.3.5
'@jridgewell/trace-mapping': 0.3.25
jsesc: 2.5.2
@@ -14602,22 +14632,22 @@ snapshots:
'@babel/helper-function-name@7.23.0':
dependencies:
'@babel/template': 7.24.0
'@babel/types': 7.26.0
'@babel/types': 7.26.3
'@babel/helper-hoist-variables@7.22.5':
dependencies:
'@babel/types': 7.26.0
'@babel/types': 7.26.3
'@babel/helper-member-expression-to-functions@7.25.9':
dependencies:
'@babel/traverse': 7.26.3
'@babel/types': 7.26.0
'@babel/types': 7.26.3
transitivePeerDependencies:
- supports-color
'@babel/helper-module-imports@7.22.15':
dependencies:
'@babel/types': 7.26.0
'@babel/types': 7.26.3
'@babel/helper-module-imports@7.25.9':
dependencies:
@@ -14646,7 +14676,7 @@ snapshots:
'@babel/helper-optimise-call-expression@7.25.9':
dependencies:
'@babel/types': 7.26.0
'@babel/types': 7.26.3
'@babel/helper-plugin-utils@7.22.5': {}
@@ -14672,7 +14702,7 @@ snapshots:
'@babel/helper-simple-access@7.22.5':
dependencies:
'@babel/types': 7.26.0
'@babel/types': 7.26.3
'@babel/helper-skip-transparent-expression-wrappers@7.25.9':
dependencies:
@@ -14683,7 +14713,7 @@ snapshots:
'@babel/helper-split-export-declaration@7.22.6':
dependencies:
'@babel/types': 7.26.0
'@babel/types': 7.26.3
'@babel/helper-string-parser@7.24.8': {}
@@ -14703,7 +14733,7 @@ snapshots:
dependencies:
'@babel/template': 7.25.9
'@babel/traverse': 7.26.3
'@babel/types': 7.26.0
'@babel/types': 7.26.3
transitivePeerDependencies:
- supports-color
@@ -14711,14 +14741,14 @@ snapshots:
dependencies:
'@babel/template': 7.24.0
'@babel/traverse': 7.24.0
'@babel/types': 7.26.0
'@babel/types': 7.26.3
transitivePeerDependencies:
- supports-color
'@babel/helpers@7.26.0':
dependencies:
'@babel/template': 7.25.9
'@babel/types': 7.26.0
'@babel/types': 7.26.3
'@babel/highlight@7.24.6':
dependencies:
@@ -14733,7 +14763,7 @@ snapshots:
'@babel/parser@7.26.2':
dependencies:
'@babel/types': 7.26.0
'@babel/types': 7.26.3
'@babel/parser@7.26.3':
dependencies:
@@ -15272,14 +15302,14 @@ snapshots:
'@babel/template@7.24.0':
dependencies:
'@babel/code-frame': 7.24.6
'@babel/parser': 7.26.2
'@babel/types': 7.26.0
'@babel/parser': 7.26.3
'@babel/types': 7.26.3
'@babel/template@7.25.9':
dependencies:
'@babel/code-frame': 7.26.2
'@babel/parser': 7.26.2
'@babel/types': 7.26.0
'@babel/parser': 7.26.3
'@babel/types': 7.26.3
'@babel/traverse@7.24.0':
dependencies:
@@ -15289,8 +15319,8 @@ snapshots:
'@babel/helper-function-name': 7.23.0
'@babel/helper-hoist-variables': 7.22.5
'@babel/helper-split-export-declaration': 7.22.6
'@babel/parser': 7.26.2
'@babel/types': 7.26.0
'@babel/parser': 7.26.3
'@babel/types': 7.26.3
debug: 4.3.7
globals: 11.12.0
transitivePeerDependencies:
@@ -15465,6 +15495,12 @@ snapshots:
'@codemirror/view': 6.26.3
crelt: 1.0.5
'@codemirror/search@6.5.6':
dependencies:
'@codemirror/state': 6.4.1
'@codemirror/view': 6.26.3
crelt: 1.0.5
'@codemirror/state@6.3.3': {}
'@codemirror/state@6.4.1': {}
@@ -17107,6 +17143,12 @@ snapshots:
- encoding
- supports-color
'@replit/codemirror-indentation-markers@6.5.3(@codemirror/language@6.10.1)(@codemirror/state@6.4.1)(@codemirror/view@6.26.3)':
dependencies:
'@codemirror/language': 6.10.1
'@codemirror/state': 6.4.1
'@codemirror/view': 6.26.3
'@rollup/pluginutils@5.1.0(rollup@4.24.0)':
dependencies:
'@types/estree': 1.0.6
@@ -18178,24 +18220,24 @@ snapshots:
'@types/babel__core@7.20.0':
dependencies:
'@babel/parser': 7.26.2
'@babel/types': 7.26.0
'@babel/parser': 7.26.3
'@babel/types': 7.26.3
'@types/babel__generator': 7.6.4
'@types/babel__template': 7.4.1
'@types/babel__traverse': 7.18.2
'@types/babel__generator@7.6.4':
dependencies:
'@babel/types': 7.26.0
'@babel/types': 7.26.3
'@types/babel__template@7.4.1':
dependencies:
'@babel/parser': 7.26.2
'@babel/types': 7.26.0
'@babel/parser': 7.26.3
'@babel/types': 7.26.3
'@types/babel__traverse@7.18.2':
dependencies:
'@babel/types': 7.26.0
'@babel/types': 7.26.3
'@types/basic-auth@1.1.3':
dependencies:
@@ -18207,7 +18249,7 @@ snapshots:
'@types/body-parser@1.19.2':
dependencies:
'@types/connect': 3.4.35
'@types/connect': 3.4.36
'@types/node': 18.16.16
'@types/caseless@0.12.5': {}
@@ -18224,10 +18266,6 @@ snapshots:
dependencies:
'@types/node': 18.16.16
'@types/connect@3.4.35':
dependencies:
'@types/node': 18.16.16
'@types/connect@3.4.36':
dependencies:
'@types/node': 18.16.16
@@ -18264,7 +18302,7 @@ snapshots:
'@types/express-serve-static-core@4.17.43(patch_hash=5orrj4qleu2iko5t27vl44u4we)':
dependencies:
'@types/node': 18.16.16
'@types/qs': 6.9.7
'@types/qs': 6.9.15
'@types/range-parser': 1.2.4
'@types/send': 0.17.4
@@ -18272,7 +18310,7 @@ snapshots:
dependencies:
'@types/body-parser': 1.19.2
'@types/express-serve-static-core': 4.17.43(patch_hash=5orrj4qleu2iko5t27vl44u4we)
'@types/qs': 6.9.7
'@types/qs': 6.9.15
'@types/serve-static': 1.15.0
'@types/file-saver@2.0.5': {}
@@ -18477,7 +18515,7 @@ snapshots:
'@types/psl@1.1.0': {}
'@types/qs@6.9.7': {}
'@types/qs@6.9.15': {}
'@types/quoted-printable@1.0.2': {}
@@ -18784,6 +18822,13 @@ snapshots:
'@typescript-eslint/types': 7.2.0
eslint-visitor-keys: 3.4.3
'@typescript/vfs@1.6.0(typescript@5.7.2)':
dependencies:
debug: 4.3.6(supports-color@8.1.1)
typescript: 5.7.2
transitivePeerDependencies:
- supports-color
'@ungap/structured-clone@1.2.0': {}
'@vitejs/plugin-legacy@6.0.0(terser@5.16.1)(vite@6.0.2(@types/node@18.16.16)(jiti@1.21.0)(sass@1.64.1)(terser@5.16.1))':
@@ -18947,7 +18992,7 @@ snapshots:
'@vue/compiler-sfc@3.5.13':
dependencies:
'@babel/parser': 7.25.6
'@babel/parser': 7.26.3
'@vue/compiler-core': 3.5.13
'@vue/compiler-dom': 3.5.13
'@vue/compiler-ssr': 3.5.13
@@ -19528,7 +19573,7 @@ snapshots:
babel-plugin-jest-hoist@29.5.0:
dependencies:
'@babel/template': 7.24.0
'@babel/types': 7.26.0
'@babel/types': 7.26.3
'@types/babel__core': 7.20.0
'@types/babel__traverse': 7.18.2
@@ -19580,7 +19625,7 @@ snapshots:
babel-walk@3.0.0-canary-5:
dependencies:
'@babel/types': 7.26.0
'@babel/types': 7.26.3
balanced-match@1.0.2: {}
@@ -20102,6 +20147,8 @@ snapshots:
dependencies:
delayed-stream: 1.0.0
comlink@4.4.1: {}
commander@10.0.1: {}
commander@11.1.0: {}
@@ -20194,8 +20241,8 @@ snapshots:
constantinople@4.0.1:
dependencies:
'@babel/parser': 7.26.2
'@babel/types': 7.26.0
'@babel/parser': 7.26.3
'@babel/types': 7.26.3
content-disposition@0.5.4:
dependencies:
@@ -22668,7 +22715,7 @@ snapshots:
istanbul-lib-instrument@5.2.1:
dependencies:
'@babel/core': 7.24.0
'@babel/parser': 7.26.2
'@babel/parser': 7.26.3
'@istanbuljs/schema': 0.1.3
istanbul-lib-coverage: 3.2.2
semver: 7.6.0
@@ -23652,8 +23699,8 @@ snapshots:
magicast@0.3.5:
dependencies:
'@babel/parser': 7.26.2
'@babel/types': 7.26.0
'@babel/parser': 7.26.3
'@babel/types': 7.26.3
source-map-js: 1.2.1
mailparser@3.6.7:
@@ -27601,8 +27648,8 @@ snapshots:
with@7.0.2:
dependencies:
'@babel/parser': 7.26.2
'@babel/types': 7.26.0
'@babel/parser': 7.26.3
'@babel/types': 7.26.3
assert-never: 1.2.1
babel-walk: 3.0.0-canary-5