Tower: upload cetmix_tower_server 16.0.3.0.1 (via marketplace)

This commit is contained in:
2026-04-27 08:18:08 +00:00
parent 5cecb3364e
commit 121fcd2639

View File

@@ -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<void>}
*/
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<void>}
*/
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<void>}
*/
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<void>}
*/
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};