diff --git a/source/compose.manager/php/compose_manager_main.php b/source/compose.manager/php/compose_manager_main.php index 8d07a17..e570129 100644 --- a/source/compose.manager/php/compose_manager_main.php +++ b/source/compose.manager/php/compose_manager_main.php @@ -160,6 +160,7 @@ function createComboButton($text, $id, $onClick, $onClickParams, $items) { const shell_label = ; $('head').append( $('').attr('href', '') ); +$('head').append( $('').attr('href', '') ); if (typeof swal2 === "undefined") { $('head').append( $('').attr('href', '') ); @@ -174,11 +175,156 @@ function dirname( path ) { return path.replace( /\\/g, '/' ).replace( /\/[^\/]*$/, '' ); } -$(function() { - var editor = ace.edit("itemEditor"); - editor.setTheme(aceTheme); - editor.setShowPrintMargin(false); -}) +// Editor modal state +var editorModal = { + editors: {}, + currentTab: null, + originalContent: {}, + modifiedTabs: new Set(), + currentProject: null, + validationTimeout: null +}; + +// Debounce helper for validation +function debounceValidation(type, content) { + if (editorModal.validationTimeout) { + clearTimeout(editorModal.validationTimeout); + } + editorModal.validationTimeout = setTimeout(function() { + validateYaml(type, content); + }, 300); +} + +// Calculate unRAID header offset dynamically +function updateModalOffset() { + var headerOffset = 0; + var header = document.getElementById('header'); + var menu = document.getElementById('menu'); + var tabs = document.querySelector('div.tabs'); + + if (header) { + headerOffset += header.offsetHeight; + } + if (menu) { + headerOffset += menu.offsetHeight; + } + if (tabs) { + headerOffset += tabs.offsetHeight; + } + + // Add a small buffer + headerOffset += 10; + + // Set CSS custom property + document.documentElement.style.setProperty('--unraid-header-offset', headerOffset + 'px'); + var overlay = document.getElementById('editor-modal-overlay'); + if (overlay) { + overlay.style.setProperty('--unraid-header-offset', headerOffset + 'px'); + } +} + +// Editor modal will be initialized when document is fully ready +// (deferred to ensure DOM elements exist) + +function initEditorModal() { + // Initialize Ace editors for each tab + ['compose', 'env', 'override'].forEach(function(type) { + var editor = ace.edit('editor-' + type); + editor.setTheme(aceTheme); + editor.setShowPrintMargin(false); + editor.setOptions({ + fontSize: '0.875rem', + tabSize: 2, + useSoftTabs: true, + wrap: true + }); + + // Set mode based on type + if (type === 'env') { + editor.getSession().setMode('ace/mode/sh'); + } else { + editor.getSession().setMode('ace/mode/yaml'); + } + + // Track modifications + editor.on('change', function() { + var currentContent = editor.getValue(); + var originalContent = editorModal.originalContent[type] || ''; + var tabEl = $('#editor-tab-' + type); + + if (currentContent !== originalContent) { + editorModal.modifiedTabs.add(type); + tabEl.addClass('modified'); + } else { + editorModal.modifiedTabs.delete(type); + tabEl.removeClass('modified'); + } + + updateSaveButtonState(); + debounceValidation(type, currentContent); + }); + + editorModal.editors[type] = editor; + }); + + // Keyboard shortcuts - use namespaced event to avoid duplicates + $(document).off('keydown.editorModal').on('keydown.editorModal', function(e) { + if ($('#editor-modal-overlay').hasClass('active')) { + // Ctrl+S or Cmd+S to save current + if ((e.ctrlKey || e.metaKey) && e.key === 's') { + e.preventDefault(); + saveCurrentTab(); + } + // Escape to close + if (e.key === 'Escape') { + e.preventDefault(); + closeEditorModal(); + } + // Arrow key navigation for tabs + if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { + var $activeTab = $('.editor-tab.active'); + if ($activeTab.is(':focus') || $activeTab.parent().find(':focus').length) { + e.preventDefault(); + var tabs = ['compose', 'env', 'override']; + var currentIdx = tabs.indexOf(editorModal.currentTab); + var newIdx; + if (e.key === 'ArrowLeft') { + newIdx = currentIdx > 0 ? currentIdx - 1 : tabs.length - 1; + } else { + newIdx = currentIdx < tabs.length - 1 ? currentIdx + 1 : 0; + } + switchEditorTab(tabs[newIdx]); + $('#editor-tab-' + tabs[newIdx]).focus(); + } + } + // Focus trapping + if (e.key === 'Tab') { + var $modal = $('#editor-modal-overlay'); + var $focusable = $modal.find('a, button, input, textarea, select, [tabindex]:not([tabindex="-1"])').filter(':visible:not(:disabled)'); + if ($focusable.length === 0) return; + var first = $focusable[0]; + var last = $focusable[$focusable.length - 1]; + var activeElement = document.activeElement; + + // If focus is outside the modal, move it to the first focusable element + if (!$.contains($modal[0], activeElement)) { + e.preventDefault(); + first.focus(); + return; + } + + // Handle Tab and Shift+Tab to keep focus within the modal + if (!e.shiftKey && activeElement === last) { + e.preventDefault(); + first.focus(); + } else if (e.shiftKey && activeElement === first) { + e.preventDefault(); + last.focus(); + } + } + } + }); +} $(function() { $(".tipsterallowed").show(); @@ -374,24 +520,20 @@ function applyDesc(myID) { function editStack(myID) { var buttonsList = {}; - buttonsList["compose_file"] = { text: "Compose File" }; - buttonsList["env_file"] = { text: "ENV File" }; + buttonsList["edit_files"] = { text: "Edit Files" }; buttonsList["override_file"] = { text: "UI Labels" }; buttonsList["stack_settings"] = { text: "Stack Settings" }; buttonsList["Cancel"] = { text: "Cancel", value: null, }; swal2({ - title: "Select Stack File to Edit", + title: "Select Action", className: 'edit-stack-form', buttons: buttonsList, }).then((result) => { if (result) { switch(result) { - case 'compose_file': - editComposeFile(myID); - break; - case 'env_file': - editEnv(myID); + case 'edit_files': + openEditorModal(myID); break; case 'override_file': generateOverride(myID); @@ -406,6 +548,307 @@ className: 'edit-stack-form', }); } +// New Editor Modal Functions +function openEditorModal(myID) { + $("#"+myID).tooltipster("close"); + var project = $("#"+myID).attr("data-scriptname"); + var projectName = $("#"+myID).attr("data-namename"); + + editorModal.currentProject = project; + editorModal.modifiedTabs = new Set(); + editorModal.originalContent = {}; + + // Reset all tabs to unmodified state + $('.editor-tab').removeClass('modified active'); + $('.editor-container').removeClass('active'); + + // Set modal title + $('#editor-modal-title').text('Editing: ' + projectName); + $('#editor-file-info').text(compose_root + '/' + project); + + // Show loading state + $('#editor-modal-overlay').addClass('active'); + $('#editor-validation').html(' Loading files...').removeClass('valid error warning'); + + // Load all files + loadEditorFiles(project); +} + +function loadEditorFiles(project) { + var loadPromises = []; + + // Load compose file + loadPromises.push( + $.post(caURL, {action:'getYml', script:project}).then(function(data) { + if (data) { + var response = jQuery.parseJSON(data); + editorModal.originalContent['compose'] = response.content || ''; + editorModal.editors['compose'].setValue(response.content || '', -1); + } + }).fail(function() { + var errorContent = '# Error loading file'; + editorModal.originalContent['compose'] = errorContent; + editorModal.editors['compose'].setValue(errorContent, -1); + }) + ); + + // Load env file + loadPromises.push( + $.post(caURL, {action:'getEnv', script:project}).then(function(data) { + if (data) { + var response = jQuery.parseJSON(data); + editorModal.originalContent['env'] = response.content || ''; + editorModal.editors['env'].setValue(response.content || '', -1); + } + }).fail(function() { + var errorContent = '# Error loading file'; + editorModal.originalContent['env'] = errorContent; + editorModal.editors['env'].setValue(errorContent, -1); + }) + ); + + // Load override file + loadPromises.push( + $.post(caURL, {action:'getOverride', script:project}).then(function(data) { + if (data) { + var response = jQuery.parseJSON(data); + editorModal.originalContent['override'] = response.content || ''; + editorModal.editors['override'].setValue(response.content || '', -1); + } + }).fail(function() { + var errorContent = '# Error loading file'; + editorModal.originalContent['override'] = errorContent; + editorModal.editors['override'].setValue(errorContent, -1); + }) + ); + + // When all files are loaded + $.when.apply($, loadPromises).then(function() { + // Activate the compose tab by default (also runs validation) + switchEditorTab('compose'); + }).fail(function() { + $('#editor-validation').html(' Error loading some files').removeClass('valid').addClass('error'); + }); +} + +function switchEditorTab(tabName) { + // Validate tab name + var validTabs = ['compose', 'env', 'override']; + if (validTabs.indexOf(tabName) === -1) { + console.error('Invalid tab name: ' + tabName); + return; + } + + // Update tab buttons and ARIA states + $('.editor-tab').removeClass('active').attr('aria-selected', 'false'); + $('#editor-tab-' + tabName).addClass('active').attr('aria-selected', 'true'); + + // Update editor containers + $('.editor-container').removeClass('active'); + $('#editor-container-' + tabName).addClass('active'); + + // Focus and resize the editor + editorModal.editors[tabName].focus(); + editorModal.editors[tabName].resize(); + + editorModal.currentTab = tabName; + + // Update validation for current tab + validateYaml(tabName, editorModal.editors[tabName].getValue()); +} + +function validateYaml(type, content) { + if (type === 'env') { + // Basic validation for env files + updateValidation(type, content); + return; + } + + try { + if (content.trim()) { + jsyaml.load(content); + } + updateValidation(type, content, true); + } catch (e) { + updateValidation(type, content, false, e.message); + } +} + +function updateValidation(type, content, isValid, errorMsg) { + var validationEl = $('#editor-validation'); + + // Handle env files separately (no YAML validation needed) + if (type === 'env') { + var lines = content.split('\n').filter(l => l.trim() && !l.trim().startsWith('#')); + validationEl.html(' ' + lines.length + ' environment variable(s)'); + validationEl.removeClass('error warning').addClass('valid'); + return; + } + + // If isValid is undefined, run actual YAML validation + if (isValid === undefined) { + validateYaml(type, content); + return; + } + + if (isValid) { + validationEl.html(' YAML syntax is valid'); + validationEl.removeClass('error warning').addClass('valid'); + } else { + // Truncate error message to first line for cleaner display + var shortError = errorMsg.split('\n')[0].substring(0, 100); + if (errorMsg.length > 100) shortError += '...'; + // Use text node to prevent XSS from malicious YAML content + validationEl.empty() + .append(' YAML Error: ') + .append(document.createTextNode(shortError)); + validationEl.removeClass('valid warning').addClass('error'); + } +} + +function updateSaveButtonState() { + var hasChanges = editorModal.modifiedTabs.size > 0; + $('#editor-btn-save-all').prop('disabled', !hasChanges); + + if (hasChanges) { + $('#editor-btn-save-all').text('Save All (' + editorModal.modifiedTabs.size + ')'); + } else { + $('#editor-btn-save-all').text('Save All'); + } +} + +function saveCurrentTab() { + var currentTab = editorModal.currentTab; + if (!currentTab || !editorModal.modifiedTabs.has(currentTab)) return; + + saveTab(currentTab).then(function() { + // Brief feedback in validation panel + $('#editor-validation').html(' Saved!').removeClass('error warning').addClass('valid'); + setTimeout(function() { + validateYaml(currentTab, editorModal.editors[currentTab].getValue()); + }, 1500); + }).catch(function() { + // Error already handled in saveTab's .fail() handler + }); +} + +function saveTab(tabName) { + var content = editorModal.editors[tabName].getValue(); + var project = editorModal.currentProject; + var actionStr = null; + + switch(tabName) { + case 'compose': + actionStr = 'saveYml'; + break; + case 'env': + actionStr = 'saveEnv'; + break; + case 'override': + actionStr = 'saveOverride'; + break; + default: + return Promise.reject('Unknown tab'); + } + + return $.post(caURL, {action:actionStr, script:project, scriptContents:content}).then(function(data) { + if (data) { + editorModal.originalContent[tabName] = content; + editorModal.modifiedTabs.delete(tabName); + $('#editor-tab-' + tabName).removeClass('modified'); + updateSaveButtonState(); + + // Regenerate override and profiles if compose file was saved + if (tabName === 'compose') { + generateOverride(null, project); + generateProfiles(null, project); + } + + return true; + } + return false; + }).fail(function() { + swal2({ + title: "Save Failed", + text: "Failed to save " + tabName + " file. Please try again.", + icon: "error" + }); + return false; + }); +} + +function saveAllTabs() { + var savePromises = []; + + if (editorModal.modifiedTabs.size === 0) { + return; + } + + editorModal.modifiedTabs.forEach(function(tabName) { + savePromises.push(saveTab(tabName)); + }); + + $.when.apply($, savePromises).then(function() { + var results = Array.prototype.slice.call(arguments); + var allSucceeded = results.every(function(result) { + return result === true; + }); + + if (allSucceeded) { + swal2({ + title: "Saved!", + text: "All changes have been saved.", + icon: "success", + timer: 1500, + buttons: false + }); + } else { + swal2({ + title: "Partial Save", + text: "Some files could not be saved. Please check the error messages and try again.", + icon: "warning" + }); + } + }).fail(function() { + swal2({ + title: "Save Failed", + text: "An error occurred while saving. Please try again.", + icon: "error" + }); + }); +} + +function closeEditorModal() { + if (editorModal.modifiedTabs.size > 0) { + swal2({ + title: "Unsaved Changes", + text: "You have unsaved changes. Are you sure you want to close?", + icon: "warning", + buttons: ["Cancel", "Discard Changes"], + dangerMode: true, + }).then((willClose) => { + if (willClose) { + doCloseEditorModal(); + } + }); + } else { + doCloseEditorModal(); + } +} + +function doCloseEditorModal() { + $('#editor-modal-overlay').removeClass('active'); + editorModal.currentProject = null; + editorModal.modifiedTabs = new Set(); + editorModal.originalContent = {}; + // Clear editor content to avoid showing stale content on next open + ['compose', 'env', 'override'].forEach(function(type) { + if (editorModal.editors[type]) { + editorModal.editors[type].setValue('', -1); + } + }); +} + function build_override_input_table( id, value, label, placeholder, disable=false) { var disabled = disable ? `disabled` : ``; html = `