From 2b747c38b2698ad7f27a1c6230b64a1a79ad4034 Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Mon, 26 Aug 2024 09:52:14 +0200 Subject: [PATCH 01/43] Code editor composable WIP --- .../CodeNodeEditor/CodeNodeEditor.vue | 61 ++-- .../src/components/CodeNodeEditor/linter.ts | 19 +- .../src/components/CodeNodeEditor/theme.ts | 199 +++++++------ .../src/composables/useCodeEditor.ts | 266 ++++++++++++++++++ 4 files changed, 400 insertions(+), 145 deletions(-) create mode 100644 packages/editor-ui/src/composables/useCodeEditor.ts diff --git a/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue b/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue index 36a5d169e1ec9..451a7a850ebe5 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue +++ b/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue @@ -47,9 +47,6 @@ diff --git a/packages/editor-ui/src/components/CodeNodeEditor/theme.ts b/packages/editor-ui/src/components/CodeNodeEditor/theme.ts index 84bb08391b422..746f4aa17d1c2 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/theme.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/theme.ts @@ -198,11 +198,19 @@ export const codeEditorTheme = ({ isReadOnly, minHeight, maxHeight, rows }: Them '.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-base)', + background: 'var(--color-background-light)', color: 'var(--color-text-base)', }, '.cm-panels-bottom': { @@ -213,6 +221,7 @@ export const codeEditorTheme = ({ isReadOnly, minHeight, maxHeight, rows }: Them background: 'var(--color-foreground-xlight)', borderRadius: 'var(--border-radius-base)', border: 'var(--border-base)', + fontSize: '90%', }, '.cm-textfield:focus': { outline: 'none', @@ -221,6 +230,17 @@ export const codeEditorTheme = ({ isReadOnly, minHeight, maxHeight, rows }: Them '.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)', @@ -228,6 +248,7 @@ export const codeEditorTheme = ({ isReadOnly, minHeight, maxHeight, rows }: Them backgroundColor: 'var(--color-foreground-xlight)', backgroundImage: 'none', borderRadius: 'var(--border-radius-base)', + fontSize: '90%', }, }), codeEditorSyntaxHighlighting, diff --git a/packages/editor-ui/src/composables/useCodeEditor.ts b/packages/editor-ui/src/composables/useCodeEditor.ts index 67a7ae064cf9c..98ce4f95f4212 100644 --- a/packages/editor-ui/src/composables/useCodeEditor.ts +++ b/packages/editor-ui/src/composables/useCodeEditor.ts @@ -108,6 +108,7 @@ export const useCodeEditor = ({ } async function getFullLanguageExtensions(): Promise { + if (!editor.value) return []; const lang = toValue(language); const langExtensions: Extension[] = [languageFacet.of(lang)]; @@ -115,7 +116,7 @@ export const useCodeEditor = ({ case 'javaScript': { const params = (toValue(languageParams) as CodeEditorLanguageParamsMap['javaScript']) ?? {}; const mode: CodeExecutionMode = 'mode' in params ? params.mode : 'runOnceForAllItems'; - const { extension, updateMode } = await useTypescript(readEditorValue(), mode, toValue(id)); + const { extension, updateMode } = await useTypescript(editor.value, mode, toValue(id)); onUpdateMode.value = updateMode; langExtensions.push(extension); break; diff --git a/packages/editor-ui/src/plugins/codemirror/format.ts b/packages/editor-ui/src/plugins/codemirror/format.ts index 4370437f27682..feeb7ae34e7bf 100644 --- a/packages/editor-ui/src/plugins/codemirror/format.ts +++ b/packages/editor-ui/src/plugins/codemirror/format.ts @@ -1,6 +1,8 @@ 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'; @@ -13,6 +15,7 @@ export function formatDocument(view: EditorView) { void formatWithCursor(view.state.doc.toString(), { cursorOffset: view.state.selection.main.anchor, parser, + plugins: [babelPlugin, estreePlugin], }).then(({ formatted, cursorOffset }) => { view.dispatch({ changes: { diff --git a/packages/editor-ui/src/plugins/codemirror/lsp/types.ts b/packages/editor-ui/src/plugins/codemirror/lsp/types.ts index ad0c3cfe7b19d..86dad992c97b9 100644 --- a/packages/editor-ui/src/plugins/codemirror/lsp/types.ts +++ b/packages/editor-ui/src/plugins/codemirror/lsp/types.ts @@ -29,7 +29,10 @@ export type LanguageServiceWorker = { updateFile(content: string): void; updateMode(mode: CodeExecutionMode): void; updateNodeTypes(): void; - getCompletionsAtPos(pos: number, wordBefore: string): Promise; + getCompletionsAtPos( + pos: number, + wordBefore: string, + ): Promise<{ result: CompletionResult; isGlobal: boolean } | null>; getDiagnostics(): Diagnostic[]; getHoverTooltip(pos: number): HoverInfo | null; }; diff --git a/packages/editor-ui/src/plugins/codemirror/lsp/typescript.ts b/packages/editor-ui/src/plugins/codemirror/lsp/typescript.ts index a94dc83c5a717..f3395d2370fca 100644 --- a/packages/editor-ui/src/plugins/codemirror/lsp/typescript.ts +++ b/packages/editor-ui/src/plugins/codemirror/lsp/typescript.ts @@ -6,8 +6,9 @@ import { useNDVStore } from '@/stores/ndv.store'; import { useWorkflowsStore } from '@/stores/workflows.store'; import { executionDataToJson } from '@/utils/nodeTypesUtils'; import { - completeFromList, + autocompletion, snippetCompletion, + type Completion, type CompletionSource, } from '@codemirror/autocomplete'; import { javascriptLanguage } from '@codemirror/lang-javascript'; @@ -18,9 +19,10 @@ import { EditorView, hoverTooltip } from '@codemirror/view'; import * as Comlink from 'comlink'; import { NodeConnectionType, type CodeExecutionMode, type INodeExecutionData } from 'n8n-workflow'; import { watch } from 'vue'; +import { forceParse } from '../../../utils/forceParse'; import { autocompletableNodeNames } from '../completions/utils'; -import { n8nAutocompletion } from '../n8nLang'; import type { LanguageServiceWorker } from './types'; +import { ROOT_DOLLAR_COMPLETIONS } from '../completions/constants'; export const tsFacet = Facet.define< { worker: Comlink.Remote }, @@ -31,29 +33,93 @@ export const tsFacet = Facet.define< }, }); +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', + }), +]; + const tsCompletions: CompletionSource = async (context) => { const { worker } = context.state.facet(tsFacet); const { pos } = context; - let word = context.matchBefore(/[\$\w]*/); + let word = context.matchBefore(/[\$\w]+/); if (!word?.text) { word = context.matchBefore(/\./); } - const result = await worker.getCompletionsAtPos(context.pos, word?.text ?? ''); + if (!word) return null; + + const completionResult = await worker.getCompletionsAtPos(context.pos, word?.text ?? ''); if (context.aborted) return null; - if (!result) return result; + if (!completionResult) return null; + + const { result, isGlobal } = completionResult; + + const options = [...result.options]; + + if (isGlobal) { + options.push(...snippets); + } return { from: word ? (word.text === '.' ? word.to : word.from) : pos, - options: result.options, + options, }; }; const tsLint: LintSource = async (view) => { const { worker } = view.state.facet(tsFacet); const docLength = view.state.doc.length; + console.log(); return (await worker.getDiagnostics()).filter((diag) => { return diag.from < docLength && diag.to <= docLength && diag.from >= 0; }); @@ -102,7 +168,7 @@ const tsHover: HoverSource = async (view, pos) => { }; }; -export async function useTypescript(initialValue: string, mode: CodeExecutionMode, id: string) { +export async function useTypescript(view: EditorView, mode: CodeExecutionMode, id: string) { const worker = Comlink.wrap( new Worker(new URL('./worker/typescript.worker.ts', import.meta.url), { type: 'module' }), ); @@ -112,17 +178,21 @@ export async function useTypescript(initialValue: string, mode: CodeExecutionMod const { debounce } = useDebounce(); const activeNodeName = ndvStore.activeNodeName; - console.log('init'); - watch( [() => workflowsStore.getWorkflowExecution, () => workflowsStore.getWorkflowRunData], - debounce(async () => await worker.updateNodeTypes(), { debounceTime: 200, trailing: true }), + debounce( + async () => { + await worker.updateNodeTypes(); + forceParse(view); + }, + { debounceTime: 200, trailing: true }, + ), ); await worker.init( { id, - content: initialValue, + content: view.state.doc.toString(), allNodeNames: autocompletableNodeNames(), variables: useEnvironmentsStore().variables.map((v) => v.key), inputNodeNames: activeNodeName @@ -160,62 +230,9 @@ export async function useTypescript(initialValue: string, mode: CodeExecutionMod tsFacet.of({ worker }), new LanguageSupport(javascriptLanguage, [ javascriptLanguage.data.of({ autocomplete: tsCompletions }), - javascriptLanguage.data.of({ - autocomplete: completeFromList([ - 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', - }), - ]), - }), ]), - n8nAutocompletion(), + + autocompletion({ icons: false, aboveCursor: true }), linter(tsLint), hoverTooltip(tsHover, { hideOnChange: true, diff --git a/packages/editor-ui/src/plugins/codemirror/lsp/worker/typescript.worker.ts b/packages/editor-ui/src/plugins/codemirror/lsp/worker/typescript.worker.ts index 8d10f367fddb9..94f4943e5e61f 100644 --- a/packages/editor-ui/src/plugins/codemirror/lsp/worker/typescript.worker.ts +++ b/packages/editor-ui/src/plugins/codemirror/lsp/worker/typescript.worker.ts @@ -1,4 +1,4 @@ -import type { Completion } from '@codemirror/autocomplete'; +import { type Completion } from '@codemirror/autocomplete'; import * as tsvfs from '@typescript/vfs'; import * as Comlink from 'comlink'; import ts, { type DiagnosticWithLocation } from 'typescript'; @@ -9,6 +9,7 @@ import { convertTSDiagnosticToCM, fnPrefix, isDiagnosticWithLocation, + isIgnoredDiagnostic, returnTypeForMode, schemaToTypescriptTypes, tsPosToCm, @@ -257,7 +258,7 @@ declare global { updateFile(fileName, wrapInFunction(content, mode)); await loadTypesIfNeeded(); }, - async getCompletionsAtPos(pos, word) { + async getCompletionsAtPos(pos) { const tsPos = cmPosToTs(pos, fnPrefix(returnTypeForMode(mode))); const completionInfo = env.languageService.getCompletionsAtPosition(fileName, tsPos, {}, {}); @@ -273,15 +274,24 @@ declare global { ) .map((entry): Completion => { const boost = -Number(entry.sortText) || 0; + let type = entry.kind ? String(entry.kind) : undefined; + + if (type === 'member') type = 'property'; + return { label: entry.name, + type, + commitCharacters: entry.commitCharacters ?? completionInfo.defaultCommitCharacters, boost, }; }); return { - from: pos, - options, + result: { + from: pos, + options, + }, + isGlobal: completionInfo.isGlobalCompletion, }; }, getDiagnostics() { @@ -293,8 +303,9 @@ declare global { ...env.languageService.getSyntacticDiagnostics(fileName), ]; - const diagnostics = tsDiagnostics.filter((diagnostic): diagnostic is DiagnosticWithLocation => - isDiagnosticWithLocation(diagnostic), + const diagnostics = tsDiagnostics.filter( + (diagnostic): diagnostic is DiagnosticWithLocation => + isDiagnosticWithLocation(diagnostic) && !isIgnoredDiagnostic(diagnostic), ); return diagnostics.map((d) => convertTSDiagnosticToCM(d, fnPrefix(returnTypeForMode(mode)))); diff --git a/packages/editor-ui/src/plugins/codemirror/lsp/worker/utils.ts b/packages/editor-ui/src/plugins/codemirror/lsp/worker/utils.ts index 6dbf36dc08168..40a9df34c0d2e 100644 --- a/packages/editor-ui/src/plugins/codemirror/lsp/worker/utils.ts +++ b/packages/editor-ui/src/plugins/codemirror/lsp/worker/utils.ts @@ -29,19 +29,25 @@ export function tsPosToCm(pos: number, prefix: string) { export function tsCategoryToSeverity( diagnostic: Pick, ): Diagnostic['severity'] { - if (diagnostic.code === 7027) { - // Unreachable code detected - return 'warning'; - } - switch (diagnostic.category) { - case ts.DiagnosticCategory.Error: - return 'error'; - case ts.DiagnosticCategory.Message: - return 'info'; - case ts.DiagnosticCategory.Warning: + switch (diagnostic.code) { + case 6133: + // No unused variables + return 'warning'; + case 7027: + // Unreachable code detected return 'warning'; - case ts.DiagnosticCategory.Suggestion: - return 'info'; + 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'; + } + } } } @@ -60,6 +66,11 @@ export function isDiagnosticWithLocation( ); } +export 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, @@ -74,6 +85,16 @@ export function tsDiagnosticMessage(diagnostic: Pick ul[role='listbox'] { font-family: var(--font-family-monospace); - height: min(250px, 50vh); - max-height: none; + max-height: min(250px, 50vh); max-width: 200px; border: var(--border-base); @@ -98,7 +97,7 @@ padding: 0; overflow: hidden; - .cm-diagnostic-error { + .cm-diagnostic { border-left: none; } } From faddbdd8711039daea8c60d7f92b267ee97b648e Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Thu, 12 Dec 2024 15:27:20 +0100 Subject: [PATCH 23/43] Add docs to typescript hover --- .../editor-ui/src/plugins/codemirror/lsp/typescript.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/editor-ui/src/plugins/codemirror/lsp/typescript.ts b/packages/editor-ui/src/plugins/codemirror/lsp/typescript.ts index f3395d2370fca..f6471e9cca505 100644 --- a/packages/editor-ui/src/plugins/codemirror/lsp/typescript.ts +++ b/packages/editor-ui/src/plugins/codemirror/lsp/typescript.ts @@ -119,7 +119,6 @@ const tsCompletions: CompletionSource = async (context) => { const tsLint: LintSource = async (view) => { const { worker } = view.state.facet(tsFacet); const docLength = view.state.doc.length; - console.log(); return (await worker.getDiagnostics()).filter((diag) => { return diag.from < docLength && diag.to <= docLength && diag.from >= 0; }); @@ -163,6 +162,14 @@ const tsHover: HoverSource = async (view, pos) => { span.innerText = part.text; } } + + const documentation = info.quickInfo?.documentation?.find((doc) => doc.kind === 'text')?.text; + if (documentation) { + const docElement = document.createElement('div'); + docElement.textContent = documentation; + wrapper.appendChild(docElement); + } + return { dom: div }; }, }; From ce99bf58946120a17f13a568050d0c4ff23fa44e Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Tue, 17 Dec 2024 12:43:22 +0100 Subject: [PATCH 24/43] Fix fullscreen modal, fix restore editor from local storage --- .../src/components/ParameterInput.vue | 10 +++--- .../src/composables/useCodeEditor.ts | 8 +++-- .../src/plugins/codemirror/lsp/typescript.ts | 32 ++++++++++++------- .../lsp/worker/typescript.worker.ts | 3 -- .../src/plugins/i18n/locales/en.json | 16 +++++++--- 5 files changed, 42 insertions(+), 27 deletions(-) diff --git a/packages/editor-ui/src/components/ParameterInput.vue b/packages/editor-ui/src/components/ParameterInput.vue index a10fe105a15e5..de06cf7f7ff30 100644 --- a/packages/editor-ui/src/components/ParameterInput.vue +++ b/packages/editor-ui/src/components/ParameterInput.vue @@ -1684,13 +1684,13 @@ onUpdated(async () => { height: calc(100% - var(--spacing-4xl)); margin-bottom: 0; - :global(.el-dialog__body) { - height: 100%; - padding: var(--spacing-s); + :global(.el-dialog__header) { + padding-bottom: 0; } - :global(.el-dialog__header) { - display: none; + :global(.el-dialog__body) { + height: calc(100% - var(--spacing-3xl)); + padding: var(--spacing-s); } } diff --git a/packages/editor-ui/src/composables/useCodeEditor.ts b/packages/editor-ui/src/composables/useCodeEditor.ts index 98ce4f95f4212..5d2f2ddc80043 100644 --- a/packages/editor-ui/src/composables/useCodeEditor.ts +++ b/packages/editor-ui/src/composables/useCodeEditor.ts @@ -300,9 +300,11 @@ export const useCodeEditor = ({ extensions: allExtensions, }; - const state = parsedStoredState - ? EditorState.fromJSON(parsedStoredState, config, storedStateFields) - : EditorState.create(config); + 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(); diff --git a/packages/editor-ui/src/plugins/codemirror/lsp/typescript.ts b/packages/editor-ui/src/plugins/codemirror/lsp/typescript.ts index f6471e9cca505..fe6cfdd0a9e46 100644 --- a/packages/editor-ui/src/plugins/codemirror/lsp/typescript.ts +++ b/packages/editor-ui/src/plugins/codemirror/lsp/typescript.ts @@ -4,13 +4,10 @@ import { useNodeHelpers } from '@/composables/useNodeHelpers'; 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 { escapeMappingString } from '@/utils/mappingUtils'; import { executionDataToJson } from '@/utils/nodeTypesUtils'; -import { - autocompletion, - snippetCompletion, - type Completion, - type CompletionSource, -} from '@codemirror/autocomplete'; +import { autocompletion, snippetCompletion, type CompletionSource } from '@codemirror/autocomplete'; import { javascriptLanguage } from '@codemirror/lang-javascript'; import { LanguageSupport } from '@codemirror/language'; import { linter, type LintSource } from '@codemirror/lint'; @@ -19,10 +16,8 @@ import { EditorView, hoverTooltip } from '@codemirror/view'; import * as Comlink from 'comlink'; import { NodeConnectionType, type CodeExecutionMode, type INodeExecutionData } from 'n8n-workflow'; import { watch } from 'vue'; -import { forceParse } from '../../../utils/forceParse'; import { autocompletableNodeNames } from '../completions/utils'; import type { LanguageServiceWorker } from './types'; -import { ROOT_DOLLAR_COMPLETIONS } from '../completions/constants'; export const tsFacet = Facet.define< { worker: Comlink.Remote }, @@ -92,7 +87,7 @@ const tsCompletions: CompletionSource = async (context) => { let word = context.matchBefore(/[\$\w]+/); if (!word?.text) { - word = context.matchBefore(/\./); + word = context.matchBefore(/[\.\(\'\"]/); } if (!word) return null; @@ -104,14 +99,27 @@ const tsCompletions: CompletionSource = async (context) => { const { result, isGlobal } = completionResult; - const options = [...result.options]; + let options = [...result.options]; if (isGlobal) { - options.push(...snippets); + options = options + .flatMap((opt) => { + if (opt.label === '$') { + return [ + opt, + ...autocompletableNodeNames().map((name) => ({ + ...opt, + label: `$('${escapeMappingString(name)}')`, + })), + ]; + } + return opt; + }) + .concat(snippets); } return { - from: word ? (word.text === '.' ? word.to : word.from) : pos, + from: word ? (['"', "'", '(', '.'].includes(word.text) ? word.to : word.from) : pos, options, }; }; diff --git a/packages/editor-ui/src/plugins/codemirror/lsp/worker/typescript.worker.ts b/packages/editor-ui/src/plugins/codemirror/lsp/worker/typescript.worker.ts index 94f4943e5e61f..a63e938f5515e 100644 --- a/packages/editor-ui/src/plugins/codemirror/lsp/worker/typescript.worker.ts +++ b/packages/editor-ui/src/plugins/codemirror/lsp/worker/typescript.worker.ts @@ -263,7 +263,6 @@ declare global { const completionInfo = env.languageService.getCompletionsAtPosition(fileName, tsPos, {}, {}); - console.log(completionInfo); if (!completionInfo) return null; const options = completionInfo.entries @@ -322,8 +321,6 @@ declare global { env.languageService.getTypeDefinitionAtPosition(fileName, tsPos) ?? env.languageService.getDefinitionAtPosition(fileName, tsPos); - console.log(quickInfo, typeDef); - return { start, end: start + quickInfo.textSpan.length, diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 6e909620e04d4..20e6978c3a3d1 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -46,6 +46,7 @@ "generic.copy": "Copy", "generic.delete": "Delete", "generic.dontShowAgain": "Don't show again", + "generic.enterprise": "Enterprise", "generic.executions": "Executions", "generic.tests": "Tests", "generic.or": "or", @@ -77,6 +78,8 @@ "generic.ownedByMe": "(You)", "generic.moreInfo": "More info", "generic.next": "Next", + "generic.pro": "Pro", + "generic.viewDocs": "View docs", "about.aboutN8n": "About n8n", "about.close": "Close", "about.license": "License", @@ -212,6 +215,7 @@ "chatEmbed.packageInfo.link": "Read the full documentation", "chatEmbed.chatTriggerNode": "You can use a Chat Trigger Node to embed the chat widget directly into n8n.", "chatEmbed.url": "https://www.npmjs.com/package/{'@'}n8n/chat", + "codeEdit.edit": "Edit", "codeNodeEditor.askAi": "✨ Ask AI", "codeNodeEditor.completer.$()": "Output data of the {nodeName} node", "codeNodeEditor.completer.$execution": "Retrieve or set metadata for the current execution", @@ -628,7 +632,7 @@ "credentials.noResults.withSearch.switchToShared.preamble": "some credentials may be", "credentials.noResults.withSearch.switchToShared.link": "hidden", "credentials.create.personal.toast.title": "Credential successfully created", - "credentials.create.personal.toast.text": "This credential is currently private to you.", + "credentials.create.personal.toast.text": "This credential has been created inside your personal space.", "credentials.create.project.toast.title": "Credential successfully created in {projectName}", "credentials.create.project.toast.text": "All members from {projectName} will have access to this credential.", "dataDisplay.needHelp": "Need help?", @@ -661,6 +665,7 @@ "error.goBack": "Go back", "error.pageNotFound": "Oops, couldn’t find that", "executions.ExecutionStatus": "Execution status", + "executions.concurrency.docsLink": "https://docs.n8n.io/hosting/scaling/concurrency-control/", "executionDetails.confirmMessage.confirmButtonText": "Yes, delete", "executionDetails.confirmMessage.headline": "Delete Execution?", "executionDetails.confirmMessage.message": "Are you sure that you want to delete the current execution?", @@ -701,7 +706,7 @@ "executionsLandingPage.noResults": "No executions found", "executionsList.activeExecutions.none": "No active executions", "executionsList.activeExecutions.header": "{running}/{cap} active executions", - "executionsList.activeExecutions.tooltip": "Current active executions: {running} out of {cap} allowed by your plan. Upgrade to increase the limit.", + "executionsList.activeExecutions.tooltip": "Current active executions: {running} out of {cap}. This instance is limited to {cap} concurrent production executions.", "executionsList.allWorkflows": "All Workflows", "executionsList.anyStatus": "Any Status", "executionsList.autoRefresh": "Auto refresh", @@ -770,7 +775,9 @@ "executionsList.view": "View", "executionsList.stop": "Stop", "executionsList.statusTooltipText.waitingForWebhook": "The workflow is waiting indefinitely for an incoming webhook call.", - "executionsList.statusTooltipText.waitingForConcurrencyCapacity": "This execution will start once concurrency capacity is available. This instance is limited to {concurrencyCap} concurrent production executions.", + "executionsList.statusTooltipText.waitingForConcurrencyCapacity": "This execution will start once concurrency capacity is available. {instance}", + "executionsList.statusTooltipText.waitingForConcurrencyCapacity.cloud": "Your plan is limited to {concurrencyCap} concurrent production executions. {link}", + "executionsList.statusTooltipText.waitingForConcurrencyCapacity.self": "This instance is limited to {concurrencyCap} concurrent production executions. {link}", "executionsList.statusTooltipText.theWorkflowIsWaitingIndefinitely": "The workflow is waiting indefinitely for an incoming webhook call.", "executionsList.debug.button.copyToEditor": "Copy to editor", "executionsList.debug.button.debugInEditor": "Debug in editor", @@ -2324,7 +2331,7 @@ "workflows.concurrentChanges.confirmMessage.cancelButtonText": "Cancel", "workflows.concurrentChanges.confirmMessage.confirmButtonText": "Overwrite and Save", "workflows.create.personal.toast.title": "Workflow successfully created", - "workflows.create.personal.toast.text": "This workflow is currently private to you.", + "workflows.create.personal.toast.text": "This workflow has been created inside your personal space.", "workflows.create.project.toast.title": "Workflow successfully created in {projectName}", "workflows.create.project.toast.text": "All members from {projectName} will have access to this workflow.", "importCurlModal.title": "Import cURL command", @@ -2566,6 +2573,7 @@ "projects.error.title": "Project error", "projects.create.limit": "{num} project | {num} projects", "projects.create.limitReached": "You have reached the {planName} plan limit of {limit}. Upgrade your plan to unlock more projects. {link}", + "projects.create.limitReached.cloud": "You have reached the {planName} plan limit of {limit}. Upgrade your plan to unlock more projects.", "projects.create.limitReached.self": "Upgrade to unlock projects for more granular control over sharing, access and organisation of workflows", "projects.create.limitReached.link": "View plans", "projects.move.resource.modal.title": "Choose a project or user to move this {resourceTypeLabel} to", From d3fa42ca9676f1fa4d1b29743666daa09ece641d Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Wed, 18 Dec 2024 17:18:08 +0100 Subject: [PATCH 25/43] Refactor and clean up code --- .../src/components/CodeNodeEditor/theme.ts | 4 + .../src/composables/useCodeEditor.ts | 29 +- .../src/plugins/codemirror/lsp/typescript.ts | 265 ------------- .../lsp/worker/type-declarations/luxon.d.ts | 8 - .../lsp/worker/typescript.worker.ts | 352 ------------------ .../plugins/codemirror/lsp/worker/utils.ts | 166 --------- .../typescript/client/completionSource.ts | 47 +++ .../codemirror/typescript/client/facet.ts | 12 + .../typescript/client/hoverTooltip.ts | 54 +++ .../codemirror/typescript/client/linter.ts | 11 + .../codemirror/typescript/client/snippets.ts | 54 +++ .../typescript/client/useTypescript.ts | 111 ++++++ .../codemirror/{lsp => typescript}/types.ts | 30 +- .../worker/__snapshots__/utils.test.ts.snap | 0 .../{lsp => typescript}/worker/cache.ts | 2 + .../typescript/worker/completions.ts | 51 +++ .../codemirror/typescript/worker/constants.ts | 24 ++ .../typescript/worker/dynamicTypes.ts | 77 ++++ .../codemirror/typescript/worker/env.ts | 60 +++ .../typescript/worker/hoverTooltip.ts | 24 ++ .../codemirror/typescript/worker/linter.ts | 111 ++++++ .../worker/npmTypesLoader.ts} | 0 .../worker/type-declarations/globals.d.ts | 16 + .../n8n-once-for-all-items.d.ts | 4 +- .../n8n-once-for-each-item.d.ts | 4 +- .../worker/type-declarations/n8n.d.ts | 13 +- .../typescript/worker/typescript.worker.ts | 162 ++++++++ .../typescript/worker/typescriptAst.ts | 38 ++ .../{lsp => typescript}/worker/utils.test.ts | 0 .../codemirror/typescript/worker/utils.ts | 27 ++ packages/editor-ui/tsconfig.json | 2 +- 31 files changed, 919 insertions(+), 839 deletions(-) delete mode 100644 packages/editor-ui/src/plugins/codemirror/lsp/typescript.ts delete mode 100644 packages/editor-ui/src/plugins/codemirror/lsp/worker/type-declarations/luxon.d.ts delete mode 100644 packages/editor-ui/src/plugins/codemirror/lsp/worker/typescript.worker.ts delete mode 100644 packages/editor-ui/src/plugins/codemirror/lsp/worker/utils.ts create mode 100644 packages/editor-ui/src/plugins/codemirror/typescript/client/completionSource.ts create mode 100644 packages/editor-ui/src/plugins/codemirror/typescript/client/facet.ts create mode 100644 packages/editor-ui/src/plugins/codemirror/typescript/client/hoverTooltip.ts create mode 100644 packages/editor-ui/src/plugins/codemirror/typescript/client/linter.ts create mode 100644 packages/editor-ui/src/plugins/codemirror/typescript/client/snippets.ts create mode 100644 packages/editor-ui/src/plugins/codemirror/typescript/client/useTypescript.ts rename packages/editor-ui/src/plugins/codemirror/{lsp => typescript}/types.ts (55%) rename packages/editor-ui/src/plugins/codemirror/{lsp => typescript}/worker/__snapshots__/utils.test.ts.snap (100%) rename packages/editor-ui/src/plugins/codemirror/{lsp => typescript}/worker/cache.ts (97%) create mode 100644 packages/editor-ui/src/plugins/codemirror/typescript/worker/completions.ts create mode 100644 packages/editor-ui/src/plugins/codemirror/typescript/worker/constants.ts create mode 100644 packages/editor-ui/src/plugins/codemirror/typescript/worker/dynamicTypes.ts create mode 100644 packages/editor-ui/src/plugins/codemirror/typescript/worker/env.ts create mode 100644 packages/editor-ui/src/plugins/codemirror/typescript/worker/hoverTooltip.ts create mode 100644 packages/editor-ui/src/plugins/codemirror/typescript/worker/linter.ts rename packages/editor-ui/src/plugins/codemirror/{lsp/worker/typesLoader.ts => typescript/worker/npmTypesLoader.ts} (100%) create mode 100644 packages/editor-ui/src/plugins/codemirror/typescript/worker/type-declarations/globals.d.ts rename packages/editor-ui/src/plugins/codemirror/{lsp => typescript}/worker/type-declarations/n8n-once-for-all-items.d.ts (76%) rename packages/editor-ui/src/plugins/codemirror/{lsp => typescript}/worker/type-declarations/n8n-once-for-each-item.d.ts (69%) rename packages/editor-ui/src/plugins/codemirror/{lsp => typescript}/worker/type-declarations/n8n.d.ts (82%) create mode 100644 packages/editor-ui/src/plugins/codemirror/typescript/worker/typescript.worker.ts create mode 100644 packages/editor-ui/src/plugins/codemirror/typescript/worker/typescriptAst.ts rename packages/editor-ui/src/plugins/codemirror/{lsp => typescript}/worker/utils.test.ts (100%) create mode 100644 packages/editor-ui/src/plugins/codemirror/typescript/worker/utils.ts diff --git a/packages/editor-ui/src/components/CodeNodeEditor/theme.ts b/packages/editor-ui/src/components/CodeNodeEditor/theme.ts index 746f4aa17d1c2..3ac9e06d7462e 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/theme.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/theme.ts @@ -182,8 +182,12 @@ export const codeEditorTheme = ({ isReadOnly, minHeight, maxHeight, rows }: Them 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)', diff --git a/packages/editor-ui/src/composables/useCodeEditor.ts b/packages/editor-ui/src/composables/useCodeEditor.ts index 5d2f2ddc80043..bdc47d833e331 100644 --- a/packages/editor-ui/src/composables/useCodeEditor.ts +++ b/packages/editor-ui/src/composables/useCodeEditor.ts @@ -1,6 +1,6 @@ import { codeEditorTheme } from '@/components/CodeNodeEditor/theme'; import { editorKeymap } from '@/plugins/codemirror/keymap'; -import { useTypescript } from '@/plugins/codemirror/lsp/typescript'; +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'; @@ -45,7 +45,6 @@ import { } from 'vue'; import { useCompleter } from '../components/CodeNodeEditor/completer'; import { mappingDropCursor } from '../plugins/codemirror/dragAndDrop'; -import { forceParse } from '../utils/forceParse'; import { languageFacet, type CodeEditorLanguage } from '../plugins/codemirror/format'; export type CodeEditorLanguageParamsMap = { @@ -93,10 +92,13 @@ export const useCodeEditor = ({ const themeExtensions = ref(new Compartment()); const autocompleteStatus = ref<'pending' | 'active' | null>(null); const dragging = ref(false); - const onUpdateMode = ref<(mode: CodeExecutionMode) => void>(() => {}); 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'; + }); function getInitialLanguageExtensions(lang: CodeEditorLanguage): Extension[] { switch (lang) { @@ -114,16 +116,10 @@ export const useCodeEditor = ({ switch (lang) { case 'javaScript': { - const params = (toValue(languageParams) as CodeEditorLanguageParamsMap['javaScript']) ?? {}; - const mode: CodeExecutionMode = 'mode' in params ? params.mode : 'runOnceForAllItems'; - const { extension, updateMode } = await useTypescript(editor.value, mode, toValue(id)); - onUpdateMode.value = updateMode; - langExtensions.push(extension); + langExtensions.push(await useTypescript(editor.value, mode, toValue(id))); break; } case 'python': { - const params = (toValue(languageParams) as CodeEditorLanguageParamsMap['javaScript']) ?? {}; - const mode: CodeExecutionMode = 'mode' in params ? params.mode : 'runOnceForAllItems'; const pythonAutocomplete = useCompleter(mode, editor.value ?? null).autocompletionExtension( 'python', ); @@ -330,19 +326,6 @@ export const useCodeEditor = ({ watch(toRef(language), setLanguageExtensions); - watch(toRef(languageParams), async (params) => { - if (!editor.value) return; - - if ('mode' in params) { - onUpdateMode.value((params as CodeEditorLanguageParamsMap['javaScript']).mode); - forceParse(editor.value); - } - - editor.value.dispatch({ - effects: languageExtensions.value.reconfigure(await getFullLanguageExtensions()), - }); - }); - watch(toRef(isReadOnly), setReadOnlyExtensions); watch(toRef(theme), () => { diff --git a/packages/editor-ui/src/plugins/codemirror/lsp/typescript.ts b/packages/editor-ui/src/plugins/codemirror/lsp/typescript.ts deleted file mode 100644 index fe6cfdd0a9e46..0000000000000 --- a/packages/editor-ui/src/plugins/codemirror/lsp/typescript.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { useDataSchema } from '@/composables/useDataSchema'; -import { useDebounce } from '@/composables/useDebounce'; -import { useNodeHelpers } from '@/composables/useNodeHelpers'; -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 { escapeMappingString } from '@/utils/mappingUtils'; -import { executionDataToJson } from '@/utils/nodeTypesUtils'; -import { autocompletion, snippetCompletion, type CompletionSource } from '@codemirror/autocomplete'; -import { javascriptLanguage } from '@codemirror/lang-javascript'; -import { LanguageSupport } from '@codemirror/language'; -import { linter, type LintSource } from '@codemirror/lint'; -import { combineConfig, Facet } from '@codemirror/state'; -import { EditorView, hoverTooltip } from '@codemirror/view'; -import * as Comlink from 'comlink'; -import { NodeConnectionType, type CodeExecutionMode, type INodeExecutionData } from 'n8n-workflow'; -import { watch } from 'vue'; -import { autocompletableNodeNames } from '../completions/utils'; -import type { LanguageServiceWorker } from './types'; - -export const tsFacet = Facet.define< - { worker: Comlink.Remote }, - { worker: Comlink.Remote } ->({ - combine(configs) { - return combineConfig(configs, {}); - }, -}); - -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', - }), -]; - -const tsCompletions: CompletionSource = async (context) => { - const { worker } = context.state.facet(tsFacet); - const { pos } = context; - - let word = context.matchBefore(/[\$\w]+/); - if (!word?.text) { - word = context.matchBefore(/[\.\(\'\"]/); - } - - if (!word) return null; - - const completionResult = await worker.getCompletionsAtPos(context.pos, word?.text ?? ''); - - if (context.aborted) return null; - if (!completionResult) 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 ? (['"', "'", '(', '.'].includes(word.text) ? word.to : word.from) : pos, - options, - }; -}; - -const tsLint: LintSource = async (view) => { - const { worker } = view.state.facet(tsFacet); - const docLength = view.state.doc.length; - return (await worker.getDiagnostics()).filter((diag) => { - return diag.from < docLength && diag.to <= docLength && diag.from >= 0; - }); -}; - -type HoverSource = Parameters[0]; -const tsHover: HoverSource = async (view, pos) => { - const { worker } = view.state.facet(tsFacet); - - 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.textContent = documentation; - wrapper.appendChild(docElement); - } - - return { dom: div }; - }, - }; -}; - -export async function useTypescript(view: EditorView, mode: CodeExecutionMode, id: string) { - const worker = Comlink.wrap( - new Worker(new URL('./worker/typescript.worker.ts', import.meta.url), { type: 'module' }), - ); - const { getInputDataWithPinned, getSchemaForExecutionData } = useDataSchema(); - const ndvStore = useNDVStore(); - const workflowsStore = useWorkflowsStore(); - const { debounce } = useDebounce(); - const activeNodeName = ndvStore.activeNodeName; - - watch( - [() => workflowsStore.getWorkflowExecution, () => workflowsStore.getWorkflowRunData], - debounce( - async () => { - await worker.updateNodeTypes(); - forceParse(view); - }, - { debounceTime: 200, trailing: true }, - ), - ); - - await worker.init( - { - id, - content: view.state.doc.toString(), - allNodeNames: autocompletableNodeNames(), - variables: useEnvironmentsStore().variables.map((v) => v.key), - inputNodeNames: activeNodeName - ? workflowsStore - .getCurrentWorkflow() - .getParentNodes(activeNodeName, NodeConnectionType.Main, 1) - : [], - 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) }; - } - - return undefined; - }), - ); - - return { - extension: [ - tsFacet.of({ worker }), - new LanguageSupport(javascriptLanguage, [ - javascriptLanguage.data.of({ autocomplete: tsCompletions }), - ]), - - autocompletion({ icons: false, aboveCursor: true }), - linter(tsLint), - hoverTooltip(tsHover, { - hideOnChange: true, - hoverTime: 500, - }), - EditorView.updateListener.of(async (update) => { - if (!update.docChanged) return; - await worker.updateFile(update.state.doc.toString()); - }), - ], - updateMode: (newMode: CodeExecutionMode) => { - void worker.updateMode(newMode); - }, - }; -} diff --git a/packages/editor-ui/src/plugins/codemirror/lsp/worker/type-declarations/luxon.d.ts b/packages/editor-ui/src/plugins/codemirror/lsp/worker/type-declarations/luxon.d.ts deleted file mode 100644 index ae08d480c8091..0000000000000 --- a/packages/editor-ui/src/plugins/codemirror/lsp/worker/type-declarations/luxon.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -export {}; - -import luxon from 'luxon'; - -declare global { - const DateTime: typeof luxon.DateTime; - type DateTime = luxon.DateTime; -} diff --git a/packages/editor-ui/src/plugins/codemirror/lsp/worker/typescript.worker.ts b/packages/editor-ui/src/plugins/codemirror/lsp/worker/typescript.worker.ts deleted file mode 100644 index a63e938f5515e..0000000000000 --- a/packages/editor-ui/src/plugins/codemirror/lsp/worker/typescript.worker.ts +++ /dev/null @@ -1,352 +0,0 @@ -import { type Completion } from '@codemirror/autocomplete'; -import * as tsvfs from '@typescript/vfs'; -import * as Comlink from 'comlink'; -import ts, { type DiagnosticWithLocation } from 'typescript'; -import type { LanguageServiceWorker, NodeDataFetcher } from '../types'; -import { indexedDbCache } from './cache'; -import { - cmPosToTs, - convertTSDiagnosticToCM, - fnPrefix, - isDiagnosticWithLocation, - isIgnoredDiagnostic, - returnTypeForMode, - schemaToTypescriptTypes, - tsPosToCm, - wrapInFunction, -} from './utils'; - -import { pascalCase } from 'change-case'; -import type { CodeExecutionMode } from 'n8n-workflow'; -import luxonTypes from './type-declarations/luxon.d.ts?raw'; -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 n8nTypes from './type-declarations/n8n.d.ts?raw'; -import { loadTypes } from './typesLoader'; - -self.process = { env: {} } as NodeJS.Process; - -const TS_COMPLETE_BLOCKLIST: ts.ScriptElementKind[] = [ts.ScriptElementKind.warning]; - -const worker = (): LanguageServiceWorker => { - let env: tsvfs.VirtualTypeScriptEnvironment; - let nodeDataFetcher: NodeDataFetcher = async () => undefined; - const loadedNodeTypesMap: Record = {}; - let inputNodeNames: string[]; - let allNodeNames: string[]; - let mode: CodeExecutionMode; - let fileName: string; - - function updateFile(fileName: string, content: string) { - const exists = env.getSourceFile(fileName); - if (exists) { - env.updateFile(fileName, content); - } else { - env.createFile(fileName, content); - } - } - - async function loadNodeTypes(nodeName: string) { - const data = await nodeDataFetcher(nodeName); - - if (data?.json) { - const schema = data.json; - const typeName = pascalCase(nodeName); - const type = schemaToTypescriptTypes(schema, typeName); - loadedNodeTypesMap[nodeName] = { type, typeName }; - updateFile( - 'n8n-dynamic.d.ts', - `export {}; - -declare global { - type NodeName = ${allNodeNames.map((name) => `'${name}'`).join(' | ')}; - - ${Object.values(loadedNodeTypesMap) - .map(({ type }) => type) - .join(';\n')} - - interface NodeDataMap { - ${Object.entries(loadedNodeTypesMap) - .map(([nodeName, { typeName }]) => `'${nodeName}': NodeData<{}, ${typeName}, {}, {}>`) - .join(';\n')} - } -}`, - ); - } - } - - async function setInputNodeTypes(nodeName: string, mode: CodeExecutionMode) { - const typeName = pascalCase(nodeName); - updateFile( - 'n8n-dynamic-input.d.ts', - `export {}; - -declare global { - type N8nInputItem = N8nItem<${typeName}, {}>; - - interface N8nInput { - ${ - mode === 'runOnceForAllItems' - ? `all(branchIndex?: number, runIndex?: number): Array; -first(branchIndex?: number, runIndex?: number): N8nInputItem; -last(branchIndex?: number, runIndex?: number): N8nInputItem; -itemMatching(itemIndex: number): N8nInputItem;` - : 'item: N8nInputItem;' - } - } -}`, - ); - } - - async function loadTypesIfNeeded() { - 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; - } - - const file = env.getSourceFile(fileName); - // If we are completing a N8nJson type -> fetch types first - // $('Node A').item.json. - if (file) { - 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, - ); - - if (nodeNames.length === 0) return; - - for (const nodeName of nodeNames) { - if (!loadedNodeTypesMap[nodeName]) { - await loadNodeTypes(nodeName); - } - } - } - } - - return { - async init(options, nodeDataFetcherArg) { - nodeDataFetcher = nodeDataFetcherArg; - inputNodeNames = options.inputNodeNames; - allNodeNames = options.allNodeNames; - mode = options.mode; - fileName = `${options.id}.js`; - - const compilerOptions: 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, - }; - - const cache = await indexedDbCache('typescript-cache', 'fs-map'); - const fsMap = await tsvfs.createDefaultMapFromCDN( - compilerOptions, - ts.version, - true, - ts, - undefined, - undefined, - cache, - ); - - 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); - } - } - - fsMap.set('n8n.d.ts', n8nTypes); - fsMap.set('luxon.d.ts', luxonTypes); - fsMap.set('n8n-dynamic.d.ts', 'export {}'); - fsMap.set( - 'n8n-dynamic-input.d.ts', - `export {}; -declare global { - interface N8nInput { - ${ - mode === 'runOnceForAllItems' - ? `all(branchIndex?: number, runIndex?: number): Array; - first(branchIndex?: number, runIndex?: number): N8nItem; - last(branchIndex?: number, runIndex?: number): N8nItem; - itemMatching(itemIndex: number): N8nItem;` - : 'item: N8nItem;' - } - } -}`, - ); - fsMap.set(fileName, wrapInFunction(options.content, mode)); - - fsMap.set( - 'n8n-mode-specific.d.ts', - mode === 'runOnceForAllItems' ? runOnceForAllItemsTypes : runOnceForEachItemTypes, - ); - - const system = tsvfs.createSystem(fsMap); - env = tsvfs.createVirtualTypeScriptEnvironment( - system, - Array.from(fsMap.keys()), - ts, - compilerOptions, - ); - - if (options.variables) { - env.createFile( - 'n8n-variables.d.ts', - `export {} -declare global { - interface N8nVars { - ${options.variables.map((key) => `${key}: string;`).join('\n')} - } -}`, - ); - } - - 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)) { - env.createFile(path, content); - } - } else { - await loadTypes('luxon', '3.2.0', (path, types) => { - cache.setItem(path, types); - env.createFile(path, types); - }); - } - - await loadTypesIfNeeded(); - await Promise.all( - options.inputNodeNames.map(async (nodeName) => await loadNodeTypes(nodeName)), - ); - await Promise.all( - inputNodeNames.map(async (nodeName) => await setInputNodeTypes(nodeName, mode)), - ); - }, - updateFile: async (content) => { - updateFile(fileName, wrapInFunction(content, mode)); - await loadTypesIfNeeded(); - }, - async getCompletionsAtPos(pos) { - const tsPos = cmPosToTs(pos, fnPrefix(returnTypeForMode(mode))); - - const completionInfo = env.languageService.getCompletionsAtPosition(fileName, tsPos, {}, {}); - - if (!completionInfo) return null; - - const options = completionInfo.entries - .filter( - (entry) => - !TS_COMPLETE_BLOCKLIST.includes(entry.kind) && - (entry.sortText < '15' || completionInfo.optionalReplacementSpan?.length), - ) - .map((entry): Completion => { - const boost = -Number(entry.sortText) || 0; - let type = entry.kind ? String(entry.kind) : undefined; - - if (type === 'member') type = 'property'; - - return { - label: entry.name, - type, - commitCharacters: entry.commitCharacters ?? completionInfo.defaultCommitCharacters, - boost, - }; - }); - - return { - result: { - from: pos, - options, - }, - isGlobal: completionInfo.isGlobalCompletion, - }; - }, - getDiagnostics() { - 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, fnPrefix(returnTypeForMode(mode)))); - }, - getHoverTooltip(pos) { - const tsPos = cmPosToTs(pos, fnPrefix(returnTypeForMode(mode))); - const quickInfo = env.languageService.getQuickInfoAtPosition(fileName, tsPos); - - if (!quickInfo) return null; - - const start = tsPosToCm(quickInfo.textSpan.start, fnPrefix(returnTypeForMode(mode))); - - const typeDef = - env.languageService.getTypeDefinitionAtPosition(fileName, tsPos) ?? - env.languageService.getDefinitionAtPosition(fileName, tsPos); - - return { - start, - end: start + quickInfo.textSpan.length, - typeDef, - quickInfo, - }; - }, - async updateMode(newMode) { - mode = newMode; - updateFile( - 'n8n-mode-specific.d.ts', - mode === 'runOnceForAllItems' ? runOnceForAllItemsTypes : runOnceForEachItemTypes, - ); - await Promise.all( - inputNodeNames.map(async (nodeName) => await setInputNodeTypes(nodeName, mode)), - ); - }, - async updateNodeTypes() { - const nodeNames = Object.keys(loadedNodeTypesMap); - - await Promise.all(nodeNames.map(async (nodeName) => await loadNodeTypes(nodeName))); - await Promise.all( - inputNodeNames.map(async (nodeName) => await setInputNodeTypes(nodeName, mode)), - ); - }, - }; -}; - -Comlink.expose(worker()); diff --git a/packages/editor-ui/src/plugins/codemirror/lsp/worker/utils.ts b/packages/editor-ui/src/plugins/codemirror/lsp/worker/utils.ts deleted file mode 100644 index 40a9df34c0d2e..0000000000000 --- a/packages/editor-ui/src/plugins/codemirror/lsp/worker/utils.ts +++ /dev/null @@ -1,166 +0,0 @@ -import type { Schema } from '@/Interface'; -import type { Diagnostic } from '@codemirror/lint'; -import { type CodeExecutionMode } from 'n8n-workflow'; -import ts from 'typescript'; - -export const fnPrefix = (returnType: string) => `( -/** - * @returns {${returnType}} -*/ -() => {\n`; - -export function wrapInFunction(script: string, mode: CodeExecutionMode): string { - return `${fnPrefix(returnTypeForMode(mode))}${script}\n})()`; -} - -export function cmPosToTs(pos: number, prefix: string) { - return pos + prefix.length; -} - -export function tsPosToCm(pos: number, prefix: string) { - return pos - prefix.length; -} - -/** - * TypeScript has a set of diagnostic categories, - * which maps roughly onto CodeMirror's categories. - * Here, we do the mapping. - */ -export function tsCategoryToSeverity( - diagnostic: Pick, -): 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. - */ -export function isDiagnosticWithLocation( - diagnostic: ts.Diagnostic, -): diagnostic is ts.DiagnosticWithLocation { - return !!( - diagnostic.file && - typeof diagnostic.start === 'number' && - typeof diagnostic.length === 'number' - ); -} - -export 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. - */ -export function tsDiagnosticMessage(diagnostic: Pick): string { - if (typeof diagnostic.messageText === 'string') { - return diagnostic.messageText; - } - // TODO: go through linked list - return diagnostic.messageText.messageText; -} - -export function tsDiagnosticClassName(diagnostic: ts.Diagnostic) { - switch (diagnostic.code) { - case 6133: - // No unused variables - return 'cm-faded'; - default: - return undefined; - } -} - -export function convertTSDiagnosticToCM(d: ts.DiagnosticWithLocation, prefix: string): Diagnostic { - const start = tsPosToCm(d.start, prefix); - const message = tsDiagnosticMessage(d); - - return { - from: start, - to: start + d.length, - message, - markClass: tsDiagnosticClassName(d), - severity: tsCategoryToSeverity(d), - }; -} - -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)) { - // Handle tuple type if array has different types - if (schema.value.length > 0) { - return `Array<${schema.value[0].type}>`; - } - return '[]'; - } - 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 function returnTypeForMode(mode: CodeExecutionMode): string { - const returnItem = '{ json: { [key: string]: unknown } } | { [key: string]: unknown }'; - if (mode === 'runOnceForAllItems') { - return `Promise> | Array<${returnItem}>`; - } - - return `Promise<${returnItem}> | ${returnItem}`; -} diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/client/completionSource.ts b/packages/editor-ui/src/plugins/codemirror/typescript/client/completionSource.ts new file mode 100644 index 0000000000000..7086af7ef627a --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/typescript/client/completionSource.ts @@ -0,0 +1,47 @@ +import { escapeMappingString } from '@/utils/mappingUtils'; +import type { CompletionSource } from '@codemirror/autocomplete'; +import { autocompletableNodeNames } from '../../completions/utils'; +import { typescriptWorkerFacet } from './facet'; +import { snippets } from './snippets'; + +export const typescriptCompletionSource: CompletionSource = async (context) => { + const { worker } = context.state.facet(typescriptWorkerFacet); + const { pos } = context; + + let word = context.matchBefore(/[\$\w]+/); + if (!word?.text) { + word = context.matchBefore(/[\.\(\'\"]/); + } + + if (!word) return null; + + const completionResult = await worker.getCompletionsAtPos(context.pos, word?.text ?? ''); + + 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 ? (['"', "'", '(', '.'].includes(word.text) ? word.to : word.from) : pos, + options, + }; +}; diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/client/facet.ts b/packages/editor-ui/src/plugins/codemirror/typescript/client/facet.ts new file mode 100644 index 0000000000000..57bb2a2350308 --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/typescript/client/facet.ts @@ -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 }, + { worker: Comlink.Remote } +>({ + combine(configs) { + return combineConfig(configs, {}); + }, +}); diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/client/hoverTooltip.ts b/packages/editor-ui/src/plugins/codemirror/typescript/client/hoverTooltip.ts new file mode 100644 index 0000000000000..30c1f177e1d9d --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/typescript/client/hoverTooltip.ts @@ -0,0 +1,54 @@ +import type { hoverTooltip } from '@codemirror/view'; +import { typescriptWorkerFacet } from './facet'; + +type HoverSource = Parameters[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 }; + }, + }; +}; diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/client/linter.ts b/packages/editor-ui/src/plugins/codemirror/typescript/client/linter.ts new file mode 100644 index 0000000000000..c0c4042acac93 --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/typescript/client/linter.ts @@ -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; + }); +}; diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/client/snippets.ts b/packages/editor-ui/src/plugins/codemirror/typescript/client/snippets.ts new file mode 100644 index 0000000000000..b686625a023a4 --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/typescript/client/snippets.ts @@ -0,0 +1,54 @@ +import { snippetCompletion } from '@codemirror/autocomplete'; + +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', + }), +]; diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/client/useTypescript.ts b/packages/editor-ui/src/plugins/codemirror/typescript/client/useTypescript.ts new file mode 100644 index 0000000000000..48167f4cb5a02 --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/typescript/client/useTypescript.ts @@ -0,0 +1,111 @@ +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 { linter } from '@codemirror/lint'; +import { EditorView, hoverTooltip } from '@codemirror/view'; +import * as Comlink from 'comlink'; +import { NodeConnectionType, type CodeExecutionMode, type INodeExecutionData } from 'n8n-workflow'; +import { toRef, toValue, watch, type MaybeRefOrGetter } from 'vue'; +import type { RemoteLanguageServiceWorkerInit } from '../types'; +import { typescriptCompletionSource } from './completionSource'; +import { typescriptWorkerFacet } from './facet'; +import { typescriptHoverTooltips } from './hoverTooltip'; +import { typescriptLintSource } from './linter'; + +export async function useTypescript( + view: MaybeRefOrGetter, + mode: MaybeRefOrGetter, + id: MaybeRefOrGetter, +) { + const { init } = Comlink.wrap( + new Worker(new URL('../worker/typescript.worker.ts', import.meta.url), { type: 'module' }), + ); + const { getInputDataWithPinned, getSchemaForExecutionData } = useDataSchema(); + const ndvStore = useNDVStore(); + const workflowsStore = useWorkflowsStore(); + const { debounce } = useDebounce(); + const activeNodeName = ndvStore.activeNodeName; + + watch( + [() => workflowsStore.getWorkflowExecution, () => workflowsStore.getWorkflowRunData], + debounce( + async () => { + await worker.updateNodeTypes(); + forceParse(toValue(view)); + }, + { debounceTime: 200, trailing: true }, + ), + ); + + watch(toRef(mode), async (newMode) => { + await worker.updateMode(newMode); + forceParse(toValue(view)); + }); + + const worker = await init( + { + id: toValue(id), + content: toValue(view).state.doc.toString(), + allNodeNames: autocompletableNodeNames(), + variables: useEnvironmentsStore().variables.map((v) => v.key), + inputNodeNames: activeNodeName + ? workflowsStore + .getCurrentWorkflow() + .getParentNodes(activeNodeName, NodeConnectionType.Main, 1) + : [], + mode: toValue(mode), + }, + Comlink.proxy(async (nodeName) => { + const node = workflowsStore.getNodeByName(nodeName); + + 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), + params: getSchemaForExecutionData([node.parameters]), + }; + } + + return undefined; + }), + ); + + return [ + typescriptWorkerFacet.of({ worker }), + new LanguageSupport(javascriptLanguage, [ + javascriptLanguage.data.of({ autocomplete: typescriptCompletionSource }), + ]), + + autocompletion({ icons: false, aboveCursor: true }), + linter(typescriptLintSource), + hoverTooltip(typescriptHoverTooltips, { + hideOnChange: true, + hoverTime: 500, + }), + EditorView.updateListener.of(async (update) => { + if (!update.docChanged) return; + await worker.updateFile(update.state.doc.toString()); + }), + ]; +} diff --git a/packages/editor-ui/src/plugins/codemirror/lsp/types.ts b/packages/editor-ui/src/plugins/codemirror/typescript/types.ts similarity index 55% rename from packages/editor-ui/src/plugins/codemirror/lsp/types.ts rename to packages/editor-ui/src/plugins/codemirror/typescript/types.ts index 86dad992c97b9..e72b28278a7f9 100644 --- a/packages/editor-ui/src/plugins/codemirror/lsp/types.ts +++ b/packages/editor-ui/src/plugins/codemirror/typescript/types.ts @@ -1,8 +1,9 @@ +import type { Schema } from '@/Interface'; import type { CompletionResult } from '@codemirror/autocomplete'; import type { Diagnostic } from '@codemirror/lint'; -import type ts from 'typescript'; -import type { Schema } from '@/Interface'; import type { CodeExecutionMode } from 'n8n-workflow'; +import type ts from 'typescript'; +import type * as Comlink from 'comlink'; export interface HoverInfo { start: number; @@ -20,19 +21,28 @@ export type WorkerInitOptions = { mode: CodeExecutionMode; }; -export type NodeDataFetcher = ( - nodeName: string, -) => Promise<{ json: Schema | undefined; binary: string[] } | undefined>; +export type NodeData = { json: Schema | undefined; binary: string[]; params: Schema }; +export type NodeDataFetcher = (nodeName: string) => Promise; export type LanguageServiceWorker = { - init(options: WorkerInitOptions, nodeDataFetcher: NodeDataFetcher): Promise; updateFile(content: string): void; updateMode(mode: CodeExecutionMode): void; updateNodeTypes(): void; - getCompletionsAtPos( - pos: number, - wordBefore: string, - ): Promise<{ result: CompletionResult; isGlobal: boolean } | null>; + 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; +}; + +export type RemoteLanguageServiceWorkerInit = { + init( + options: WorkerInitOptions, + nodeDataFetcher: NodeDataFetcher, + ): Comlink.Remote; +}; diff --git a/packages/editor-ui/src/plugins/codemirror/lsp/worker/__snapshots__/utils.test.ts.snap b/packages/editor-ui/src/plugins/codemirror/typescript/worker/__snapshots__/utils.test.ts.snap similarity index 100% rename from packages/editor-ui/src/plugins/codemirror/lsp/worker/__snapshots__/utils.test.ts.snap rename to packages/editor-ui/src/plugins/codemirror/typescript/worker/__snapshots__/utils.test.ts.snap diff --git a/packages/editor-ui/src/plugins/codemirror/lsp/worker/cache.ts b/packages/editor-ui/src/plugins/codemirror/typescript/worker/cache.ts similarity index 97% rename from packages/editor-ui/src/plugins/codemirror/lsp/worker/cache.ts rename to packages/editor-ui/src/plugins/codemirror/typescript/worker/cache.ts index a6a9d70aeb3a9..1a516c3bb71d1 100644 --- a/packages/editor-ui/src/plugins/codemirror/lsp/worker/cache.ts +++ b/packages/editor-ui/src/plugins/codemirror/typescript/worker/cache.ts @@ -1,3 +1,5 @@ +export type IndexedDbCache = Awaited>; + export async function indexedDbCache(dbName: string, storeName: string) { let cache: Record = {}; diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/worker/completions.ts b/packages/editor-ui/src/plugins/codemirror/typescript/worker/completions.ts new file mode 100644 index 0000000000000..19a7472a1f145 --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/typescript/worker/completions.ts @@ -0,0 +1,51 @@ +import type * as tsvfs from '@typescript/vfs'; +import type ts from 'typescript'; +import { TS_COMPLETE_BLOCKLIST, TYPESCRIPT_AUTOCOMPLETE_THRESHOLD } from './constants'; +import type { Completion } from '@codemirror/autocomplete'; + +function typescriptCompletionToEditor( + completionInfo: ts.WithMetadata, + entry: ts.CompletionEntry, +): Completion { + const boost = -Number(entry.sortText) || 0; + let type = entry.kind ? String(entry.kind) : undefined; + + if (type === 'member') type = 'property'; + + return { + label: entry.name, + type, + commitCharacters: entry.commitCharacters ?? completionInfo.defaultCommitCharacters, + boost, + }; +} + +function filterTypescriptCompletions( + completionInfo: ts.WithMetadata, + 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, + }; +} diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/worker/constants.ts b/packages/editor-ui/src/plugins/codemirror/typescript/worker/constants.ts new file mode 100644 index 0000000000000..30844c37b7919 --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/typescript/worker/constants.ts @@ -0,0 +1,24 @@ +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', + MODE_TYPES: 'n8n-mode-specific.d.ts', + N8N_TYPES: 'n8n.d.ts', + GLOBAL_TYPES: 'globals.d.ts', +}; diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/worker/dynamicTypes.ts b/packages/editor-ui/src/plugins/codemirror/typescript/worker/dynamicTypes.ts new file mode 100644 index 0000000000000..14813733f139c --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/typescript/worker/dynamicTypes.ts @@ -0,0 +1,77 @@ +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)) { + // Handle tuple type if array has different types + 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 }) { + 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}, {}, {}>`) + .join(';\n')} +} +`); +} + +export async function getDynamicInputNodeTypes(inputNodeNames: string[]) { + const typeNames = inputNodeNames.map((nodeName) => pascalCase(nodeName)); + + return globalTypeDefinition(`type N8nInputItem = ${typeNames.join(' | ')}`); +} diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/worker/env.ts b/packages/editor-ui/src/plugins/codemirror/typescript/worker/env.ts new file mode 100644 index 0000000000000..e746f2a853f37 --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/typescript/worker/env.ts @@ -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) { + 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, + ); +} diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/worker/hoverTooltip.ts b/packages/editor-ui/src/plugins/codemirror/typescript/worker/hoverTooltip.ts new file mode 100644 index 0000000000000..e6a5c9ff2ffb6 --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/typescript/worker/hoverTooltip.ts @@ -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, + }; +} diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/worker/linter.ts b/packages/editor-ui/src/plugins/codemirror/typescript/worker/linter.ts new file mode 100644 index 0000000000000..c53774cbfe645 --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/typescript/worker/linter.ts @@ -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, +): 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): 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)); +} diff --git a/packages/editor-ui/src/plugins/codemirror/lsp/worker/typesLoader.ts b/packages/editor-ui/src/plugins/codemirror/typescript/worker/npmTypesLoader.ts similarity index 100% rename from packages/editor-ui/src/plugins/codemirror/lsp/worker/typesLoader.ts rename to packages/editor-ui/src/plugins/codemirror/typescript/worker/npmTypesLoader.ts diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/worker/type-declarations/globals.d.ts b/packages/editor-ui/src/plugins/codemirror/typescript/worker/type-declarations/globals.d.ts new file mode 100644 index 0000000000000..4ea8fc639b2aa --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/typescript/worker/type-declarations/globals.d.ts @@ -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; +} diff --git a/packages/editor-ui/src/plugins/codemirror/lsp/worker/type-declarations/n8n-once-for-all-items.d.ts b/packages/editor-ui/src/plugins/codemirror/typescript/worker/type-declarations/n8n-once-for-all-items.d.ts similarity index 76% rename from packages/editor-ui/src/plugins/codemirror/lsp/worker/type-declarations/n8n-once-for-all-items.d.ts rename to packages/editor-ui/src/plugins/codemirror/typescript/worker/type-declarations/n8n-once-for-all-items.d.ts index a8d87db9bc59b..c58abe3f09983 100644 --- a/packages/editor-ui/src/plugins/codemirror/lsp/worker/type-declarations/n8n-once-for-all-items.d.ts +++ b/packages/editor-ui/src/plugins/codemirror/typescript/worker/type-declarations/n8n-once-for-all-items.d.ts @@ -1,5 +1,4 @@ export {}; -export type N8nReturn = Promise> | Array; declare global { interface NodeData { @@ -10,4 +9,7 @@ declare global { last(branchIndex?: number, runIndex?: number): N8nItem; itemMatching(itemIndex: number): N8nItem; } + + // @ts-expect-error N8nInputItem is populated dynamically + type N8nInput = NodeData<{}, N8nInputItem, {}, {}>; } diff --git a/packages/editor-ui/src/plugins/codemirror/lsp/worker/type-declarations/n8n-once-for-each-item.d.ts b/packages/editor-ui/src/plugins/codemirror/typescript/worker/type-declarations/n8n-once-for-each-item.d.ts similarity index 69% rename from packages/editor-ui/src/plugins/codemirror/lsp/worker/type-declarations/n8n-once-for-each-item.d.ts rename to packages/editor-ui/src/plugins/codemirror/typescript/worker/type-declarations/n8n-once-for-each-item.d.ts index 62cf012d5dd39..e6b7727c96b8d 100644 --- a/packages/editor-ui/src/plugins/codemirror/lsp/worker/type-declarations/n8n-once-for-each-item.d.ts +++ b/packages/editor-ui/src/plugins/codemirror/typescript/worker/type-declarations/n8n-once-for-each-item.d.ts @@ -1,5 +1,4 @@ export {}; -export type N8nReturn = Promise | Object; declare global { interface NodeData { @@ -8,6 +7,9 @@ declare global { params: P; } + // @ts-expect-error N8nInputItem is populated dynamically + type N8nInput = NodeData<{}, N8nInputItem, {}, {}>; + const $itemIndex: number; const $json: N8nInput['item']['json']; const $binary: N8nInput['item']['binary']; diff --git a/packages/editor-ui/src/plugins/codemirror/lsp/worker/type-declarations/n8n.d.ts b/packages/editor-ui/src/plugins/codemirror/typescript/worker/type-declarations/n8n.d.ts similarity index 82% rename from packages/editor-ui/src/plugins/codemirror/lsp/worker/type-declarations/n8n.d.ts rename to packages/editor-ui/src/plugins/codemirror/typescript/worker/type-declarations/n8n.d.ts index 9a2d1f8234cdf..427e952c33e41 100644 --- a/packages/editor-ui/src/plugins/codemirror/lsp/worker/type-declarations/n8n.d.ts +++ b/packages/editor-ui/src/plugins/codemirror/typescript/worker/type-declarations/n8n.d.ts @@ -3,16 +3,8 @@ import type { DateTime } from 'luxon'; export {}; declare global { - /** [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; - interface N8nJson { - [key: string]: number | boolean | string | Object | Array | Date | DateTime; + [key: string]: any; } interface N8nBinary { @@ -35,9 +27,6 @@ declare global { binary: Record; } - // Will be populated dynamically - interface N8nInput {} - interface N8nCustomData { set(key: string, value: string): void; get(key: string): string; diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/worker/typescript.worker.ts b/packages/editor-ui/src/plugins/codemirror/typescript/worker/typescript.worker.ts new file mode 100644 index 0000000000000..1f2564fae6efc --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/typescript/worker/typescript.worker.ts @@ -0,0 +1,162 @@ +import * as Comlink from 'comlink'; +import type { LanguageServiceWorker, LanguageServiceWorkerInit } from '../types'; +import { indexedDbCache } from './cache'; +import { fnPrefix, wrapInFunction } 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 { TYPESCRIPT_FILES } from './constants'; +import { + getDynamicInputNodeTypes, + getDynamicNodeTypes, + 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'; + +self.process = { env: {} } as NodeJS.Process; + +const worker: LanguageServiceWorkerInit = { + async init(options, nodeDataFetcher) { + const loadedNodeTypesMap: Map = reactive(new Map()); + + const inputNodeNames = options.inputNodeNames; + const allNodeNames = options.allNodeNames; + const codeFileName = `${options.id}.js`; + const mode = ref(options.mode); + + const cache = await indexedDbCache('typescript-cache', 'fs-map'); + const env = await setupTypescriptEnv({ + cache, + mode: mode.value, + code: { content: options.content, 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); + + if (data?.json) { + const schema = data.json; + const typeName = pascalCase(nodeName); + const type = schemaToTypescriptTypes(schema, typeName); + 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); + } + } + } + + await loadTypesIfNeeded(); + await Promise.all( + options.inputNodeNames.map(async (nodeName) => await loadNodeTypes(nodeName)), + ); + + function updateFile(fileName: string, content: string) { + const exists = env.getSourceFile(fileName); + if (exists) { + env.updateFile(fileName, content); + } else { + env.createFile(fileName, content); + } + } + + 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 }, + ); + + return Comlink.proxy({ + updateFile: async (content) => { + updateFile(codeFileName, wrapInFunction(content, mode.value)); + await loadTypesIfNeeded(); + }, + async getCompletionsAtPos(pos) { + 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); diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/worker/typescriptAst.ts b/packages/editor-ui/src/plugins/codemirror/typescript/worker/typescriptAst.ts new file mode 100644 index 0000000000000..8c94b29da0df6 --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/typescript/worker/typescriptAst.ts @@ -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, + ); + + return nodeNames; +} diff --git a/packages/editor-ui/src/plugins/codemirror/lsp/worker/utils.test.ts b/packages/editor-ui/src/plugins/codemirror/typescript/worker/utils.test.ts similarity index 100% rename from packages/editor-ui/src/plugins/codemirror/lsp/worker/utils.test.ts rename to packages/editor-ui/src/plugins/codemirror/typescript/worker/utils.test.ts diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/worker/utils.ts b/packages/editor-ui/src/plugins/codemirror/typescript/worker/utils.ts new file mode 100644 index 0000000000000..aa457191ff9c3 --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/typescript/worker/utils.ts @@ -0,0 +1,27 @@ +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 { + const returnItem = '{ json: { [key: string]: unknown } } | { [key: string]: unknown }'; + if (mode === 'runOnceForAllItems') { + return `Promise> | Array<${returnItem}>`; + } + + return `Promise<${returnItem}> | ${returnItem}`; +} diff --git a/packages/editor-ui/tsconfig.json b/packages/editor-ui/tsconfig.json index 568122484dd17..7ba161b78d92d 100644 --- a/packages/editor-ui/tsconfig.json +++ b/packages/editor-ui/tsconfig.json @@ -28,5 +28,5 @@ "useUnknownInCatchVariables": false }, "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"], - "exclude": ["src/plugins/codemirror/lsp/worker/**/*.d.ts"] + "exclude": ["src/plugins/codemirror/typescript/worker/**/*.d.ts"] } From 2a43a5b7d8cdf43445b42e56b2ebffea7d428e86 Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Wed, 18 Dec 2024 17:20:42 +0100 Subject: [PATCH 26/43] Fix typescript compilation issues --- .../typescript/client/completionSource.ts | 2 +- .../worker/__snapshots__/utils.test.ts.snap | 108 ------------------ .../{utils.test.ts => dynamicTypes.test.ts} | 14 +-- 3 files changed, 5 insertions(+), 119 deletions(-) delete mode 100644 packages/editor-ui/src/plugins/codemirror/typescript/worker/__snapshots__/utils.test.ts.snap rename packages/editor-ui/src/plugins/codemirror/typescript/worker/{utils.test.ts => dynamicTypes.test.ts} (77%) diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/client/completionSource.ts b/packages/editor-ui/src/plugins/codemirror/typescript/client/completionSource.ts index 7086af7ef627a..8c6ffdcbc5431 100644 --- a/packages/editor-ui/src/plugins/codemirror/typescript/client/completionSource.ts +++ b/packages/editor-ui/src/plugins/codemirror/typescript/client/completionSource.ts @@ -15,7 +15,7 @@ export const typescriptCompletionSource: CompletionSource = async (context) => { if (!word) return null; - const completionResult = await worker.getCompletionsAtPos(context.pos, word?.text ?? ''); + const completionResult = await worker.getCompletionsAtPos(context.pos); if (!completionResult || context.aborted) return null; diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/worker/__snapshots__/utils.test.ts.snap b/packages/editor-ui/src/plugins/codemirror/typescript/worker/__snapshots__/utils.test.ts.snap deleted file mode 100644 index a73459dd59e82..0000000000000 --- a/packages/editor-ui/src/plugins/codemirror/typescript/worker/__snapshots__/utils.test.ts.snap +++ /dev/null @@ -1,108 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`typescript worker utils > generateExtensionTypes > should work 1`] = ` -"export {} -declare global { - interface Array { -removeDuplicates(fieldNames: any): any; -unique(fieldNames: any): any; -first(): any; -last(): any; -pluck(fieldNames: string): Array; -randomItem(): any; -sum(): number; -min(): number; -max(): number; -average(): number; -isNotEmpty(): boolean; -isEmpty(): boolean; -compact(): Array; -smartJoin(keyField: string,nameField: string): Object; -chunk(length: number): Array; -renameKeys(from: string,to: string): Array; -merge(otherArray: Array): Object; -union(otherArray: Array): Array; -difference(otherArray: Array): Array; -intersection(otherArray: Array): Array; -append(elements: any): Array; -toJsonString(): string; -} -interface Date { -beginningOf(unit?: DurationUnit): DateTime; -endOfMonth(): DateTime; -extract(unit?: string): number; -isBetween(date1: string | DateTime,date2: string | DateTime): boolean; -isDst(): boolean; -isInLast(n: number,unit?: DurationUnit): boolean; -isWeekend(): boolean; -minus(n: number | object,unit?: string): DateTime; -plus(n: number | object,unit?: string): DateTime; -format(fmt: string): string; -toDateTime(): DateTime; -diffTo(otherDateTime: string | DateTime,unit: string | string[]): number | Record; -diffToNow(unit: string | string[]): number | Record; -isEmpty(): boolean; -isNotEmpty(): boolean; -} -interface Number { -ceil(): number; -floor(): number; -format(locale?: string,options?: object): string; -round(decimalPlaces?: number): number; -abs(): number; -isInteger(): boolean; -isEven(): boolean; -isOdd(): boolean; -toBoolean(): boolean; -toDateTime(format?: string): DateTime; -} -interface Object { -isEmpty(): boolean; -isNotEmpty(): boolean; -hasField(name: string): boolean; -removeField(key: string): Object; -removeFieldsContaining(value: string): Object; -keepFieldsContaining(value: string): Object; -compact(): Object; -urlEncode(): string; -keys(): Array; -values(): Array; -toJsonString(): string; -} -interface String { -hash(algo?: string): string; -removeMarkdown(): string; -removeTags(): string; -toDate(): Date; -toDateTime(format?: string): DateTime; -toBoolean(): boolean; -toNumber(): number; -toFloat(): number; -toInt(radix?: number): number; -toSentenceCase(): string; -toSnakeCase(): string; -toTitleCase(): string; -urlDecode(allChars?: boolean): string; -urlEncode(allChars?: boolean): string; -quote(mark?: string): string; -replaceSpecialChars(): string; -length(): number; -isDomain(): boolean; -isEmail(): boolean; -isNumeric(): boolean; -isUrl(): boolean; -isEmpty(): boolean; -isNotEmpty(): boolean; -extractEmail(): string; -extractDomain(): string; -extractUrl(): string; -extractUrlPath(): string; -parseJson(): any; -base64Encode(): string; -base64Decode(): string; -} -interface Boolean { -toNumber(): number; -} -}" -`; diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/worker/utils.test.ts b/packages/editor-ui/src/plugins/codemirror/typescript/worker/dynamicTypes.test.ts similarity index 77% rename from packages/editor-ui/src/plugins/codemirror/typescript/worker/utils.test.ts rename to packages/editor-ui/src/plugins/codemirror/typescript/worker/dynamicTypes.test.ts index 3ff54c95a50cf..668a86d4c677c 100644 --- a/packages/editor-ui/src/plugins/codemirror/typescript/worker/utils.test.ts +++ b/packages/editor-ui/src/plugins/codemirror/typescript/worker/dynamicTypes.test.ts @@ -1,14 +1,8 @@ -import { generateExtensionTypes, schemaToTypescriptTypes } from './utils'; - -describe('typescript worker utils', () => { - describe('generateExtensionTypes', () => { - it('should work', async () => { - expect(await generateExtensionTypes()).toMatchSnapshot(); - }); - }); +import { schemaToTypescriptTypes } from './dynamicTypes'; +describe('typescript worker dynamicTypes', () => { describe('schemaToTypescriptTypes', () => { - it('should work', () => { + it('should convert a schema to a typescript type', () => { expect( schemaToTypescriptTypes( { @@ -56,7 +50,7 @@ describe('typescript worker utils', () => { ], path: '', }, - 'Node name 1', + 'NodeName_1', ), ).toEqual(`interface NodeName_1 { test: string; From 9dceb02ec3c64a2574ca9036f0983ceb5d00cf1c Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Wed, 18 Dec 2024 18:12:25 +0100 Subject: [PATCH 27/43] Add support for binary autocompletion --- .../typescript/client/useTypescript.ts | 2 +- .../codemirror/typescript/worker/constants.ts | 2 + .../typescript/worker/dynamicTypes.ts | 19 ++++++- .../n8n-once-for-all-items.d.ts | 4 +- .../n8n-once-for-each-item.d.ts | 4 +- .../worker/type-declarations/n8n.d.ts | 3 +- .../typescript/worker/typescript.worker.ts | 56 +++++++++++++++---- 7 files changed, 70 insertions(+), 20 deletions(-) diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/client/useTypescript.ts b/packages/editor-ui/src/plugins/codemirror/typescript/client/useTypescript.ts index 48167f4cb5a02..b095cad2b9f8f 100644 --- a/packages/editor-ui/src/plugins/codemirror/typescript/client/useTypescript.ts +++ b/packages/editor-ui/src/plugins/codemirror/typescript/client/useTypescript.ts @@ -82,7 +82,7 @@ export async function useTypescript( return { json: schema, - binary: Object.keys(binaryData), + binary: Object.keys(binaryData.reduce((acc, obj) => ({ ...acc, ...obj }), {})), params: getSchemaForExecutionData([node.parameters]), }; } diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/worker/constants.ts b/packages/editor-ui/src/plugins/codemirror/typescript/worker/constants.ts index 30844c37b7919..215d7a2544284 100644 --- a/packages/editor-ui/src/plugins/codemirror/typescript/worker/constants.ts +++ b/packages/editor-ui/src/plugins/codemirror/typescript/worker/constants.ts @@ -18,7 +18,9 @@ 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'; diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/worker/dynamicTypes.ts b/packages/editor-ui/src/plugins/codemirror/typescript/worker/dynamicTypes.ts index 14813733f139c..e8cde87824e12 100644 --- a/packages/editor-ui/src/plugins/codemirror/typescript/worker/dynamicTypes.ts +++ b/packages/editor-ui/src/plugins/codemirror/typescript/worker/dynamicTypes.ts @@ -64,7 +64,10 @@ ${Array.from(loadedNodes.values()) interface NodeDataMap { ${Array.from(loadedNodes.entries()) - .map(([nodeName, { typeName }]) => `'${nodeName}': NodeData<{}, ${typeName}, {}, {}>`) + .map( + ([nodeName, { typeName }]) => + `'${nodeName}': NodeData<${typeName}Context, ${typeName}Json, ${typeName}BinaryKeys, ${typeName}Params>`, + ) .join(';\n')} } `); @@ -73,5 +76,17 @@ interface NodeDataMap { export async function getDynamicInputNodeTypes(inputNodeNames: string[]) { const typeNames = inputNodeNames.map((nodeName) => pascalCase(nodeName)); - return globalTypeDefinition(`type N8nInputItem = ${typeNames.join(' | ')}`); + 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')} +}`); } diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/worker/type-declarations/n8n-once-for-all-items.d.ts b/packages/editor-ui/src/plugins/codemirror/typescript/worker/type-declarations/n8n-once-for-all-items.d.ts index c58abe3f09983..1487c6e495d9f 100644 --- a/packages/editor-ui/src/plugins/codemirror/typescript/worker/type-declarations/n8n-once-for-all-items.d.ts +++ b/packages/editor-ui/src/plugins/codemirror/typescript/worker/type-declarations/n8n-once-for-all-items.d.ts @@ -10,6 +10,6 @@ declare global { itemMatching(itemIndex: number): N8nItem; } - // @ts-expect-error N8nInputItem is populated dynamically - type N8nInput = NodeData<{}, N8nInputItem, {}, {}>; + // @ts-expect-error N8nInputJson is populated dynamically + type N8nInput = NodeData; } diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/worker/type-declarations/n8n-once-for-each-item.d.ts b/packages/editor-ui/src/plugins/codemirror/typescript/worker/type-declarations/n8n-once-for-each-item.d.ts index e6b7727c96b8d..b1d6f6ddf18b5 100644 --- a/packages/editor-ui/src/plugins/codemirror/typescript/worker/type-declarations/n8n-once-for-each-item.d.ts +++ b/packages/editor-ui/src/plugins/codemirror/typescript/worker/type-declarations/n8n-once-for-each-item.d.ts @@ -7,8 +7,8 @@ declare global { params: P; } - // @ts-expect-error N8nInputItem is populated dynamically - type N8nInput = NodeData<{}, N8nInputItem, {}, {}>; + // @ts-expect-error N8nInputJson is populated dynamically + type N8nInput = NodeData<{}, N8nInputJson, {}, {}>; const $itemIndex: number; const $json: N8nInput['item']['json']; diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/worker/type-declarations/n8n.d.ts b/packages/editor-ui/src/plugins/codemirror/typescript/worker/type-declarations/n8n.d.ts index 427e952c33e41..341ed4a41ff2c 100644 --- a/packages/editor-ui/src/plugins/codemirror/typescript/worker/type-declarations/n8n.d.ts +++ b/packages/editor-ui/src/plugins/codemirror/typescript/worker/type-declarations/n8n.d.ts @@ -16,7 +16,6 @@ declare global { mimeType: string; } - // TODO: populate dynamically interface N8nVars {} // TODO: populate dynamically @@ -63,7 +62,7 @@ declare global { const $now: DateTime; const $today: DateTime; - const $parameter: N8nParameter; + const $parameter: N8nInput['params']; const $vars: N8nVars; const $nodeVersion: number; diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/worker/typescript.worker.ts b/packages/editor-ui/src/plugins/codemirror/typescript/worker/typescript.worker.ts index 1f2564fae6efc..381fcb54b1007 100644 --- a/packages/editor-ui/src/plugins/codemirror/typescript/worker/typescript.worker.ts +++ b/packages/editor-ui/src/plugins/codemirror/typescript/worker/typescript.worker.ts @@ -8,10 +8,11 @@ import type { CodeExecutionMode } from 'n8n-workflow'; import { pascalCase } from 'change-case'; import { computed, reactive, ref, watch } from 'vue'; import { getCompletionsAtPos } from './completions'; -import { TYPESCRIPT_FILES } from './constants'; +import { LUXON_VERSION, TYPESCRIPT_FILES } from './constants'; import { getDynamicInputNodeTypes, getDynamicNodeTypes, + getDynamicVariableTypes, schemaToTypescriptTypes, } from './dynamicTypes'; import { setupTypescriptEnv } from './env'; @@ -21,6 +22,7 @@ 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'; self.process = { env: {} } as NodeJS.Process; @@ -53,12 +55,20 @@ const worker: LanguageServiceWorkerInit = { async function loadNodeTypes(nodeName: string) { const data = await nodeDataFetcher(nodeName); - if (data?.json) { - const schema = data.json; - const typeName = pascalCase(nodeName); - const type = schemaToTypescriptTypes(schema, typeName); - loadedNodeTypesMap.set(nodeName, { type, typeName }); - } + 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() { @@ -75,10 +85,27 @@ const worker: LanguageServiceWorkerInit = { } } - await loadTypesIfNeeded(); - await Promise.all( - options.inputNodeNames.map(async (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); @@ -89,6 +116,13 @@ const worker: LanguageServiceWorkerInit = { } } + const loadInputNodes = options.inputNodeNames.map( + async (nodeName) => await loadNodeTypes(nodeName), + ); + await Promise.all( + loadInputNodes.concat(loadTypesIfNeeded(), loadLuxonTypes(), setVariableTypes()), + ); + watch( loadedNodeTypesMap, async (loadedNodes) => { From 93d039e3ca5b60b9b271c5ae9f1c68d0e441d3fb Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Fri, 20 Dec 2024 11:06:52 +0100 Subject: [PATCH 28/43] Fix prefix match function --- .../plugins/codemirror/completions/utils.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/packages/editor-ui/src/plugins/codemirror/completions/utils.ts b/packages/editor-ui/src/plugins/codemirror/completions/utils.ts index 6d4ee5c56d0c3..d4476f55691c3 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/utils.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/utils.ts @@ -112,19 +112,15 @@ 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'); - } - - return strings.reduce((acc, next) => { - let i = 0; + if (strings.length < 2) return ''; - 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) => From 4fe1f7c8a28c3501a56ad3befebde681a20f3b4e Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Fri, 20 Dec 2024 11:07:47 +0100 Subject: [PATCH 29/43] Improve comment completion, make filtering consistent with expressions --- .../typescript/client/completionSource.ts | 35 +++++++++++++++---- .../codemirror/typescript/client/snippets.ts | 6 ++++ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/client/completionSource.ts b/packages/editor-ui/src/plugins/codemirror/typescript/client/completionSource.ts index 8c6ffdcbc5431..52bd5bd388172 100644 --- a/packages/editor-ui/src/plugins/codemirror/typescript/client/completionSource.ts +++ b/packages/editor-ui/src/plugins/codemirror/typescript/client/completionSource.ts @@ -1,16 +1,27 @@ import { escapeMappingString } from '@/utils/mappingUtils'; -import type { CompletionSource } from '@codemirror/autocomplete'; -import { autocompletableNodeNames } from '../../completions/utils'; +import type { Completion, CompletionSource } from '@codemirror/autocomplete'; +import { + autocompletableNodeNames, + longestCommonPrefix, + prefixMatch, +} from '../../completions/utils'; import { typescriptWorkerFacet } from './facet'; -import { snippets } from './snippets'; +import { blockCommentSnippet, snippets } from './snippets'; + +const START_CHARACTERS = ['"', "'", '(', '.', '@']; export const typescriptCompletionSource: CompletionSource = async (context) => { const { worker } = context.state.facet(typescriptWorkerFacet); - const { pos } = context; let word = context.matchBefore(/[\$\w]+/); if (!word?.text) { - word = context.matchBefore(/[\.\(\'\"]/); + word = context.matchBefore(/[\.\(\'\"\@]/); + } + + const blockComment = context.matchBefore(/\/\*?\*?/); + if (blockComment) { + // Autocomplete a block comment snippet + return { from: blockComment?.from, options: [blockCommentSnippet] }; } if (!word) return null; @@ -41,7 +52,17 @@ export const typescriptCompletionSource: CompletionSource = async (context) => { } return { - from: word ? (['"', "'", '(', '.'].includes(word.text) ? word.to : word.from) : pos, - options, + 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, word.text), + ), }; }; diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/client/snippets.ts b/packages/editor-ui/src/plugins/codemirror/typescript/client/snippets.ts index b686625a023a4..720e1002494fc 100644 --- a/packages/editor-ui/src/plugins/codemirror/typescript/client/snippets.ts +++ b/packages/editor-ui/src/plugins/codemirror/typescript/client/snippets.ts @@ -1,5 +1,10 @@ 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}', { @@ -51,4 +56,5 @@ export const snippets = [ label: 'while', detail: 'While Statement', }), + blockCommentSnippet, ]; From d8e87d303635743651b3c96ce5a176653f9721ba Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Fri, 20 Dec 2024 11:08:01 +0100 Subject: [PATCH 30/43] Improve return type of code node --- .../worker/type-declarations/n8n.d.ts | 18 ++++++++++++++++++ .../codemirror/typescript/worker/utils.ts | 7 +------ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/worker/type-declarations/n8n.d.ts b/packages/editor-ui/src/plugins/codemirror/typescript/worker/type-declarations/n8n.d.ts index 341ed4a41ff2c..9cc91124592b1 100644 --- a/packages/editor-ui/src/plugins/codemirror/typescript/worker/type-declarations/n8n.d.ts +++ b/packages/editor-ui/src/plugins/codemirror/typescript/worker/type-declarations/n8n.d.ts @@ -3,6 +3,24 @@ import type { DateTime } from 'luxon'; export {}; declare global { + type OutputItemWithoutJsonKey = { + [key: string]: unknown; + } & { json?: never }; + + type OutputItemWithJsonKey = { + json: { + [key: string]: unknown; + }; + }; + + type MaybePromise = Promise | T; + + type OneOutputItem = OutputItemWithJsonKey | OutputItemWithoutJsonKey; + type AllOutputItems = OneOutputItem | Array; + + type N8nOutputItem = MaybePromise; + type N8nOutputItems = MaybePromise; + interface N8nJson { [key: string]: any; } diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/worker/utils.ts b/packages/editor-ui/src/plugins/codemirror/typescript/worker/utils.ts index aa457191ff9c3..cc0f22c64ef53 100644 --- a/packages/editor-ui/src/plugins/codemirror/typescript/worker/utils.ts +++ b/packages/editor-ui/src/plugins/codemirror/typescript/worker/utils.ts @@ -18,10 +18,5 @@ declare global { } export function returnTypeForMode(mode: CodeExecutionMode): string { - const returnItem = '{ json: { [key: string]: unknown } } | { [key: string]: unknown }'; - if (mode === 'runOnceForAllItems') { - return `Promise> | Array<${returnItem}>`; - } - - return `Promise<${returnItem}> | ${returnItem}`; + return mode === 'runOnceForAllItems' ? 'N8nOutputItems' : 'N8nOutputItem'; } From 0bcb8cdebb8ddcc6c5046a5e330405d81ec46f20 Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Fri, 20 Dec 2024 11:59:02 +0100 Subject: [PATCH 31/43] Fix expression autocomplete styles --- .../editor-ui/src/styles/plugins/_codemirror.scss | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/editor-ui/src/styles/plugins/_codemirror.scss b/packages/editor-ui/src/styles/plugins/_codemirror.scss index 1342e55b80327..210791d5621a5 100644 --- a/packages/editor-ui/src/styles/plugins/_codemirror.scss +++ b/packages/editor-ui/src/styles/plugins/_codemirror.scss @@ -16,8 +16,9 @@ > ul[role='listbox'] { font-family: var(--font-family-monospace); - max-height: min(250px, 50vh); - 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); @@ -32,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; @@ -305,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, @@ -338,7 +343,7 @@ 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); @@ -346,6 +351,6 @@ .autocomplete-info-container { height: auto; - max-height: min(250px, 50vh); + max-height: min(220px, 20vh); } } From 6cde8c404ab187653ab46664451ac4525f353745 Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Fri, 20 Dec 2024 11:59:31 +0100 Subject: [PATCH 32/43] Remove hardcoded mention of luxon package --- .../typescript/worker/npmTypesLoader.ts | 42 ++++++++++++++----- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/worker/npmTypesLoader.ts b/packages/editor-ui/src/plugins/codemirror/typescript/worker/npmTypesLoader.ts index 817e0ee05ed11..a1eadf750c7bb 100644 --- a/packages/editor-ui/src/plugins/codemirror/typescript/worker/npmTypesLoader.ts +++ b/packages/editor-ui/src/plugins/codemirror/typescript/worker/npmTypesLoader.ts @@ -5,6 +5,27 @@ type NPMTreeMeta = { version: string; }; +const jsDelivrApi = { + async getFileTree(packageName: string, version = 'latest'): Promise { + 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 { + 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, @@ -13,11 +34,11 @@ export const loadTypes = async ( const { files } = await loadTypesFileTree(packageName, version); await Promise.all( files - .filter(({ name }) => name.endsWith('.d.ts') || name === '/package.json') + .filter((file) => isRequiredTypePackageFile(file.name)) .map( - async ({ name }) => - await loadFileContent(name).then((content) => - onFileReceived(`/node_modules/@types/luxon${name}`, content), + async (file) => + await loadFileContent(packageName, file.name, version).then((content) => + onFileReceived(toLocalFilePath(packageName, file.name), content), ), ), ); @@ -27,12 +48,13 @@ export const loadTypesFileTree = async ( packageName: string, version: string, ): Promise => { - const url = `https://data.jsdelivr.com/v1/package/npm/@types/${packageName}@${version}/flat`; - const res = await fetch(url); - return await res.json(); + return await jsDelivrApi.getFileTree(`@types/${packageName}`, version); }; -export const loadFileContent = async (file: string) => { - const url = `https://cdn.jsdelivr.net/npm/@types/luxon@3.2.0${file}`; - return await fetch(url).then(async (res) => await res.text()); +export const loadFileContent = async ( + packageName: string, + fileName: string, + version = 'latest', +) => { + return await jsDelivrApi.getFileContent(`@types/${packageName}`, fileName, version); }; From 3dd50c10da96521da6dbbf9a745896a2f53fae1a Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Fri, 20 Dec 2024 14:05:26 +0100 Subject: [PATCH 33/43] Restore line highlighting, fix selection color --- .../design-system/src/css/_primitives.scss | 1 + .../design-system/src/css/_tokens.dark.scss | 2 +- packages/design-system/src/css/_tokens.scss | 4 +-- .../CodeNodeEditor/baseExtensions.ts | 35 ------------------- .../src/composables/useCodeEditor.ts | 2 ++ 5 files changed, 6 insertions(+), 38 deletions(-) delete mode 100644 packages/editor-ui/src/components/CodeNodeEditor/baseExtensions.ts diff --git a/packages/design-system/src/css/_primitives.scss b/packages/design-system/src/css/_primitives.scss index 71c6b4c10fa6e..73d47a7fd4596 100644 --- a/packages/design-system/src/css/_primitives.scss +++ b/packages/design-system/src/css/_primitives.scss @@ -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%); diff --git a/packages/design-system/src/css/_tokens.dark.scss b/packages/design-system/src/css/_tokens.dark.scss index 766c0aaad3c4b..ca907c4be2efb 100644 --- a/packages/design-system/src/css/_tokens.dark.scss +++ b/packages/design-system/src/css/_tokens.dark.scss @@ -165,7 +165,7 @@ --color-json-highlight: var(--color-background-base); --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: #3392ff44; diff --git a/packages/design-system/src/css/_tokens.scss b/packages/design-system/src/css/_tokens.scss index b37865951acb8..fd52f8d6e16e1 100644 --- a/packages/design-system/src/css/_tokens.scss +++ b/packages/design-system/src/css/_tokens.scss @@ -209,9 +209,9 @@ --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-caret: var(--prim-gray-820); --color-code-selection: #0366d625; --color-code-selection-highlight: #34d05840; --color-code-gutter-background: var(--prim-gray-0); diff --git a/packages/editor-ui/src/components/CodeNodeEditor/baseExtensions.ts b/packages/editor-ui/src/components/CodeNodeEditor/baseExtensions.ts deleted file mode 100644 index 3327e0be3dc48..0000000000000 --- a/packages/editor-ui/src/components/CodeNodeEditor/baseExtensions.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { history } from '@codemirror/commands'; -import { bracketMatching, foldGutter, indentOnInput } from '@codemirror/language'; -import { lintGutter } from '@codemirror/lint'; -import { type Extension, Prec } from '@codemirror/state'; -import { - dropCursor, - EditorView, - highlightActiveLine, - highlightActiveLineGutter, - highlightSpecialChars, - keymap, - lineNumbers, -} from '@codemirror/view'; - -import { codeInputHandler } from '@/plugins/codemirror/inputHandlers/code.inputHandler'; -import { editorKeymap } 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(editorKeymap)), -]; diff --git a/packages/editor-ui/src/composables/useCodeEditor.ts b/packages/editor-ui/src/composables/useCodeEditor.ts index bdc47d833e331..7ca315ebddfc5 100644 --- a/packages/editor-ui/src/composables/useCodeEditor.ts +++ b/packages/editor-ui/src/composables/useCodeEditor.ts @@ -22,6 +22,7 @@ import { dropCursor, EditorView, highlightActiveLineGutter, + highlightActiveLine, highlightSpecialChars, keymap, lineNumbers, @@ -270,6 +271,7 @@ export const useCodeEditor = ({ indentOnInput(), bracketMatching(), closeBrackets(), + highlightActiveLine(), highlightActiveLineGutter(), mappingDropCursor(), indentationMarkers({ From 352a3390e0f8b598cb0c35dd572a48dfc632aa81 Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Fri, 20 Dec 2024 15:48:00 +0100 Subject: [PATCH 34/43] Set cursor correctly for function completions --- .../plugins/codemirror/completions/utils.ts | 2 +- .../{completionSource.ts => completions.ts} | 33 +++++++++++++++---- .../typescript/client/useTypescript.ts | 3 +- .../typescript/worker/completions.ts | 21 ++++++++---- .../typescript/worker/dynamicTypes.ts | 1 - 5 files changed, 43 insertions(+), 17 deletions(-) rename packages/editor-ui/src/plugins/codemirror/typescript/client/{completionSource.ts => completions.ts} (68%) diff --git a/packages/editor-ui/src/plugins/codemirror/completions/utils.ts b/packages/editor-ui/src/plugins/codemirror/completions/utils.ts index d4476f55691c3..929d1d918c2a7 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/utils.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/utils.ts @@ -124,7 +124,7 @@ export function longestCommonPrefix(...strings: string[]) { } 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 diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/client/completionSource.ts b/packages/editor-ui/src/plugins/codemirror/typescript/client/completions.ts similarity index 68% rename from packages/editor-ui/src/plugins/codemirror/typescript/client/completionSource.ts rename to packages/editor-ui/src/plugins/codemirror/typescript/client/completions.ts index 52bd5bd388172..a21109b22d2b8 100644 --- a/packages/editor-ui/src/plugins/codemirror/typescript/client/completionSource.ts +++ b/packages/editor-ui/src/plugins/codemirror/typescript/client/completions.ts @@ -1,5 +1,10 @@ import { escapeMappingString } from '@/utils/mappingUtils'; -import type { Completion, CompletionSource } from '@codemirror/autocomplete'; +import { + insertCompletionText, + pickedCompletion, + type Completion, + type CompletionSource, +} from '@codemirror/autocomplete'; import { autocompletableNodeNames, longestCommonPrefix, @@ -58,11 +63,25 @@ export const typescriptCompletionSource: CompletionSource = async (context) => { 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, word.text), - ), + options: options + .filter( + (option) => + word.text === '' || + START_CHARACTERS.includes(word.text) || + prefixMatch(option.label, word.text), + ) + .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; + }), }; }; diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/client/useTypescript.ts b/packages/editor-ui/src/plugins/codemirror/typescript/client/useTypescript.ts index b095cad2b9f8f..fcfedd1d44fc0 100644 --- a/packages/editor-ui/src/plugins/codemirror/typescript/client/useTypescript.ts +++ b/packages/editor-ui/src/plugins/codemirror/typescript/client/useTypescript.ts @@ -16,7 +16,7 @@ import * as Comlink from 'comlink'; import { NodeConnectionType, type CodeExecutionMode, type INodeExecutionData } from 'n8n-workflow'; import { toRef, toValue, watch, type MaybeRefOrGetter } from 'vue'; import type { RemoteLanguageServiceWorkerInit } from '../types'; -import { typescriptCompletionSource } from './completionSource'; +import { typescriptCompletionSource } from './completions'; import { typescriptWorkerFacet } from './facet'; import { typescriptHoverTooltips } from './hoverTooltip'; import { typescriptLintSource } from './linter'; @@ -96,7 +96,6 @@ export async function useTypescript( new LanguageSupport(javascriptLanguage, [ javascriptLanguage.data.of({ autocomplete: typescriptCompletionSource }), ]), - autocompletion({ icons: false, aboveCursor: true }), linter(typescriptLintSource), hoverTooltip(typescriptHoverTooltips, { diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/worker/completions.ts b/packages/editor-ui/src/plugins/codemirror/typescript/worker/completions.ts index 19a7472a1f145..1a634397cf4ae 100644 --- a/packages/editor-ui/src/plugins/codemirror/typescript/worker/completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/typescript/worker/completions.ts @@ -1,21 +1,30 @@ 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'; -import type { Completion } from '@codemirror/autocomplete'; + +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, entry: ts.CompletionEntry, ): Completion { const boost = -Number(entry.sortText) || 0; - let type = entry.kind ? String(entry.kind) : undefined; - - if (type === 'member') type = 'property'; + const type = convertTsKindtoEditorCompletionType(entry.kind); return { - label: entry.name, - type, + label: type && ['method', 'function'].includes(type) ? entry.name + '()' : entry.name, + type: convertTsKindtoEditorCompletionType(entry.kind), commitCharacters: entry.commitCharacters ?? completionInfo.defaultCommitCharacters, + detail: entry.labelDetails?.detail, boost, }; } diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/worker/dynamicTypes.ts b/packages/editor-ui/src/plugins/codemirror/typescript/worker/dynamicTypes.ts index e8cde87824e12..0991472246d55 100644 --- a/packages/editor-ui/src/plugins/codemirror/typescript/worker/dynamicTypes.ts +++ b/packages/editor-ui/src/plugins/codemirror/typescript/worker/dynamicTypes.ts @@ -18,7 +18,6 @@ function processSchema(schema: Schema): string { case 'array': if (Array.isArray(schema.value)) { - // Handle tuple type if array has different types if (schema.value.length > 0) { return `Array<${processSchema(schema.value[0])}>`; } From 1c632ffe484306d6724a5575be1604d62f2b5079 Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Mon, 6 Jan 2025 09:26:04 +0100 Subject: [PATCH 35/43] Don't store huge code in localstorage --- packages/editor-ui/src/composables/useCodeEditor.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/editor-ui/src/composables/useCodeEditor.ts b/packages/editor-ui/src/composables/useCodeEditor.ts index 7ca315ebddfc5..01f066f90a8ea 100644 --- a/packages/editor-ui/src/composables/useCodeEditor.ts +++ b/packages/editor-ui/src/composables/useCodeEditor.ts @@ -359,7 +359,12 @@ export const useCodeEditor = ({ if (editor.value) { const stateToStore = editor.value.state.toJSON(storedStateFields); - localStorage.setItem(storedStateId.value, JSON.stringify(stateToStore)); + try { + localStorage.setItem(storedStateId.value, JSON.stringify(stateToStore)); + } catch (error) { + // Code is too large, localStorage quota exceeded + localStorage.removeItem(storedStateId.value); + } editor.value.destroy(); } }); From 130d794d0f424a603f2bc1f5ffc96557b1c0d9d5 Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Mon, 6 Jan 2025 14:19:13 +0100 Subject: [PATCH 36/43] Fix bug with placeholder in code node editor --- .../src/components/CodeNodeEditor/CodeNodeEditor.vue | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue b/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue index df0266da427a2..5cf104b094d29 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue +++ b/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue @@ -87,6 +87,10 @@ const { highlightLine, readEditorValue, editor } = useCodeEditor({ onMounted(() => { if (!props.isReadOnly) codeNodeEditorEventBus.on('highlightLine', highlightLine); codeNodeEditorEventBus.on('codeDiffApplied', diffApplied); + + if (!props.modelValue) { + emit('update:modelValue', placeholder.value); + } }); onBeforeUnmount(() => { From de1a28b073f428b55ee10f790d02c1500c29dd96 Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Mon, 6 Jan 2025 15:47:34 +0100 Subject: [PATCH 37/43] Ignore outside editor changes while focused --- .../src/components/CodeNodeEditor/theme.ts | 2 +- .../src/components/ParameterInput.vue | 22 ++++++------------- .../src/composables/useCodeEditor.ts | 2 +- 3 files changed, 9 insertions(+), 17 deletions(-) diff --git a/packages/editor-ui/src/components/CodeNodeEditor/theme.ts b/packages/editor-ui/src/components/CodeNodeEditor/theme.ts index 3ac9e06d7462e..41f2325660957 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/theme.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/theme.ts @@ -75,7 +75,7 @@ const codeEditorSyntaxHighlighting = syntaxHighlighting( tag: [tags.url, tags.escape, tags.regexp, tags.link], color: 'var(--color-code-tags-keyword)', }, - { tag: [tags.meta, tags.comment], color: 'var(--color-code-tags-comment)' }, + { 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' }, diff --git a/packages/editor-ui/src/components/ParameterInput.vue b/packages/editor-ui/src/components/ParameterInput.vue index 2b1bcff68744a..39bd71b9382a0 100644 --- a/packages/editor-ui/src/components/ParameterInput.vue +++ b/packages/editor-ui/src/components/ParameterInput.vue @@ -1105,10 +1105,7 @@ onUpdated(async () => { :before-close="closeCodeEditDialog" data-test-id="code-editor-fullscreen" > -
+
{ @update:model-value="valueChangedDebounced" /> { @update:model-value="valueChangedDebounced" /> { @update:model-value="valueChangedDebounced" /> { /> { > { { { { ({ }); watch(toRef(editorValue), () => { - if (!editor.value) return; + if (!editor.value || hasFocus.value) return; const newValue = toValue(editorValue); const currentValue = readEditorValue(); From b970ab1da2b1766d64281de853d7202777cd446c Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Mon, 6 Jan 2025 16:08:11 +0100 Subject: [PATCH 38/43] Fix tab/esc keymap in dialog --- packages/editor-ui/src/plugins/codemirror/keymap.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/editor-ui/src/plugins/codemirror/keymap.ts b/packages/editor-ui/src/plugins/codemirror/keymap.ts index 43e9158539756..b656b9a8cc834 100644 --- a/packages/editor-ui/src/plugins/codemirror/keymap.ts +++ b/packages/editor-ui/src/plugins/codemirror/keymap.ts @@ -257,8 +257,20 @@ export const editorKeymap: KeyBinding[] = [ { 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 }, From ebad9db9f74e026e3d722e5a0451ae65ca0bf35f Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Tue, 7 Jan 2025 09:14:14 +0100 Subject: [PATCH 39/43] Tweak theme --- packages/design-system/src/css/_tokens.scss | 4 ++-- .../editor-ui/src/components/CodeNodeEditor/theme.ts | 10 ++++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/design-system/src/css/_tokens.scss b/packages/design-system/src/css/_tokens.scss index aadd5377445db..3b6b7451be84c 100644 --- a/packages/design-system/src/css/_tokens.scss +++ b/packages/design-system/src/css/_tokens.scss @@ -187,9 +187,9 @@ --color-code-tags-regex: #032f62; --color-code-tags-primitive: #005cc5; --color-code-tags-keyword: #d73a49; - --color-code-tags-variable: #e36209; + --color-code-tags-variable: #005cc5; --color-code-tags-parameter: #24292e; - --color-code-tags-function: #005cc5; + --color-code-tags-function: #6f42c1; --color-code-tags-constant: #005cc5; --color-code-tags-property: #005cc5; --color-code-tags-type: #005cc5; diff --git a/packages/editor-ui/src/components/CodeNodeEditor/theme.ts b/packages/editor-ui/src/components/CodeNodeEditor/theme.ts index 41f2325660957..aa5977bc3ef8a 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/theme.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/theme.ts @@ -39,19 +39,17 @@ const codeEditorSyntaxHighlighting = syntaxHighlighting( { tag: tags.keyword, color: 'var(--color-code-tags-keyword)' }, { tag: [ - tags.name, tags.deleted, tags.character, tags.macroName, tags.definition(tags.name), - tags.separator, + tags.definition(tags.variableName), tags.atom, tags.bool, - tags.special(tags.variableName), ], color: 'var(--color-code-tags-variable)', }, - { tag: [tags.propertyName], color: 'var(--color-code-tags-property)' }, + { 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)', @@ -82,6 +80,10 @@ const codeEditorSyntaxHighlighting = syntaxHighlighting( { 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)', + }, ]), ); From fb4d3c06d99636f3ba2c244842da401166eee5b2 Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Tue, 7 Jan 2025 16:51:30 +0100 Subject: [PATCH 40/43] Improve performance by only sending changes to worker --- .../components/CodeNodeEditor/constants.ts | 2 +- .../src/composables/useCodeEditor.ts | 4 +- .../typescript/client/completions.ts | 13 +- .../typescript/client/useTypescript.ts | 165 ++++++++++-------- .../plugins/codemirror/typescript/types.ts | 5 +- .../n8n-once-for-all-items.d.ts | 2 +- .../worker/type-declarations/n8n.d.ts | 8 +- .../typescript/worker/typescript.worker.ts | 38 +++- .../typescript/worker/typescriptAst.ts | 6 +- .../codemirror/typescript/worker/utils.ts | 42 +++++ 10 files changed, 195 insertions(+), 90 deletions(-) diff --git a/packages/editor-ui/src/components/CodeNodeEditor/constants.ts b/packages/editor-ui/src/components/CodeNodeEditor/constants.ts index d9dcaccabd6b5..2f648e41d2187 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/constants.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/constants.ts @@ -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. diff --git a/packages/editor-ui/src/composables/useCodeEditor.ts b/packages/editor-ui/src/composables/useCodeEditor.ts index 9f7f02d2733f2..23a0d9d7de062 100644 --- a/packages/editor-ui/src/composables/useCodeEditor.ts +++ b/packages/editor-ui/src/composables/useCodeEditor.ts @@ -100,6 +100,7 @@ export const useCodeEditor = ({ const params = toValue(languageParams); return params && 'mode' in params ? params.mode : 'runOnceForAllItems'; }); + const { createWorker: createTsWorker } = useTypescript(editor, mode, toValue(id)); function getInitialLanguageExtensions(lang: CodeEditorLanguage): Extension[] { switch (lang) { @@ -117,7 +118,8 @@ export const useCodeEditor = ({ switch (lang) { case 'javaScript': { - langExtensions.push(await useTypescript(editor.value, mode, toValue(id))); + const tsExtension = await createTsWorker(); + langExtensions.push(tsExtension); break; } case 'python': { diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/client/completions.ts b/packages/editor-ui/src/plugins/codemirror/typescript/client/completions.ts index a21109b22d2b8..6a6cac8282442 100644 --- a/packages/editor-ui/src/plugins/codemirror/typescript/client/completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/typescript/client/completions.ts @@ -14,13 +14,17 @@ import { typescriptWorkerFacet } from './facet'; import { blockCommentSnippet, snippets } from './snippets'; const START_CHARACTERS = ['"', "'", '(', '.', '@']; +const START_CHARACTERS_REGEX = /[\.\(\'\"\@]/; export const typescriptCompletionSource: CompletionSource = async (context) => { const { worker } = context.state.facet(typescriptWorkerFacet); - let word = context.matchBefore(/[\$\w]+/); + let word = context.matchBefore(START_CHARACTERS_REGEX); if (!word?.text) { - word = context.matchBefore(/[\.\(\'\"\@]/); + word = context.matchBefore(/[\"\'].*/); + } + if (!word?.text) { + word = context.matchBefore(/[\$\w]+/); } const blockComment = context.matchBefore(/\/\*?\*?/); @@ -68,7 +72,10 @@ export const typescriptCompletionSource: CompletionSource = async (context) => { (option) => word.text === '' || START_CHARACTERS.includes(word.text) || - prefixMatch(option.label, word.text), + prefixMatch( + option.label.replace(START_CHARACTERS_REGEX, ''), + word.text.replace(START_CHARACTERS_REGEX, ''), + ), ) .map((completion) => { if (completion.label.endsWith('()')) { diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/client/useTypescript.ts b/packages/editor-ui/src/plugins/codemirror/typescript/client/useTypescript.ts index fcfedd1d44fc0..aef7eb51267db 100644 --- a/packages/editor-ui/src/plugins/codemirror/typescript/client/useTypescript.ts +++ b/packages/editor-ui/src/plugins/codemirror/typescript/client/useTypescript.ts @@ -10,101 +10,122 @@ import { executionDataToJson } from '@/utils/nodeTypesUtils'; import { autocompletion } from '@codemirror/autocomplete'; import { javascriptLanguage } from '@codemirror/lang-javascript'; import { LanguageSupport } from '@codemirror/language'; -import { linter } from '@codemirror/lint'; +import { Text, type Extension } from '@codemirror/state'; import { EditorView, hoverTooltip } from '@codemirror/view'; import * as Comlink from 'comlink'; import { NodeConnectionType, type CodeExecutionMode, type INodeExecutionData } from 'n8n-workflow'; -import { toRef, toValue, watch, type MaybeRefOrGetter } from 'vue'; -import type { RemoteLanguageServiceWorkerInit } from '../types'; +import { ref, toRef, toValue, watch, type MaybeRefOrGetter } from 'vue'; +import type { LanguageServiceWorker, RemoteLanguageServiceWorkerInit } from '../types'; import { typescriptCompletionSource } from './completions'; import { typescriptWorkerFacet } from './facet'; import { typescriptHoverTooltips } from './hoverTooltip'; +import { linter } from '@codemirror/lint'; import { typescriptLintSource } from './linter'; -export async function useTypescript( - view: MaybeRefOrGetter, +export function useTypescript( + view: MaybeRefOrGetter, mode: MaybeRefOrGetter, id: MaybeRefOrGetter, ) { - const { init } = Comlink.wrap( - new Worker(new URL('../worker/typescript.worker.ts', import.meta.url), { type: 'module' }), - ); const { getInputDataWithPinned, getSchemaForExecutionData } = useDataSchema(); const ndvStore = useNDVStore(); const workflowsStore = useWorkflowsStore(); const { debounce } = useDebounce(); const activeNodeName = ndvStore.activeNodeName; + const worker = ref>(); - watch( - [() => workflowsStore.getWorkflowExecution, () => workflowsStore.getWorkflowRunData], - debounce( - async () => { - await worker.updateNodeTypes(); - forceParse(toValue(view)); + async function createWorker(): Promise { + const { init } = Comlink.wrap( + new Worker(new URL('../worker/typescript.worker.ts', import.meta.url), { type: 'module' }), + ); + worker.value = await init( + { + id: toValue(id), + content: Comlink.proxy((toValue(view)?.state.doc ?? Text.empty).toJSON()), + allNodeNames: autocompletableNodeNames(), + variables: useEnvironmentsStore().variables.map((v) => v.key), + inputNodeNames: activeNodeName + ? workflowsStore + .getCurrentWorkflow() + .getParentNodes(activeNodeName, NodeConnectionType.Main, 1) + : [], + mode: toValue(mode), }, - { debounceTime: 200, trailing: true }, - ), - ); + Comlink.proxy(async (nodeName) => { + const node = workflowsStore.getNodeByName(nodeName); - watch(toRef(mode), async (newMode) => { - await worker.updateMode(newMode); - forceParse(toValue(view)); - }); + if (node) { + const inputData: INodeExecutionData[] = getInputDataWithPinned(node); + const schema = getSchemaForExecutionData(executionDataToJson(inputData), true); + const execution = workflowsStore.getWorkflowExecution; + const binaryData = useNodeHelpers() + .getBinaryData( + execution?.data?.resultData?.runData ?? null, + node.name, + ndvStore.ndvInputRunIndex ?? 0, + 0, + ) + .filter((data) => Boolean(data && Object.keys(data).length)); + + return { + json: schema, + binary: Object.keys(binaryData.reduce((acc, obj) => ({ ...acc, ...obj }), {})), + params: getSchemaForExecutionData([node.parameters]), + }; + } + + return undefined; + }), + ); - const worker = await init( - { - id: toValue(id), - content: toValue(view).state.doc.toString(), - allNodeNames: autocompletableNodeNames(), - variables: useEnvironmentsStore().variables.map((v) => v.key), - inputNodeNames: activeNodeName - ? workflowsStore - .getCurrentWorkflow() - .getParentNodes(activeNodeName, NodeConnectionType.Main, 1) - : [], - mode: toValue(mode), - }, - Comlink.proxy(async (nodeName) => { - const node = workflowsStore.getNodeByName(nodeName); + const editor = toValue(view); - if (node) { - const inputData: INodeExecutionData[] = getInputDataWithPinned(node); - const schema = getSchemaForExecutionData(executionDataToJson(inputData), true); - const execution = workflowsStore.getWorkflowExecution; - const binaryData = useNodeHelpers() - .getBinaryData( - execution?.data?.resultData?.runData ?? null, - node.name, - ndvStore.ndvInputRunIndex ?? 0, - 0, - ) - .filter((data) => Boolean(data && Object.keys(data).length)); + if (editor) { + forceParse(editor); + } - return { - json: schema, - binary: Object.keys(binaryData.reduce((acc, obj) => ({ ...acc, ...obj }), {})), - params: getSchemaForExecutionData([node.parameters]), - }; - } + return [ + typescriptWorkerFacet.of({ worker: worker.value }), + new LanguageSupport(javascriptLanguage, [ + javascriptLanguage.data.of({ autocomplete: typescriptCompletionSource }), + ]), + autocompletion({ icons: false, aboveCursor: true }), + linter(typescriptLintSource), + hoverTooltip(typescriptHoverTooltips, { + hideOnChange: true, + hoverTime: 500, + }), + EditorView.updateListener.of(async (update) => { + if (update.docChanged) { + void worker.value?.updateFile(update.changes.toJSON()); + } + }), + ]; + } - return undefined; - }), + async function onWorkflowDataChange() { + const editor = toValue(view); + if (!editor || !worker.value) return; + + await worker.value.updateNodeTypes(); + + forceParse(editor); + } + + watch( + [() => workflowsStore.getWorkflowExecution, () => workflowsStore.getWorkflowRunData], + debounce(onWorkflowDataChange, { debounceTime: 200, trailing: true }), ); - return [ - typescriptWorkerFacet.of({ worker }), - new LanguageSupport(javascriptLanguage, [ - javascriptLanguage.data.of({ autocomplete: typescriptCompletionSource }), - ]), - autocompletion({ icons: false, aboveCursor: true }), - linter(typescriptLintSource), - hoverTooltip(typescriptHoverTooltips, { - hideOnChange: true, - hoverTime: 500, - }), - EditorView.updateListener.of(async (update) => { - if (!update.docChanged) return; - await worker.updateFile(update.state.doc.toString()); - }), - ]; + watch(toRef(mode), async (newMode) => { + const editor = toValue(view); + if (!editor || !worker.value) return; + + await worker.value.updateMode(newMode); + forceParse(editor); + }); + + return { + createWorker, + }; } diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/types.ts b/packages/editor-ui/src/plugins/codemirror/typescript/types.ts index e72b28278a7f9..ab795f7401201 100644 --- a/packages/editor-ui/src/plugins/codemirror/typescript/types.ts +++ b/packages/editor-ui/src/plugins/codemirror/typescript/types.ts @@ -4,6 +4,7 @@ import type { Diagnostic } from '@codemirror/lint'; import type { CodeExecutionMode } from 'n8n-workflow'; import type ts from 'typescript'; import type * as Comlink from 'comlink'; +import type { ChangeSet } from '@codemirror/state'; export interface HoverInfo { start: number; @@ -14,7 +15,7 @@ export interface HoverInfo { export type WorkerInitOptions = { id: string; - content: string; + content: string[]; allNodeNames: string[]; inputNodeNames: string[]; variables: string[]; @@ -25,7 +26,7 @@ export type NodeData = { json: Schema | undefined; binary: string[]; params: Sch export type NodeDataFetcher = (nodeName: string) => Promise; export type LanguageServiceWorker = { - updateFile(content: string): void; + updateFile(changes: ChangeSet): void; updateMode(mode: CodeExecutionMode): void; updateNodeTypes(): void; getCompletionsAtPos(pos: number): Promise<{ result: CompletionResult; isGlobal: boolean } | null>; diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/worker/type-declarations/n8n-once-for-all-items.d.ts b/packages/editor-ui/src/plugins/codemirror/typescript/worker/type-declarations/n8n-once-for-all-items.d.ts index 1487c6e495d9f..fd6f13ee3db8f 100644 --- a/packages/editor-ui/src/plugins/codemirror/typescript/worker/type-declarations/n8n-once-for-all-items.d.ts +++ b/packages/editor-ui/src/plugins/codemirror/typescript/worker/type-declarations/n8n-once-for-all-items.d.ts @@ -1,7 +1,7 @@ export {}; declare global { - interface NodeData { + interface NodeData { context: C; params: P; all(branchIndex?: number, runIndex?: number): Array>; diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/worker/type-declarations/n8n.d.ts b/packages/editor-ui/src/plugins/codemirror/typescript/worker/type-declarations/n8n.d.ts index 9cc91124592b1..c211470bf7c97 100644 --- a/packages/editor-ui/src/plugins/codemirror/typescript/worker/type-declarations/n8n.d.ts +++ b/packages/editor-ui/src/plugins/codemirror/typescript/worker/type-declarations/n8n.d.ts @@ -94,6 +94,10 @@ declare global { function $min(...numbers: number[]): number; function $max(...numbers: number[]): number; - // @ts-expect-error NodeName and NodeDataMap are created dynamically - function $(nodeName: K): NodeDataMap[K]; + type SomeOtherString = string & NonNullable; + // @ts-expect-error NodeName is created dynamically + function $( + nodeName: K | SomeOtherString, + // @ts-expect-error NodeDataMap is created dynamically + ): K extends keyof NodeDataMap ? NodeDataMap[K] : NodeData; } diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/worker/typescript.worker.ts b/packages/editor-ui/src/plugins/codemirror/typescript/worker/typescript.worker.ts index 381fcb54b1007..a6467d1cfd0bc 100644 --- a/packages/editor-ui/src/plugins/codemirror/typescript/worker/typescript.worker.ts +++ b/packages/editor-ui/src/plugins/codemirror/typescript/worker/typescript.worker.ts @@ -1,7 +1,7 @@ import * as Comlink from 'comlink'; import type { LanguageServiceWorker, LanguageServiceWorkerInit } from '../types'; import { indexedDbCache } from './cache'; -import { fnPrefix, wrapInFunction } from './utils'; +import { bufferChangeSets, fnPrefix } from './utils'; import type { CodeExecutionMode } from 'n8n-workflow'; @@ -23,6 +23,8 @@ import { getUsedNodeNames } from './typescriptAst'; import runOnceForAllItemsTypes from './type-declarations/n8n-once-for-all-items.d.ts?raw'; import runOnceForEachItemTypes from './type-declarations/n8n-once-for-each-item.d.ts?raw'; import { loadTypes } from './npmTypesLoader'; +import { ChangeSet, Text } from '@codemirror/state'; +import { until } from '@vueuse/core'; self.process = { env: {} } as NodeJS.Process; @@ -34,12 +36,13 @@ const worker: LanguageServiceWorkerInit = { const allNodeNames = options.allNodeNames; const codeFileName = `${options.id}.js`; const mode = ref(options.mode); + const busyApplyingChangesToCode = ref(false); const cache = await indexedDbCache('typescript-cache', 'fs-map'); const env = await setupTypescriptEnv({ cache, mode: mode.value, - code: { content: options.content, fileName: codeFileName }, + code: { content: Text.of(options.content).toString(), fileName: codeFileName }, }); const prefix = computed(() => fnPrefix(mode.value)); @@ -149,12 +152,37 @@ const worker: LanguageServiceWorkerInit = { { immediate: true }, ); + watch(prefix, (newPrefix, oldPrefix) => { + env.updateFile(codeFileName, newPrefix, { start: 0, length: oldPrefix.length }); + }); + + const applyChangesToCode = bufferChangeSets((bufferedChanges) => { + bufferedChanges.iterChanges((start, end, _fromNew, _toNew, text) => { + const length = end - start; + + env.updateFile(codeFileName, text.toString(), { + start: editorPositionToTypescript(start), + length, + }); + }); + + void loadTypesIfNeeded(); + }); + + const waitForChangesAppliedToCode = async () => { + await until(busyApplyingChangesToCode).toBe(false, { timeout: 500 }); + }; + return Comlink.proxy({ - updateFile: async (content) => { - updateFile(codeFileName, wrapInFunction(content, mode.value)); - await loadTypesIfNeeded(); + updateFile: async (changes) => { + busyApplyingChangesToCode.value = true; + void applyChangesToCode(ChangeSet.fromJSON(changes)).then(() => { + busyApplyingChangesToCode.value = false; + }); }, async getCompletionsAtPos(pos) { + await waitForChangesAppliedToCode(); + return await getCompletionsAtPos({ pos: editorPositionToTypescript(pos), fileName: codeFileName, diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/worker/typescriptAst.ts b/packages/editor-ui/src/plugins/codemirror/typescript/worker/typescriptAst.ts index 8c94b29da0df6..ea44d75faa541 100644 --- a/packages/editor-ui/src/plugins/codemirror/typescript/worker/typescriptAst.ts +++ b/packages/editor-ui/src/plugins/codemirror/typescript/worker/typescriptAst.ts @@ -30,9 +30,9 @@ export async function getUsedNodeNames(file: ts.SourceFile) { if (callExpressions.length === 0) return []; - const nodeNames = (callExpressions as ts.CallExpression[]).map( - (e) => (e.arguments.at(0) as ts.StringLiteral)?.text, - ); + const nodeNames = (callExpressions as ts.CallExpression[]) + .map((e) => (e.arguments.at(0) as ts.StringLiteral)?.text) + .filter(Boolean); return nodeNames; } diff --git a/packages/editor-ui/src/plugins/codemirror/typescript/worker/utils.ts b/packages/editor-ui/src/plugins/codemirror/typescript/worker/utils.ts index cc0f22c64ef53..b43a7bcd1b1d5 100644 --- a/packages/editor-ui/src/plugins/codemirror/typescript/worker/utils.ts +++ b/packages/editor-ui/src/plugins/codemirror/typescript/worker/utils.ts @@ -1,3 +1,4 @@ +import { ChangeSet } from '@codemirror/state'; import { type CodeExecutionMode } from 'n8n-workflow'; export const fnPrefix = (mode: CodeExecutionMode) => `( @@ -20,3 +21,44 @@ declare global { 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((resolve) => { + timeoutId = setTimeout( + () => { + fn(changeSet); + resolve(); + changeSet = ChangeSet.empty(0); + }, + calculateBufferWindowMs( + changeSet.length, + MIN_CHANGE_BUFFER_WINDOW_MS, + MAX_CHANGE_BUFFER_WINDOW_MS, + ), + ); + }); + }; +} From dd44359c959a1c64532758bb73482e7282da75ca Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Tue, 7 Jan 2025 21:57:05 +0100 Subject: [PATCH 41/43] Fix e2e test --- cypress/e2e/6-code-node.cy.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cypress/e2e/6-code-node.cy.ts b/cypress/e2e/6-code-node.cy.ts index 5bc7d05ee204c..3a83b4e2ab64a 100644 --- a/cypress/e2e/6-code-node.cy.ts +++ b/cypress/e2e/6-code-node.cy.ts @@ -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', 7); getParameter().contains('all').realHover(); cy.get('.cm-tooltip-lint').should( 'have.text', From 0fae850c34cbbe1d74eee97ec844014fd5b29c04 Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Wed, 8 Jan 2025 09:43:58 +0100 Subject: [PATCH 42/43] Emit last update before unmount --- cypress/e2e/6-code-node.cy.ts | 2 +- packages/editor-ui/src/composables/useCodeEditor.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/cypress/e2e/6-code-node.cy.ts b/cypress/e2e/6-code-node.cy.ts index 3a83b4e2ab64a..674d91af18405 100644 --- a/cypress/e2e/6-code-node.cy.ts +++ b/cypress/e2e/6-code-node.cy.ts @@ -81,7 +81,7 @@ $input.item() return [] `); - getParameter().get('.cm-lintRange-error').should('have.length', 7); + getParameter().get('.cm-lintRange-error').should('have.length', 5); getParameter().contains('all').realHover(); cy.get('.cm-tooltip-lint').should( 'have.text', diff --git a/packages/editor-ui/src/composables/useCodeEditor.ts b/packages/editor-ui/src/composables/useCodeEditor.ts index 23a0d9d7de062..e40ede161436c 100644 --- a/packages/editor-ui/src/composables/useCodeEditor.ts +++ b/packages/editor-ui/src/composables/useCodeEditor.ts @@ -85,6 +85,7 @@ export const useCodeEditor = ({ const editor = ref(); const hasFocus = ref(false); const hasChanges = ref(false); + const lastChange = ref(); const selection = ref(EditorSelection.cursor(0)) as Ref; const customExtensions = ref(new Compartment()); const readOnlyExtensions = ref(new Compartment()); @@ -163,6 +164,7 @@ export const useCodeEditor = ({ if (update.docChanged) { hasChanges.value = true; + lastChange.value = update; emitChanges(update); } } @@ -367,6 +369,7 @@ export const useCodeEditor = ({ // Code is too large, localStorage quota exceeded localStorage.removeItem(storedStateId.value); } + if (lastChange.value) onChange(lastChange.value); editor.value.destroy(); } }); From 910fd9b958f3f8b58a418e12275b9f6477bf66a0 Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Wed, 8 Jan 2025 09:56:48 +0100 Subject: [PATCH 43/43] Use browser crypto API to generate uuid --- .../editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue | 3 +-- packages/editor-ui/src/components/ParameterInput.vue | 3 +-- packages/editor-ui/src/composables/useCodeEditor.ts | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue b/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue index 5cf104b094d29..c17ff804c0aa2 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue +++ b/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue @@ -19,7 +19,6 @@ import { CODE_PLACEHOLDERS } from './constants'; import { useLinter } from './linter'; import { useSettingsStore } from '@/stores/settings.store'; import { dropInCodeEditor } from '@/plugins/codemirror/dragAndDrop'; -import { v4 as uuid } from 'uuid'; type Props = { mode: CodeExecutionMode; @@ -38,7 +37,7 @@ const props = withDefaults(defineProps(), { language: 'javaScript', isReadOnly: false, rows: 4, - id: uuid(), + id: crypto.randomUUID(), }); const emit = defineEmits<{ 'update:modelValue': [value: string]; diff --git a/packages/editor-ui/src/components/ParameterInput.vue b/packages/editor-ui/src/components/ParameterInput.vue index 1db3d41392218..ba04cbfc2fc2c 100644 --- a/packages/editor-ui/src/components/ParameterInput.vue +++ b/packages/editor-ui/src/components/ParameterInput.vue @@ -65,7 +65,6 @@ import { N8nIcon, N8nInput, N8nInputNumber, N8nOption, N8nSelect } from 'n8n-des import type { EventBus } from 'n8n-design-system/utils'; import { createEventBus } from 'n8n-design-system/utils'; import { useRouter } from 'vue-router'; -import { v4 as uuid } from 'uuid'; type Picker = { $emit: (arg0: string, arg1: Date) => void }; @@ -478,7 +477,7 @@ const shortPath = computed(() => { }); const parameterId = computed(() => { - return `${node.value?.id ?? uuid()}${props.path}`; + return `${node.value?.id ?? crypto.randomUUID()}${props.path}`; }); const isResourceLocatorParameter = computed(() => { diff --git a/packages/editor-ui/src/composables/useCodeEditor.ts b/packages/editor-ui/src/composables/useCodeEditor.ts index e40ede161436c..0a63189eeda40 100644 --- a/packages/editor-ui/src/composables/useCodeEditor.ts +++ b/packages/editor-ui/src/composables/useCodeEditor.ts @@ -101,7 +101,7 @@ export const useCodeEditor = ({ const params = toValue(languageParams); return params && 'mode' in params ? params.mode : 'runOnceForAllItems'; }); - const { createWorker: createTsWorker } = useTypescript(editor, mode, toValue(id)); + const { createWorker: createTsWorker } = useTypescript(editor, mode, id); function getInitialLanguageExtensions(lang: CodeEditorLanguage): Extension[] { switch (lang) {