From 5b21d8a7bcd3d333ad000ad67af37d3610f83826 Mon Sep 17 00:00:00 2001 From: web Date: Tue, 12 Aug 2025 12:32:00 -0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(editor):=20Enhance=20Go=20Te?= =?UTF-8?q?mplate=20Editor=20with=20schema=20support=20and=20improved=20co?= =?UTF-8?q?mpletion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/dashboard/subscribe/protocol-form.tsx | 87 ++ .../custom-components/editor/go-template.tsx | 1173 +++++++++++++---- .../editor/monaco-editor.tsx | 5 + 3 files changed, 1023 insertions(+), 242 deletions(-) diff --git a/apps/admin/app/dashboard/subscribe/protocol-form.tsx b/apps/admin/app/dashboard/subscribe/protocol-form.tsx index 0ff4930..250d357 100644 --- a/apps/admin/app/dashboard/subscribe/protocol-form.tsx +++ b/apps/admin/app/dashboard/subscribe/protocol-form.tsx @@ -519,6 +519,93 @@ export function ProtocolForm() { {t('form.fields.template')} field.onChange(value)} diff --git a/packages/ui/src/custom-components/editor/go-template.tsx b/packages/ui/src/custom-components/editor/go-template.tsx index f685439..48624ea 100644 --- a/packages/ui/src/custom-components/editor/go-template.tsx +++ b/packages/ui/src/custom-components/editor/go-template.tsx @@ -5,16 +5,162 @@ import { MonacoEditor, MonacoEditorProps, } from '@workspace/ui/custom-components/editor/monaco-editor'; -import { useMemo } from 'react'; -// Import Dracula theme +import * as monaco from 'monaco-editor'; import DraculaTheme from 'monaco-themes/themes/Dracula.json' with { type: 'json' }; +import { useEffect, useRef } from 'react'; + +type SchemaType = 'string' | 'number' | 'boolean' | 'object' | 'array' | 'null'; + +interface SchemaProperty { + type: SchemaType; + items?: SchemaProperty; + properties?: Record; + description?: string; +} export interface GoTemplateEditorProps extends Omit { - schema?: Record; + schema?: Record | Record; enableSprig?: boolean; } -// Go template syntax keywords +interface CompletionItem { + label: string; + kind: number; + insertText: string; + documentation: string; + sortText: string; +} + +const SORT_PREFIXES = { + RANGE_VAR: 'aa_var_', + NESTED: 'a_nested_', + CURRENT: 'a_current_', + VAR_FIELD: 'b_var_', + ROOT_FIELD: 'c_field_', + RANGE_OP: 'd_range_', + WITH_OP: 'd_with_', + ROOT_IN_RANGE: 'y_root_', + KEYWORD: 'z_keyword_', + SPRIG: 'zz_sprig_', +} as const; + +const COMPLETION_KINDS = { + VARIABLE: 6, + PROPERTY: 10, + FUNCTION: 3, + KEYWORD: 14, +} as const; + +const MATCH_SCORES = { + EXACT: 1000, + DOT_FIELD: 1000, + PREFIX_BASE: 800, + DOT_VARIABLE: 500, + INCLUDE_BASE: 500, + FUZZY_BASE: 100, + OTHER: 100, +} as const; + +const REGEX_PATTERNS = { + RANGE_ASSIGNMENT: /(?:\$\w+\s*,\s*)?(\$\w+)\s*:=\s*(\.\w+(?:\.\w+)*)/, + FIELD_PATH: /^(\.\w+(?:\.\w+)*)$/, + NESTED_DOT: /(\.\w+(?:\.\w+)*)\./, + NESTED_VAR: /(\$\w+\.\w+(?:\.\w+)*)\.$/, + NESTED_GENERAL: /([\w.]+)\.$/, + WORD_WITH_SPACES: /(\s*)(\S*)$/, + LEADING_SPACES: /^(\s+)/, + DOT_VAR_CLEAN: /^[.$]/, +} as const; + +const COMPLETION_SNIPPETS = { + IF_BLOCK: { + label: 'if...end', + kind: 15, + insertText: 'if ${1:condition}\n\t$0\nend', + insertTextRules: 4, + documentation: 'Create an if block', + sortText: `${SORT_PREFIXES.KEYWORD}if_block`, + }, + RANGE_BLOCK: { + label: 'range...end', + kind: 15, + insertText: 'range ${1:.items}\n\t$0\nend', + insertTextRules: 4, + documentation: 'Create a range loop block', + sortText: `${SORT_PREFIXES.KEYWORD}range_block`, + }, + WITH_BLOCK: { + label: 'with...end', + kind: 15, + insertText: 'with ${1:.field}\n\t$0\nend', + insertTextRules: 4, + documentation: 'Create a with block', + sortText: `${SORT_PREFIXES.KEYWORD}with_block`, + }, +} as const; + +const calculateMatchScore = (label: string, searchText: string): number => { + if (!searchText) return 0; + + if (searchText === '.') { + if (label.startsWith('.')) return MATCH_SCORES.DOT_FIELD; + if (label.startsWith('$')) return MATCH_SCORES.DOT_VARIABLE; + return MATCH_SCORES.OTHER; + } + + const labelLower = label.toLowerCase(); + const searchLower = searchText.toLowerCase(); + const cleanLabel = labelLower.replace(REGEX_PATTERNS.DOT_VAR_CLEAN, ''); + const cleanSearch = searchLower.replace(REGEX_PATTERNS.DOT_VAR_CLEAN, ''); + + if (cleanLabel === cleanSearch || labelLower === searchLower) { + return MATCH_SCORES.EXACT; + } + + if (cleanLabel.startsWith(cleanSearch) || labelLower.startsWith(searchLower)) { + const matchLength = Math.max(cleanSearch.length, searchLower.length); + const totalLength = Math.max(cleanLabel.length, labelLower.length); + return MATCH_SCORES.PREFIX_BASE + (matchLength / totalLength) * 100; + } + + let includeIndex = cleanLabel.indexOf(cleanSearch); + if (includeIndex === -1) { + includeIndex = labelLower.indexOf(searchLower); + } + + if (includeIndex !== -1) { + const totalLength = Math.max(cleanLabel.length, labelLower.length); + const positionScore = ((totalLength - includeIndex) / totalLength) * 50; + const matchLength = Math.max(cleanSearch.length, searchLower.length); + const lengthScore = (matchLength / totalLength) * 100; + return MATCH_SCORES.INCLUDE_BASE + positionScore + lengthScore; + } + + let fuzzyScore = 0; + let searchIndex = 0; + const targetText = cleanSearch ? cleanLabel : labelLower; + const searchQuery = cleanSearch || searchLower; + + for (let i = 0; i < targetText.length && searchIndex < searchQuery.length; i++) { + if (targetText[i] === searchQuery[searchIndex]) { + fuzzyScore += 10; + searchIndex++; + } + } + + return searchIndex === searchQuery.length ? MATCH_SCORES.FUZZY_BASE + fuzzyScore : 0; +}; + +const generateDynamicSortText = ( + item: CompletionItem, + searchText: string, + baseCategory: string, +): string => { + const matchScore = calculateMatchScore(item.label, searchText); + const scorePrefix = (10000 - matchScore).toString().padStart(5, '0'); + return `${scorePrefix}_${baseCategory}_${item.label}`; +}; + const GO_TEMPLATE_KEYWORDS = [ 'if', 'else', @@ -24,23 +170,25 @@ const GO_TEMPLATE_KEYWORDS = [ 'template', 'define', 'block', + 'include', + 'not', 'and', 'or', - 'not', 'eq', 'ne', 'lt', 'le', 'gt', 'ge', - 'print', + 'len', + 'index', + 'slice', 'printf', + 'print', 'println', ]; -// Sprig function list (commonly used) const SPRIG_FUNCTIONS = [ - // String functions 'trim', 'trimAll', 'trimSuffix', @@ -59,6 +207,7 @@ const SPRIG_FUNCTIONS = [ 'randAlphaNum', 'randAlpha', 'randNumeric', + 'randAscii', 'wrap', 'wrapWith', 'contains', @@ -71,28 +220,44 @@ const SPRIG_FUNCTIONS = [ 'nindent', 'replace', 'plural', - 'sha1sum', - 'sha256sum', - 'adler32sum', - 'htmlEscape', - 'htmlUnescape', - 'urlEscape', - 'urlUnescape', + 'snakecase', + 'camelcase', + 'kebabcase', + 'swapcase', + 'shuffle', + + 'splitList', + 'split', + 'join', + 'sortAlpha', - // Math functions 'add', + 'add1', 'sub', - 'mul', 'div', 'mod', + 'mul', 'max', 'min', - 'ceil', 'floor', + 'ceil', 'round', + 'randInt', + + 'until', + 'untilStep', + 'seq', + + 'addf', + 'add1f', + 'subf', + 'divf', + 'mulf', + 'maxf', + 'minf', - // Date functions 'now', + 'ago', 'date', 'dateInZone', 'duration', @@ -105,44 +270,6 @@ const SPRIG_FUNCTIONS = [ 'toDate', 'mustToDate', - // Lists and Dict functions - 'list', - 'first', - 'rest', - 'last', - 'initial', - 'reverse', - 'uniq', - 'compact', - 'slice', - 'has', - 'set', - 'unset', - 'hasKey', - 'pluck', - 'keys', - 'pick', - 'omit', - 'merge', - 'mergeOverwrite', - 'values', - 'append', - 'prepend', - 'concat', - 'dict', - 'get', - 'index', - 'dig', - - // Type functions - 'typeOf', - 'typeIs', - 'typeIsLike', - 'kindOf', - 'kindIs', - 'deepEqual', - - // Default functions 'default', 'empty', 'coalesce', @@ -150,243 +277,805 @@ const SPRIG_FUNCTIONS = [ 'toJson', 'toPrettyJson', 'toRawJson', - 'mustFromJson', - 'mustToJson', - 'mustToPrettyJson', + 'ternary', - // Encoding functions 'b64enc', 'b64dec', - 'b32enc', - 'b32dec', + 'base32enc', + 'base32dec', + + 'list', + 'first', + 'rest', + 'last', + 'initial', + 'append', + 'prepend', + 'concat', + 'reverse', + 'uniq', + 'without', + 'has', + 'compact', + 'slice', + + 'get', + 'set', + 'dict', + 'hasKey', + 'pluck', + 'dig', + 'deepCopy', + 'keys', + 'pick', + 'omit', + 'merge', + 'mergeOverwrite', + 'values', + + 'atoi', + 'int', + 'int64', + 'float64', + 'toDecimal', + 'toString', + 'toStrings', + + 'regexMatch', + 'mustRegexMatch', + 'regexFindAll', + 'mustRegexFindAll', + 'regexFind', + 'mustRegexFind', + 'regexReplaceAll', + 'mustRegexReplaceAll', + 'regexReplaceAllLiteral', + 'mustRegexReplaceAllLiteral', + 'regexSplit', + 'mustRegexSplit', + 'regexQuoteMeta', + + 'sha1sum', + 'sha256sum', + 'adler32sum', + 'htpasswd', + 'derivePassword', + 'buildCustomCert', + 'genCA', + 'genCAWithKey', + 'genSelfSignedCert', + 'genSelfSignedCertWithKey', + 'genSignedCert', + 'genSignedCertWithKey', + 'encryptAES', + 'decryptAES', + 'genPrivateKey', + 'genPublicKey', + + 'base', + 'dir', + 'ext', + 'clean', + 'isAbs', + 'osBase', + 'osDir', + 'osExt', + 'osClean', + 'osIsAbs', - // Flow control 'fail', - 'required', - 'tpl', - // UUID functions 'uuidv4', - // OS functions 'env', 'expandenv', + + 'semver', + 'semverCompare', + + 'typeOf', + 'typeIs', + 'typeIsLike', + 'kindOf', + 'kindIs', + 'deepEqual', + + 'getHostByName', + + 'urlParse', + 'urlJoin', + 'urlquery', ]; export function GoTemplateEditor({ schema, enableSprig = true, ...props }: GoTemplateEditorProps) { - const completionItems = useMemo(() => { - const items = []; + const providersRef = useRef<{ + completionProvider?: monaco.IDisposable; + semanticTokensProvider?: monaco.IDisposable; + }>({}); - // Add Go template keywords - items.push( - ...GO_TEMPLATE_KEYWORDS.map((keyword) => ({ - label: keyword, - kind: 14, - insertText: keyword, - documentation: `Go template keyword: ${keyword}`, - })), - ); + const cleanup = () => { + if (providersRef.current.completionProvider) { + providersRef.current.completionProvider.dispose(); + } + if (providersRef.current.semanticTokensProvider) { + providersRef.current.semanticTokensProvider.dispose(); + } + providersRef.current = {}; + }; - // Add Sprig functions if enabled - if (enableSprig) { - items.push( - ...SPRIG_FUNCTIONS.map((func) => ({ - label: func, - kind: 3, // Function - insertText: `${func} `, - documentation: `Sprig function: ${func}`, - })), + useEffect(() => { + return cleanup; + }, []); + + const generateSmartCompletions = ( + schema: Record | Record | undefined, + activeRangeField?: string | null, + rangeVariable?: string | null, + ): CompletionItem[] => { + if (!schema) return []; + + const isSchemaProperty = (obj: unknown): obj is SchemaProperty => { + return obj !== null && typeof obj === 'object' && 'type' in obj; + }; + + if (activeRangeField) { + const rangeCompletions = getRangeCompletions( + schema, + activeRangeField, + rangeVariable || null, + isSchemaProperty, ); + + const hasAlias = rangeVariable !== null; + + if (hasAlias) { + const rootCompletions = getRootCompletions(schema, isSchemaProperty, true); + return [...rangeCompletions, ...rootCompletions]; + } else { + return rangeCompletions; + } } - // Add schema field completion - if (schema && typeof schema === 'object') { - const addSchemaFields = (schemaObj: Record, prefix = '.') => { - // Handle JSON Schema properties - if (schemaObj.properties && typeof schemaObj.properties === 'object') { - const properties = schemaObj.properties as Record; - Object.keys(properties).forEach((key) => { - const property = properties[key] as Record; - const fullPath = prefix === '.' ? `.${key}` : `${prefix}.${key}`; - const type = property.type || 'unknown'; + return getRootCompletions(schema, isSchemaProperty); + }; - items.push({ - label: fullPath, - kind: 10, // Property - insertText: fullPath, - documentation: `Schema field: ${fullPath} (${type})${property.description ? ` - ${property.description}` : ''}`, - }); + const getRootCompletions = ( + schema: Record, + isSchemaProperty: (obj: unknown) => obj is SchemaProperty, + isInRangeContext = false, + ): CompletionItem[] => { + const items: CompletionItem[] = []; + const sortPrefix = isInRangeContext ? SORT_PREFIXES.ROOT_IN_RANGE : SORT_PREFIXES.ROOT_FIELD; - // Recursively add nested object properties - if (property.type === 'object' && property.properties) { - addSchemaFields(property, fullPath); - } - }); + Object.keys(schema).forEach((key) => { + const value = schema[key]; + + if (isSchemaProperty(value)) { + const prop = value as SchemaProperty; + addFieldCompletions(items, key, prop, '', false, sortPrefix); + } else { + items.push({ + label: `.${key}`, + kind: COMPLETION_KINDS.PROPERTY, + insertText: `.${key}`, + documentation: `${isInRangeContext ? '(root) ' : ''}Field: ${key}`, + sortText: `${sortPrefix}${key}`, + }); + } + }); + + return items; + }; + + const getRangeCompletions = ( + schema: Record, + rangeField: string, + rangeVariable: string | null, + isSchemaProperty: (obj: unknown) => obj is SchemaProperty, + ): CompletionItem[] => { + const items: CompletionItem[] = []; + if (rangeVariable) { + items.push({ + label: rangeVariable, + kind: COMPLETION_KINDS.VARIABLE, + insertText: rangeVariable, + documentation: `Range variable for current item in ${rangeField}`, + sortText: `${SORT_PREFIXES.RANGE_VAR}${rangeVariable}`, + }); + } + + const fieldPath = rangeField.startsWith('.') ? rangeField.slice(1) : rangeField; + const pathParts = fieldPath.split('.'); + + let currentSchema: Record | SchemaProperty | null = schema; + for (const part of pathParts) { + if (currentSchema && typeof currentSchema === 'object' && !isSchemaProperty(currentSchema)) { + const schemaObj = currentSchema as Record; + if (schemaObj[part]) { + currentSchema = schemaObj[part] as Record | SchemaProperty; + if ( + isSchemaProperty(currentSchema) && + currentSchema.type === 'object' && + currentSchema.properties + ) { + currentSchema = currentSchema.properties; + } + } else { + return items; } - // Handle direct object structure (non-JSON Schema format) - else { - Object.keys(schemaObj).forEach((key) => { - if (key === 'properties' || key === 'type' || key === 'description') return; + } else { + return items; + } + } - const value = schemaObj[key]; - const fullPath = prefix === '.' ? `.${key}` : `${prefix}.${key}`; + if (isSchemaProperty(currentSchema) && currentSchema.type === 'array' && currentSchema.items) { + const itemSchema = currentSchema.items; + if (itemSchema.type === 'object' && itemSchema.properties) { + Object.keys(itemSchema.properties).forEach((key) => { + const prop = itemSchema.properties![key]; - items.push({ - label: fullPath, - kind: 10, // Property - insertText: fullPath, - documentation: `Schema field: ${fullPath} (${typeof value})`, - }); - - if (value && typeof value === 'object' && !Array.isArray(value)) { - addSchemaFields(value as Record, fullPath); + if (isSchemaProperty(prop)) { + if (!rangeVariable) { + addFieldCompletions(items, key, prop, '', true); } - }); - } - }; + } else { + if (!rangeVariable) { + items.push({ + label: `.${key}`, + kind: COMPLETION_KINDS.PROPERTY, + insertText: `.${key}`, + documentation: `Field in current item: ${key}`, + sortText: `${SORT_PREFIXES.CURRENT}${key}`, + }); + } + } - addSchemaFields(schema); + if (rangeVariable) { + if (isSchemaProperty(prop)) { + items.push({ + label: `${rangeVariable}.${key}`, + kind: COMPLETION_KINDS.PROPERTY, + insertText: `${rangeVariable}.${key}`, + documentation: `${prop.description || key} (${prop.type}) via range variable`, + sortText: `${SORT_PREFIXES.VAR_FIELD}${rangeVariable}_${key}`, + }); + } else { + items.push({ + label: `${rangeVariable}.${key}`, + kind: COMPLETION_KINDS.PROPERTY, + insertText: `${rangeVariable}.${key}`, + documentation: `Field ${key} via range variable ${rangeVariable}`, + sortText: `${SORT_PREFIXES.VAR_FIELD}${rangeVariable}_${key}`, + }); + } + } + }); + } } return items; - }, [schema, enableSprig]); + }; - const handleEditorMount = (editor: unknown, monaco: Monaco) => { - // Register custom Go template language - monaco.languages.register({ id: 'gotemplate' }); + const addFieldCompletions = ( + items: CompletionItem[], + key: string, + prop: SchemaProperty, + prefix: string, + isRangeContext = false, + customSortPrefix?: string, + ) => { + const fieldPath = prefix ? `${prefix}.${key}` : `.${key}`; + const contextPrefix = isRangeContext ? '(current item) ' : ''; + const sortPrefix = + customSortPrefix || (isRangeContext ? SORT_PREFIXES.CURRENT : SORT_PREFIXES.ROOT_FIELD); - // Set syntax highlighting - monaco.languages.setMonarchTokensProvider('gotemplate', { + items.push({ + label: fieldPath, + kind: COMPLETION_KINDS.PROPERTY, + insertText: fieldPath, + documentation: `${contextPrefix}${prop.description || key} (${prop.type})`, + sortText: `${sortPrefix}${fieldPath}`, + }); + + if (prop.type === 'array' && !isRangeContext) { + items.push({ + label: `range ${fieldPath}`, + kind: COMPLETION_KINDS.KEYWORD, + insertText: `range ${fieldPath}`, + documentation: `Loop through ${fieldPath} array`, + sortText: `${SORT_PREFIXES.RANGE_OP}${fieldPath}`, + }); + } + + if (prop.type === 'object' && !isRangeContext) { + items.push({ + label: `with ${fieldPath}`, + kind: COMPLETION_KINDS.KEYWORD, + insertText: `with ${fieldPath}`, + documentation: `Set context to ${fieldPath} object`, + sortText: `${SORT_PREFIXES.WITH_OP}${fieldPath}`, + }); + } + }; + + const getNestedFieldCompletions = ( + schema: Record | Record | undefined, + fieldPath: string, + isSchemaProperty: (obj: unknown) => obj is SchemaProperty, + ): CompletionItem[] => { + if (!schema) return []; + + const items: CompletionItem[] = []; + let cleanPath = fieldPath; + + if (cleanPath.startsWith('$')) { + cleanPath = cleanPath.replace(/^\$\w+\./, ''); + } else if (cleanPath.startsWith('.')) { + cleanPath = cleanPath.slice(1); + } + + if (!cleanPath || cleanPath === '') { + Object.keys(schema).forEach((key) => { + const value = schema[key]; + if (isSchemaProperty(value)) { + items.push({ + label: key, + kind: 10, + insertText: key, + documentation: `${value.description || key} (${value.type})`, + sortText: `a_nested_${key}`, + }); + } else { + items.push({ + label: key, + kind: 10, + insertText: key, + documentation: `Field: ${key}`, + sortText: `a_nested_${key}`, + }); + } + }); + return items; + } + + const pathParts = cleanPath.split('.').filter((part) => part && part.length > 0); + + let currentSchema: Record | SchemaProperty | null = schema; + + for (const part of pathParts) { + if (currentSchema && typeof currentSchema === 'object' && !isSchemaProperty(currentSchema)) { + const schemaObj = currentSchema as Record; + if (schemaObj[part]) { + currentSchema = schemaObj[part] as Record | SchemaProperty; + } else { + return items; + } + } else if (isSchemaProperty(currentSchema)) { + if (currentSchema.type === 'object' && currentSchema.properties) { + const prop = currentSchema.properties[part] as + | SchemaProperty + | Record + | undefined; + if (prop) { + currentSchema = prop; + } else { + return items; + } + } else { + return items; + } + } else { + return items; + } + } + + if ( + isSchemaProperty(currentSchema) && + currentSchema.type === 'object' && + currentSchema.properties + ) { + Object.keys(currentSchema.properties).forEach((key) => { + const prop = currentSchema.properties![key]; + if (isSchemaProperty(prop)) { + items.push({ + label: key, + kind: COMPLETION_KINDS.PROPERTY, + insertText: key, + documentation: `${prop.description || key} (${prop.type})`, + sortText: `${SORT_PREFIXES.NESTED}${key}`, + }); + } else { + items.push({ + label: key, + kind: COMPLETION_KINDS.PROPERTY, + insertText: key, + documentation: `Field: ${key}`, + sortText: `${SORT_PREFIXES.NESTED}${key}`, + }); + } + }); + } + + return items; + }; + + const handleBeforeMount = (monaco: Monaco) => { + cleanup(); + + monaco.languages.register({ id: 'go-template' }); + + monaco.languages.setMonarchTokensProvider('go-template', { tokenizer: { root: [ - // Comments - match {{/*...*/}} first - [/\{\{\/\*[\s\S]*?\*\/\}\}/, 'comment'], - // Template tags - enter template state - [/\{\{-?/, { token: 'template-tag', next: '@template' }], - // Regular text - [/[^{]+/, ''], - [/[{]/, ''], + [/\{\{\/\*/, 'comment', '@comment'], + [/\{\{/, 'template-tag', '@template'], + [/./, 'text'], ], + template: [ - // Exit template - [/-?\}\}/, { token: 'template-tag', next: '@pop' }], - // Strings in template - [/"([^"\\]|\\.)*"/, 'string'], - [/'([^'\\]|\\.)*'/, 'string'], - // Go template keywords - exact word match - [new RegExp(`\\b(${GO_TEMPLATE_KEYWORDS.join('|')})\\b`), 'keyword'], - // Sprig functions - exact word match - [new RegExp(`\\b(${SPRIG_FUNCTIONS.join('|')})\\b`), 'function'], - // Variables starting with $ - [/\$\w+/, 'variable'], - // Properties starting with . - [/\.\w+/, 'property'], - // Numbers - [/\d+(\.\d+)?/, 'number'], - // Operators and other tokens + [/\/\*/, 'comment', '@comment'], + [/\}\}/, 'template-tag', '@pop'], + [/"([^"\\]|\\.)*$/, 'string.invalid'], + [/"/, 'string', '@string'], + [ + /\b(if|else|end|with|range|template|define|block|include|not|and|or|eq|ne|lt|le|gt|ge|len|index|slice|printf|print|println)\b/, + 'keyword', + ], + [/\.[a-zA-Z_][a-zA-Z0-9_]*/, 'variable'], + [/\$[a-zA-Z_][a-zA-Z0-9_]*/, 'variable'], + [/\d*\.\d+([eE][-+]?\d+)?/, 'number.float'], + [/\d+/, 'number'], [/[|:]/, 'operator'], - // Whitespace - [/\s+/, ''], - // Any other characters in template - [/./, ''], + [/[a-zA-Z_][a-zA-Z0-9_]*/, 'function'], + [/\s+/, 'white'], ], + + comment: [ + [/\*\//, 'comment', '@pop'], + [/./, 'comment'], + ], + string: [ [/[^\\"]+/, 'string'], [/\\./, 'string.escape'], [/"/, 'string', '@pop'], ], - string_single: [ - [/[^\\']+/, 'string'], - [/\\./, 'string.escape'], - [/'/, 'string', '@pop'], - ], }, }); - // Override the Dracula theme with Go template colors + providersRef.current.completionProvider = monaco.languages.registerCompletionItemProvider( + 'go-template', + { + triggerCharacters: ['.', ' '], + provideCompletionItems: (model, position) => { + try { + const textUntilPosition = model.getValueInRange({ + startLineNumber: position.lineNumber, + startColumn: 1, + endLineNumber: position.lineNumber, + endColumn: position.column, + }); + + const lastOpenBrace = textUntilPosition.lastIndexOf('{{'); + const lastCloseBrace = textUntilPosition.lastIndexOf('}}'); + const insideTemplate = lastOpenBrace > lastCloseBrace && lastOpenBrace !== -1; + + if (!insideTemplate) { + return { suggestions: [] }; + } + + const fullText = model.getValue(); + const currentPosition = model.getOffsetAt(position); + const textBeforePosition = fullText.substring(0, currentPosition); + + const rangeMatches = [...textBeforePosition.matchAll(/\{\{\s*range\s+([^}]+)\s*\}\}/g)]; + const endMatches = [...textBeforePosition.matchAll(/\{\{\s*end\s*\}\}/g)]; + + let activeRangeField: string | null = null; + let rangeVariable: string | null = null; + if (rangeMatches.length > endMatches.length) { + const lastRange = rangeMatches[rangeMatches.length - 1]; + if (lastRange && lastRange[1]) { + const rangeField = lastRange[1].trim(); + + const patterns = [REGEX_PATTERNS.RANGE_ASSIGNMENT, REGEX_PATTERNS.FIELD_PATH]; + + for (const pattern of patterns) { + const match = rangeField.match(pattern); + if (match) { + if (pattern.source.includes(':=')) { + rangeVariable = match[1] || null; + activeRangeField = match[2] || null; + } else { + activeRangeField = match[1] || null; + } + break; + } + } + } + } + + const templateFunctions: CompletionItem[] = GO_TEMPLATE_KEYWORDS.map((keyword) => ({ + label: keyword, + kind: COMPLETION_KINDS.KEYWORD, + insertText: keyword, + documentation: `Go template keyword: ${keyword}`, + sortText: `${SORT_PREFIXES.KEYWORD}${keyword}`, + })); + + const snippetCompletions: CompletionItem[] = Object.values(COMPLETION_SNIPPETS); + + const sprigFunctions: CompletionItem[] = enableSprig + ? SPRIG_FUNCTIONS.map((fn) => ({ + label: fn, + kind: COMPLETION_KINDS.FUNCTION, + insertText: fn, + documentation: `Sprig function: ${fn}`, + sortText: `${SORT_PREFIXES.SPRIG}${fn}`, + })) + : []; + + const baseCompletionItems: CompletionItem[] = [ + ...templateFunctions, + ...snippetCompletions, + ...sprigFunctions, + ]; + let allCompletions: CompletionItem[] = [...baseCompletionItems]; + + if (schema) { + const schemaCompletions = generateSmartCompletions( + schema, + activeRangeField, + rangeVariable, + ); + allCompletions = [...allCompletions, ...schemaCompletions]; + } + + const wordStart = textUntilPosition.lastIndexOf(' ') + 1; + const templateStart = textUntilPosition.lastIndexOf('{{') + 2; + const actualStart = Math.max(wordStart, templateStart); + const currentWord = textUntilPosition.substring(actualStart).trim(); + + let dotMatches = currentWord.match(REGEX_PATTERNS.NESTED_DOT); + if (!dotMatches) { + const beforeCursor = textUntilPosition.substring(Math.max(templateStart, 0)); + dotMatches = + beforeCursor.match(REGEX_PATTERNS.NESTED_DOT) || + beforeCursor.match(REGEX_PATTERNS.NESTED_VAR) || + beforeCursor.match(REGEX_PATTERNS.NESTED_GENERAL); + } + const isNestedField = dotMatches && textUntilPosition.endsWith('.') && schema; + + const justTypedDot = currentWord.endsWith('.') || textUntilPosition.endsWith('.'); + const justTypedSpace = textUntilPosition.endsWith(' '); + const wordForFiltering = justTypedDot ? currentWord.slice(0, -1) : currentWord; + + if (isNestedField && schema && dotMatches) { + const fieldPath = dotMatches[1]; + if (fieldPath) { + const nestedCompletions = getNestedFieldCompletions( + schema, + fieldPath, + (obj: unknown): obj is SchemaProperty => { + return obj !== null && typeof obj === 'object' && 'type' in obj; + }, + ); + allCompletions = [...allCompletions, ...nestedCompletions]; + } + } + + const isVariableOrFieldItem = (item: CompletionItem): boolean => { + return ( + item.label.startsWith('$') || + item.label.startsWith('.') || + item.sortText?.startsWith(SORT_PREFIXES.RANGE_VAR) || + item.sortText?.startsWith(SORT_PREFIXES.VAR_FIELD) || + item.sortText?.startsWith(SORT_PREFIXES.ROOT_FIELD) || + item.sortText?.startsWith(SORT_PREFIXES.CURRENT) + ); + }; + + const isRangeContextItem = (item: CompletionItem): boolean => { + return ( + isVariableOrFieldItem(item) || + item.sortText?.startsWith(SORT_PREFIXES.ROOT_IN_RANGE) + ); + }; + + const filteredCompletions = allCompletions.filter((item) => { + if (isNestedField) { + return item.sortText?.startsWith(SORT_PREFIXES.NESTED); + } + + if (justTypedDot) { + return activeRangeField ? isRangeContextItem(item) : isVariableOrFieldItem(item); + } + + if (justTypedSpace) { + return true; + } + + if (!wordForFiltering) { + return true; + } + + const label = item.label.toLowerCase(); + const word = wordForFiltering.toLowerCase(); + + if (word.startsWith('$')) { + return label.startsWith('$') || label.includes(word); + } + + return ( + label.includes(word) || + label.startsWith(word) || + label.replace(/[^a-zA-Z0-9]/g, '').includes(word.replace(/[^a-zA-Z0-9]/g, '')) + ); + }); + + const createUniqueCompletions = (completions: CompletionItem[]): CompletionItem[] => { + const seen = new Set(); + return completions.filter((item) => { + const key = `${item.label}:${item.insertText}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); + }; + + const uniqueFilteredCompletions = createUniqueCompletions(filteredCompletions); + + const getCategoryFromSortText = (sortText: string): string => { + if (sortText.startsWith(SORT_PREFIXES.RANGE_VAR)) return 'var'; + if (sortText.startsWith(SORT_PREFIXES.NESTED)) return 'nested'; + if (sortText.startsWith(SORT_PREFIXES.CURRENT)) return 'current'; + if (sortText.startsWith(SORT_PREFIXES.VAR_FIELD)) return 'range_var'; + if (sortText.startsWith(SORT_PREFIXES.ROOT_FIELD)) return 'field'; + if (sortText.startsWith(SORT_PREFIXES.RANGE_OP)) return 'range_op'; + if (sortText.startsWith(SORT_PREFIXES.WITH_OP)) return 'with_op'; + if (sortText.startsWith(SORT_PREFIXES.ROOT_IN_RANGE)) return 'root'; + if (sortText.startsWith(SORT_PREFIXES.KEYWORD)) return 'keyword'; + if (sortText.startsWith(SORT_PREFIXES.SPRIG)) return 'sprig'; + return 'other'; + }; + + const getSearchText = (): string => { + if (isNestedField) { + return currentWord.split('.').pop() || ''; + } + if (justTypedDot) { + const afterDot = currentWord.replace(/.*\./, ''); + return afterDot === '' && activeRangeField ? '.' : afterDot; + } + if (justTypedSpace) { + return ''; + } + if (wordForFiltering.startsWith('.')) { + return wordForFiltering.slice(1); + } + return wordForFiltering; + }; + + const searchText = getSearchText(); + + const dynamicallySortedCompletions = uniqueFilteredCompletions + .map((item: CompletionItem) => ({ + ...item, + sortText: generateDynamicSortText( + item, + searchText, + getCategoryFromSortText(item.sortText), + ), + })) + .sort((a: CompletionItem, b: CompletionItem) => a.sortText.localeCompare(b.sortText)); + + return { + suggestions: dynamicallySortedCompletions.map((item: CompletionItem) => { + let insertText = item.insertText; + let startColumn = actualStart; + + const templateContent = textUntilPosition.substring(templateStart); + + if (isNestedField) { + startColumn = position.column; + } else if (justTypedDot && item.insertText.startsWith('.')) { + insertText = item.insertText.slice(1); + startColumn = position.column; + } else if (justTypedDot) { + startColumn = position.column; + } else if (justTypedSpace) { + insertText = `${item.insertText} `; + startColumn = position.column; + } else { + const wordMatch = templateContent.match(REGEX_PATTERNS.WORD_WITH_SPACES); + if (wordMatch) { + const [, leadingSpaces, currentWordInTemplate] = wordMatch; + if (leadingSpaces && currentWordInTemplate) { + startColumn = + templateStart + templateContent.length - currentWordInTemplate.length; + } else { + const spaceMatch = templateContent.match(REGEX_PATTERNS.LEADING_SPACES); + if ( + spaceMatch && + spaceMatch[1] && + templateContent.trim() === currentWordInTemplate + ) { + startColumn = templateStart + spaceMatch[1].length; + } + } + } + } + + return { + ...item, + insertText, + range: { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn, + endColumn: position.column, + }, + }; + }), + }; + } catch (error) { + console.error('Go template completion error:', error); + return { suggestions: [] }; + } + }, + }, + ); + + providersRef.current.semanticTokensProvider = + monaco.languages.registerDocumentSemanticTokensProvider('go-template', { + getLegend: () => ({ + tokenTypes: ['variable', 'function', 'keyword', 'string', 'number'], + tokenModifiers: [], + }), + provideDocumentSemanticTokens: (model) => { + const tokens: number[] = []; + const text = model.getValue(); + const lines = text.split('\n'); + + lines.forEach((line, lineIndex) => { + const templateMatches = [...line.matchAll(/\{\{([^}]+)\}\}/g)]; + templateMatches.forEach((match) => { + if (match.index !== undefined && match[1]) { + const content = match[1].trim(); + const startCol = match.index + 2; + const length = content.length; + + if (content.startsWith('.') || content.startsWith('$')) { + tokens.push(lineIndex, startCol, length, 0, 0); + } + } + }); + }); + + return { data: new Uint32Array(tokens) }; + }, + releaseDocumentSemanticTokens: () => {}, + }); + monaco.editor.defineTheme('transparentTheme', { base: DraculaTheme.base as 'vs' | 'vs-dark' | 'hc-black', inherit: DraculaTheme.inherit, rules: [ ...DraculaTheme.rules, - { token: 'template-tag', foreground: 'FFB86C', fontStyle: 'bold' }, // Dracula orange for template tags - { token: 'keyword', foreground: 'FF79C6' }, // Dracula pink for keywords - { token: 'function', foreground: '50FA7B' }, // Dracula green for functions - { token: 'variable', foreground: 'F1FA8C' }, // Dracula yellow for variables - { token: 'property', foreground: '8BE9FD' }, // Dracula cyan for properties - { token: 'operator', foreground: 'FF79C6' }, // Dracula pink for operators + { token: 'template-tag', foreground: 'FFB86C', fontStyle: 'bold' }, + { token: 'template-keyword', foreground: 'BD93F9', fontStyle: 'bold' }, + { token: 'template-string', foreground: 'F1FA8C' }, + { token: 'template-function', foreground: '50FA7B' }, + { token: 'template-variable', foreground: 'F8F8F2' }, + { token: 'keyword', foreground: 'FF79C6' }, ], colors: { + ...DraculaTheme.colors, 'editor.background': '#00000000', }, }); - - // Force theme refresh - const editorInstance = editor as { updateOptions?: (options: unknown) => void }; - if (editorInstance && editorInstance.updateOptions) { - editorInstance.updateOptions({ theme: 'transparentTheme' }); - } - - // Register auto-completion - monaco.languages.registerCompletionItemProvider('gotemplate', { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - provideCompletionItems: (model: any, position: any) => { - const range = { - startLineNumber: position.lineNumber, - endLineNumber: position.lineNumber, - startColumn: position.column, - endColumn: position.column, - }; - - return { - suggestions: completionItems.map((item) => ({ - ...item, - range, - })), - }; - }, - }); - - // Register hover provider - monaco.languages.registerHoverProvider('gotemplate', { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - provideHover: (model: any, position: any) => { - const word = model.getWordAtPosition(position); - if (word) { - const wordText = word.word; - - if (GO_TEMPLATE_KEYWORDS.includes(wordText)) { - return { - range: new monaco.Range( - position.lineNumber, - word.startColumn, - position.lineNumber, - word.endColumn, - ), - contents: [{ value: `**Go Template Keyword**: ${wordText}` }], - }; - } - - if (SPRIG_FUNCTIONS.includes(wordText)) { - return { - range: new monaco.Range( - position.lineNumber, - word.startColumn, - position.lineNumber, - word.endColumn, - ), - contents: [{ value: `**Sprig Function**: ${wordText}` }], - }; - } - } - - return null; - }, - }); - - if (props.onMount) { - props.onMount(editor as Parameters>[0], monaco); - } }; return ( @@ -394,9 +1083,9 @@ export function GoTemplateEditor({ schema, enableSprig = true, ...props }: GoTem title='Go Template Editor' description={`Go text/template syntax${enableSprig ? ' with Sprig functions' : ''}`} {...props} - language='gotemplate' + language='go-template' placeholder='Enter your Go template here...' - onMount={handleEditorMount} + beforeMount={handleBeforeMount} /> ); } diff --git a/packages/ui/src/custom-components/editor/monaco-editor.tsx b/packages/ui/src/custom-components/editor/monaco-editor.tsx index aabec96..aa78a28 100644 --- a/packages/ui/src/custom-components/editor/monaco-editor.tsx +++ b/packages/ui/src/custom-components/editor/monaco-editor.tsx @@ -17,6 +17,7 @@ export interface MonacoEditorProps { placeholder?: string; render?: (value?: string) => React.ReactNode; onMount?: OnMount; + beforeMount?: (monaco: Monaco) => void; language?: string; className?: string; } @@ -39,6 +40,7 @@ export function MonacoEditor({ placeholder = 'Start typing...', render, onMount, + beforeMount, language = 'markdown', className, }: MonacoEditorProps) { @@ -154,6 +156,9 @@ export function MonacoEditor({ 'editor.background': '#00000000', }, }); + if (beforeMount) { + beforeMount(monaco); + } }} /> {!internalValue?.trim() && placeholder && (