mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2026-09-28 20:51:45 +02:00
feat(editor): New Code editor based on the TypeScript language service (#12285)
This commit is contained in:
@@ -112,23 +112,19 @@ export function expressionWithFirstItem(syntaxTree: Tree, expression: string): s
|
||||
}
|
||||
|
||||
export function longestCommonPrefix(...strings: string[]) {
|
||||
if (strings.length < 2) {
|
||||
throw new Error('Expected at least two strings');
|
||||
}
|
||||
if (strings.length < 2) return '';
|
||||
|
||||
return strings.reduce((acc, next) => {
|
||||
let i = 0;
|
||||
|
||||
while (acc[i] && next[i] && acc[i] === next[i]) {
|
||||
i++;
|
||||
return strings.reduce((prefix, str) => {
|
||||
while (!str.startsWith(prefix)) {
|
||||
prefix = prefix.slice(0, -1);
|
||||
if (prefix === '') return '';
|
||||
}
|
||||
|
||||
return acc.slice(0, i);
|
||||
}, '');
|
||||
return prefix;
|
||||
}, strings[0]);
|
||||
}
|
||||
|
||||
export const prefixMatch = (first: string, second: string) =>
|
||||
first.toLocaleLowerCase().startsWith(second.toLocaleLowerCase()) && first !== second;
|
||||
first.toLocaleLowerCase().startsWith(second.toLocaleLowerCase());
|
||||
|
||||
export const isPseudoParam = (candidate: string) => {
|
||||
const PSEUDO_PARAMS = ['notice']; // user input disallowed
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { EditorSelection, Facet } from '@codemirror/state';
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import { formatWithCursor, type BuiltInParserName } from 'prettier';
|
||||
import babelPlugin from 'prettier/plugins/babel';
|
||||
import estreePlugin from 'prettier/plugins/estree';
|
||||
|
||||
export type CodeEditorLanguage = 'json' | 'html' | 'javaScript' | 'python';
|
||||
|
||||
export const languageFacet = Facet.define<CodeEditorLanguage, CodeEditorLanguage>({
|
||||
combine: (values) => values[0] ?? 'javaScript',
|
||||
});
|
||||
|
||||
export function formatDocument(view: EditorView) {
|
||||
function format(parser: BuiltInParserName) {
|
||||
void formatWithCursor(view.state.doc.toString(), {
|
||||
cursorOffset: view.state.selection.main.anchor,
|
||||
parser,
|
||||
plugins: [babelPlugin, estreePlugin],
|
||||
}).then(({ formatted, cursorOffset }) => {
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: view.state.doc.length,
|
||||
insert: formatted,
|
||||
},
|
||||
selection: EditorSelection.single(cursorOffset),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const langauge = view.state.facet(languageFacet);
|
||||
switch (langauge) {
|
||||
case 'javaScript':
|
||||
format('babel');
|
||||
break;
|
||||
case 'html':
|
||||
format('html');
|
||||
break;
|
||||
case 'json':
|
||||
format('json');
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
+267
-46
@@ -1,51 +1,83 @@
|
||||
import {
|
||||
acceptCompletion,
|
||||
closeCompletion,
|
||||
completionStatus,
|
||||
deleteBracketPair,
|
||||
moveCompletionSelection,
|
||||
selectedCompletion,
|
||||
startCompletion,
|
||||
} from '@codemirror/autocomplete';
|
||||
import { indentLess, indentMore, insertNewlineAndIndent, redo, undo } from '@codemirror/commands';
|
||||
import type { EditorView, KeyBinding } from '@codemirror/view';
|
||||
|
||||
export const tabKeyMap = (blurOnTab = false): KeyBinding[] => [
|
||||
{
|
||||
any(view, event) {
|
||||
if (
|
||||
event.key === 'Tab' ||
|
||||
(event.key === 'Escape' && completionStatus(view.state) !== null)
|
||||
) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'Tab',
|
||||
run: (view) => {
|
||||
if (selectedCompletion(view.state)) {
|
||||
return acceptCompletion(view);
|
||||
}
|
||||
|
||||
if (!blurOnTab) return indentMore(view);
|
||||
return false;
|
||||
},
|
||||
},
|
||||
{ key: 'Shift-Tab', run: indentLess },
|
||||
];
|
||||
|
||||
export const enterKeyMap: KeyBinding[] = [
|
||||
{
|
||||
key: 'Enter',
|
||||
run: (view) => {
|
||||
if (selectedCompletion(view.state)) {
|
||||
return acceptCompletion(view);
|
||||
}
|
||||
|
||||
return insertNewlineAndIndent(view);
|
||||
},
|
||||
},
|
||||
];
|
||||
import {
|
||||
insertNewlineAndIndent,
|
||||
cursorCharLeft,
|
||||
selectCharLeft,
|
||||
deleteLine,
|
||||
moveLineDown,
|
||||
moveLineUp,
|
||||
copyLineDown,
|
||||
copyLineUp,
|
||||
selectLine,
|
||||
cursorMatchingBracket,
|
||||
indentMore,
|
||||
indentLess,
|
||||
cursorLineBoundaryBackward,
|
||||
selectLineBoundaryBackward,
|
||||
cursorDocStart,
|
||||
selectDocStart,
|
||||
cursorLineBoundaryForward,
|
||||
selectLineBoundaryForward,
|
||||
cursorDocEnd,
|
||||
selectDocEnd,
|
||||
cursorGroupLeft,
|
||||
selectGroupLeft,
|
||||
cursorPageDown,
|
||||
cursorPageUp,
|
||||
deleteCharBackward,
|
||||
deleteCharForward,
|
||||
deleteGroupBackward,
|
||||
deleteGroupForward,
|
||||
deleteToLineEnd,
|
||||
deleteToLineStart,
|
||||
selectAll,
|
||||
selectPageDown,
|
||||
selectPageUp,
|
||||
cursorCharRight,
|
||||
cursorGroupRight,
|
||||
selectCharRight,
|
||||
selectGroupRight,
|
||||
cursorLineUp,
|
||||
selectLineUp,
|
||||
cursorLineDown,
|
||||
selectLineDown,
|
||||
cursorLineEnd,
|
||||
cursorLineStart,
|
||||
selectLineEnd,
|
||||
selectLineStart,
|
||||
splitLine,
|
||||
transposeChars,
|
||||
redo,
|
||||
undo,
|
||||
undoSelection,
|
||||
toggleComment,
|
||||
lineComment,
|
||||
lineUncomment,
|
||||
toggleBlockComment,
|
||||
} from '@codemirror/commands';
|
||||
import {
|
||||
closeSearchPanel,
|
||||
gotoLine,
|
||||
openSearchPanel,
|
||||
replaceAll,
|
||||
selectMatches,
|
||||
selectNextOccurrence,
|
||||
selectSelectionMatches,
|
||||
} from '@codemirror/search';
|
||||
import { addCursorAtEachSelectionLine, addCursorDown, addCursorUp } from './multiCursor';
|
||||
import { foldAll, foldCode, unfoldAll, unfoldCode } from '@codemirror/language';
|
||||
import { nextDiagnostic, previousDiagnostic, openLintPanel } from '@codemirror/lint';
|
||||
import { EditorSelection } from '@codemirror/state';
|
||||
import { formatDocument } from './format';
|
||||
|
||||
const SELECTED_AUTOCOMPLETE_OPTION_SELECTOR = '.cm-tooltip-autocomplete li[aria-selected]';
|
||||
const onAutocompleteNavigate = (dir: 'up' | 'down') => (view: EditorView) => {
|
||||
@@ -59,7 +91,20 @@ const onAutocompleteNavigate = (dir: 'up' | 'down') => (view: EditorView) => {
|
||||
return false;
|
||||
};
|
||||
|
||||
export const autocompleteKeyMap: KeyBinding[] = [
|
||||
// Keymap based on VSCode
|
||||
export const editorKeymap: KeyBinding[] = [
|
||||
{ key: 'Ctrl-Space', run: startCompletion },
|
||||
{ key: 'Escape', run: closeCompletion },
|
||||
{
|
||||
key: 'Escape',
|
||||
run: (view) => {
|
||||
if (view.state.selection.ranges.length > 1) {
|
||||
view.dispatch({ selection: EditorSelection.single(view.state.selection.main.head) });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'ArrowDown',
|
||||
run: onAutocompleteNavigate('down'),
|
||||
@@ -68,9 +113,185 @@ export const autocompleteKeyMap: KeyBinding[] = [
|
||||
key: 'ArrowUp',
|
||||
run: onAutocompleteNavigate('up'),
|
||||
},
|
||||
];
|
||||
{ key: 'PageDown', run: moveCompletionSelection(true, 'page') },
|
||||
{ key: 'PageUp', run: moveCompletionSelection(false, 'page') },
|
||||
{ key: 'Enter', run: acceptCompletion },
|
||||
{ key: 'Tab', run: acceptCompletion },
|
||||
|
||||
export const historyKeyMap: KeyBinding[] = [
|
||||
{ key: 'Mod-z', run: undo },
|
||||
{ key: 'Mod-Shift-z', run: redo },
|
||||
{ key: 'Mod-f', run: openSearchPanel, scope: 'editor search-panel' },
|
||||
{ key: 'Escape', run: closeSearchPanel, scope: 'editor search-panel' },
|
||||
{ key: 'Alt-Enter', run: selectMatches, scope: 'editor search-panel' },
|
||||
{ key: 'Mod-Alt-Enter', run: replaceAll, scope: 'editor search-panel' },
|
||||
{ key: 'Ctrl-g', run: gotoLine },
|
||||
{ key: 'Mod-d', run: selectNextOccurrence, preventDefault: true },
|
||||
{ key: 'Shift-Mod-l', run: selectSelectionMatches },
|
||||
{ key: 'Enter', run: insertNewlineAndIndent, shift: insertNewlineAndIndent },
|
||||
{
|
||||
key: 'ArrowLeft',
|
||||
run: cursorCharLeft,
|
||||
shift: selectCharLeft,
|
||||
preventDefault: true,
|
||||
},
|
||||
{
|
||||
key: 'Mod-ArrowLeft',
|
||||
mac: 'Alt-ArrowLeft',
|
||||
run: cursorGroupLeft,
|
||||
shift: selectGroupLeft,
|
||||
},
|
||||
{
|
||||
key: 'ArrowRight',
|
||||
run: cursorCharRight,
|
||||
shift: selectCharRight,
|
||||
preventDefault: true,
|
||||
},
|
||||
{
|
||||
key: 'Mod-ArrowRight',
|
||||
mac: 'Alt-ArrowRight',
|
||||
run: cursorGroupRight,
|
||||
shift: selectGroupRight,
|
||||
},
|
||||
|
||||
{
|
||||
key: 'ArrowUp',
|
||||
run: cursorLineUp,
|
||||
shift: selectLineUp,
|
||||
preventDefault: true,
|
||||
},
|
||||
{
|
||||
key: 'ArrowDown',
|
||||
run: cursorLineDown,
|
||||
shift: selectLineDown,
|
||||
preventDefault: true,
|
||||
},
|
||||
|
||||
{
|
||||
key: 'Home',
|
||||
run: cursorLineBoundaryBackward,
|
||||
shift: selectLineBoundaryBackward,
|
||||
},
|
||||
{
|
||||
mac: 'Cmd-ArrowLeft',
|
||||
run: cursorLineBoundaryBackward,
|
||||
shift: selectLineBoundaryBackward,
|
||||
},
|
||||
{ key: 'Mod-Home', run: cursorDocStart, shift: selectDocStart },
|
||||
{ mac: 'Cmd-ArrowUp', run: cursorDocStart, shift: selectDocStart },
|
||||
|
||||
{ key: 'PageUp', run: cursorPageUp, shift: selectPageUp },
|
||||
{ mac: 'Ctrl-ArrowUp', run: cursorPageUp, shift: selectPageUp },
|
||||
|
||||
{ key: 'PageDown', run: cursorPageDown, shift: selectPageDown },
|
||||
{ mac: 'Ctrl-ArrowDown', run: cursorPageDown, shift: selectPageDown },
|
||||
|
||||
{
|
||||
key: 'End',
|
||||
run: cursorLineBoundaryForward,
|
||||
shift: selectLineBoundaryForward,
|
||||
},
|
||||
{
|
||||
mac: 'Cmd-ArrowRight',
|
||||
run: cursorLineBoundaryForward,
|
||||
shift: selectLineBoundaryForward,
|
||||
},
|
||||
|
||||
{
|
||||
key: 'Mod-Alt-ArrowUp',
|
||||
linux: 'Shift-Alt-ArrowUp',
|
||||
run: addCursorUp,
|
||||
preventDefault: true,
|
||||
},
|
||||
{
|
||||
key: 'Mod-Alt-ArrowDown',
|
||||
linux: 'Shift-Alt-ArrowDown',
|
||||
run: addCursorDown,
|
||||
preventDefault: true,
|
||||
},
|
||||
|
||||
{
|
||||
key: 'Shift-Alt-i',
|
||||
run: addCursorAtEachSelectionLine,
|
||||
},
|
||||
|
||||
{ key: 'Mod-End', run: cursorDocEnd, shift: selectDocEnd },
|
||||
{ mac: 'Cmd-ArrowDown', run: cursorDocEnd, shift: selectDocEnd },
|
||||
|
||||
{ key: 'Mod-a', run: selectAll },
|
||||
{ key: 'Backspace', run: deleteBracketPair },
|
||||
{ key: 'Backspace', run: deleteCharBackward, shift: deleteCharBackward },
|
||||
{ key: 'Delete', run: deleteCharForward },
|
||||
{ key: 'Mod-Backspace', mac: 'Alt-Backspace', run: deleteGroupBackward },
|
||||
{ key: 'Mod-Delete', mac: 'Alt-Delete', run: deleteGroupForward },
|
||||
{ mac: 'Mod-Backspace', run: deleteToLineStart },
|
||||
{ mac: 'Mod-Delete', run: deleteToLineEnd },
|
||||
|
||||
{
|
||||
mac: 'Ctrl-b',
|
||||
run: cursorCharLeft,
|
||||
shift: selectCharLeft,
|
||||
preventDefault: true,
|
||||
},
|
||||
{ mac: 'Ctrl-f', run: cursorCharRight, shift: selectCharRight },
|
||||
|
||||
{ mac: 'Ctrl-p', run: cursorLineUp, shift: selectLineUp },
|
||||
{ mac: 'Ctrl-n', run: cursorLineDown, shift: selectLineDown },
|
||||
|
||||
{ mac: 'Ctrl-a', run: cursorLineStart, shift: selectLineStart },
|
||||
{ mac: 'Ctrl-e', run: cursorLineEnd, shift: selectLineEnd },
|
||||
|
||||
{ mac: 'Ctrl-d', run: deleteCharForward },
|
||||
{ mac: 'Ctrl-h', run: deleteCharBackward },
|
||||
{ mac: 'Ctrl-k', run: deleteToLineEnd },
|
||||
{ mac: 'Ctrl-Alt-h', run: deleteGroupBackward },
|
||||
|
||||
{ mac: 'Ctrl-o', run: splitLine },
|
||||
{ mac: 'Ctrl-t', run: transposeChars },
|
||||
|
||||
{ mac: 'Ctrl-v', run: cursorPageDown },
|
||||
{ mac: 'Alt-v', run: cursorPageUp },
|
||||
|
||||
{ key: 'Shift-Mod-k', run: deleteLine },
|
||||
{ key: 'Alt-ArrowDown', run: moveLineDown },
|
||||
{ key: 'Alt-ArrowUp', run: moveLineUp },
|
||||
{ win: 'Shift-Alt-ArrowDown', mac: 'Shift-Alt-ArrowDown', run: copyLineDown },
|
||||
{ win: 'Shift-Alt-ArrowUp', mac: 'Shift-Alt-ArrowUp', run: copyLineUp },
|
||||
|
||||
{ key: 'Mod-l', run: selectLine, preventDefault: true },
|
||||
{ key: 'Shift-Mod-\\', run: cursorMatchingBracket },
|
||||
{
|
||||
any(view, event) {
|
||||
if (
|
||||
event.key === 'Tab' ||
|
||||
(event.key === 'Escape' && completionStatus(view.state) !== null)
|
||||
) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
{ key: 'Tab', run: indentMore, shift: indentLess, preventDefault: true },
|
||||
|
||||
{ key: 'Mod-[', run: indentLess },
|
||||
{ key: 'Mod-]', run: indentMore },
|
||||
|
||||
{ key: 'Ctrl-Shift-[', mac: 'Cmd-Alt-[', run: foldCode },
|
||||
{ key: 'Ctrl-Shift-]', mac: 'Cmd-Alt-]', run: unfoldCode },
|
||||
{ key: 'Mod-k Mod-0', run: foldAll },
|
||||
{ key: 'Mod-k Mod-j', run: unfoldAll },
|
||||
|
||||
{ key: 'Mod-k Mod-c', run: lineComment },
|
||||
{ key: 'Mod-k Mod-u', run: lineUncomment },
|
||||
{ key: 'Mod-/', run: toggleComment },
|
||||
{ key: 'Shift-Alt-a', run: toggleBlockComment },
|
||||
|
||||
{ key: 'Mod-z', run: undo, preventDefault: true },
|
||||
{ key: 'Mod-y', run: redo, preventDefault: true },
|
||||
{ key: 'Mod-Shift-z', run: redo, preventDefault: true },
|
||||
{ key: 'Mod-u', run: undoSelection, preventDefault: true },
|
||||
|
||||
{ key: 'Mod-Shift-m', run: openLintPanel },
|
||||
{ key: 'F8', run: nextDiagnostic },
|
||||
{ key: 'Shift-F8', run: previousDiagnostic },
|
||||
|
||||
{ key: 'Shift-Alt-f', linux: 'Ctrl-Shift-i', run: formatDocument },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { EditorSelection } from '@codemirror/state';
|
||||
import type { Command } from '@codemirror/view';
|
||||
|
||||
const createAddCursor =
|
||||
(direction: 'up' | 'down'): Command =>
|
||||
(view) => {
|
||||
const forward = direction === 'down';
|
||||
|
||||
let selection = view.state.selection;
|
||||
|
||||
for (const r of selection.ranges) {
|
||||
selection = selection.addRange(view.moveVertically(r, forward));
|
||||
}
|
||||
|
||||
view.dispatch({ selection });
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const addCursorUp = createAddCursor('up');
|
||||
export const addCursorDown = createAddCursor('down');
|
||||
|
||||
export const addCursorAtEachSelectionLine: Command = (view) => {
|
||||
let selection: EditorSelection | null = null;
|
||||
for (const r of view.state.selection.ranges) {
|
||||
if (r.empty) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (let pos = r.from; pos <= r.to; ) {
|
||||
const line = view.state.doc.lineAt(pos);
|
||||
|
||||
const anchor = Math.min(line.to, r.to);
|
||||
|
||||
if (selection) {
|
||||
selection = selection.addRange(EditorSelection.range(anchor, anchor));
|
||||
} else {
|
||||
selection = EditorSelection.single(anchor);
|
||||
}
|
||||
|
||||
pos = line.to + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!selection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
view.dispatch({ selection });
|
||||
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import { escapeMappingString } from '@/utils/mappingUtils';
|
||||
import {
|
||||
insertCompletionText,
|
||||
pickedCompletion,
|
||||
type Completion,
|
||||
type CompletionSource,
|
||||
} from '@codemirror/autocomplete';
|
||||
import {
|
||||
autocompletableNodeNames,
|
||||
longestCommonPrefix,
|
||||
prefixMatch,
|
||||
} from '../../completions/utils';
|
||||
import { typescriptWorkerFacet } from './facet';
|
||||
import { blockCommentSnippet, snippets } from './snippets';
|
||||
|
||||
const START_CHARACTERS = ['"', "'", '(', '.', '@'];
|
||||
const START_CHARACTERS_REGEX = /[\.\(\'\"\@]/;
|
||||
|
||||
export const typescriptCompletionSource: CompletionSource = async (context) => {
|
||||
const { worker } = context.state.facet(typescriptWorkerFacet);
|
||||
|
||||
let word = context.matchBefore(START_CHARACTERS_REGEX);
|
||||
if (!word?.text) {
|
||||
word = context.matchBefore(/[\"\'].*/);
|
||||
}
|
||||
if (!word?.text) {
|
||||
word = context.matchBefore(/[\$\w]+/);
|
||||
}
|
||||
|
||||
const blockComment = context.matchBefore(/\/\*?\*?/);
|
||||
if (blockComment) {
|
||||
// Autocomplete a block comment snippet
|
||||
return { from: blockComment?.from, options: [blockCommentSnippet] };
|
||||
}
|
||||
|
||||
if (!word) return null;
|
||||
|
||||
const completionResult = await worker.getCompletionsAtPos(context.pos);
|
||||
|
||||
if (!completionResult || context.aborted) return null;
|
||||
|
||||
const { result, isGlobal } = completionResult;
|
||||
|
||||
let options = [...result.options];
|
||||
|
||||
if (isGlobal) {
|
||||
options = options
|
||||
.flatMap((opt) => {
|
||||
if (opt.label === '$') {
|
||||
return [
|
||||
opt,
|
||||
...autocompletableNodeNames().map((name) => ({
|
||||
...opt,
|
||||
label: `$('${escapeMappingString(name)}')`,
|
||||
})),
|
||||
];
|
||||
}
|
||||
return opt;
|
||||
})
|
||||
.concat(snippets);
|
||||
}
|
||||
|
||||
return {
|
||||
from: word ? (START_CHARACTERS.includes(word.text) ? word.to : word.from) : context.pos,
|
||||
filter: false,
|
||||
getMatch(completion: Completion) {
|
||||
const lcp = longestCommonPrefix(completion.label, word.text);
|
||||
return [0, lcp.length];
|
||||
},
|
||||
options: options
|
||||
.filter(
|
||||
(option) =>
|
||||
word.text === '' ||
|
||||
START_CHARACTERS.includes(word.text) ||
|
||||
prefixMatch(
|
||||
option.label.replace(START_CHARACTERS_REGEX, ''),
|
||||
word.text.replace(START_CHARACTERS_REGEX, ''),
|
||||
),
|
||||
)
|
||||
.map((completion) => {
|
||||
if (completion.label.endsWith('()')) {
|
||||
completion.apply = (view, _, from, to) => {
|
||||
const cursorPosition = from + completion.label.length - 1;
|
||||
view.dispatch({
|
||||
...insertCompletionText(view.state, completion.label, from, to),
|
||||
annotations: pickedCompletion.of(completion),
|
||||
selection: { anchor: cursorPosition, head: cursorPosition },
|
||||
});
|
||||
};
|
||||
}
|
||||
return completion;
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Facet, combineConfig } from '@codemirror/state';
|
||||
import type { LanguageServiceWorker } from '../types';
|
||||
import type * as Comlink from 'comlink';
|
||||
|
||||
export const typescriptWorkerFacet = Facet.define<
|
||||
{ worker: Comlink.Remote<LanguageServiceWorker> },
|
||||
{ worker: Comlink.Remote<LanguageServiceWorker> }
|
||||
>({
|
||||
combine(configs) {
|
||||
return combineConfig(configs, {});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { hoverTooltip } from '@codemirror/view';
|
||||
import { typescriptWorkerFacet } from './facet';
|
||||
|
||||
type HoverSource = Parameters<typeof hoverTooltip>[0];
|
||||
export const typescriptHoverTooltips: HoverSource = async (view, pos) => {
|
||||
const { worker } = view.state.facet(typescriptWorkerFacet);
|
||||
|
||||
const info = await worker.getHoverTooltip(pos);
|
||||
|
||||
if (!info) return null;
|
||||
|
||||
return {
|
||||
pos: info.start,
|
||||
end: info.end,
|
||||
above: true,
|
||||
create: () => {
|
||||
const div = document.createElement('div');
|
||||
div.classList.add('cm-tooltip-lint');
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.classList.add('cm-diagnostic');
|
||||
div.appendChild(wrapper);
|
||||
const text = document.createElement('div');
|
||||
text.classList.add('cm-diagnosticText');
|
||||
wrapper.appendChild(text);
|
||||
|
||||
if (info.quickInfo?.displayParts) {
|
||||
for (const part of info.quickInfo.displayParts) {
|
||||
const span = text.appendChild(document.createElement('span'));
|
||||
if (
|
||||
part.kind === 'keyword' &&
|
||||
['string', 'number', 'boolean', 'object'].includes(part.text)
|
||||
) {
|
||||
span.className = 'ts-primitive';
|
||||
} else if (part.kind === 'punctuation' && ['(', ')'].includes(part.text)) {
|
||||
span.className = 'ts-text';
|
||||
} else {
|
||||
span.className = `ts-${part.kind}`;
|
||||
}
|
||||
span.innerText = part.text;
|
||||
}
|
||||
}
|
||||
|
||||
const documentation = info.quickInfo?.documentation?.find((doc) => doc.kind === 'text')?.text;
|
||||
if (documentation) {
|
||||
const docElement = document.createElement('div');
|
||||
docElement.classList.add('cm-diagnosticDocs');
|
||||
docElement.textContent = documentation;
|
||||
wrapper.appendChild(docElement);
|
||||
}
|
||||
|
||||
return { dom: div };
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { LintSource } from '@codemirror/lint';
|
||||
import { typescriptWorkerFacet } from './facet';
|
||||
|
||||
export const typescriptLintSource: LintSource = async (view) => {
|
||||
const { worker } = view.state.facet(typescriptWorkerFacet);
|
||||
const docLength = view.state.doc.length;
|
||||
|
||||
return (await worker.getDiagnostics()).filter((diag) => {
|
||||
return diag.from < docLength && diag.to <= docLength && diag.from >= 0;
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { snippetCompletion } from '@codemirror/autocomplete';
|
||||
|
||||
export const blockCommentSnippet = snippetCompletion('/**\n * #{}\n */', {
|
||||
label: '/**',
|
||||
detail: 'Block Comment',
|
||||
});
|
||||
|
||||
export const snippets = [
|
||||
snippetCompletion('console.log(#{})', { label: 'log', detail: 'Log to console' }),
|
||||
snippetCompletion('for (const #{1:element} of #{2:array}) {\n\t#{}\n}', {
|
||||
label: 'forof',
|
||||
detail: 'For-of Loop',
|
||||
}),
|
||||
snippetCompletion(
|
||||
'for (const #{1:key} in #{2:object}) {\n\tif (Object.prototype.hasOwnProperty.call(#{2:object}, #{1:key})) {\n\t\tconst #{3:element} = #{2:object}[#{1:key}];\n\t\t#{}\n\t}\n}',
|
||||
{
|
||||
label: 'forin',
|
||||
detail: 'For-in Loop',
|
||||
},
|
||||
),
|
||||
snippetCompletion(
|
||||
'for (let #{1:index} = 0; #{1:index} < #{2:array}.length; #{1:index}++) {\n\tconst #{3:element} = #{2:array}[#{1:index}];\n\t#{}\n}',
|
||||
{
|
||||
label: 'for',
|
||||
detail: 'For Loop',
|
||||
},
|
||||
),
|
||||
snippetCompletion('if (#{1:condition}) {\n\t#{}\n}', {
|
||||
label: 'if',
|
||||
detail: 'If Statement',
|
||||
}),
|
||||
snippetCompletion('if (#{1:condition}) {\n\t#{}\n} else {\n\t\n}', {
|
||||
label: 'ifelse',
|
||||
detail: 'If-Else Statement',
|
||||
}),
|
||||
snippetCompletion('function #{1:name}(#{2:params}) {\n\t#{}\n}', {
|
||||
label: 'function',
|
||||
detail: 'Function Statement',
|
||||
}),
|
||||
snippetCompletion('function #{1:name}(#{2:params}) {\n\t#{}\n}', {
|
||||
label: 'fn',
|
||||
detail: 'Function Statement',
|
||||
}),
|
||||
snippetCompletion(
|
||||
'switch (#{1:key}) {\n\tcase #{2:value}:\n\t\t#{}\n\t\tbreak;\n\tdefault:\n\t\tbreak;\n}',
|
||||
{
|
||||
label: 'switch',
|
||||
detail: 'Switch Statement',
|
||||
},
|
||||
),
|
||||
snippetCompletion('try {\n\t#{}\n} catch (#{1:error}) {\n\t\n}', {
|
||||
label: 'trycatch',
|
||||
detail: 'Try-Catch Statement',
|
||||
}),
|
||||
snippetCompletion('while (#{1:condition}) {\n\t#{}\n}', {
|
||||
label: 'while',
|
||||
detail: 'While Statement',
|
||||
}),
|
||||
blockCommentSnippet,
|
||||
];
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useDataSchema } from '@/composables/useDataSchema';
|
||||
import { useDebounce } from '@/composables/useDebounce';
|
||||
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||
import { autocompletableNodeNames } from '@/plugins/codemirror/completions/utils';
|
||||
import useEnvironmentsStore from '@/stores/environments.ee.store';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { forceParse } from '@/utils/forceParse';
|
||||
import { executionDataToJson } from '@/utils/nodeTypesUtils';
|
||||
import { autocompletion } from '@codemirror/autocomplete';
|
||||
import { javascriptLanguage } from '@codemirror/lang-javascript';
|
||||
import { LanguageSupport } from '@codemirror/language';
|
||||
import { Text, type Extension } from '@codemirror/state';
|
||||
import { EditorView, hoverTooltip } from '@codemirror/view';
|
||||
import * as Comlink from 'comlink';
|
||||
import { NodeConnectionType, type CodeExecutionMode, type INodeExecutionData } from 'n8n-workflow';
|
||||
import { ref, toRef, toValue, watch, type MaybeRefOrGetter } from 'vue';
|
||||
import type { LanguageServiceWorker, RemoteLanguageServiceWorkerInit } from '../types';
|
||||
import { typescriptCompletionSource } from './completions';
|
||||
import { typescriptWorkerFacet } from './facet';
|
||||
import { typescriptHoverTooltips } from './hoverTooltip';
|
||||
import { linter } from '@codemirror/lint';
|
||||
import { typescriptLintSource } from './linter';
|
||||
|
||||
export function useTypescript(
|
||||
view: MaybeRefOrGetter<EditorView | undefined>,
|
||||
mode: MaybeRefOrGetter<CodeExecutionMode>,
|
||||
id: MaybeRefOrGetter<string>,
|
||||
) {
|
||||
const { getInputDataWithPinned, getSchemaForExecutionData } = useDataSchema();
|
||||
const ndvStore = useNDVStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const { debounce } = useDebounce();
|
||||
const activeNodeName = ndvStore.activeNodeName;
|
||||
const worker = ref<Comlink.Remote<LanguageServiceWorker>>();
|
||||
|
||||
async function createWorker(): Promise<Extension> {
|
||||
const { init } = Comlink.wrap<RemoteLanguageServiceWorkerInit>(
|
||||
new Worker(new URL('../worker/typescript.worker.ts', import.meta.url), { type: 'module' }),
|
||||
);
|
||||
worker.value = await init(
|
||||
{
|
||||
id: toValue(id),
|
||||
content: Comlink.proxy((toValue(view)?.state.doc ?? Text.empty).toJSON()),
|
||||
allNodeNames: autocompletableNodeNames(),
|
||||
variables: useEnvironmentsStore().variables.map((v) => v.key),
|
||||
inputNodeNames: activeNodeName
|
||||
? workflowsStore
|
||||
.getCurrentWorkflow()
|
||||
.getParentNodes(activeNodeName, NodeConnectionType.Main, 1)
|
||||
: [],
|
||||
mode: toValue(mode),
|
||||
},
|
||||
Comlink.proxy(async (nodeName) => {
|
||||
const node = workflowsStore.getNodeByName(nodeName);
|
||||
|
||||
if (node) {
|
||||
const inputData: INodeExecutionData[] = getInputDataWithPinned(node);
|
||||
const schema = getSchemaForExecutionData(executionDataToJson(inputData), true);
|
||||
const execution = workflowsStore.getWorkflowExecution;
|
||||
const binaryData = useNodeHelpers()
|
||||
.getBinaryData(
|
||||
execution?.data?.resultData?.runData ?? null,
|
||||
node.name,
|
||||
ndvStore.ndvInputRunIndex ?? 0,
|
||||
0,
|
||||
)
|
||||
.filter((data) => Boolean(data && Object.keys(data).length));
|
||||
|
||||
return {
|
||||
json: schema,
|
||||
binary: Object.keys(binaryData.reduce((acc, obj) => ({ ...acc, ...obj }), {})),
|
||||
params: getSchemaForExecutionData([node.parameters]),
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}),
|
||||
);
|
||||
|
||||
const editor = toValue(view);
|
||||
|
||||
if (editor) {
|
||||
forceParse(editor);
|
||||
}
|
||||
|
||||
return [
|
||||
typescriptWorkerFacet.of({ worker: worker.value }),
|
||||
new LanguageSupport(javascriptLanguage, [
|
||||
javascriptLanguage.data.of({ autocomplete: typescriptCompletionSource }),
|
||||
]),
|
||||
autocompletion({ icons: false, aboveCursor: true }),
|
||||
linter(typescriptLintSource),
|
||||
hoverTooltip(typescriptHoverTooltips, {
|
||||
hideOnChange: true,
|
||||
hoverTime: 500,
|
||||
}),
|
||||
EditorView.updateListener.of(async (update) => {
|
||||
if (update.docChanged) {
|
||||
void worker.value?.updateFile(update.changes.toJSON());
|
||||
}
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
async function onWorkflowDataChange() {
|
||||
const editor = toValue(view);
|
||||
if (!editor || !worker.value) return;
|
||||
|
||||
await worker.value.updateNodeTypes();
|
||||
|
||||
forceParse(editor);
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => workflowsStore.getWorkflowExecution, () => workflowsStore.getWorkflowRunData],
|
||||
debounce(onWorkflowDataChange, { debounceTime: 200, trailing: true }),
|
||||
);
|
||||
|
||||
watch(toRef(mode), async (newMode) => {
|
||||
const editor = toValue(view);
|
||||
if (!editor || !worker.value) return;
|
||||
|
||||
await worker.value.updateMode(newMode);
|
||||
forceParse(editor);
|
||||
});
|
||||
|
||||
return {
|
||||
createWorker,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Schema } from '@/Interface';
|
||||
import type { CompletionResult } from '@codemirror/autocomplete';
|
||||
import type { Diagnostic } from '@codemirror/lint';
|
||||
import type { CodeExecutionMode } from 'n8n-workflow';
|
||||
import type ts from 'typescript';
|
||||
import type * as Comlink from 'comlink';
|
||||
import type { ChangeSet } from '@codemirror/state';
|
||||
|
||||
export interface HoverInfo {
|
||||
start: number;
|
||||
end: number;
|
||||
typeDef?: readonly ts.DefinitionInfo[];
|
||||
quickInfo: ts.QuickInfo | undefined;
|
||||
}
|
||||
|
||||
export type WorkerInitOptions = {
|
||||
id: string;
|
||||
content: string[];
|
||||
allNodeNames: string[];
|
||||
inputNodeNames: string[];
|
||||
variables: string[];
|
||||
mode: CodeExecutionMode;
|
||||
};
|
||||
|
||||
export type NodeData = { json: Schema | undefined; binary: string[]; params: Schema };
|
||||
export type NodeDataFetcher = (nodeName: string) => Promise<NodeData | undefined>;
|
||||
|
||||
export type LanguageServiceWorker = {
|
||||
updateFile(changes: ChangeSet): void;
|
||||
updateMode(mode: CodeExecutionMode): void;
|
||||
updateNodeTypes(): void;
|
||||
getCompletionsAtPos(pos: number): Promise<{ result: CompletionResult; isGlobal: boolean } | null>;
|
||||
getDiagnostics(): Diagnostic[];
|
||||
getHoverTooltip(pos: number): HoverInfo | null;
|
||||
};
|
||||
|
||||
export type LanguageServiceWorkerInit = {
|
||||
init(
|
||||
options: WorkerInitOptions,
|
||||
nodeDataFetcher: NodeDataFetcher,
|
||||
): Promise<LanguageServiceWorker>;
|
||||
};
|
||||
|
||||
export type RemoteLanguageServiceWorkerInit = {
|
||||
init(
|
||||
options: WorkerInitOptions,
|
||||
nodeDataFetcher: NodeDataFetcher,
|
||||
): Comlink.Remote<LanguageServiceWorker>;
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
export type IndexedDbCache = Awaited<ReturnType<typeof indexedDbCache>>;
|
||||
|
||||
export async function indexedDbCache(dbName: string, storeName: string) {
|
||||
let cache: Record<string, string> = {};
|
||||
|
||||
void (await loadCache());
|
||||
|
||||
async function loadCache() {
|
||||
await transaction('readonly', async (store, db) => {
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
const request = store.openCursor();
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result;
|
||||
|
||||
if (cursor) {
|
||||
cache[cursor.key as string] = cursor.value.value;
|
||||
cursor.continue();
|
||||
} else {
|
||||
db.close();
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
request.onerror = (event) => {
|
||||
db.close();
|
||||
reject(event);
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function openDb(): Promise<IDBDatabase> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(dbName, 1);
|
||||
request.onupgradeneeded = () => {
|
||||
request.result.createObjectStore(storeName, { keyPath: 'key' });
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
function setItem(key: string, value: string): void {
|
||||
cache[key] = value;
|
||||
void persistToIndexedDB(key, value);
|
||||
}
|
||||
|
||||
function getItem(key: string): string | null {
|
||||
return cache[key] ?? null;
|
||||
}
|
||||
|
||||
function removeItem(key: string): void {
|
||||
delete cache[key];
|
||||
void deleteFromIndexedDB(key);
|
||||
}
|
||||
|
||||
function clear(): void {
|
||||
cache = {};
|
||||
void clearIndexedDB();
|
||||
}
|
||||
|
||||
async function getAllWithPrefix(prefix: string) {
|
||||
const keyRange = IDBKeyRange.bound(prefix, prefix + '\uffff', false, false);
|
||||
|
||||
const results: Record<string, string> = {};
|
||||
return await transaction('readonly', async (store) => {
|
||||
return await new Promise<Record<string, string>>((resolve, reject) => {
|
||||
const request = store.openCursor(keyRange);
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result;
|
||||
|
||||
if (cursor) {
|
||||
results[cursor.key as string] = cursor.value.value;
|
||||
cursor.continue();
|
||||
} else {
|
||||
resolve(results);
|
||||
}
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(request.error);
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function transaction<T>(
|
||||
mode: 'readonly' | 'readwrite',
|
||||
action: (store: IDBObjectStore, db: IDBDatabase) => Promise<T> | T,
|
||||
): Promise<T> {
|
||||
const db = await openDb();
|
||||
const tx = db.transaction(storeName, mode);
|
||||
const store = tx.objectStore(storeName);
|
||||
|
||||
const result = await action(store, db);
|
||||
|
||||
return await new Promise<T>((resolve, reject) => {
|
||||
tx.oncomplete = () => {
|
||||
db.close();
|
||||
resolve(result);
|
||||
};
|
||||
tx.onerror = () => {
|
||||
db.close();
|
||||
reject(tx.error);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function persistToIndexedDB(key: string, value: string) {
|
||||
await transaction('readwrite', (store) => {
|
||||
store.put({ key, value });
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteFromIndexedDB(key: string) {
|
||||
await transaction('readwrite', (store) => {
|
||||
store.delete(key);
|
||||
});
|
||||
}
|
||||
|
||||
async function clearIndexedDB() {
|
||||
await transaction('readwrite', (store) => {
|
||||
store.clear();
|
||||
});
|
||||
}
|
||||
|
||||
return { getItem, removeItem, setItem, clear, getAllWithPrefix };
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type * as tsvfs from '@typescript/vfs';
|
||||
import type ts from 'typescript';
|
||||
|
||||
import { type Completion } from '@codemirror/autocomplete';
|
||||
import { TS_COMPLETE_BLOCKLIST, TYPESCRIPT_AUTOCOMPLETE_THRESHOLD } from './constants';
|
||||
|
||||
function convertTsKindtoEditorCompletionType(kind: ts.ScriptElementKind) {
|
||||
if (!kind) return undefined;
|
||||
|
||||
const type = String(kind);
|
||||
if (type === 'member') return 'property';
|
||||
|
||||
return type;
|
||||
}
|
||||
|
||||
function typescriptCompletionToEditor(
|
||||
completionInfo: ts.WithMetadata<ts.CompletionInfo>,
|
||||
entry: ts.CompletionEntry,
|
||||
): Completion {
|
||||
const boost = -Number(entry.sortText) || 0;
|
||||
const type = convertTsKindtoEditorCompletionType(entry.kind);
|
||||
|
||||
return {
|
||||
label: type && ['method', 'function'].includes(type) ? entry.name + '()' : entry.name,
|
||||
type: convertTsKindtoEditorCompletionType(entry.kind),
|
||||
commitCharacters: entry.commitCharacters ?? completionInfo.defaultCommitCharacters,
|
||||
detail: entry.labelDetails?.detail,
|
||||
boost,
|
||||
};
|
||||
}
|
||||
|
||||
function filterTypescriptCompletions(
|
||||
completionInfo: ts.WithMetadata<ts.CompletionInfo>,
|
||||
entry: ts.CompletionEntry,
|
||||
) {
|
||||
return (
|
||||
!TS_COMPLETE_BLOCKLIST.includes(entry.kind) &&
|
||||
(entry.sortText < TYPESCRIPT_AUTOCOMPLETE_THRESHOLD ||
|
||||
completionInfo.optionalReplacementSpan?.length)
|
||||
);
|
||||
}
|
||||
|
||||
export async function getCompletionsAtPos({
|
||||
pos,
|
||||
fileName,
|
||||
env,
|
||||
}: { pos: number; fileName: string; env: tsvfs.VirtualTypeScriptEnvironment }) {
|
||||
const completionInfo = env.languageService.getCompletionsAtPosition(fileName, pos, {}, {});
|
||||
|
||||
if (!completionInfo) return null;
|
||||
|
||||
const options = completionInfo.entries
|
||||
.filter((entry) => filterTypescriptCompletions(completionInfo, entry))
|
||||
.map((entry) => typescriptCompletionToEditor(completionInfo, entry));
|
||||
|
||||
return {
|
||||
result: { from: pos, options },
|
||||
isGlobal: completionInfo.isGlobalCompletion,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import ts from 'typescript';
|
||||
|
||||
export const TS_COMPLETE_BLOCKLIST: ts.ScriptElementKind[] = [ts.ScriptElementKind.warning];
|
||||
export const COMPILER_OPTIONS: ts.CompilerOptions = {
|
||||
allowJs: true,
|
||||
checkJs: true,
|
||||
target: ts.ScriptTarget.ESNext,
|
||||
lib: ['es2023'],
|
||||
module: ts.ModuleKind.ESNext,
|
||||
strict: true,
|
||||
noUnusedLocals: true,
|
||||
noUnusedParameters: true,
|
||||
importHelpers: false,
|
||||
skipDefaultLibCheck: true,
|
||||
noEmit: true,
|
||||
};
|
||||
export const TYPESCRIPT_AUTOCOMPLETE_THRESHOLD = '15';
|
||||
export const TYPESCRIPT_FILES = {
|
||||
DYNAMIC_TYPES: 'n8n-dynamic.d.ts',
|
||||
DYNAMIC_INPUT_TYPES: 'n8n-dynamic-input.d.ts',
|
||||
DYNAMIC_VARIABLES_TYPES: 'n8n-variables.d.ts',
|
||||
MODE_TYPES: 'n8n-mode-specific.d.ts',
|
||||
N8N_TYPES: 'n8n.d.ts',
|
||||
GLOBAL_TYPES: 'globals.d.ts',
|
||||
};
|
||||
export const LUXON_VERSION = '3.2.0';
|
||||
@@ -0,0 +1,66 @@
|
||||
import { schemaToTypescriptTypes } from './dynamicTypes';
|
||||
|
||||
describe('typescript worker dynamicTypes', () => {
|
||||
describe('schemaToTypescriptTypes', () => {
|
||||
it('should convert a schema to a typescript type', () => {
|
||||
expect(
|
||||
schemaToTypescriptTypes(
|
||||
{
|
||||
type: 'object',
|
||||
value: [
|
||||
{
|
||||
key: 'test',
|
||||
type: 'string',
|
||||
value: '',
|
||||
path: '.test',
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
key: 'nested',
|
||||
path: '.nested',
|
||||
value: [
|
||||
{
|
||||
key: 'amount',
|
||||
type: 'number',
|
||||
value: '',
|
||||
path: '.amount',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'array',
|
||||
key: 'nestedArray',
|
||||
path: '.nestedArray',
|
||||
value: [
|
||||
{
|
||||
type: 'object',
|
||||
key: 'nested',
|
||||
path: '.nestedArray.nested',
|
||||
value: [
|
||||
{
|
||||
key: 'amount',
|
||||
type: 'number',
|
||||
value: '',
|
||||
path: '.amount',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
path: '',
|
||||
},
|
||||
'NodeName_1',
|
||||
),
|
||||
).toEqual(`interface NodeName_1 {
|
||||
test: string;
|
||||
nested: {
|
||||
amount: number;
|
||||
};
|
||||
nestedArray: Array<{
|
||||
amount: number;
|
||||
}>;
|
||||
}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Schema } from '@/Interface';
|
||||
import { pascalCase } from 'change-case';
|
||||
import { globalTypeDefinition } from './utils';
|
||||
|
||||
function processSchema(schema: Schema): string {
|
||||
switch (schema.type) {
|
||||
case 'string':
|
||||
case 'number':
|
||||
case 'boolean':
|
||||
case 'bigint':
|
||||
case 'symbol':
|
||||
case 'null':
|
||||
case 'undefined':
|
||||
return schema.type;
|
||||
|
||||
case 'function':
|
||||
return 'Function';
|
||||
|
||||
case 'array':
|
||||
if (Array.isArray(schema.value)) {
|
||||
if (schema.value.length > 0) {
|
||||
return `Array<${processSchema(schema.value[0])}>`;
|
||||
}
|
||||
return 'any[]';
|
||||
}
|
||||
|
||||
return `${schema.value}[]`;
|
||||
|
||||
case 'object':
|
||||
if (!Array.isArray(schema.value)) {
|
||||
return '{}';
|
||||
}
|
||||
|
||||
const properties = schema.value
|
||||
.map((prop) => {
|
||||
const key = prop.key ?? 'unknown';
|
||||
const type = processSchema(prop);
|
||||
return ` ${key}: ${type};`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return `{\n${properties}\n}`;
|
||||
|
||||
default:
|
||||
return 'any';
|
||||
}
|
||||
}
|
||||
|
||||
export function schemaToTypescriptTypes(schema: Schema, interfaceName: string): string {
|
||||
return `interface ${interfaceName} ${processSchema(schema)}`;
|
||||
}
|
||||
|
||||
export async function getDynamicNodeTypes({
|
||||
nodeNames,
|
||||
loadedNodes,
|
||||
}: { nodeNames: string[]; loadedNodes: Map<string, { type: string; typeName: string }> }) {
|
||||
return globalTypeDefinition(`
|
||||
type NodeName = ${nodeNames.map((name) => `'${name}'`).join(' | ')};
|
||||
|
||||
${Array.from(loadedNodes.values())
|
||||
.map(({ type }) => type)
|
||||
.join(';\n')}
|
||||
|
||||
interface NodeDataMap {
|
||||
${Array.from(loadedNodes.entries())
|
||||
.map(
|
||||
([nodeName, { typeName }]) =>
|
||||
`'${nodeName}': NodeData<${typeName}Context, ${typeName}Json, ${typeName}BinaryKeys, ${typeName}Params>`,
|
||||
)
|
||||
.join(';\n')}
|
||||
}
|
||||
`);
|
||||
}
|
||||
|
||||
export async function getDynamicInputNodeTypes(inputNodeNames: string[]) {
|
||||
const typeNames = inputNodeNames.map((nodeName) => pascalCase(nodeName));
|
||||
|
||||
return globalTypeDefinition(`
|
||||
type N8nInputJson = ${typeNames.map((typeName) => `${typeName}Json`).join(' | ')};
|
||||
type N8nInputBinaryKeys = ${typeNames.map((typeName) => `${typeName}BinaryKeys`).join(' | ')};
|
||||
type N8nInputContext = ${typeNames.map((typeName) => `${typeName}Context`).join(' | ')};
|
||||
type N8nInputParams = ${typeNames.map((typeName) => `${typeName}Params`).join(' | ')};
|
||||
`);
|
||||
}
|
||||
|
||||
export async function getDynamicVariableTypes(variables: string[]) {
|
||||
return globalTypeDefinition(`
|
||||
interface N8nVars {
|
||||
${variables.map((key) => `${key}: string;`).join('\n')}
|
||||
}`);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import * as tsvfs from '@typescript/vfs';
|
||||
import { COMPILER_OPTIONS, TYPESCRIPT_FILES } from './constants';
|
||||
import ts from 'typescript';
|
||||
import type { IndexedDbCache } from './cache';
|
||||
|
||||
import globalTypes from './type-declarations/globals.d.ts?raw';
|
||||
import n8nTypes from './type-declarations/n8n.d.ts?raw';
|
||||
|
||||
import type { CodeExecutionMode } from 'n8n-workflow';
|
||||
import { wrapInFunction } from './utils';
|
||||
|
||||
type EnvOptions = {
|
||||
code: {
|
||||
content: string;
|
||||
fileName: string;
|
||||
};
|
||||
mode: CodeExecutionMode;
|
||||
cache: IndexedDbCache;
|
||||
};
|
||||
|
||||
export function removeUnusedLibs(fsMap: Map<string, string>) {
|
||||
for (const [name] of fsMap.entries()) {
|
||||
if (
|
||||
name === 'lib.d.ts' ||
|
||||
name.startsWith('/lib.dom') ||
|
||||
name.startsWith('/lib.webworker') ||
|
||||
name.startsWith('/lib.scripthost') ||
|
||||
name.endsWith('.full.d.ts')
|
||||
) {
|
||||
fsMap.delete(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function setupTypescriptEnv({ cache, code, mode }: EnvOptions) {
|
||||
const fsMap = await tsvfs.createDefaultMapFromCDN(
|
||||
COMPILER_OPTIONS,
|
||||
ts.version,
|
||||
true,
|
||||
ts,
|
||||
undefined,
|
||||
undefined,
|
||||
cache,
|
||||
);
|
||||
|
||||
removeUnusedLibs(fsMap);
|
||||
|
||||
fsMap.set(TYPESCRIPT_FILES.N8N_TYPES, n8nTypes);
|
||||
fsMap.set(TYPESCRIPT_FILES.GLOBAL_TYPES, globalTypes);
|
||||
|
||||
fsMap.set(code.fileName, wrapInFunction(code.content, mode));
|
||||
|
||||
const system = tsvfs.createSystem(fsMap);
|
||||
return tsvfs.createVirtualTypeScriptEnvironment(
|
||||
system,
|
||||
Array.from(fsMap.keys()),
|
||||
ts,
|
||||
COMPILER_OPTIONS,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type * as tsvfs from '@typescript/vfs';
|
||||
|
||||
export function getHoverTooltip({
|
||||
pos,
|
||||
fileName,
|
||||
env,
|
||||
}: { pos: number; fileName: string; env: tsvfs.VirtualTypeScriptEnvironment }) {
|
||||
const quickInfo = env.languageService.getQuickInfoAtPosition(fileName, pos);
|
||||
|
||||
if (!quickInfo) return null;
|
||||
|
||||
const start = quickInfo.textSpan.start;
|
||||
|
||||
const typeDef =
|
||||
env.languageService.getTypeDefinitionAtPosition(fileName, pos) ??
|
||||
env.languageService.getDefinitionAtPosition(fileName, pos);
|
||||
|
||||
return {
|
||||
start,
|
||||
end: start + quickInfo.textSpan.length,
|
||||
typeDef,
|
||||
quickInfo,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { Diagnostic } from '@codemirror/lint';
|
||||
import type * as tsvfs from '@typescript/vfs';
|
||||
import ts from 'typescript';
|
||||
import type { DiagnosticWithLocation } from 'typescript';
|
||||
|
||||
/**
|
||||
* TypeScript has a set of diagnostic categories,
|
||||
* which maps roughly onto CodeMirror's categories.
|
||||
* Here, we do the mapping.
|
||||
*/
|
||||
export function tsCategoryToSeverity(
|
||||
diagnostic: Pick<ts.DiagnosticWithLocation, 'category' | 'code'>,
|
||||
): Diagnostic['severity'] {
|
||||
switch (diagnostic.code) {
|
||||
case 6133:
|
||||
// No unused variables
|
||||
return 'warning';
|
||||
case 7027:
|
||||
// Unreachable code detected
|
||||
return 'warning';
|
||||
default: {
|
||||
switch (diagnostic.category) {
|
||||
case ts.DiagnosticCategory.Error:
|
||||
return 'error';
|
||||
case ts.DiagnosticCategory.Message:
|
||||
return 'info';
|
||||
case ts.DiagnosticCategory.Warning:
|
||||
return 'warning';
|
||||
case ts.DiagnosticCategory.Suggestion:
|
||||
return 'info';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Not all TypeScript diagnostic relate to specific
|
||||
* ranges in source code: here we filter for those that
|
||||
* do.
|
||||
*/
|
||||
function isDiagnosticWithLocation(
|
||||
diagnostic: ts.Diagnostic,
|
||||
): diagnostic is ts.DiagnosticWithLocation {
|
||||
return !!(
|
||||
diagnostic.file &&
|
||||
typeof diagnostic.start === 'number' &&
|
||||
typeof diagnostic.length === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
function isIgnoredDiagnostic(diagnostic: ts.Diagnostic) {
|
||||
// No implicit any
|
||||
return diagnostic.code === 7006;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message for a diagnostic. TypeScript
|
||||
* is kind of weird: messageText might have the message,
|
||||
* or a pointer to the message. This follows the chain
|
||||
* to get a string, regardless of which case we're in.
|
||||
*/
|
||||
function tsDiagnosticMessage(diagnostic: Pick<ts.Diagnostic, 'messageText'>): string {
|
||||
if (typeof diagnostic.messageText === 'string') {
|
||||
return diagnostic.messageText;
|
||||
}
|
||||
// TODO: go through linked list
|
||||
return diagnostic.messageText.messageText;
|
||||
}
|
||||
|
||||
function tsDiagnosticClassName(diagnostic: ts.Diagnostic) {
|
||||
switch (diagnostic.code) {
|
||||
case 6133:
|
||||
// No unused variables
|
||||
return 'cm-faded';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function convertTSDiagnosticToCM(d: ts.DiagnosticWithLocation): Diagnostic {
|
||||
const start = d.start;
|
||||
const message = tsDiagnosticMessage(d);
|
||||
|
||||
return {
|
||||
from: start,
|
||||
to: start + d.length,
|
||||
message,
|
||||
markClass: tsDiagnosticClassName(d),
|
||||
severity: tsCategoryToSeverity(d),
|
||||
};
|
||||
}
|
||||
|
||||
export function getDiagnostics({
|
||||
env,
|
||||
fileName,
|
||||
}: { env: tsvfs.VirtualTypeScriptEnvironment; fileName: string }) {
|
||||
const exists = env.getSourceFile(fileName);
|
||||
if (!exists) return [];
|
||||
|
||||
const tsDiagnostics = [
|
||||
...env.languageService.getSemanticDiagnostics(fileName),
|
||||
...env.languageService.getSyntacticDiagnostics(fileName),
|
||||
];
|
||||
|
||||
const diagnostics = tsDiagnostics.filter(
|
||||
(diagnostic): diagnostic is DiagnosticWithLocation =>
|
||||
isDiagnosticWithLocation(diagnostic) && !isIgnoredDiagnostic(diagnostic),
|
||||
);
|
||||
|
||||
return diagnostics.map((d) => convertTSDiagnosticToCM(d));
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
type NPMTreeMeta = {
|
||||
default: string;
|
||||
files: Array<{ name: string }>;
|
||||
moduleName: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
const jsDelivrApi = {
|
||||
async getFileTree(packageName: string, version = 'latest'): Promise<NPMTreeMeta> {
|
||||
const url = `https://data.jsdelivr.com/v1/package/npm/${packageName}@${version}/flat`;
|
||||
const res = await fetch(url);
|
||||
return await res.json();
|
||||
},
|
||||
async getFileContent(packageName: string, fileName: string, version = 'latest'): Promise<string> {
|
||||
const url = `https://cdn.jsdelivr.net/npm/${packageName}@${version}${fileName}`;
|
||||
const res = await fetch(url);
|
||||
return await res.text();
|
||||
},
|
||||
};
|
||||
|
||||
function isRequiredTypePackageFile(fileName: string) {
|
||||
return fileName.endsWith('.d.ts') || fileName === '/package.json';
|
||||
}
|
||||
|
||||
function toLocalFilePath(packageName: string, fileName: string) {
|
||||
return `/node_modules/@types/${packageName}${fileName}`;
|
||||
}
|
||||
|
||||
export const loadTypes = async (
|
||||
packageName: string,
|
||||
version: string,
|
||||
onFileReceived: (path: string, content: string) => void,
|
||||
): Promise<void> => {
|
||||
const { files } = await loadTypesFileTree(packageName, version);
|
||||
await Promise.all(
|
||||
files
|
||||
.filter((file) => isRequiredTypePackageFile(file.name))
|
||||
.map(
|
||||
async (file) =>
|
||||
await loadFileContent(packageName, file.name, version).then((content) =>
|
||||
onFileReceived(toLocalFilePath(packageName, file.name), content),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
export const loadTypesFileTree = async (
|
||||
packageName: string,
|
||||
version: string,
|
||||
): Promise<NPMTreeMeta> => {
|
||||
return await jsDelivrApi.getFileTree(`@types/${packageName}`, version);
|
||||
};
|
||||
|
||||
export const loadFileContent = async (
|
||||
packageName: string,
|
||||
fileName: string,
|
||||
version = 'latest',
|
||||
) => {
|
||||
return await jsDelivrApi.getFileContent(`@types/${packageName}`, fileName, version);
|
||||
};
|
||||
+16
@@ -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;
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
interface NodeData<C = any, J extends N8nJson = any, B extends string = string, P = any> {
|
||||
context: C;
|
||||
params: P;
|
||||
all(branchIndex?: number, runIndex?: number): Array<N8nItem<J, B>>;
|
||||
first(branchIndex?: number, runIndex?: number): N8nItem<J, B>;
|
||||
last(branchIndex?: number, runIndex?: number): N8nItem<J, B>;
|
||||
itemMatching(itemIndex: number): N8nItem<J, B>;
|
||||
}
|
||||
|
||||
// @ts-expect-error N8nInputJson is populated dynamically
|
||||
type N8nInput = NodeData<N8nInputContext, N8nInputJson, N8nInputBinaryKeys, N8nInputParams>;
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
interface NodeData<C, J extends N8nJson, B extends string, P> {
|
||||
context: C;
|
||||
item: N8nItem<J, B>;
|
||||
params: P;
|
||||
}
|
||||
|
||||
// @ts-expect-error N8nInputJson is populated dynamically
|
||||
type N8nInput = NodeData<{}, N8nInputJson, {}, {}>;
|
||||
|
||||
const $itemIndex: number;
|
||||
const $json: N8nInput['item']['json'];
|
||||
const $binary: N8nInput['item']['binary'];
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import type { DateTime } from 'luxon';
|
||||
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
type OutputItemWithoutJsonKey = {
|
||||
[key: string]: unknown;
|
||||
} & { json?: never };
|
||||
|
||||
type OutputItemWithJsonKey = {
|
||||
json: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
type MaybePromise<T> = Promise<T> | T;
|
||||
|
||||
type OneOutputItem = OutputItemWithJsonKey | OutputItemWithoutJsonKey;
|
||||
type AllOutputItems = OneOutputItem | Array<OneOutputItem>;
|
||||
|
||||
type N8nOutputItem = MaybePromise<OneOutputItem>;
|
||||
type N8nOutputItems = MaybePromise<AllOutputItems>;
|
||||
|
||||
interface N8nJson {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface N8nBinary {
|
||||
id: string;
|
||||
fileName: string;
|
||||
fileExtension: string;
|
||||
fileType: string;
|
||||
fileSize: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
interface N8nVars {}
|
||||
|
||||
// TODO: populate dynamically
|
||||
interface N8nParameter {}
|
||||
|
||||
interface N8nItem<J extends N8nJson = N8nJson, B extends string = string> {
|
||||
json: J & N8nJson;
|
||||
binary: Record<B, N8nBinary>;
|
||||
}
|
||||
|
||||
interface N8nCustomData {
|
||||
set(key: string, value: string): void;
|
||||
get(key: string): string;
|
||||
getAll(): Record<string, string>;
|
||||
setAll(values: Record<string, string>): void;
|
||||
}
|
||||
|
||||
type N8nExecutionMode = 'test' | 'production';
|
||||
interface N8nExecution {
|
||||
id: string;
|
||||
mode: N8nExecutionMode;
|
||||
resumeUrl?: string;
|
||||
resumeFormUrl?: string;
|
||||
customData: N8nCustomData;
|
||||
}
|
||||
|
||||
interface N8nWorkflow {
|
||||
id: string;
|
||||
active: boolean;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface N8nPrevNode {
|
||||
name: string;
|
||||
outputIndex: number;
|
||||
runIndex: number;
|
||||
}
|
||||
|
||||
const $input: N8nInput;
|
||||
const $execution: N8nExecution;
|
||||
const $workflow: N8nWorkflow;
|
||||
const $prevNode: N8nPrevNode;
|
||||
const $runIndex: number;
|
||||
const $now: DateTime;
|
||||
const $today: DateTime;
|
||||
|
||||
const $parameter: N8nInput['params'];
|
||||
const $vars: N8nVars;
|
||||
const $nodeVersion: number;
|
||||
|
||||
function $jmespath(object: Object | Array<any>, expression: string): any;
|
||||
function $if<B extends boolean, T, F>(
|
||||
condition: B,
|
||||
valueIfTrue: T,
|
||||
valueIfFalse: F,
|
||||
): B extends true ? T : T extends false ? F : T | F;
|
||||
function $ifEmpty<V, E>(value: V, valueIfEmpty: E): V | E;
|
||||
function $min(...numbers: number[]): number;
|
||||
function $max(...numbers: number[]): number;
|
||||
|
||||
type SomeOtherString = string & NonNullable<unknown>;
|
||||
// @ts-expect-error NodeName is created dynamically
|
||||
function $<K extends NodeName>(
|
||||
nodeName: K | SomeOtherString,
|
||||
// @ts-expect-error NodeDataMap is created dynamically
|
||||
): K extends keyof NodeDataMap ? NodeDataMap[K] : NodeData;
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import * as Comlink from 'comlink';
|
||||
import type { LanguageServiceWorker, LanguageServiceWorkerInit } from '../types';
|
||||
import { indexedDbCache } from './cache';
|
||||
import { bufferChangeSets, fnPrefix } from './utils';
|
||||
|
||||
import type { CodeExecutionMode } from 'n8n-workflow';
|
||||
|
||||
import { pascalCase } from 'change-case';
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { getCompletionsAtPos } from './completions';
|
||||
import { LUXON_VERSION, TYPESCRIPT_FILES } from './constants';
|
||||
import {
|
||||
getDynamicInputNodeTypes,
|
||||
getDynamicNodeTypes,
|
||||
getDynamicVariableTypes,
|
||||
schemaToTypescriptTypes,
|
||||
} from './dynamicTypes';
|
||||
import { setupTypescriptEnv } from './env';
|
||||
import { getHoverTooltip } from './hoverTooltip';
|
||||
import { getDiagnostics } from './linter';
|
||||
import { getUsedNodeNames } from './typescriptAst';
|
||||
|
||||
import runOnceForAllItemsTypes from './type-declarations/n8n-once-for-all-items.d.ts?raw';
|
||||
import runOnceForEachItemTypes from './type-declarations/n8n-once-for-each-item.d.ts?raw';
|
||||
import { loadTypes } from './npmTypesLoader';
|
||||
import { ChangeSet, Text } from '@codemirror/state';
|
||||
import { until } from '@vueuse/core';
|
||||
|
||||
self.process = { env: {} } as NodeJS.Process;
|
||||
|
||||
const worker: LanguageServiceWorkerInit = {
|
||||
async init(options, nodeDataFetcher) {
|
||||
const loadedNodeTypesMap: Map<string, { type: string; typeName: string }> = reactive(new Map());
|
||||
|
||||
const inputNodeNames = options.inputNodeNames;
|
||||
const allNodeNames = options.allNodeNames;
|
||||
const codeFileName = `${options.id}.js`;
|
||||
const mode = ref<CodeExecutionMode>(options.mode);
|
||||
const busyApplyingChangesToCode = ref(false);
|
||||
|
||||
const cache = await indexedDbCache('typescript-cache', 'fs-map');
|
||||
const env = await setupTypescriptEnv({
|
||||
cache,
|
||||
mode: mode.value,
|
||||
code: { content: Text.of(options.content).toString(), fileName: codeFileName },
|
||||
});
|
||||
|
||||
const prefix = computed(() => fnPrefix(mode.value));
|
||||
|
||||
function editorPositionToTypescript(pos: number) {
|
||||
return pos + prefix.value.length;
|
||||
}
|
||||
|
||||
function typescriptPositionToEditor(pos: number) {
|
||||
return pos - prefix.value.length;
|
||||
}
|
||||
|
||||
async function loadNodeTypes(nodeName: string) {
|
||||
const data = await nodeDataFetcher(nodeName);
|
||||
|
||||
const typeName = pascalCase(nodeName);
|
||||
const jsonType = data?.json
|
||||
? schemaToTypescriptTypes(data.json, `${typeName}Json`)
|
||||
: `type ${typeName}Json = N8nJson`;
|
||||
const paramsType = data?.params
|
||||
? schemaToTypescriptTypes(data.params, `${typeName}Params`)
|
||||
: `type ${typeName}Params = {}`;
|
||||
|
||||
// Using || on purpose to handle empty string
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
const binaryType = `type ${typeName}BinaryKeys = ${data?.binary.map((key) => `'${key}'`).join(' | ') || 'string'}`;
|
||||
const contextType = `type ${typeName}Context = {}`;
|
||||
const type = [jsonType, binaryType, paramsType, contextType].join('\n');
|
||||
loadedNodeTypesMap.set(nodeName, { type, typeName });
|
||||
}
|
||||
|
||||
async function loadTypesIfNeeded() {
|
||||
const file = env.getSourceFile(codeFileName);
|
||||
|
||||
if (!file) return;
|
||||
|
||||
const nodeNames = await getUsedNodeNames(file);
|
||||
|
||||
for (const nodeName of nodeNames) {
|
||||
if (!loadedNodeTypesMap.has(nodeName)) {
|
||||
await loadNodeTypes(nodeName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLuxonTypes() {
|
||||
if (cache.getItem('/node_modules/@types/luxon/package.json')) {
|
||||
const fileMap = await cache.getAllWithPrefix('/node_modules/@types/luxon');
|
||||
|
||||
for (const [path, content] of Object.entries(fileMap)) {
|
||||
updateFile(path, content);
|
||||
}
|
||||
} else {
|
||||
await loadTypes('luxon', LUXON_VERSION, (path, types) => {
|
||||
cache.setItem(path, types);
|
||||
updateFile(path, types);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function setVariableTypes() {
|
||||
updateFile(
|
||||
TYPESCRIPT_FILES.DYNAMIC_VARIABLES_TYPES,
|
||||
await getDynamicVariableTypes(options.variables),
|
||||
);
|
||||
}
|
||||
|
||||
function updateFile(fileName: string, content: string) {
|
||||
const exists = env.getSourceFile(fileName);
|
||||
if (exists) {
|
||||
env.updateFile(fileName, content);
|
||||
} else {
|
||||
env.createFile(fileName, content);
|
||||
}
|
||||
}
|
||||
|
||||
const loadInputNodes = options.inputNodeNames.map(
|
||||
async (nodeName) => await loadNodeTypes(nodeName),
|
||||
);
|
||||
await Promise.all(
|
||||
loadInputNodes.concat(loadTypesIfNeeded(), loadLuxonTypes(), setVariableTypes()),
|
||||
);
|
||||
|
||||
watch(
|
||||
loadedNodeTypesMap,
|
||||
async (loadedNodes) => {
|
||||
updateFile(
|
||||
TYPESCRIPT_FILES.DYNAMIC_INPUT_TYPES,
|
||||
await getDynamicInputNodeTypes(inputNodeNames),
|
||||
);
|
||||
updateFile(
|
||||
TYPESCRIPT_FILES.DYNAMIC_TYPES,
|
||||
await getDynamicNodeTypes({ nodeNames: allNodeNames, loadedNodes }),
|
||||
);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
mode,
|
||||
(newMode) => {
|
||||
updateFile(
|
||||
TYPESCRIPT_FILES.MODE_TYPES,
|
||||
newMode === 'runOnceForAllItems' ? runOnceForAllItemsTypes : runOnceForEachItemTypes,
|
||||
);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(prefix, (newPrefix, oldPrefix) => {
|
||||
env.updateFile(codeFileName, newPrefix, { start: 0, length: oldPrefix.length });
|
||||
});
|
||||
|
||||
const applyChangesToCode = bufferChangeSets((bufferedChanges) => {
|
||||
bufferedChanges.iterChanges((start, end, _fromNew, _toNew, text) => {
|
||||
const length = end - start;
|
||||
|
||||
env.updateFile(codeFileName, text.toString(), {
|
||||
start: editorPositionToTypescript(start),
|
||||
length,
|
||||
});
|
||||
});
|
||||
|
||||
void loadTypesIfNeeded();
|
||||
});
|
||||
|
||||
const waitForChangesAppliedToCode = async () => {
|
||||
await until(busyApplyingChangesToCode).toBe(false, { timeout: 500 });
|
||||
};
|
||||
|
||||
return Comlink.proxy<LanguageServiceWorker>({
|
||||
updateFile: async (changes) => {
|
||||
busyApplyingChangesToCode.value = true;
|
||||
void applyChangesToCode(ChangeSet.fromJSON(changes)).then(() => {
|
||||
busyApplyingChangesToCode.value = false;
|
||||
});
|
||||
},
|
||||
async getCompletionsAtPos(pos) {
|
||||
await waitForChangesAppliedToCode();
|
||||
|
||||
return await getCompletionsAtPos({
|
||||
pos: editorPositionToTypescript(pos),
|
||||
fileName: codeFileName,
|
||||
env,
|
||||
});
|
||||
},
|
||||
getDiagnostics() {
|
||||
return getDiagnostics({ env, fileName: codeFileName }).map((diagnostic) => ({
|
||||
...diagnostic,
|
||||
from: typescriptPositionToEditor(diagnostic.from),
|
||||
to: typescriptPositionToEditor(diagnostic.to),
|
||||
}));
|
||||
},
|
||||
getHoverTooltip(pos) {
|
||||
const tooltip = getHoverTooltip({
|
||||
pos: editorPositionToTypescript(pos),
|
||||
fileName: codeFileName,
|
||||
env,
|
||||
});
|
||||
|
||||
if (!tooltip) return null;
|
||||
|
||||
tooltip.start = typescriptPositionToEditor(tooltip.start);
|
||||
tooltip.end = typescriptPositionToEditor(tooltip.end);
|
||||
|
||||
return tooltip;
|
||||
},
|
||||
async updateMode(newMode) {
|
||||
mode.value = newMode;
|
||||
},
|
||||
async updateNodeTypes() {
|
||||
const loadedNodeNames = Array.from(loadedNodeTypesMap.keys());
|
||||
await Promise.all(loadedNodeNames.map(async (nodeName) => await loadNodeTypes(nodeName)));
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
Comlink.expose(worker);
|
||||
@@ -0,0 +1,38 @@
|
||||
import ts from 'typescript';
|
||||
|
||||
function findNodes(node: ts.Node, check: (node: ts.Node) => boolean): ts.Node[] {
|
||||
const result: ts.Node[] = [];
|
||||
|
||||
// If the current node matches the condition, add it to the result
|
||||
if (check(node)) {
|
||||
result.push(node);
|
||||
}
|
||||
|
||||
// Recursively check all child nodes
|
||||
node.forEachChild((child) => {
|
||||
result.push(...findNodes(child, check));
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get nodes mentioned in the code
|
||||
* Check if code includes calls to $('Node A')
|
||||
*/
|
||||
export async function getUsedNodeNames(file: ts.SourceFile) {
|
||||
const callExpressions = findNodes(
|
||||
file,
|
||||
(n) =>
|
||||
n.kind === ts.SyntaxKind.CallExpression &&
|
||||
(n as ts.CallExpression).expression.getText() === '$',
|
||||
);
|
||||
|
||||
if (callExpressions.length === 0) return [];
|
||||
|
||||
const nodeNames = (callExpressions as ts.CallExpression[])
|
||||
.map((e) => (e.arguments.at(0) as ts.StringLiteral)?.text)
|
||||
.filter(Boolean);
|
||||
|
||||
return nodeNames;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { ChangeSet } from '@codemirror/state';
|
||||
import { type CodeExecutionMode } from 'n8n-workflow';
|
||||
|
||||
export const fnPrefix = (mode: CodeExecutionMode) => `(
|
||||
/**
|
||||
* @returns {${returnTypeForMode(mode)}}
|
||||
*/
|
||||
() => {\n`;
|
||||
|
||||
export function wrapInFunction(script: string, mode: CodeExecutionMode): string {
|
||||
return `${fnPrefix(mode)}${script}\n})()`;
|
||||
}
|
||||
|
||||
export function globalTypeDefinition(types: string) {
|
||||
return `export {};
|
||||
declare global {
|
||||
${types}
|
||||
}`;
|
||||
}
|
||||
|
||||
export function returnTypeForMode(mode: CodeExecutionMode): string {
|
||||
return mode === 'runOnceForAllItems' ? 'N8nOutputItems' : 'N8nOutputItem';
|
||||
}
|
||||
|
||||
const MAX_CHANGE_BUFFER_CHAR_SIZE = 10_000_000;
|
||||
const MIN_CHANGE_BUFFER_WINDOW_MS = 50;
|
||||
const MAX_CHANGE_BUFFER_WINDOW_MS = 500;
|
||||
|
||||
// Longer buffer window for large code
|
||||
function calculateBufferWindowMs(docSize: number, minDelay: number, maxDelay: number): number {
|
||||
const clampedSize = Math.min(docSize, MAX_CHANGE_BUFFER_CHAR_SIZE);
|
||||
const normalizedSize = clampedSize / MAX_CHANGE_BUFFER_CHAR_SIZE;
|
||||
|
||||
return Math.ceil(minDelay + (maxDelay - minDelay) * normalizedSize);
|
||||
}
|
||||
|
||||
// Create a buffer function to accumulate and compose changesets
|
||||
export function bufferChangeSets(fn: (changeset: ChangeSet) => void) {
|
||||
let changeSet = ChangeSet.empty(0);
|
||||
let timeoutId: NodeJS.Timeout | null = null;
|
||||
|
||||
return async (changes: ChangeSet) => {
|
||||
changeSet = changeSet.compose(changes);
|
||||
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
return await new Promise<void>((resolve) => {
|
||||
timeoutId = setTimeout(
|
||||
() => {
|
||||
fn(changeSet);
|
||||
resolve();
|
||||
changeSet = ChangeSet.empty(0);
|
||||
},
|
||||
calculateBufferWindowMs(
|
||||
changeSet.length,
|
||||
MIN_CHANGE_BUFFER_WINDOW_MS,
|
||||
MAX_CHANGE_BUFFER_WINDOW_MS,
|
||||
),
|
||||
);
|
||||
});
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user