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;