diff --git a/public/scripts/extensions.js b/public/scripts/extensions.js
index 92d1cb239..2c2c52261 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,6 +1095,76 @@ 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
@@ -1443,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/src/endpoints/extensions.js b/src/endpoints/extensions.js
index cd8a31cd9..0d4545f0c 100644
--- a/src/endpoints/extensions.js
+++ b/src/endpoints/extensions.js
@@ -158,6 +158,113 @@ router.post('/update', async (request, response) => {
}
});
+router.post('/branches', async (request, response) => {
+ try {
+ const git = simpleGit();
+
+ const { extensionName, global } = request.body;
+
+ if (!extensionName) {
+ return response.status(400).send('Bad Request: extensionName is required in the request body.');
+ }
+
+ if (global && !request.user.profile.admin) {
+ console.error(`User ${request.user.profile.handle} does not have permission to update global extensions.`);
+ return response.status(403).send('Forbidden: No permission to update global extensions.');
+ }
+
+
+ const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
+ const extensionPath = path.join(basePath, extensionName);
+
+ if (!fs.existsSync(extensionPath)) {
+ return response.status(404).send(`Directory does not exist at ${extensionPath}`);
+ }
+
+ // Unshallow the repository if it is shallow
+ const isShallow = await git.cwd(extensionPath).revparse(['--is-shallow-repository']) === 'true';
+ if (isShallow) {
+ console.info(`Unshallowing the repository at ${extensionPath}`);
+ await git.cwd(extensionPath).fetch('origin', ['--unshallow']);
+ }
+
+ // Fetch all branches
+ await git.cwd(extensionPath).remote(['set-branches', 'origin', '*']);
+ await git.cwd(extensionPath).fetch('origin');
+ const localBranches = await git.cwd(extensionPath).branchLocal();
+ const remoteBranches = await git.cwd(extensionPath).branch(['-r', '--list', 'origin/*']);
+ const result = [
+ ...Object.values(localBranches.branches),
+ ...Object.values(remoteBranches.branches),
+ ].map(b => ({ current: b.current, commit: b.commit, name: b.name, label: b.label }));
+
+ return response.send(result);
+ } catch (error) {
+ console.error('Getting branches failed', error);
+ return response.status(500).send('Internal Server Error. Try again later.');
+ }
+});
+
+router.post('/switch', async (request, response) => {
+ try {
+ const git = simpleGit();
+
+ const { extensionName, branch, global } = request.body;
+
+ if (!extensionName || !branch) {
+ return response.status(400).send('Bad Request: extensionName and branch are required in the request body.');
+ }
+
+ if (global && !request.user.profile.admin) {
+ console.error(`User ${request.user.profile.handle} does not have permission to update global extensions.`);
+ return response.status(403).send('Forbidden: No permission to update global extensions.');
+ }
+
+ const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
+ const extensionPath = path.join(basePath, extensionName);
+
+ if (!fs.existsSync(extensionPath)) {
+ return response.status(404).send(`Directory does not exist at ${extensionPath}`);
+ }
+
+ const branches = await git.cwd(extensionPath).branchLocal();
+
+ if (String(branch).startsWith('origin/')) {
+ const localBranch = branch.replace('origin/', '');
+ if (branches.all.includes(localBranch)) {
+ console.info(`Branch ${localBranch} already exists locally, checking it out`);
+ await git.cwd(extensionPath).checkout(localBranch);
+ return response.sendStatus(204);
+ }
+
+ console.info(`Branch ${localBranch} does not exist locally, creating it from ${branch}`);
+ await git.cwd(extensionPath).checkoutBranch(localBranch, branch);
+ return response.sendStatus(204);
+ }
+
+ if (!branches.all.includes(branch)) {
+ console.error(`Branch ${branch} does not exist locally`);
+ return response.status(404).send(`Branch ${branch} does not exist locally`);
+ }
+
+ // Check if the branch is already checked out
+ const currentBranch = await git.cwd(extensionPath).branch();
+ if (currentBranch.current === branch) {
+ console.info(`Branch ${branch} is already checked out`);
+ return response.sendStatus(204);
+ }
+
+ // Checkout the branch
+ await git.cwd(extensionPath).checkout(branch);
+ console.info(`Checked out branch ${branch} at ${extensionPath}`);
+
+ return response.sendStatus(204);
+ } catch (error) {
+ console.error('Switching branches failed', error);
+ return response.status(500).send('Internal Server Error. Check the server logs for more details.');
+ }
+});
+
router.post('/move', async (request, response) => {
try {
const { extensionName, source, destination } = request.body;