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({
(
(
(
(
(
(
(
(