diff --git a/public/scripts/RossAscends-mods.js b/public/scripts/RossAscends-mods.js
index 5179cad62..d7f4025e0 100644
--- a/public/scripts/RossAscends-mods.js
+++ b/public/scripts/RossAscends-mods.js
@@ -1120,7 +1120,7 @@ export function initRossMods() {
const result = await Popup.show.confirm('Regenerate Message', 'Are you sure you want to regenerate the latest message?', {
customInputs: [{ id: 'regenerateWithCtrlEnter', label: 'Don\'t ask again' }],
onClose: (popup) => {
- regenerateWithCtrlEnter = popup.inputResults.get('regenerateWithCtrlEnter') ?? false;
+ regenerateWithCtrlEnter = Boolean(popup.inputResults.get('regenerateWithCtrlEnter') ?? false);
},
});
if (!result) {
diff --git a/public/scripts/extensions.js b/public/scripts/extensions.js
index 5c22da7a0..df6e8b898 100644
--- a/public/scripts/extensions.js
+++ b/public/scripts/extensions.js
@@ -661,6 +661,7 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal,
let deleteButton = isExternal ? `` : '';
let updateButton = isExternal ? `` : '';
let moveButton = isExternal && isUserAdmin ? `` : '';
+ let branchButton = isExternal && isUserAdmin ? `` : '';
let modulesInfo = '';
if (isActive && Array.isArray(manifest.optional)) {
@@ -701,6 +702,7 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal,
${updateButton}
+ ${branchButton}
${moveButton}
${deleteButton}
@@ -944,6 +946,44 @@ async function onDeleteClick() {
}
}
+async function onBranchClick() {
+ const extensionName = $(this).data('name');
+ const isCurrentUserAdmin = isAdmin();
+ const isGlobal = getExtensionType(extensionName) === 'global';
+ if (isGlobal && !isCurrentUserAdmin) {
+ toastr.error(t`You don't have permission to switch branch.`);
+ return;
+ }
+
+ let newBranch = '';
+
+ const branches = await getExtensionBranches(extensionName, isGlobal);
+ const selectElement = document.createElement('select');
+ selectElement.classList.add('text_pole', 'wide100p');
+ selectElement.addEventListener('change', function () {
+ newBranch = this.value;
+ });
+ for (const branch of branches) {
+ const option = document.createElement('option');
+ option.value = branch.name;
+ option.textContent = `${branch.name} (${branch.commit}) [${branch.label}]`;
+ option.selected = branch.current;
+ selectElement.appendChild(option);
+ }
+
+ const popup = new Popup(selectElement, POPUP_TYPE.CONFIRM, '', {
+ okButton: t`Switch`,
+ cancelButton: t`Cancel`,
+ });
+ const popupResult = await popup.show();
+
+ if (!popupResult || !newBranch) {
+ return;
+ }
+
+ await switchExtensionBranch(extensionName, isGlobal, newBranch);
+}
+
async function onMoveClick() {
const extensionName = $(this).data('name');
const isCurrentUserAdmin = isAdmin();
@@ -1055,13 +1095,83 @@ async function getExtensionVersion(extensionName, abortSignal) {
}
}
+/**
+ * Gets the list of branches for a specific extension.
+ * @param {string} extensionName The name of the extension
+ * @param {boolean} isGlobal Whether the extension is global or not
+ * @returns {Promise} List of branches for the extension
+ * @typedef {object} ExtensionBranch
+ * @property {string} name The name of the branch
+ * @property {string} commit The commit hash of the branch
+ * @property {boolean} current Whether this branch is the current one
+ * @property {string} label The commit label of the branch
+ */
+async function getExtensionBranches(extensionName, isGlobal) {
+ try {
+ const response = await fetch('/api/extensions/branches', {
+ method: 'POST',
+ headers: getRequestHeaders(),
+ body: JSON.stringify({
+ extensionName,
+ global: isGlobal,
+ }),
+ });
+
+ if (!response.ok) {
+ const text = await response.text();
+ toastr.error(text || response.statusText, t`Extension branches fetch failed`);
+ console.error('Extension branches fetch failed', response.status, response.statusText, text);
+ return [];
+ }
+
+ return await response.json();
+ } catch (error) {
+ console.error('Error:', error);
+ return [];
+ }
+}
+
+/**
+ * Switches the branch of an extension.
+ * @param {string} extensionName The name of the extension
+ * @param {boolean} isGlobal If the extension is global
+ * @param {string} branch Branch name to switch to
+ * @returns {Promise}
+ */
+async function switchExtensionBranch(extensionName, isGlobal, branch) {
+ try {
+ const response = await fetch('/api/extensions/switch', {
+ method: 'POST',
+ headers: getRequestHeaders(),
+ body: JSON.stringify({
+ extensionName,
+ branch,
+ global: isGlobal,
+ }),
+ });
+
+ if (!response.ok) {
+ const text = await response.text();
+ toastr.error(text || response.statusText, t`Extension branch switch failed`);
+ console.error('Extension branch switch failed', response.status, response.statusText, text);
+ return;
+ }
+
+ toastr.success(t`Extension ${extensionName} switched to ${branch}`);
+ await loadExtensionSettings({}, false, false);
+ void showExtensionsDetails();
+ } catch (error) {
+ console.error('Error:', error);
+ }
+}
+
/**
* Installs a third-party extension via the API.
* @param {string} url Extension repository URL
* @param {boolean} global Is the extension global?
* @returns {Promise}
*/
-export async function installExtension(url, global) {
+export async function installExtension(url, global, branch = '') {
console.debug('Extension installation started', url);
toastr.info(t`Please wait...`, t`Installing extension`);
@@ -1072,6 +1182,7 @@ export async function installExtension(url, global) {
body: JSON.stringify({
url,
global,
+ branch,
}),
});
@@ -1406,9 +1517,17 @@ export async function openThirdPartyExtensionMenu(suggestUrl = '') {
await popup.complete(POPUP_RESULT.AFFIRMATIVE);
},
};
+ /** @type {import('./popup.js').CustomPopupInput} */
+ const branchNameInput = {
+ id: 'extension_branch_name',
+ label: t`Branch or tag name (optional)`,
+ type: 'text',
+ tooltip: 'e.g. main, dev, v1.0.0',
+ };
const customButtons = isCurrentUserAdmin ? [installForAllButton] : [];
- const popup = new Popup(html, POPUP_TYPE.INPUT, suggestUrl ?? '', { okButton, customButtons });
+ const customInputs = [branchNameInput];
+ const popup = new Popup(html, POPUP_TYPE.INPUT, suggestUrl ?? '', { okButton, customButtons, customInputs });
const input = await popup.show();
if (!input) {
@@ -1417,7 +1536,8 @@ export async function openThirdPartyExtensionMenu(suggestUrl = '') {
}
const url = String(input).trim();
- await installExtension(url, global);
+ const branchName = String(popup.inputResults.get('extension_branch_name') ?? '').trim();
+ await installExtension(url, global, branchName);
}
export async function initExtensions() {
@@ -1433,6 +1553,7 @@ export async function initExtensions() {
$(document).on('click', '.extensions_info .extension_block .btn_update', onUpdateClick);
$(document).on('click', '.extensions_info .extension_block .btn_delete', onDeleteClick);
$(document).on('click', '.extensions_info .extension_block .btn_move', onMoveClick);
+ $(document).on('click', '.extensions_info .extension_block .btn_branch', onBranchClick);
/**
* Handles the click event for the third-party extension import button.
diff --git a/public/scripts/extensions/assets/index.js b/public/scripts/extensions/assets/index.js
index 014db82cd..c3e14fda4 100644
--- a/public/scripts/extensions/assets/index.js
+++ b/public/scripts/extensions/assets/index.js
@@ -291,7 +291,7 @@ async function installAsset(url, assetType, filename) {
try {
if (category === 'extension') {
console.debug(DEBUG_PREFIX, 'Installing extension ', url);
- await installExtension(url);
+ await installExtension(url, false);
console.debug(DEBUG_PREFIX, 'Extension installed.');
return;
}
@@ -309,7 +309,7 @@ async function installAsset(url, assetType, filename) {
console.debug(DEBUG_PREFIX, 'Importing character ', filename);
const blob = await result.blob();
const file = new File([blob], filename, { type: blob.type });
- await processDroppedFiles([file], true);
+ await processDroppedFiles([file]);
console.debug(DEBUG_PREFIX, 'Character downloaded.');
}
}
diff --git a/public/scripts/popup.js b/public/scripts/popup.js
index aaadce1b1..3a1b03877 100644
--- a/public/scripts/popup.js
+++ b/public/scripts/popup.js
@@ -71,7 +71,8 @@ export const POPUP_RESULT = {
* @property {string} id - The id for the html element
* @property {string} label - The label text for the input
* @property {string?} [tooltip=null] - Optional tooltip icon displayed behind the label
- * @property {boolean?} [defaultState=false] - The default state when opening the popup (false if not set)
+ * @property {boolean|string|undefined} [defaultState=false] - The default state when opening the popup (false if not set)
+ * @property {string?} [type='checkbox'] - The type of the input (default is checkbox)
*/
/**
@@ -157,7 +158,7 @@ export class Popup {
/** @type {POPUP_RESULT|number} */ result;
/** @type {any} */ value;
- /** @type {Map?} */ inputResults;
+ /** @type {Map?} */ inputResults;
/** @type {any} */ cropData;
/** @type {HTMLElement} */ lastFocus;
@@ -260,28 +261,53 @@ export class Popup {
return;
}
- const label = document.createElement('label');
- label.classList.add('checkbox_label', 'justifyCenter');
- label.setAttribute('for', input.id);
- const inputElement = document.createElement('input');
- inputElement.type = 'checkbox';
- inputElement.id = input.id;
- inputElement.checked = input.defaultState ?? false;
- label.appendChild(inputElement);
- const labelText = document.createElement('span');
- labelText.innerText = input.label;
- labelText.dataset.i18n = input.label;
- label.appendChild(labelText);
+ if (!input.type || input.type === 'checkbox') {
+ const label = document.createElement('label');
+ label.classList.add('checkbox_label', 'justifyCenter');
+ label.setAttribute('for', input.id);
+ const inputElement = document.createElement('input');
+ inputElement.type = 'checkbox';
+ inputElement.id = input.id;
+ inputElement.checked = Boolean(input.defaultState ?? false);
+ label.appendChild(inputElement);
+ const labelText = document.createElement('span');
+ labelText.innerText = input.label;
+ labelText.dataset.i18n = input.label;
+ label.appendChild(labelText);
- if (input.tooltip) {
- const tooltip = document.createElement('div');
- tooltip.classList.add('fa-solid', 'fa-circle-info', 'opacity50p');
- tooltip.title = input.tooltip;
- tooltip.dataset.i18n = '[title]' + input.tooltip;
- label.appendChild(tooltip);
+ if (input.tooltip) {
+ const tooltip = document.createElement('div');
+ tooltip.classList.add('fa-solid', 'fa-circle-info', 'opacity50p');
+ tooltip.title = input.tooltip;
+ tooltip.dataset.i18n = '[title]' + input.tooltip;
+ label.appendChild(tooltip);
+ }
+
+ this.inputControls.appendChild(label);
+ } else if (input.type === 'text') {
+ const label = document.createElement('label');
+ label.classList.add('text_label', 'justifyCenter');
+ label.setAttribute('for', input.id);
+
+ const inputElement = document.createElement('input');
+ inputElement.classList.add('text_pole');
+ inputElement.type = 'text';
+ inputElement.id = input.id;
+ inputElement.value = String(input.defaultState ?? '');
+ inputElement.placeholder = input.tooltip ?? '';
+
+ const labelText = document.createElement('span');
+ labelText.innerText = input.label;
+ labelText.dataset.i18n = input.label;
+
+ label.appendChild(labelText);
+ label.appendChild(inputElement);
+
+ this.inputControls.appendChild(label);
+ } else {
+ console.warn('Unknown custom input type. Only checkbox and text are supported.', input);
+ return;
}
-
- this.inputControls.appendChild(label);
});
// Set the default button class
@@ -529,7 +555,8 @@ export class Popup {
this.inputResults = new Map(this.customInputs.map(input => {
/** @type {HTMLInputElement} */
const inputControl = this.dlg.querySelector(`#${input.id}`);
- return [inputControl.id, inputControl.checked];
+ const value = input.type === 'text' ? inputControl.value : inputControl.checked;
+ return [inputControl.id, value];
}));
}
@@ -619,7 +646,7 @@ export class Popup {
/** @readonly @type {Popup[]} Remember all popups */
popups: [],
- /** @type {{value: any, result: POPUP_RESULT|number?, inputResults: Map?}?} Last popup result */
+ /** @type {{value: any, result: POPUP_RESULT|number?, inputResults: Map?}?} Last popup result */
lastResult: null,
/** @returns {boolean} Checks if any modal popup dialog is open */
diff --git a/src/endpoints/extensions.js b/src/endpoints/extensions.js
index 2827d8058..6ac291b8e 100644
--- a/src/endpoints/extensions.js
+++ b/src/endpoints/extensions.js
@@ -30,17 +30,23 @@ async function getManifest(extensionPath) {
* @returns {Promise