mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 09:36:44 +00:00
feat(editor): New Code editor based on the TypeScript language service (#12285)
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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%);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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 },
|
||||
]),
|
||||
),
|
||||
];
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -97,7 +97,7 @@ onMounted(() => {
|
||||
extensions: [
|
||||
EditorState.readOnly.of(true),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.domEventHandlers({ scroll: forceParse }),
|
||||
EditorView.domEventHandlers({ scroll: (_, view) => forceParse(view) }),
|
||||
...props.extensions,
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
448
packages/editor-ui/src/composables/useCodeEditor.ts
Normal file
448
packages/editor-ui/src/composables/useCodeEditor.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
|
||||
47
packages/editor-ui/src/plugins/codemirror/format.ts
Normal file
47
packages/editor-ui/src/plugins/codemirror/format.ts
Normal 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;
|
||||
}
|
||||
313
packages/editor-ui/src/plugins/codemirror/keymap.ts
vendored
313
packages/editor-ui/src/plugins/codemirror/keymap.ts
vendored
@@ -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 },
|
||||
];
|
||||
|
||||
52
packages/editor-ui/src/plugins/codemirror/multiCursor.ts
Normal file
52
packages/editor-ui/src/plugins/codemirror/multiCursor.ts
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -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, {});
|
||||
},
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}>;
|
||||
}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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')}
|
||||
}`);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
16
packages/editor-ui/src/plugins/codemirror/typescript/worker/type-declarations/globals.d.ts
vendored
Normal file
16
packages/editor-ui/src/plugins/codemirror/typescript/worker/type-declarations/globals.d.ts
vendored
Normal 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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
103
packages/editor-ui/src/plugins/codemirror/typescript/worker/type-declarations/n8n.d.ts
vendored
Normal file
103
packages/editor-ui/src/plugins/codemirror/typescript/worker/type-declarations/n8n.d.ts
vendored
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
});
|
||||
};
|
||||
}
|
||||
5
packages/editor-ui/src/shims-modules.d.ts
vendored
5
packages/editor-ui/src/shims-modules.d.ts
vendored
@@ -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';
|
||||
|
||||
|
||||
2
packages/editor-ui/src/shims.d.ts
vendored
2
packages/editor-ui/src/shims.d.ts
vendored
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: '_' },
|
||||
});
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -98,6 +98,9 @@ export default mergeConfig(
|
||||
sourcemap: !!release,
|
||||
target: browserslistToEsbuild(browsers),
|
||||
},
|
||||
worker: {
|
||||
format: 'es',
|
||||
},
|
||||
}),
|
||||
vitestConfig,
|
||||
);
|
||||
|
||||
147
pnpm-lock.yaml
generated
147
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user