Tower: upload cetmix_tower_server 16.0.3.0.1 (via marketplace)
This commit is contained in:
@@ -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};
|
||||
Reference in New Issue
Block a user