Merge pull request #3933 from SillyTavern/feat/ext-installer-branch Add branch selection on extension installer

b25322b844cac8ced9993ca12a9d0cd7c97dfe82

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

Signed
5 files changed, +293 -31Showing whitespace changes
public/scripts/RossAscends-mods.js+1 -1
@@ -1120,7 +1120,7 @@ export function initRossMods() {
11201120 const result = await Popup.show.confirm('Regenerate Message', 'Are you sure you want to regenerate the latest message?', {
11211121 customInputs: [{ id: 'regenerateWithCtrlEnter', label: 'Don\'t ask again' }],
11221122 onClose: (popup) => {
11231123 regenerateWithCtrlEnter = Boolean(popup.inputResults.get('regenerateWithCtrlEnter') ?? false);
11241124 },
11251125 });
11261126 if (!result) {
public/scripts/extensions.js+124 -3
@@ -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,12 +1096,82 @@ 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?
10621172 * @returns {Promise<void>}
10631173 */
10641174export async function installExtension(url, global, branch = '') {
10651175 console.debug('Extension installation started', url);
10661176
10671177 toastr.info(t`Please wait...`, t`Installing extension`);
@@ -1072,6 +1182,7 @@ export async function installExtension(url, global) {
10721182 body: JSON.stringify({
10731183 url,
10741184 global,
1185+ branch,
10751186 }),
10761187 });
10771188
@@ -1406,9 +1517,17 @@ export async function openThirdPartyExtensionMenu(suggestUrl = '') {
14061517 await popup.complete(POPUP_RESULT.AFFIRMATIVE);
14071518 },
14081519 };
1520+ /** @type {import('./popup.js').CustomPopupInput} */
1521+ const branchNameInput = {
1522+ id: 'extension_branch_name',
1523+ label: t`Branch or tag name (optional)`,
1524+ type: 'text',
1525+ tooltip: 'e.g. main, dev, v1.0.0',
1526+ };
14091527
14101528 const customButtons = isCurrentUserAdmin ? [installForAllButton] : [];
1411- const popup = new Popup(html, POPUP_TYPE.INPUT, suggestUrl ?? '', { okButton, customButtons });
1529+ const customInputs = [branchNameInput];
1530+ const popup = new Popup(html, POPUP_TYPE.INPUT, suggestUrl ?? '', { okButton, customButtons, customInputs });
14121531 const input = await popup.show();
14131532
14141533 if (!input) {
@@ -1417,7 +1536,8 @@ export async function openThirdPartyExtensionMenu(suggestUrl = '') {
14171536 }
14181537
14191538 const url = String(input).trim();
1420- await installExtension(url, global);
1539+ const branchName = String(popup.inputResults.get('extension_branch_name') ?? '').trim();
1540+ await installExtension(url, global, branchName);
14211541}
14221542
14231543export async function initExtensions() {
@@ -1433,6 +1553,7 @@ export async function initExtensions() {
14331553 $(document).on('click', '.extensions_info .extension_block .btn_update', onUpdateClick);
14341554 $(document).on('click', '.extensions_info .extension_block .btn_delete', onDeleteClick);
14351555 $(document).on('click', '.extensions_info .extension_block .btn_move', onMoveClick);
1556+ $(document).on('click', '.extensions_info .extension_block .btn_branch', onBranchClick);
14361557
14371558 /**
14381559 * Handles the click event for the third-party extension import button.
public/scripts/extensions/assets/index.js+2 -2
@@ -291,7 +291,7 @@ async function installAsset(url, assetType, filename) {
291291 try {
292292 if (category === 'extension') {
293293 console.debug(DEBUG_PREFIX, 'Installing extension ', url);
294294 await installExtension(url, false);
295295 console.debug(DEBUG_PREFIX, 'Extension installed.');
296296 return;
297297 }
@@ -309,7 +309,7 @@ async function installAsset(url, assetType, filename) {
309309 console.debug(DEBUG_PREFIX, 'Importing character ', filename);
310310 const blob = await result.blob();
311311 const file = new File([blob], filename, { type: blob.type });
312312 await processDroppedFiles([file], true);
313313 console.debug(DEBUG_PREFIX, 'Character downloaded.');
314314 }
315315 }
public/scripts/popup.js+32 -5
@@ -71,7 +71,8 @@ export const POPUP_RESULT = {
7171 * @property {string} id - The id for the html element
7272 * @property {string} label - The label text for the input
7373 * @property {string?} [tooltip=null] - Optional tooltip icon displayed behind the label
7474 * @property {boolean?|string|undefined} [defaultState=false] - The default state when opening the popup (false if not set)
75+ * @property {string?} [type='checkbox'] - The type of the input (default is checkbox)
7576 */
7677
7778/**
@@ -157,7 +158,7 @@ export class Popup {
157158
158159 /** @type {POPUP_RESULT|number} */ result;
159160 /** @type {any} */ value;
160161 /** @type {Map<string,string|boolean>?} */ inputResults;
161162 /** @type {any} */ cropData;
162163
163164 /** @type {HTMLElement} */ lastFocus;
@@ -260,13 +261,14 @@ export class Popup {
260261 return;
261262 }
262263
264+ if (!input.type || input.type === 'checkbox') {
263265 const label = document.createElement('label');
264266 label.classList.add('checkbox_label', 'justifyCenter');
265267 label.setAttribute('for', input.id);
266268 const inputElement = document.createElement('input');
267269 inputElement.type = 'checkbox';
268270 inputElement.id = input.id;
269271 inputElement.checked = Boolean(input.defaultState ?? false);
270272 label.appendChild(inputElement);
271273 const labelText = document.createElement('span');
272274 labelText.innerText = input.label;
@@ -282,6 +284,30 @@ export class Popup {
282284 }
283285
284286 this.inputControls.appendChild(label);
287+ } else if (input.type === 'text') {
288+ const label = document.createElement('label');
289+ label.classList.add('text_label', 'justifyCenter');
290+ label.setAttribute('for', input.id);
291+
292+ const inputElement = document.createElement('input');
293+ inputElement.classList.add('text_pole');
294+ inputElement.type = 'text';
295+ inputElement.id = input.id;
296+ inputElement.value = String(input.defaultState ?? '');
297+ inputElement.placeholder = input.tooltip ?? '';
298+
299+ const labelText = document.createElement('span');
300+ labelText.innerText = input.label;
301+ labelText.dataset.i18n = input.label;
302+
303+ label.appendChild(labelText);
304+ label.appendChild(inputElement);
305+
306+ this.inputControls.appendChild(label);
307+ } else {
308+ console.warn('Unknown custom input type. Only checkbox and text are supported.', input);
309+ return;
310+ }
285311 });
286312
287313 // Set the default button class
@@ -529,7 +555,8 @@ export class Popup {
529555 this.inputResults = new Map(this.customInputs.map(input => {
530556 /** @type {HTMLInputElement} */
531557 const inputControl = this.dlg.querySelector(`#${input.id}`);
532- return [inputControl.id, inputControl.checked];
558+ const value = input.type === 'text' ? inputControl.value : inputControl.checked;
559+ return [inputControl.id, value];
533560 }));
534561 }
535562
@@ -619,7 +646,7 @@ export class Popup {
619646 /** @readonly @type {Popup[]} Remember all popups */
620647 popups: [],
621648
622649 /** @type {{value: any, result: POPUP_RESULT|number?, inputResults: Map<string, string|boolean>?}?} Last popup result */
623650 lastResult: null,
624651
625652 /** @returns {boolean} Checks if any modal popup dialog is open */
src/endpoints/extensions.js+134 -20
@@ -30,17 +30,23 @@ async function getManifest(extensionPath) {
3030 * @returns {Promise<Object>} - Returns the extension information as an object
3131 */
3232async function checkIfRepoIsUpToDate(extensionPath) {
3333 const git = simpleGit({ baseDir: extensionPath });
3434 await git.cwd(extensionPath).fetch('origin');
3535 const currentBranch = await git.cwd(extensionPath).branch();
3636 const currentCommitHash = await git.cwd(extensionPath).revparse(['HEAD']);
3737 const log = await git.cwd(extensionPath).log({
3838 from: currentCommitHash,
3939 to: `origin/${currentBranch.current}`,
4040 });
4141
4242 // Fetch remote repository information
4343 const remotes = await git.cwd(extensionPath).getRemotes(true);
44+ if (remotes.length === 0) {
45+ return {
46+ isUpToDate: true,
47+ remoteUrl: '',
48+ };
49+ }
4450
4551 return {
4652 isUpToDate: log.total === 0,
@@ -76,7 +82,7 @@ router.post('/install', async (request, response) => {
7682 fs.mkdirSync(PUBLIC_DIRECTORIES.globalExtensions);
7783 }
7884
7985 const { url, global, branch } = request.body;
8086
8187 if (global && !request.user.profile.admin) {
8288 console.error(`User ${request.user.profile.handle} does not have permission to install global extensions.`);
@@ -90,8 +96,12 @@ router.post('/install', async (request, response) => {
9096 return response.status(409).send(`Directory already exists at ${extensionPath}`);
9197 }
9298
9399 awaitconst git.clone(url,cloneOptions extensionPath,= { '--depth': 1 });
94- console.info(`Extension has been cloned at ${extensionPath}`);
100+ if (branch) {
101+ cloneOptions['--branch'] = branch;
102+ }
103+ await git.clone(url, extensionPath, cloneOptions);
104+ console.info(`Extension has been cloned to ${extensionPath} from ${url} at ${branch || '(default)'} branch`);
95105
96106 const { version, author, display_name } = await getManifest(extensionPath);
97107
@@ -114,7 +124,6 @@ router.post('/install', async (request, response) => {
114124 * @returns {void}
115125 */
116126router.post('/update', async (request, response) => {
117- const git = simpleGit();
118127 if (!request.body.extensionName) {
119128 return response.status(400).send('Bad Request: extensionName is required in the request body.');
120129 }
@@ -128,22 +137,23 @@ router.post('/update', async (request, response) => {
128137 }
129138
130139 const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
131140 const extensionPath = path.join(basePath, sanitize(extensionName));
132141
133142 if (!fs.existsSync(extensionPath)) {
134143 return response.status(404).send(`Directory does not exist at ${extensionPath}`);
135144 }
136145
137146 const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath);
138147 const currentBranchgit = await git.cwdsimpleGit({ baseDir: extensionPath).branch( });
148+ const currentBranch = await git.branch();
139149 if (!isUpToDate) {
140150 await git.cwd(extensionPath).pull('origin', currentBranch.current);
141151 console.info(`Extension has been updated at ${extensionPath}`);
142152 } else {
143153 console.info(`Extension is up to date at ${extensionPath}`);
144154 }
145155 await git.cwd(extensionPath).fetch('origin');
146156 const fullCommitHash = await git.cwd(extensionPath).revparse(['HEAD']);
147157 const shortCommitHash = fullCommitHash.slice(0, 7);
148158
149159 return response.send({ shortCommitHash, extensionPath, isUpToDate, remoteUrl });
@@ -154,6 +164,110 @@ router.post('/update', async (request, response) => {
154164 }
155165});
156166
167+router.post('/branches', async (request, response) => {
168+ try {
169+ const { extensionName, global } = request.body;
170+
171+ if (!extensionName) {
172+ return response.status(400).send('Bad Request: extensionName is required in the request body.');
173+ }
174+
175+ if (global && !request.user.profile.admin) {
176+ console.error(`User ${request.user.profile.handle} does not have permission to list branches of global extensions.`);
177+ return response.status(403).send('Forbidden: No permission to list branches of global extensions.');
178+ }
179+
180+ const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
181+ const extensionPath = path.join(basePath, sanitize(extensionName));
182+
183+ if (!fs.existsSync(extensionPath)) {
184+ return response.status(404).send(`Directory does not exist at ${extensionPath}`);
185+ }
186+
187+ const git = simpleGit({ baseDir: extensionPath });
188+ // Unshallow the repository if it is shallow
189+ const isShallow = await git.revparse(['--is-shallow-repository']) === 'true';
190+ if (isShallow) {
191+ console.info(`Unshallowing the repository at ${extensionPath}`);
192+ await git.fetch('origin', ['--unshallow']);
193+ }
194+
195+ // Fetch all branches
196+ await git.remote(['set-branches', 'origin', '*']);
197+ await git.fetch('origin');
198+ const localBranches = await git.branchLocal();
199+ const remoteBranches = await git.branch(['-r', '--list', 'origin/*']);
200+ const result = [
201+ ...Object.values(localBranches.branches),
202+ ...Object.values(remoteBranches.branches),
203+ ].map(b => ({ current: b.current, commit: b.commit, name: b.name, label: b.label }));
204+
205+ return response.send(result);
206+ } catch (error) {
207+ console.error('Getting branches failed', error);
208+ return response.status(500).send('Internal Server Error. Check the server logs for more details.');
209+ }
210+});
211+
212+router.post('/switch', async (request, response) => {
213+ try {
214+ const { extensionName, branch, global } = request.body;
215+
216+ if (!extensionName || !branch) {
217+ return response.status(400).send('Bad Request: extensionName and branch are required in the request body.');
218+ }
219+
220+ if (global && !request.user.profile.admin) {
221+ console.error(`User ${request.user.profile.handle} does not have permission to switch branches of global extensions.`);
222+ return response.status(403).send('Forbidden: No permission to switch branches of global extensions.');
223+ }
224+
225+ const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions;
226+ const extensionPath = path.join(basePath, sanitize(extensionName));
227+
228+ if (!fs.existsSync(extensionPath)) {
229+ return response.status(404).send(`Directory does not exist at ${extensionPath}`);
230+ }
231+
232+ const git = simpleGit({ baseDir: extensionPath });
233+ const branches = await git.branchLocal();
234+
235+ if (String(branch).startsWith('origin/')) {
236+ const localBranch = branch.replace('origin/', '');
237+ if (branches.all.includes(localBranch)) {
238+ console.info(`Branch ${localBranch} already exists locally, checking it out`);
239+ await git.checkout(localBranch);
240+ return response.sendStatus(204);
241+ }
242+
243+ console.info(`Branch ${localBranch} does not exist locally, creating it from ${branch}`);
244+ await git.checkoutBranch(localBranch, branch);
245+ return response.sendStatus(204);
246+ }
247+
248+ if (!branches.all.includes(branch)) {
249+ console.error(`Branch ${branch} does not exist locally`);
250+ return response.status(404).send(`Branch ${branch} does not exist locally`);
251+ }
252+
253+ // Check if the branch is already checked out
254+ const currentBranch = await git.branch();
255+ if (currentBranch.current === branch) {
256+ console.info(`Branch ${branch} is already checked out`);
257+ return response.sendStatus(204);
258+ }
259+
260+ // Checkout the branch
261+ await git.checkout(branch);
262+ console.info(`Checked out branch ${branch} at ${extensionPath}`);
263+
264+ return response.sendStatus(204);
265+ } catch (error) {
266+ console.error('Switching branches failed', error);
267+ return response.status(500).send('Internal Server Error. Check the server logs for more details.');
268+ }
269+});
270+
157271router.post('/move', async (request, response) => {
158272 try {
159273 const { extensionName, source, destination } = request.body;
@@ -194,7 +308,7 @@ router.post('/move', async (request, response) => {
194308 return response.sendStatus(204);
195309 } catch (error) {
196310 console.error('Moving extension failed', error);
197311 return response.status(500).send('Internal Server Error. TryCheck againthe laterserver logs for more details.');
198312 }
199313});
200314
@@ -209,7 +323,6 @@ router.post('/move', async (request, response) => {
209323 * @returns {void}
210324 */
211325router.post('/version', async (request, response) => {
212- const git = simpleGit();
213326 if (!request.body.extensionName) {
214327 return response.status(400).send('Bad Request: extensionName is required in the request body.');
215328 }
@@ -223,19 +336,20 @@ router.post('/version', async (request, response) => {
223336 return response.status(404).send(`Directory does not exist at ${extensionPath}`);
224337 }
225338
339+ const git = simpleGit({ baseDir: extensionPath });
226340 let currentCommitHash;
227341 try {
228342 currentCommitHash = await git.cwd(extensionPath).revparse(['HEAD']);
229343 } catch (error) {
230344 // it is not a git repo, or has no commits yet, or is a bare repo
231345 // not possible to update it, most likely can't get the branch name either
232346 return response.send({ currentBranchName: '', currentCommitHash: '', isUpToDate: true, remoteUrl: '' });
233347 }
234348
235349 const currentBranch = await git.cwd(extensionPath).branch();
236350 // get only the working branch
237351 const currentBranchName = currentBranch.current;
238352 await git.cwd(extensionPath).fetch('origin');
239353 console.debug(extensionName, currentBranchName, currentCommitHash);
240354 const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath);
241355