diff --git a/frontend/src/app/registry/actions/edit/page.tsx b/frontend/src/app/registry/actions/edit/page.tsx index 143754592..4cb958ef1 100644 --- a/frontend/src/app/registry/actions/edit/page.tsx +++ b/frontend/src/app/registry/actions/edit/page.tsx @@ -218,7 +218,7 @@ function EditTemplateActionForm({ diff --git a/frontend/src/app/registry/actions/new/page.tsx b/frontend/src/app/registry/actions/new/page.tsx index c39d4569f..c6ed6e9be 100644 --- a/frontend/src/app/registry/actions/new/page.tsx +++ b/frontend/src/app/registry/actions/new/page.tsx @@ -212,7 +212,7 @@ function NewTemplateActionForm({ render={({ field }) => ( diff --git a/frontend/src/components/editor/editor.tsx b/frontend/src/components/editor/editor.tsx index 12127d051..e132f5e92 100644 --- a/frontend/src/components/editor/editor.tsx +++ b/frontend/src/components/editor/editor.tsx @@ -1,6 +1,6 @@ "use client" -import { useEffect, useRef } from "react" +import { useEffect } from "react" import { EditorFunctionRead, editorListFunctions } from "@/client" import { EditorProps, @@ -132,6 +132,203 @@ export interface CustomEditorProps extends EditorProps { workflowId?: string | null } +// Add these at the top level of the file +let providersRegistered = false +let completionDisposable: IDisposable | null = null +let hoverDisposable: IDisposable | null = null +let tokenizerDisposable: IDisposable | null = null + +// Helper function to register providers once +function registerProviders( + monaco: Monaco, + workspaceId?: string, + workflowId?: string +) { + if (providersRegistered) return + + // Register a custom token provider for YAML + monaco.languages.register({ id: "yaml-extended" }) + monaco.languages.setLanguageConfiguration("yaml-extended", yamlConf) + monaco.languages.setMonarchTokensProvider("yaml-extended", yamlLanguage) + + monaco.languages.register({ id: "tracecat-dsl" }) + const { conf, lang } = constructDslLang() + monaco.languages.setLanguageConfiguration("tracecat-dsl", conf) + tokenizerDisposable = monaco.languages.setMonarchTokensProvider( + "tracecat-dsl", + lang + ) + + // Register completion provider + completionDisposable = monaco.languages.registerCompletionItemProvider( + "yaml-extended", + { + triggerCharacters: [ + "$", + "{", + ".", + " ", + ...EXPR_CONTEXTS.map((c) => c[0]), + ], + provideCompletionItems: async (model, position, context, token) => { + const wordUntilPos = model.getWordUntilPosition(position) + const lineContent = model.getLineContent(position.lineNumber) + const textUntilPos = lineContent.substring(0, position.column - 1) + + console.log("TEXT: ", textUntilPos) + console.log("WORD: ", wordUntilPos) + console.log("lineContent", lineContent) + console.log("Trigger", context.triggerCharacter) + + const range: IRange = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: wordUntilPos.startColumn, + endColumn: position.column, + } + + // Use negative lookahead to ensure we don't match outside of expressions + if (textUntilPos.match(INSIDE_EXPR_PATTERN)) { + console.log("MATCH: inside expr") + + // 1. Check if specific context + if (textUntilPos.match(ACTION_CONTEXT_PATTERN)) { + console.log("MATCH: inside actions") + + if (textUntilPos.endsWith("ACTIONS.")) { + if (workflowId && workspaceId) { + console.log("MATCH: get action completions") + return { + suggestions: await getActionCompletions( + monaco, + range, + workspaceId, + workflowId + ), + } + } else { + console.log("Can't load action completions") + } + } + if (textUntilPos.match(ACTION_REF_PATTERN)) { + console.log("MATCH: action ref") + return { + suggestions: [ + { + label: "result", + kind: monaco.languages.CompletionItemKind.Property, + detail: "The successful result of the action", + insertText: "result", + range, + }, + { + label: "error", + kind: monaco.languages.CompletionItemKind.Property, + detail: "The error message if the action failed", + insertText: "error", + range, + }, + ], + } + } + + if (textUntilPos.match(ACTIONS_RES_ERR_PATTERN)) { + console.log("MATCH: action res err") + return { suggestions: [] } + } + + return { suggestions: [] } + } + + if (textUntilPos.endsWith("FN.") && workspaceId) { + console.log("MATCH: inside fn") + return { + suggestions: await getFunctionSuggestions( + monaco, + range, + workspaceId + ), + } + } + + if (textUntilPos.endsWith("ENV.")) { + console.log("MATCH: inside env") + return { suggestions: getEnvCompletions(range) } + } + + if (textUntilPos.endsWith("INPUTS.")) { + console.log("MATCH: inside inputs") + return { suggestions: getInputCompletions(range) } + } + + if (textUntilPos.endsWith("SECRETS.")) { + console.log("MATCH: inside secrets") + return { suggestions: getSecretCompletions(range) } + } + + if (textUntilPos.endsWith("TRIGGER.")) { + console.log("MATCH: inside trigger") + return { suggestions: getTriggerCompletions(range) } + } + + // 2. Check if general context + const shouldPad = context.triggerCharacter === "{" + return { + suggestions: getContextSuggestions(monaco, range).map((s) => ({ + ...s, + insertText: formatContextPadding(s.insertText, shouldPad), + insertTextRules: + monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + range, + })), + } + } + + if (textUntilPos.endsWith("$")) { + console.log("MATCH: starting $") + return { + suggestions: getExpressionCompletions(range), + } + } + + console.log("NO MATCH") + return { suggestions: [] } + }, + } + ) + + // Register hover provider + hoverDisposable = monaco.languages.registerHoverProvider("yaml-extended", { + provideHover: async (model, position) => { + if (!workspaceId) { + return null + } + const wordAtPosition = model.getWordAtPosition(position) + if (!wordAtPosition) return null + + const word = wordAtPosition.word + const lineContent = model.getLineContent(position.lineNumber) + + // Check if we're in a FN context + try { + const functions = await editorListFunctions({ workspaceId }) + if (lineContent.includes("FN.") && functions) { + const fn = functions.find((f) => f.name === word) + if (fn) { + return generateFunctionDocs(fn) + } + } + } catch { + console.log("Couldn't fetch function completions") + } + + return null + }, + }) + + providersRegistered = true +} + export function CustomEditor({ className, onKeyDown, @@ -139,31 +336,12 @@ export function CustomEditor({ workflowId, ...props }: CustomEditorProps) { - const completionDisposableRef = useRef(null) - const hoverDisposableRef = useRef(null) - const tokenizerDisposableRef = useRef(null) - const handleEditorDidMount = async ( editor: editor.IStandaloneCodeEditor, monaco: Monaco ) => { - // Cleanup previous providers if they exist - completionDisposableRef.current?.dispose() - hoverDisposableRef.current?.dispose() - tokenizerDisposableRef.current?.dispose() - - // Register a custom token provider for YAML - monaco.languages.register({ id: "yaml-extended" }) - monaco.languages.setLanguageConfiguration("yaml-extended", yamlConf) - monaco.languages.setMonarchTokensProvider("yaml-extended", yamlLanguage) - - monaco.languages.register({ id: "tracecat-dsl" }) - const { conf, lang } = constructDslLang() - monaco.languages.setLanguageConfiguration("tracecat-dsl", conf) - tokenizerDisposableRef.current = monaco.languages.setMonarchTokensProvider( - "tracecat-dsl", - lang - ) + // Register providers only once + registerProviders(monaco, workspaceId, workflowId ?? undefined) monaco.editor.defineTheme("myCustomTheme", { base: "vs", @@ -253,198 +431,27 @@ export function CustomEditor({ }) monaco.editor.setTheme("myCustomTheme") - completionDisposableRef.current = - monaco.languages.registerCompletionItemProvider("yaml-extended", { - triggerCharacters: [ - "$", - "{", - ".", - " ", - ...EXPR_CONTEXTS.map((c) => c[0]), - ], - provideCompletionItems: async (model, position, context, token) => { - const wordUntilPos = model.getWordUntilPosition(position) - const lineContent = model.getLineContent(position.lineNumber) - const textUntilPos = lineContent.substring(0, position.column - 1) - - console.log("TEXT: ", textUntilPos) - console.log("WORD: ", wordUntilPos) - console.log("lineContent", lineContent) - console.log("Trigger", context.triggerCharacter) - - const range: IRange = { - startLineNumber: position.lineNumber, - endLineNumber: position.lineNumber, - startColumn: wordUntilPos.startColumn, - endColumn: position.column, - } - - // Use negative lookahead to ensure we don't match outside of expressions - if (textUntilPos.match(INSIDE_EXPR_PATTERN)) { - console.log("MATCH: inside expr") - - // 1. Check if specific context - if (textUntilPos.match(ACTION_CONTEXT_PATTERN)) { - console.log("MATCH: inside actions") - - if (textUntilPos.endsWith("ACTIONS.")) { - if (workflowId && workspaceId) { - console.log("MATCH: get action completions") - return { - suggestions: await getActionCompletions( - monaco, - range, - workspaceId, - workflowId - ), - } - } else { - console.log("Can't load action completions") - } - } - if (textUntilPos.match(ACTION_REF_PATTERN)) { - console.log("MATCH: action ref") - return { - suggestions: [ - { - label: "result", - kind: monaco.languages.CompletionItemKind.Property, - detail: "The successful result of the action", - insertText: "result", - range, - }, - { - label: "error", - kind: monaco.languages.CompletionItemKind.Property, - detail: "The error message if the action failed", - insertText: "error", - range, - }, - ], - } - } - - if (textUntilPos.match(ACTIONS_RES_ERR_PATTERN)) { - console.log("MATCH: action res err") - return { suggestions: [] } - } - - return { suggestions: [] } - } - - if (textUntilPos.endsWith("FN.") && workspaceId) { - console.log("MATCH: inside fn") - return { - suggestions: await getFunctionSuggestions( - monaco, - range, - workspaceId - ), - } - } - - if (textUntilPos.endsWith("ENV.")) { - console.log("MATCH: inside env") - return { suggestions: getEnvCompletions(range) } - } - - if (textUntilPos.endsWith("INPUTS.")) { - console.log("MATCH: inside inputs") - return { suggestions: getInputCompletions(range) } - } - - if (textUntilPos.endsWith("SECRETS.")) { - console.log("MATCH: inside secrets") - return { suggestions: getSecretCompletions(range) } - } - - if (textUntilPos.endsWith("TRIGGER.")) { - console.log("MATCH: inside trigger") - return { suggestions: getTriggerCompletions(range) } - } - - // 2. Check if general context - const shouldPad = context.triggerCharacter === "{" - return { - suggestions: getContextSuggestions(monaco, range).map((s) => ({ - ...s, - insertText: formatContextPadding(s.insertText, shouldPad), - insertTextRules: - monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, - range, - })), - } - } - - if (textUntilPos.endsWith("$")) { - console.log("MATCH: starting $") - return { - suggestions: getExpressionCompletions(range), - } - } - - console.log("NO MATCH") - return { suggestions: [] } - }, - }) - - // Register and store the hover provider - hoverDisposableRef.current = monaco.languages.registerHoverProvider( - "yaml-extended", - { - provideHover: async (model, position) => { - if (!workspaceId) { - return null - } - const wordAtPosition = model.getWordAtPosition(position) - if (!wordAtPosition) return null - - const word = wordAtPosition.word - const lineContent = model.getLineContent(position.lineNumber) - - // Check if we're in a FN context - try { - const functions = await editorListFunctions({ workspaceId }) - if (lineContent.includes("FN.") && functions) { - const fn = functions.find((f) => f.name === word) - if (fn) { - return generateFunctionDocs(fn) - } - } - } catch { - console.log("Couldn't fetch function completions") - } - - return null - }, - } - ) - - // Get the suggest widget from the editor - // This shows the details of the suggestion widget by default + // Configure suggest widget const suggestController = editor.getContribution( "editor.contrib.suggestController" ) as ISuggestController | null if (suggestController) { const widget = suggestController.widget if (widget?.value && widget.value._setDetailsVisible) { - // This will default to visible details widget.value._setDetailsVisible(true) - - // Optionally set the widget size - // if (widget.value._persistedSize) { - // widget.value._persistedSize.store({ width: 200, height: 256 }) - // } } } } - // Cleanup on unmount + // Global cleanup on app unmount useEffect(() => { return () => { - completionDisposableRef.current?.dispose() - hoverDisposableRef.current?.dispose() - tokenizerDisposableRef.current?.dispose() + if (providersRegistered) { + completionDisposable?.dispose() + hoverDisposable?.dispose() + tokenizerDisposable?.dispose() + providersRegistered = false + } } }, []) diff --git a/frontend/src/components/nav/workbench-nav.tsx b/frontend/src/components/nav/workbench-nav.tsx index b982e348a..d1671dc10 100644 --- a/frontend/src/components/nav/workbench-nav.tsx +++ b/frontend/src/components/nav/workbench-nav.tsx @@ -471,7 +471,7 @@ function WorkflowManualTrigger({ ( ( ( ( ( ( ( (