Add branch management functionality for extensions
| @@ -661,6 +661,7 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, | |||
| 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>` : ''; | 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 | 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>` : ''; | 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 | 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>` : ''; | 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 | let modulesInfo = ''; | 665 | let modulesInfo = ''; |
| 665 | 666 | ||
| 666 | if (isActive && Array.isArray(manifest.optional)) { | 667 | if (isActive && Array.isArray(manifest.optional)) { |
| @@ -701,6 +702,7 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, | |||
| 701 | 702 | ||
| 702 | <div class="extension_actions flex-container alignItemsCenter"> | 703 | <div class="extension_actions flex-container alignItemsCenter"> |
| 703 | ${updateButton} | 704 | ${updateButton} |
| 705 | ${branchButton} | ||
| 704 | ${moveButton} | 706 | ${moveButton} |
| 705 | ${deleteButton} | 707 | ${deleteButton} |
| 706 | </div> | 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 | async function onMoveClick() { | 987 | async function onMoveClick() { |
| 948 | const extensionName = $(this).data('name'); | 988 | const extensionName = $(this).data('name'); |
| 949 | const isCurrentUserAdmin = isAdmin(); | 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 | * Installs a third-party extension via the API. | 1169 | * Installs a third-party extension via the API. |
| 1060 | * @param {string} url Extension repository URL | 1170 | * @param {string} url Extension repository URL |
| 1061 | * @param {boolean} global Is the extension global? | 1171 | * @param {boolean} global Is the extension global? |
| @@ -1443,6 +1553,7 @@ export async function initExtensions() { | |||
| 1443 | $(document).on('click', '.extensions_info .extension_block .btn_update', onUpdateClick); | 1553 | $(document).on('click', '.extensions_info .extension_block .btn_update', onUpdateClick); |
| 1444 | $(document).on('click', '.extensions_info .extension_block .btn_delete', onDeleteClick); | 1554 | $(document).on('click', '.extensions_info .extension_block .btn_delete', onDeleteClick); |
| 1445 | $(document).on('click', '.extensions_info .extension_block .btn_move', onMoveClick); | 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 | * Handles the click event for the third-party extension import button. | 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 | router.post('/move', async (request, response) => { | 268 | router.post('/move', async (request, response) => { |
| 162 | try { | 269 | try { |
| 163 | const { extensionName, source, destination } = request.body; | 270 | const { extensionName, source, destination } = request.body; |