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,
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 = '';
665666
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,
701702
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}
946948
949async 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
947async function onMoveClick() {987async 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}
10571097
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 */
1109async 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 */
1141async 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 URL1170 * @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);
14461557
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.
src/endpoints/extensions.js+107 -0
@@ -158,6 +158,113 @@ router.post('/update', async (request, response) => {
158 }158 }
159});159});
160160
161router.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
208router.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
161router.post('/move', async (request, response) => {268router.post('/move', async (request, response) => {
162 try {269 try {
163 const { extensionName, source, destination } = request.body;270 const { extensionName, source, destination } = request.body;