Add branch management functionality for extensions

3340009a29a200f223bdb5e5c856814f1f38e03d

Cohee <18619528+Cohee1207@users.noreply.github.com>

2 files changed, +218 -0Ignore whitespace
public/scripts/extensions.js+111 -0
@@ -661,6 +661,7 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal,
661661 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>` : '';
662662 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>` : '';
663663 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>` : '';
664665 let modulesInfo = '';
665666
666667 if (isActive && Array.isArray(manifest.optional)) {
@@ -701,6 +702,7 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal,
701702
702703 <div class="extension_actions flex-container alignItemsCenter">
703704 ${updateButton}
705+ ${branchButton}
704706 ${moveButton}
705707 ${deleteButton}
706708 </div>
@@ -944,6 +946,44 @@ async function onDeleteClick() {
944946 }
945947}
946948
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+
947987async function onMoveClick() {
948988 const extensionName = $(this).data('name');
949989 const isCurrentUserAdmin = isAdmin();
@@ -1056,6 +1096,76 @@ async function getExtensionVersion(extensionName, abortSignal) {
10561096}
10571097
10581098/**
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+/**
10591169 * Installs a third-party extension via the API.
10601170 * @param {string} url Extension repository URL
10611171 * @param {boolean} global Is the extension global?
@@ -1443,6 +1553,7 @@ export async function initExtensions() {
14431553 $(document).on('click', '.extensions_info .extension_block .btn_update', onUpdateClick);
14441554 $(document).on('click', '.extensions_info .extension_block .btn_delete', onDeleteClick);
14451555 $(document).on('click', '.extensions_info .extension_block .btn_move', onMoveClick);
1556+ $(document).on('click', '.extensions_info .extension_block .btn_branch', onBranchClick);
14461557
14471558 /**
14481559 * Handles the click event for the third-party extension import button.
src/endpoints/extensions.js+107 -0
@@ -158,6 +158,113 @@ router.post('/update', async (request, response) => {
158158 }
159159});
160160
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+
161268router.post('/move', async (request, response) => {
162269 try {
163270 const { extensionName, source, destination } = request.body;