Add branch management functionality for extensions
| @@ -661,6 +661,7 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, | ||
| 661 | 661 | let deleteButton = isExternal ? `<button class="btn_delete menu_button" data-name="${externalId}" data-i18n="[title]Delete" title="Delete"><i class="fa-fw fa-solid fa-trash-can"></i></button>` : ''; |
| 662 | 662 | 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>` : ''; |
| 663 | 663 | let moveButton = isExternal && isUserAdmin ? `<button class="btn_move menu_button" data-name="${externalId}" data-i18n="[title]Move" title="Move"><i class="fa-solid fa-folder-tree fa-fw"></i></button>` : ''; |
| 664 | + let branchButton = isExternal && isUserAdmin ? `<button class="btn_branch menu_button" data-name="${externalId}" data-i18n="[title]Switch branch" title="Switch branch"><i class="fa-solid fa-code-branch fa-fw"></i></button>` : ''; | |
| 664 | 665 | let modulesInfo = ''; |
| 665 | 666 | |
| 666 | 667 | if (isActive && Array.isArray(manifest.optional)) { |
| @@ -701,6 +702,7 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, | ||
| 701 | 702 | |
| 702 | 703 | <div class="extension_actions flex-container alignItemsCenter"> |
| 703 | 704 | ${updateButton} |
| 705 | + ${branchButton} | |
| 704 | 706 | ${moveButton} |
| 705 | 707 | ${deleteButton} |
| 706 | 708 | </div> |
| @@ -944,6 +946,44 @@ async function onDeleteClick() { | ||
| 944 | 946 | } |
| 945 | 947 | } |
| 946 | 948 | |
| 949 | +async function onBranchClick() { | |
| 950 | + const extensionName = $(this).data('name'); | |
| 951 | + const isCurrentUserAdmin = isAdmin(); | |
| 952 | + const isGlobal = getExtensionType(extensionName) === 'global'; | |
| 953 | + if (isGlobal && !isCurrentUserAdmin) { | |
| 954 | + toastr.error(t`You don't have permission to switch branch.`); | |
| 955 | + return; | |
| 956 | + } | |
| 957 | + | |
| 958 | + let newBranch = ''; | |
| 959 | + | |
| 960 | + const branches = await getExtensionBranches(extensionName, isGlobal); | |
| 961 | + const selectElement = document.createElement('select'); | |
| 962 | + selectElement.classList.add('text_pole', 'wide100p'); | |
| 963 | + selectElement.addEventListener('change', function () { | |
| 964 | + newBranch = this.value; | |
| 965 | + }); | |
| 966 | + for (const branch of branches) { | |
| 967 | + const option = document.createElement('option'); | |
| 968 | + option.value = branch.name; | |
| 969 | + option.textContent = `${branch.name} (${branch.commit}) [${branch.label}]`; | |
| 970 | + option.selected = branch.current; | |
| 971 | + selectElement.appendChild(option); | |
| 972 | + } | |
| 973 | + | |
| 974 | + const popup = new Popup(selectElement, POPUP_TYPE.CONFIRM, '', { | |
| 975 | + okButton: t`Switch`, | |
| 976 | + cancelButton: t`Cancel`, | |
| 977 | + }); | |
| 978 | + const popupResult = await popup.show(); | |
| 979 | + | |
| 980 | + if (!popupResult || !newBranch) { | |
| 981 | + return; | |
| 982 | + } | |
| 983 | + | |
| 984 | + await switchExtensionBranch(extensionName, isGlobal, newBranch); | |
| 985 | +} | |
| 986 | + | |
| 947 | 987 | async function onMoveClick() { |
| 948 | 988 | const extensionName = $(this).data('name'); |
| 949 | 989 | const isCurrentUserAdmin = isAdmin(); |
| @@ -1056,6 +1096,76 @@ async function getExtensionVersion(extensionName, abortSignal) { | ||
| 1056 | 1096 | } |
| 1057 | 1097 | |
| 1058 | 1098 | /** |
| 1099 | + * Gets the list of branches for a specific extension. | |
| 1100 | + * @param {string} extensionName The name of the extension | |
| 1101 | + * @param {boolean} isGlobal Whether the extension is global or not | |
| 1102 | + * @returns {Promise<ExtensionBranch[]>} List of branches for the extension | |
| 1103 | + * @typedef {object} ExtensionBranch | |
| 1104 | + * @property {string} name The name of the branch | |
| 1105 | + * @property {string} commit The commit hash of the branch | |
| 1106 | + * @property {boolean} current Whether this branch is the current one | |
| 1107 | + * @property {string} label The commit label of the branch | |
| 1108 | + */ | |
| 1109 | +async function getExtensionBranches(extensionName, isGlobal) { | |
| 1110 | + try { | |
| 1111 | + const response = await fetch('/api/extensions/branches', { | |
| 1112 | + method: 'POST', | |
| 1113 | + headers: getRequestHeaders(), | |
| 1114 | + body: JSON.stringify({ | |
| 1115 | + extensionName, | |
| 1116 | + global: isGlobal, | |
| 1117 | + }), | |
| 1118 | + }); | |
| 1119 | + | |
| 1120 | + if (!response.ok) { | |
| 1121 | + const text = await response.text(); | |
| 1122 | + toastr.error(text || response.statusText, t`Extension branches fetch failed`); | |
| 1123 | + console.error('Extension branches fetch failed', response.status, response.statusText, text); | |
| 1124 | + return []; | |
| 1125 | + } | |
| 1126 | + | |
| 1127 | + return await response.json(); | |
| 1128 | + } catch (error) { | |
| 1129 | + console.error('Error:', error); | |
| 1130 | + return []; | |
| 1131 | + } | |
| 1132 | +} | |
| 1133 | + | |
| 1134 | +/** | |
| 1135 | + * Switches the branch of an extension. | |
| 1136 | + * @param {string} extensionName The name of the extension | |
| 1137 | + * @param {boolean} isGlobal If the extension is global | |
| 1138 | + * @param {string} branch Branch name to switch to | |
| 1139 | + * @returns {Promise<void>} | |
| 1140 | + */ | |
| 1141 | +async function switchExtensionBranch(extensionName, isGlobal, branch) { | |
| 1142 | + try { | |
| 1143 | + const response = await fetch('/api/extensions/switch', { | |
| 1144 | + method: 'POST', | |
| 1145 | + headers: getRequestHeaders(), | |
| 1146 | + body: JSON.stringify({ | |
| 1147 | + extensionName, | |
| 1148 | + branch, | |
| 1149 | + global: isGlobal, | |
| 1150 | + }), | |
| 1151 | + }); | |
| 1152 | + | |
| 1153 | + if (!response.ok) { | |
| 1154 | + const text = await response.text(); | |
| 1155 | + toastr.error(text || response.statusText, t`Extension branch switch failed`); | |
| 1156 | + console.error('Extension branch switch failed', response.status, response.statusText, text); | |
| 1157 | + return; | |
| 1158 | + } | |
| 1159 | + | |
| 1160 | + toastr.success(t`Extension ${extensionName} switched to ${branch}`); | |
| 1161 | + await loadExtensionSettings({}, false, false); | |
| 1162 | + void showExtensionsDetails(); | |
| 1163 | + } catch (error) { | |
| 1164 | + console.error('Error:', error); | |
| 1165 | + } | |
| 1166 | +} | |
| 1167 | + | |
| 1168 | +/** | |
| 1059 | 1169 | * Installs a third-party extension via the API. |
| 1060 | 1170 | * @param {string} url Extension repository URL |
| 1061 | 1171 | * @param {boolean} global Is the extension global? |
| @@ -1443,6 +1553,7 @@ export async function initExtensions() { | ||
| 1443 | 1553 | $(document).on('click', '.extensions_info .extension_block .btn_update', onUpdateClick); |
| 1444 | 1554 | $(document).on('click', '.extensions_info .extension_block .btn_delete', onDeleteClick); |
| 1445 | 1555 | $(document).on('click', '.extensions_info .extension_block .btn_move', onMoveClick); |
| 1556 | + $(document).on('click', '.extensions_info .extension_block .btn_branch', onBranchClick); | |
| 1446 | 1557 | |
| 1447 | 1558 | /** |
| 1448 | 1559 | * Handles the click event for the third-party extension import button. |
| @@ -158,6 +158,113 @@ router.post('/update', async (request, response) => { | ||
| 158 | 158 | } |
| 159 | 159 | }); |
| 160 | 160 | |
| 161 | +router.post('/branches', async (request, response) => { | |
| 162 | + try { | |
| 163 | + const git = simpleGit(); | |
| 164 | + | |
| 165 | + const { extensionName, global } = request.body; | |
| 166 | + | |
| 167 | + if (!extensionName) { | |
| 168 | + return response.status(400).send('Bad Request: extensionName is required in the request body.'); | |
| 169 | + } | |
| 170 | + | |
| 171 | + if (global && !request.user.profile.admin) { | |
| 172 | + console.error(`User ${request.user.profile.handle} does not have permission to update global extensions.`); | |
| 173 | + return response.status(403).send('Forbidden: No permission to update global extensions.'); | |
| 174 | + } | |
| 175 | + | |
| 176 | + | |
| 177 | + const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions; | |
| 178 | + const extensionPath = path.join(basePath, extensionName); | |
| 179 | + | |
| 180 | + if (!fs.existsSync(extensionPath)) { | |
| 181 | + return response.status(404).send(`Directory does not exist at ${extensionPath}`); | |
| 182 | + } | |
| 183 | + | |
| 184 | + // Unshallow the repository if it is shallow | |
| 185 | + const isShallow = await git.cwd(extensionPath).revparse(['--is-shallow-repository']) === 'true'; | |
| 186 | + if (isShallow) { | |
| 187 | + console.info(`Unshallowing the repository at ${extensionPath}`); | |
| 188 | + await git.cwd(extensionPath).fetch('origin', ['--unshallow']); | |
| 189 | + } | |
| 190 | + | |
| 191 | + // Fetch all branches | |
| 192 | + await git.cwd(extensionPath).remote(['set-branches', 'origin', '*']); | |
| 193 | + await git.cwd(extensionPath).fetch('origin'); | |
| 194 | + const localBranches = await git.cwd(extensionPath).branchLocal(); | |
| 195 | + const remoteBranches = await git.cwd(extensionPath).branch(['-r', '--list', 'origin/*']); | |
| 196 | + const result = [ | |
| 197 | + ...Object.values(localBranches.branches), | |
| 198 | + ...Object.values(remoteBranches.branches), | |
| 199 | + ].map(b => ({ current: b.current, commit: b.commit, name: b.name, label: b.label })); | |
| 200 | + | |
| 201 | + return response.send(result); | |
| 202 | + } catch (error) { | |
| 203 | + console.error('Getting branches failed', error); | |
| 204 | + return response.status(500).send('Internal Server Error. Try again later.'); | |
| 205 | + } | |
| 206 | +}); | |
| 207 | + | |
| 208 | +router.post('/switch', async (request, response) => { | |
| 209 | + try { | |
| 210 | + const git = simpleGit(); | |
| 211 | + | |
| 212 | + const { extensionName, branch, global } = request.body; | |
| 213 | + | |
| 214 | + if (!extensionName || !branch) { | |
| 215 | + return response.status(400).send('Bad Request: extensionName and branch are required in the request body.'); | |
| 216 | + } | |
| 217 | + | |
| 218 | + if (global && !request.user.profile.admin) { | |
| 219 | + console.error(`User ${request.user.profile.handle} does not have permission to update global extensions.`); | |
| 220 | + return response.status(403).send('Forbidden: No permission to update global extensions.'); | |
| 221 | + } | |
| 222 | + | |
| 223 | + const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions; | |
| 224 | + const extensionPath = path.join(basePath, extensionName); | |
| 225 | + | |
| 226 | + if (!fs.existsSync(extensionPath)) { | |
| 227 | + return response.status(404).send(`Directory does not exist at ${extensionPath}`); | |
| 228 | + } | |
| 229 | + | |
| 230 | + const branches = await git.cwd(extensionPath).branchLocal(); | |
| 231 | + | |
| 232 | + if (String(branch).startsWith('origin/')) { | |
| 233 | + const localBranch = branch.replace('origin/', ''); | |
| 234 | + if (branches.all.includes(localBranch)) { | |
| 235 | + console.info(`Branch ${localBranch} already exists locally, checking it out`); | |
| 236 | + await git.cwd(extensionPath).checkout(localBranch); | |
| 237 | + return response.sendStatus(204); | |
| 238 | + } | |
| 239 | + | |
| 240 | + console.info(`Branch ${localBranch} does not exist locally, creating it from ${branch}`); | |
| 241 | + await git.cwd(extensionPath).checkoutBranch(localBranch, branch); | |
| 242 | + return response.sendStatus(204); | |
| 243 | + } | |
| 244 | + | |
| 245 | + if (!branches.all.includes(branch)) { | |
| 246 | + console.error(`Branch ${branch} does not exist locally`); | |
| 247 | + return response.status(404).send(`Branch ${branch} does not exist locally`); | |
| 248 | + } | |
| 249 | + | |
| 250 | + // Check if the branch is already checked out | |
| 251 | + const currentBranch = await git.cwd(extensionPath).branch(); | |
| 252 | + if (currentBranch.current === branch) { | |
| 253 | + console.info(`Branch ${branch} is already checked out`); | |
| 254 | + return response.sendStatus(204); | |
| 255 | + } | |
| 256 | + | |
| 257 | + // Checkout the branch | |
| 258 | + await git.cwd(extensionPath).checkout(branch); | |
| 259 | + console.info(`Checked out branch ${branch} at ${extensionPath}`); | |
| 260 | + | |
| 261 | + return response.sendStatus(204); | |
| 262 | + } catch (error) { | |
| 263 | + console.error('Switching branches failed', error); | |
| 264 | + return response.status(500).send('Internal Server Error. Check the server logs for more details.'); | |
| 265 | + } | |
| 266 | +}); | |
| 267 | + | |
| 161 | 268 | router.post('/move', async (request, response) => { |
| 162 | 269 | try { |
| 163 | 270 | const { extensionName, source, destination } = request.body; |