diff --git a/addons/cetmix_tower_server/static/src/components/ace_variables/ace_variables.esm.js b/addons/cetmix_tower_server/static/src/components/ace_variables/ace_variables.esm.js new file mode 100644 index 0000000..94127e5 --- /dev/null +++ b/addons/cetmix_tower_server/static/src/components/ace_variables/ace_variables.esm.js @@ -0,0 +1,507 @@ +/** @odoo-module **/ + +import {AceField} from "@web/views/fields/ace/ace_field"; +import {AutocompletePopup} from "./autocomplete_popup.esm"; +import {registry} from "@web/core/registry"; +import {useService} from "@web/core/utils/hooks"; +import {useState} from "@odoo/owl"; + +const POPUP_FALLBACK_WIDTH = 500; +const POPUP_FALLBACK_HEIGHT = 300; + +class AceCommandField extends AceField { + /** + * Initialize the component with required services and properties + */ + setup() { + super.setup(); + this.orm = useService("orm"); + this.inputListener = null; + this.clickOutsideListener = null; + this.inputTimeout = null; + this.variables = []; + this.secrets = []; + + // Use reactive state for properties that affect rendering + this.state = useState({ + showPopup: false, + popupItems: [], + popupPosition: {}, + selectedIndex: 0, + // Add popup type to distinguish between variables and secrets + popupType: "variables", + }); + + this.updateSelectedIndex = this.updateSelectedIndex.bind(this); + } + + /** + * Load variables from the backend using ORM service + * @returns {Promise} + */ + async loadVariables() { + try { + this.variables = await this.orm.searchRead( + "cx.tower.variable", + [], + ["name", "reference"] + ); + console.log(`Loaded ${this.variables.length} variables for autocomplete`); + } catch (error) { + console.error("Failed to load variables for autocomplete:", error); + this.variables = []; + this.env.services.notification.add( + "Failed to load autocomplete variables", + {type: "warning"} + ); + } + } + + /** + * Load secrets from the backend using ORM service + * @returns {Promise} + */ + async loadSecrets() { + try { + this.secrets = await this.orm.searchRead( + "cx.tower.key", + [["key_type", "=", "s"]], + ["name", "reference"] + ); + console.log(`Loaded ${this.secrets.length} secrets for autocomplete`); + } catch (error) { + console.error("Failed to load secrets for autocomplete:", error); + this.secrets = []; + this.env.services.notification.add("Failed to load autocomplete secrets", { + type: "warning", + }); + } + } + + /** + * Set up ACE editor with custom autocompletion + */ + setupAce() { + super.setupAce(); + + if (this.aceEditor) { + this.setupCustomAutocompletion(); + } + } + + /** + * Configure custom autocompletion commands and keyboard bindings for ACE editor + */ + setupCustomAutocompletion() { + // Remove any existing conflicting commands first + this.aceEditor.commands.removeCommand("startAutocomplete"); + this.aceEditor.commands.removeCommand("expandSnippet"); + + // Only add the main autocomplete trigger command + this.aceEditor.commands.addCommand({ + name: "customAutoComplete", + bindKey: {win: "Ctrl-Space", mac: null}, + exec: (editor) => { + this.showCustomCompletions(editor); + return true; + }, + }); + + // Set up input listener for {{ and #! triggers + this.inputListener = () => { + // Clear any existing timeout + if (this.inputTimeout) { + clearTimeout(this.inputTimeout); + } + // Use setTimeout to ensure the text is fully processed + this.inputTimeout = setTimeout(() => { + const cursor = this.aceEditor.getCursorPosition(); + const session = this.aceEditor.getSession(); + const line = session.getLine(cursor.row); + const textBeforeCursor = line.substring(0, cursor.column); + + // Check for variables trigger {{ + if (textBeforeCursor.endsWith("{{")) { + // Remove {{ symbols from editor + const startColumn = Math.max(0, cursor.column - 2); + const range = { + start: {row: cursor.row, column: startColumn}, + end: {row: cursor.row, column: cursor.column}, + }; + session.replace(range, ""); + + // Update cursor position + const newCursor = { + row: cursor.row, + column: startColumn, + }; + this.aceEditor.moveCursorToPosition(newCursor); + this.showCustomCompletions(this.aceEditor, "variables"); + } + // Check for secrets trigger !# + else if (textBeforeCursor.endsWith("#!")) { + // Remove !# symbols from editor + const startColumn = Math.max(0, cursor.column - 2); + const range = { + start: {row: cursor.row, column: startColumn}, + end: {row: cursor.row, column: cursor.column}, + }; + session.replace(range, ""); + + // Update cursor position + const newCursor = { + row: cursor.row, + column: startColumn, + }; + this.aceEditor.moveCursorToPosition(newCursor); + this.showCustomCompletions(this.aceEditor, "secrets"); + } + }, 10); + }; + + this.aceEditor.on("input", this.inputListener); + } + + /** + * Show custom completions popup with available variables or secrets + * @param {Object} editor - ACE editor instance + * @param {String} type - Type of completion ('variables' or 'secrets') + * @returns {Promise} + */ + async showCustomCompletions(editor, type = "variables") { + const cursor = editor.getCursorPosition(); + const session = editor.getSession(); + const line = session.getLine(cursor.row); + const textBeforeCursor = line.substring(0, cursor.column); + + let items = []; + let triggerLength = 0; + + if (type === "secrets") { + // Handle secrets + await this.loadSecrets(); + + if (!this.secrets.length) { + return; + } + + items = this.secrets; + } else { + // Handle variables + await this.loadVariables(); + + if (!this.variables.length) { + return; + } + + items = this.variables; + // Check if we're already in a variable context + const isInVariableContext = textBeforeCursor.endsWith("{{"); + + if (isInVariableContext) { + triggerLength = 2; + } + } + + const position = this.calculatePopupPosition(editor, cursor); + + // Set popup type in state + this.state.popupType = type; + + await this.showAutocompletePopup(items, position, editor, triggerLength, type); + } + + /** + * Calculate the optimal position for the autocomplete popup + * @param {Object} editor - ACE editor instance + * @param {Object} cursor - Cursor position object + * @returns {Object} Position object with left and top coordinates + */ + calculatePopupPosition(editor, cursor) { + const renderer = editor.renderer; + + // Calculate cursor position within the editor + const cursorPixelPos = renderer.textToScreenCoordinates( + cursor.row, + cursor.column + ); + + // Get scroll position + const scrollTop = window.pageYOffset || document.documentElement.scrollTop; + const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft; + + // Calculate the cursor position relative to the viewport + const viewportLeft = cursorPixelPos.pageX - scrollLeft; + const viewportTop = cursorPixelPos.pageY - scrollTop; + + // Position popup just below the cursor + const finalLeft = viewportLeft; + const finalTop = viewportTop + renderer.lineHeight; + + // Ensure popup doesn't go outside viewport + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const popup = document.querySelector(".ace-autocomplete-popup"); + const popupWidth = popup ? popup.offsetWidth : POPUP_FALLBACK_WIDTH; + const popupHeight = popup ? popup.offsetHeight : POPUP_FALLBACK_HEIGHT; + + let adjustedLeft = finalLeft; + let adjustedTop = finalTop; + + // Adjust if popup would go off-screen horizontally + if (finalLeft + popupWidth > viewportWidth) { + adjustedLeft = finalLeft - popupWidth; + } + + // Adjust if popup would go off-screen vertically + if (finalTop + popupHeight > viewportHeight) { + adjustedTop = finalTop - popupHeight - renderer.lineHeight; + } + + // Make sure popup is not positioned off-screen + adjustedLeft = Math.max(0, adjustedLeft); + adjustedTop = Math.max(0, adjustedTop); + + return { + left: adjustedLeft, + top: adjustedTop, + }; + } + + /** + * Display the autocomplete popup with variables or secrets at the specified position + * @param {Array} items - Array of available variables or secrets + * @param {Object} position - Position object with left and top coordinates + * @param {Object} editor - ACE editor instance + * @param {Number} triggerLength - Length of trigger text that should be replaced + * @param {String} type - Type of completion ('variables' or 'secrets') + * @returns {Promise} + */ + async showAutocompletePopup( + items, + position, + editor, + triggerLength, + type = "variables" + ) { + this.hideAutocompletePopup(); + + this.state.popupItems = items; + this.state.popupPosition = position; + this.state.showPopup = true; + this.state.selectedIndex = 0; + this.state.popupType = type; + this.currentEditor = editor; + this.currentTriggerLength = triggerLength; + this.currentType = type; + + // Add click outside listener + this.clickOutsideListener = (event) => { + // Check if click is outside the popup and ace editor + const popupElement = document.querySelector(".ace-autocomplete-popup"); + const aceElement = this.aceEditor.container; + + if ( + popupElement && + !popupElement.contains(event.target) && + aceElement && + !aceElement.contains(event.target) + ) { + this.hideAutocompletePopup(); + } + }; + + setTimeout(() => { + document.addEventListener("click", this.clickOutsideListener, true); + }, 0); + } + + /** + * Hide the autocomplete popup and clean up event listeners + */ + hideAutocompletePopup() { + // Remove click outside listener + if (this.clickOutsideListener) { + document.removeEventListener("click", this.clickOutsideListener, true); + this.clickOutsideListener = null; + } + + this.state.showPopup = false; + this.state.popupVariables = []; + this.currentEditor = null; + this.state.selectedIndex = 0; + + // Return focus to the ACE editor + if (this.aceEditor) { + this.aceEditor.focus(); + } + } + + /** + * Update the selected index in the autocomplete popup + * @param {Number} index - New selected index + */ + updateSelectedIndex(index) { + if (this.state) { + this.state.selectedIndex = index; + } + } + + /** + * Handle selection of a command from the autocomplete popup + * @param {Object} command - Selected command object + * @param {Object} editor - ACE editor instance + */ + handleCommandSelection(command, editor) { + if (!command || !command.reference) { + this.hideAutocompletePopup(); + return; + } + + const cursor = editor.getCursorPosition(); + const session = editor.getSession(); + const line = session.getLine(cursor.row); + const textBeforeCursor = line.substring(0, cursor.column); + + // Get line length for validation + const lineLength = session.getLine(cursor.row).length; + const currentType = this.currentType || this.state.popupType; + + let range = null; + let insertText = ""; + + if (currentType === "secrets") { + // Handle secrets insertion + // Check if we're inside a secret context (between #!cxtower.secret and !#) + const lastSecretStart = textBeforeCursor.lastIndexOf("#!cxtower.secret"); + const lastSecretEnd = textBeforeCursor.lastIndexOf("!#"); + + // Count occurrences of start and end delimiters for more robust validation + const startCount = (textBeforeCursor.match(/#!cxtower\.secret/g) || []) + .length; + const endCount = (textBeforeCursor.match(/!#/g) || []).length; + const isInsideSecret = + startCount > endCount && + lastSecretStart > lastSecretEnd && + lastSecretStart !== -1; + + if (isInsideSecret) { + // We're inside a secret context, replace from after #!cxtower to cursor + range = { + start: {row: cursor.row, column: lastSecretStart + 16}, + end: {row: cursor.row, column: cursor.column}, + }; + // Clamp range to valid bounds + range.start.column = Math.max( + 0, + Math.min(range.start.column, lineLength) + ); + range.end.column = Math.max( + range.start.column, + Math.min(range.end.column, lineLength) + ); + insertText = `${command.reference}!#`; + } else { + // We're not in a secret context, insert complete secret + const triggerLength = this.currentTriggerLength || 0; + range = { + start: {row: cursor.row, column: cursor.column - triggerLength}, + end: {row: cursor.row, column: cursor.column}, + }; + // Clamp range to valid bounds + range.start.column = Math.max( + 0, + Math.min(range.start.column, lineLength) + ); + range.end.column = Math.max( + range.start.column, + Math.min(range.end.column, lineLength) + ); + insertText = `#!cxtower.secret.${command.reference}!#`; + } + } else { + // Handle variables insertion (existing logic) + const lastOpenBrace = textBeforeCursor.lastIndexOf("{{"); + const lastCloseBrace = textBeforeCursor.lastIndexOf("}}"); + const isInsideVariable = + lastOpenBrace > lastCloseBrace && lastOpenBrace !== -1; + + if (isInsideVariable) { + // We're inside a variable context, replace from after {{ to cursor + range = { + start: {row: cursor.row, column: lastOpenBrace + 2}, + end: {row: cursor.row, column: cursor.column}, + }; + // Clamp range to valid bounds + range.start.column = Math.max( + 0, + Math.min(range.start.column, lineLength) + ); + range.end.column = Math.max( + range.start.column, + Math.min(range.end.column, lineLength) + ); + insertText = ` ${command.reference} `; + } else { + // We're not in a variable context, insert complete variable + const triggerLength = this.currentTriggerLength || 0; + range = { + start: {row: cursor.row, column: cursor.column - triggerLength}, + end: {row: cursor.row, column: cursor.column}, + }; + // Clamp range to valid bounds + range.start.column = Math.max( + 0, + Math.min(range.start.column, lineLength) + ); + range.end.column = Math.max( + range.start.column, + Math.min(range.end.column, lineLength) + ); + insertText = `{{ ${command.reference} }}`; + } + } + + // Replace the text + session.replace(range, insertText); + + // Get the updated line length after replacement + const updatedLineLength = session.getLine(cursor.row).length; + + // Position cursor after the inserted text + const newCursor = { + row: cursor.row, + column: range.start.column + insertText.length, + }; + + newCursor.column = Math.max(0, Math.min(newCursor.column, updatedLineLength)); + + editor.moveCursorToPosition(newCursor); + + this.hideAutocompletePopup(); + editor.focus(); + } + + /** + * Clean up resources when component is destroyed + */ + destroy() { + if (this.inputTimeout) { + clearTimeout(this.inputTimeout); + } + if (this.aceEditor && this.inputListener) { + this.aceEditor.off("input", this.inputListener); + } + this.hideAutocompletePopup(); + super.destroy(); + } +} + +AceCommandField.template = "cetmix_tower_server.AceCommandField"; +AceCommandField.components = { + AutocompletePopup, +}; + +registry.category("fields").add("ace_tower", AceCommandField); + +export {AceCommandField};