Redesign extension manager
| @@ -65,7 +65,7 @@ label[for="extensions_autoconnect"] { | |||
| 65 | } | 65 | } |
| 66 | 66 | ||
| 67 | .extensions_info .extension_enabled { | 67 | .extensions_info .extension_enabled { |
| 68 | color: green; | 68 | font-weight: bold; |
| 69 | } | 69 | } |
| 70 | 70 | ||
| 71 | .extensions_info .extension_disabled { | 71 | .extensions_info .extension_disabled { |
| @@ -76,6 +76,42 @@ label[for="extensions_autoconnect"] { | |||
| 76 | color: gray; | 76 | color: gray; |
| 77 | } | 77 | } |
| 78 | 78 | ||
| 79 | .extensions_info .extension_modules { | ||
| 80 | font-size: 0.8em; | ||
| 81 | font-weight: normal; | ||
| 82 | } | ||
| 83 | |||
| 84 | .extensions_info .extension_block { | ||
| 85 | display: flex; | ||
| 86 | flex-wrap: wrap; | ||
| 87 | padding: 10px; | ||
| 88 | margin-bottom: 5px; | ||
| 89 | border: 1px solid var(--SmartThemeBorderColor); | ||
| 90 | border-radius: 10px; | ||
| 91 | align-items: center; | ||
| 92 | justify-content: space-between; | ||
| 93 | gap: 10px; | ||
| 94 | } | ||
| 95 | |||
| 96 | .extensions_info .extension_name { | ||
| 97 | font-size: 1.05em; | ||
| 98 | } | ||
| 99 | |||
| 100 | .extensions_info .extension_version { | ||
| 101 | opacity: 0.8; | ||
| 102 | font-size: 0.8em; | ||
| 103 | font-weight: normal; | ||
| 104 | margin-left: 5px; | ||
| 105 | } | ||
| 106 | |||
| 107 | .extensions_info .extension_block a { | ||
| 108 | color: var(--SmartThemeBodyColor); | ||
| 109 | } | ||
| 110 | |||
| 111 | .extensions_info .extension_name.update_available { | ||
| 112 | color: limegreen; | ||
| 113 | } | ||
| 114 | |||
| 79 | input.extension_missing[type="checkbox"] { | 115 | input.extension_missing[type="checkbox"] { |
| 80 | opacity: 0.5; | 116 | opacity: 0.5; |
| 81 | } | 117 | } |
| @@ -515,64 +515,64 @@ function addExtensionScript(name, manifest) { | |||
| 515 | * @param {boolean} isDisabled - Whether the extension is disabled or not. | 515 | * @param {boolean} isDisabled - Whether the extension is disabled or not. |
| 516 | * @param {boolean} isExternal - Whether the extension is external or not. | 516 | * @param {boolean} isExternal - Whether the extension is external or not. |
| 517 | * @param {string} checkboxClass - The class for the checkbox HTML element. | 517 | * @param {string} checkboxClass - The class for the checkbox HTML element. |
| 518 | * @return {Promise<string>} - The HTML string that represents the extension. | 518 | * @return {string} - The HTML string that represents the extension. |
| 519 | */ | 519 | */ |
| 520 | async function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, checkboxClass) { | 520 | function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, checkboxClass) { |
| 521 | const displayName = manifest.display_name; | 521 | const displayName = manifest.display_name; |
| 522 | let displayVersion = manifest.version ? ` v${manifest.version}` : ''; | 522 | let displayVersion = manifest.version ? ` v${manifest.version}` : ''; |
| 523 | let isUpToDate = true; | 523 | const externalId = name.replace('third-party', ''); |
| 524 | let updateButton = ''; | ||
| 525 | let originHtml = ''; | 524 | let originHtml = ''; |
| 526 | if (isExternal) { | 525 | if (isExternal) { |
| 527 | let data = await getExtensionVersion(name.replace('third-party', '')); | 526 | originHtml = '<a>'; |
| 528 | let branch = data.currentBranchName; | ||
| 529 | let commitHash = data.currentCommitHash; | ||
| 530 | let origin = data.remoteUrl; | ||
| 531 | isUpToDate = data.isUpToDate; | ||
| 532 | displayVersion = ` (${branch}-${commitHash.substring(0, 7)})`; | ||
| 533 | updateButton = isUpToDate ? | ||
| 534 | `<span class="update-button"><button class="btn_update menu_button" data-name="${name.replace('third-party', '')}" title="Up to date"><i class="fa-solid fa-code-commit fa-fw"></i></button></span>` : | ||
| 535 | `<span class="update-button"><button class="btn_update menu_button" data-name="${name.replace('third-party', '')}" title="Update available"><i class="fa-solid fa-download fa-fw"></i></button></span>`; | ||
| 536 | originHtml = `<a href="${origin}" target="_blank" rel="noopener noreferrer">`; | ||
| 537 | } | 527 | } |
| 538 | 528 | ||
| 539 | let toggleElement = isActive || isDisabled ? | 529 | let toggleElement = isActive || isDisabled ? |
| 540 | `<input type="checkbox" title="Click to toggle" data-name="${name}" class="${isActive ? 'toggle_disable' : 'toggle_enable'} ${checkboxClass}" ${isActive ? 'checked' : ''}>` : | 530 | `<input type="checkbox" title="Click to toggle" data-name="${name}" class="${isActive ? 'toggle_disable' : 'toggle_enable'} ${checkboxClass}" ${isActive ? 'checked' : ''}>` : |
| 541 | `<input type="checkbox" title="Cannot enable extension" data-name="${name}" class="extension_missing ${checkboxClass}" disabled>`; | 531 | `<input type="checkbox" title="Cannot enable extension" data-name="${name}" class="extension_missing ${checkboxClass}" disabled>`; |
| 542 | 532 | ||
| 543 | let deleteButton = isExternal ? `<span class="delete-button"><button class="btn_delete menu_button" data-name="${name.replace('third-party', '')}" title="Delete"><i class="fa-solid fa-trash-can"></i></button></span>` : ''; | 533 | let deleteButton = isExternal ? `<button class="btn_delete menu_button" data-name="${externalId}" title="Delete"><i class="fa-fw fa-solid fa-trash-can"></i></button>` : ''; |
| 544 | 534 | let updateButton = isExternal ? `<button class="btn_update menu_button displayNone" data-name="${externalId}" title="Update available"><i class="fa-solid fa-download fa-fw"></i></button>` : ''; | |
| 545 | // if external, wrap the name in a link to the repo | 535 | let modulesInfo = ''; |
| 546 | |||
| 547 | let extensionHtml = `<hr> | ||
| 548 | <h4> | ||
| 549 | ${updateButton} | ||
| 550 | ${deleteButton} | ||
| 551 | ${originHtml} | ||
| 552 | <span class="${isActive ? 'extension_enabled' : isDisabled ? 'extension_disabled' : 'extension_missing'}"> | ||
| 553 | ${DOMPurify.sanitize(displayName)}${displayVersion} | ||
| 554 | </span> | ||
| 555 | ${isExternal ? '</a>' : ''} | ||
| 556 | |||
| 557 | <span style="float:right;">${toggleElement}</span> | ||
| 558 | </h4>`; | ||
| 559 | 536 | ||
| 560 | if (isActive && Array.isArray(manifest.optional)) { | 537 | if (isActive && Array.isArray(manifest.optional)) { |
| 561 | const optional = new Set(manifest.optional); | 538 | const optional = new Set(manifest.optional); |
| 562 | modules.forEach(x => optional.delete(x)); | 539 | modules.forEach(x => optional.delete(x)); |
| 563 | if (optional.size > 0) { | 540 | if (optional.size > 0) { |
| 564 | const optionalString = DOMPurify.sanitize([...optional].join(', ')); | 541 | const optionalString = DOMPurify.sanitize([...optional].join(', ')); |
| 565 | extensionHtml += `<p>Optional modules: <span class="optional">${optionalString}</span></p>`; | 542 | modulesInfo = `<div class="extension_modules">Optional modules: <span class="optional">${optionalString}</span></div>`; |
| 566 | } | 543 | } |
| 567 | } else if (!isDisabled) { // Neither active nor disabled | 544 | } else if (!isDisabled) { // Neither active nor disabled |
| 568 | const requirements = new Set(manifest.requires); | 545 | const requirements = new Set(manifest.requires); |
| 569 | modules.forEach(x => requirements.delete(x)); | 546 | modules.forEach(x => requirements.delete(x)); |
| 570 | if (requirements.size > 0) { | 547 | if (requirements.size > 0) { |
| 571 | const requirementsString = DOMPurify.sanitize([...requirements].join(', ')); | 548 | const requirementsString = DOMPurify.sanitize([...requirements].join(', ')); |
| 572 | extensionHtml += `<p>Missing modules: <span class="failure">${requirementsString}</span></p>`; | 549 | modulesInfo = `<div class="extension_modules">Missing modules: <span class="failure">${requirementsString}</span></div>`; |
| 573 | } | 550 | } |
| 574 | } | 551 | } |
| 575 | 552 | ||
| 553 | // if external, wrap the name in a link to the repo | ||
| 554 | |||
| 555 | let extensionHtml = ` | ||
| 556 | <div class="extension_block" data-name="${externalId}"> | ||
| 557 | <div class="extension_toggle"> | ||
| 558 | ${toggleElement} | ||
| 559 | </div> | ||
| 560 | <div class="flexGrow"> | ||
| 561 | ${originHtml} | ||
| 562 | <span class="${isActive ? 'extension_enabled' : isDisabled ? 'extension_disabled' : 'extension_missing'}"> | ||
| 563 | <span class="extension_name">${DOMPurify.sanitize(displayName)}</span> | ||
| 564 | <span class="extension_version">${displayVersion}</span> | ||
| 565 | ${modulesInfo} | ||
| 566 | </span> | ||
| 567 | ${isExternal ? '</a>' : ''} | ||
| 568 | </div> | ||
| 569 | |||
| 570 | <div class="extension_actions flex-container alignItemsCenter"> | ||
| 571 | ${updateButton} | ||
| 572 | ${deleteButton} | ||
| 573 | </div> | ||
| 574 | </div>`; | ||
| 575 | |||
| 576 | return extensionHtml; | 576 | return extensionHtml; |
| 577 | } | 577 | } |
| 578 | 578 | ||
| @@ -580,9 +580,9 @@ async function generateExtensionHtml(name, manifest, isActive, isDisabled, isExt | |||
| 580 | * Gets extension data and generates the corresponding HTML for displaying the extension. | 580 | * Gets extension data and generates the corresponding HTML for displaying the extension. |
| 581 | * | 581 | * |
| 582 | * @param {Array} extension - An array where the first element is the extension name and the second element is the extension manifest. | 582 | * @param {Array} extension - An array where the first element is the extension name and the second element is the extension manifest. |
| 583 | * @return {Promise<object>} - An object with 'isExternal' indicating whether the extension is external, and 'extensionHtml' for the extension's HTML string. | 583 | * @return {object} - An object with 'isExternal' indicating whether the extension is external, and 'extensionHtml' for the extension's HTML string. |
| 584 | */ | 584 | */ |
| 585 | async function getExtensionData(extension) { | 585 | function getExtensionData(extension) { |
| 586 | const name = extension[0]; | 586 | const name = extension[0]; |
| 587 | const manifest = extension[1]; | 587 | const manifest = extension[1]; |
| 588 | const isActive = activeExtensions.has(name); | 588 | const isActive = activeExtensions.has(name); |
| @@ -591,7 +591,7 @@ async function getExtensionData(extension) { | |||
| 591 | 591 | ||
| 592 | const checkboxClass = isDisabled ? 'checkbox_disabled' : ''; | 592 | const checkboxClass = isDisabled ? 'checkbox_disabled' : ''; |
| 593 | 593 | ||
| 594 | const extensionHtml = await generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, checkboxClass); | 594 | const extensionHtml = generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, checkboxClass); |
| 595 | 595 | ||
| 596 | return { isExternal, extensionHtml }; | 596 | return { isExternal, extensionHtml }; |
| 597 | } | 597 | } |
| @@ -616,40 +616,28 @@ function getModuleInformation() { | |||
| 616 | async function showExtensionsDetails() { | 616 | async function showExtensionsDetails() { |
| 617 | let popupPromise; | 617 | let popupPromise; |
| 618 | try { | 618 | try { |
| 619 | const htmlDefault = $('<h3>Built-in Extensions:</h3>'); | 619 | const htmlDefault = $('<div class="marginBot10"><h3 class="textAlignCenter">Built-in Extensions:</h3></div>'); |
| 620 | const htmlExternal = $('<h3>Installed Extensions:</h3>').addClass('opacity50p'); | 620 | const htmlExternal = $('<div class="marginBot10"><h3 class="textAlignCenter">Installed Extensions:</h3></div>'); |
| 621 | const htmlLoading = $(`<h3 class="flex-container alignItemsCenter justifyCenter marginTop10 marginBot5"> | 621 | const htmlLoading = $(`<div class="flex-container alignItemsCenter justifyCenter marginTop10 marginBot5"> |
| 622 | <i class="fa-solid fa-spinner fa-spin"></i> | 622 | <i class="fa-solid fa-spinner fa-spin"></i> |
| 623 | <span>Loading third-party extensions... Please wait...</span> | 623 | <span>Loading third-party extensions... Please wait...</span> |
| 624 | </h3>`); | 624 | </div>`); |
| 625 | 625 | ||
| 626 | /** @type {Promise<any>[]} */ | 626 | htmlExternal.append(htmlLoading); |
| 627 | const promises = []; | ||
| 628 | const extensions = Object.entries(manifests).sort((a, b) => a[1].loading_order - b[1].loading_order); | ||
| 629 | 627 | ||
| 630 | for (const extension of extensions) { | 628 | const extensions = Object.entries(manifests).sort((a, b) => a[1].loading_order - b[1].loading_order).map(getExtensionData); |
| 631 | promises.push(getExtensionData(extension)); | ||
| 632 | } | ||
| 633 | 629 | ||
| 634 | promises.forEach(promise => { | 630 | extensions.forEach(value => { |
| 635 | promise.then(value => { | ||
| 636 | const { isExternal, extensionHtml } = value; | 631 | const { isExternal, extensionHtml } = value; |
| 637 | const container = isExternal ? htmlExternal : htmlDefault; | 632 | const container = isExternal ? htmlExternal : htmlDefault; |
| 638 | container.append(extensionHtml); | 633 | container.append(extensionHtml); |
| 639 | }); | 634 | }); |
| 640 | }); | ||
| 641 | |||
| 642 | Promise.allSettled(promises).then(() => { | ||
| 643 | htmlLoading.remove(); | ||
| 644 | htmlExternal.removeClass('opacity50p'); | ||
| 645 | }); | ||
| 646 | 635 | ||
| 647 | const html = $('<div></div>') | 636 | const html = $('<div></div>') |
| 648 | .addClass('extensions_info') | 637 | .addClass('extensions_info') |
| 649 | .append(getModuleInformation()) | ||
| 650 | .append(htmlDefault) | 638 | .append(htmlDefault) |
| 651 | .append(htmlLoading) | 639 | .append(htmlExternal) |
| 652 | .append(htmlExternal); | 640 | .append(getModuleInformation()); |
| 653 | 641 | ||
| 654 | /** @type {import('./popup.js').CustomPopupButton} */ | 642 | /** @type {import('./popup.js').CustomPopupButton} */ |
| 655 | const updateAllButton = { | 643 | const updateAllButton = { |
| @@ -692,6 +680,7 @@ async function showExtensionsDetails() { | |||
| 692 | }, | 680 | }, |
| 693 | }); | 681 | }); |
| 694 | popupPromise = popup.show(); | 682 | popupPromise = popup.show(); |
| 683 | checkForUpdatesManual().finally(() => htmlLoading.remove()); | ||
| 695 | } catch (error) { | 684 | } catch (error) { |
| 696 | toastr.error('Error loading extensions. See browser console for details.'); | 685 | toastr.error('Error loading extensions. See browser console for details.'); |
| 697 | console.error(error); | 686 | console.error(error); |
| @@ -873,6 +862,52 @@ export function doDailyExtensionUpdatesCheck() { | |||
| 873 | }, 1); | 862 | }, 1); |
| 874 | } | 863 | } |
| 875 | 864 | ||
| 865 | async function checkForUpdatesManual() { | ||
| 866 | const promises = []; | ||
| 867 | for (const id of Object.keys(manifests).filter(x => x.startsWith('third-party'))) { | ||
| 868 | const externalId = id.replace('third-party', ''); | ||
| 869 | const promise = new Promise(async (resolve, reject) => { | ||
| 870 | try { | ||
| 871 | const data = await getExtensionVersion(externalId); | ||
| 872 | const extensionBlock = document.querySelector(`.extension_block[data-name="${externalId}"]`); | ||
| 873 | if (extensionBlock) { | ||
| 874 | if (data.isUpToDate === false) { | ||
| 875 | const buttonElement = extensionBlock.querySelector('.btn_update'); | ||
| 876 | if (buttonElement) { | ||
| 877 | buttonElement.classList.remove('displayNone'); | ||
| 878 | } | ||
| 879 | const nameElement = extensionBlock.querySelector('.extension_name'); | ||
| 880 | if (nameElement) { | ||
| 881 | nameElement.classList.add('update_available'); | ||
| 882 | } | ||
| 883 | } | ||
| 884 | let branch = data.currentBranchName; | ||
| 885 | let commitHash = data.currentCommitHash; | ||
| 886 | let origin = data.remoteUrl; | ||
| 887 | |||
| 888 | const originLink = extensionBlock.querySelector('a'); | ||
| 889 | if (originLink) { | ||
| 890 | originLink.href = origin; | ||
| 891 | originLink.target = '_blank'; | ||
| 892 | originLink.rel = 'noopener noreferrer'; | ||
| 893 | } | ||
| 894 | |||
| 895 | const versionElement = extensionBlock.querySelector('.extension_version'); | ||
| 896 | if (versionElement) { | ||
| 897 | versionElement.textContent += ` (${branch}-${commitHash.substring(0, 7)})`; | ||
| 898 | } | ||
| 899 | } | ||
| 900 | resolve(); | ||
| 901 | } catch (error) { | ||
| 902 | console.error('Error checking for extension updates', error); | ||
| 903 | reject(); | ||
| 904 | } | ||
| 905 | }); | ||
| 906 | promises.push(promise); | ||
| 907 | } | ||
| 908 | return Promise.allSettled(promises); | ||
| 909 | } | ||
| 910 | |||
| 876 | /** | 911 | /** |
| 877 | * Checks if there are updates available for 3rd-party extensions. | 912 | * Checks if there are updates available for 3rd-party extensions. |
| 878 | * @param {boolean} force Skip nag check | 913 | * @param {boolean} force Skip nag check |