Created
October 30, 2025 09:42
-
-
Save mathiasdotdev/a7e6d57cfda9a80a19c2916bcc4fe397 to your computer and use it in GitHub Desktop.
This creates a button that improves the review experience on pull requests. You can easily modify deleted files, a specific pattern, and reset viewed files.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Variables globales | |
| let previousPathname = ''; | |
| let observer = null; | |
| let dropdownInjectionTimeout = null; | |
| // Fonction principale d'observation des changements d'URL | |
| function observeUrlChanges() { | |
| // Arrêter l'observateur précédent si existant | |
| if (observer) observer.disconnect(); | |
| observer = new MutationObserver(() => { | |
| if (location.pathname !== previousPathname) { | |
| previousPathname = location.pathname; | |
| handlePathChange(); | |
| } | |
| }); | |
| observer.observe(document, { | |
| subtree: true, | |
| childList: true | |
| }); | |
| } | |
| // Gestion du changement de chemin | |
| function handlePathChange() { | |
| // Nettoyer le timeout précédent si existant | |
| if (dropdownInjectionTimeout) { | |
| clearTimeout(dropdownInjectionTimeout); | |
| } | |
| // Vérifie si l'URL correspond à la page des fichiers d'une PR | |
| if (/\/pull\/\d+\/files/.test(location.pathname)) { | |
| // Utilisation d'un observateur de mutations plutôt qu'un délai fixe | |
| waitForFilesList().then(() => { | |
| injectMarkAsViewed(); | |
| }).catch(error => { | |
| console.error('Erreur lors de l\'injection du bouton:', error); | |
| }); | |
| } | |
| } | |
| // Attend que la liste des fichiers soit chargée | |
| function waitForFilesList() { | |
| return new Promise((resolve, reject) => { | |
| // Si les éléments sont déjà présents, résoudre immédiatement | |
| if (document.querySelector('.js-reviews-container')) { | |
| resolve(); | |
| return; | |
| } | |
| // Sinon, observer les mutations jusqu'à ce que les éléments apparaissent | |
| const fileListObserver = new MutationObserver((mutations, observer) => { | |
| if (document.querySelector('.js-reviews-container')) { | |
| observer.disconnect(); | |
| resolve(); | |
| } | |
| }); | |
| fileListObserver.observe(document.body, { | |
| childList: true, | |
| subtree: true | |
| }); | |
| // Définir un timeout de sécurité | |
| setTimeout(() => { | |
| fileListObserver.disconnect(); | |
| reject(new Error('Timeout: éléments de liste de fichiers non trouvés')); | |
| }, 10000); // 10 secondes maximum | |
| }); | |
| } | |
| // Injection du bouton "Mark as viewed" | |
| function injectMarkAsViewed() { | |
| // Vérifie si le bouton est déjà injecté | |
| if (document.querySelector('#mark-as-viewed-details')) return; | |
| try { | |
| const $dropdown = createDropdown({ | |
| label: 'Mark as viewed', | |
| title: 'Select an option', | |
| buttonClassNames: ['mr-3'], | |
| buttonStyle: 'float: left;', | |
| items: [ | |
| { | |
| title: 'Matching pattern', | |
| subtitle: 'Mark as viewed all files whose filename match the pattern (string based)', | |
| onClick: markFilesMatchingPattern | |
| }, | |
| { | |
| title: 'Renamed files', | |
| subtitle: 'Mark all renamed files as viewed', | |
| onClick: markRenamedFiles | |
| }, | |
| { | |
| title: 'Deleted files', | |
| subtitle: 'Mark all deleted files as viewed', | |
| onClick: markDeletedFiles | |
| }, | |
| { | |
| title: 'Reset', | |
| subtitle: 'Reset all viewed files', | |
| onClick: resetAllViewed | |
| }, | |
| { | |
| title: 'Reset matching pattern', | |
| subtitle: 'Reset all viewed files whose filename match the pattern', | |
| onClick: resetFilesMatchingPattern | |
| } | |
| ] | |
| }); | |
| // Injecte le dropdown avant la barre de diff si elle est présente | |
| const $diffBar = document.querySelector('.diffbar-item.dropdown.js-reviews-container'); | |
| if ($diffBar) { | |
| $diffBar.before($dropdown); | |
| } | |
| } catch (error) { | |
| console.error('Erreur lors de la création du dropdown:', error); | |
| } | |
| } | |
| // Actions du dropdown | |
| function markFilesMatchingPattern() { | |
| try { | |
| const query = window.prompt('What is the pattern to match? (string based)'); | |
| if (!query) return; | |
| // Échapper les caractères spéciaux dans la requête | |
| const safeQuery = escapeHtml(query); | |
| toggleCheckboxesForSelector(`.file-header[data-path*="${safeQuery}"]`, true); | |
| } catch (error) { | |
| console.error('Erreur lors du marquage par pattern:', error); | |
| } | |
| } | |
| function markRenamedFiles() { | |
| try { | |
| const renamedFileContents = Array.from(document.querySelectorAll('.file-header + .js-file-content')) | |
| .filter($el => { | |
| const $data = $el.querySelector('.data'); | |
| return $data && $data.innerText.replace(/\s/g, '') === 'Filerenamedwithoutchanges.'; | |
| }) | |
| .map($el => $el.previousElementSibling); | |
| renamedFileContents.forEach($el => { | |
| toggleCheckbox($el.querySelector('.js-reviewed-checkbox'), true); | |
| }); | |
| } catch (error) { | |
| console.error('Erreur lors du marquage des fichiers renommés:', error); | |
| } | |
| } | |
| function markDeletedFiles() { | |
| try { | |
| toggleCheckboxesForSelector('.file-header[data-file-deleted="true"]', true); | |
| } catch (error) { | |
| console.error('Erreur lors du marquage des fichiers supprimés:', error); | |
| } | |
| } | |
| function resetAllViewed() { | |
| try { | |
| toggleCheckboxesForSelector('.js-reviewed-checkbox:checked', false); | |
| } catch (error) { | |
| console.error('Erreur lors de la réinitialisation:', error); | |
| } | |
| } | |
| function resetFilesMatchingPattern() { | |
| try { | |
| const query = window.prompt('What is the pattern to match? (string based)'); | |
| if (!query) return; | |
| // Échapper les caractères spéciaux dans la requête | |
| const safeQuery = escapeHtml(query); | |
| const fileHeaders = document.querySelectorAll(`.file-header[data-path*="${safeQuery}"]`); | |
| fileHeaders.forEach($el => { | |
| const $checkbox = $el.querySelector('.js-reviewed-checkbox'); | |
| if ($checkbox && $checkbox.checked) { | |
| $checkbox.click(); | |
| } | |
| }); | |
| } catch (error) { | |
| console.error('Erreur lors de la réinitialisation par pattern:', error); | |
| } | |
| } | |
| // Fonction utilitaire pour basculer l'état des checkboxes | |
| function toggleCheckboxesForSelector(selector, shouldBeChecked) { | |
| const elements = document.querySelectorAll(selector); | |
| elements.forEach($el => { | |
| if ($el.classList && $el.classList.contains('js-reviewed-checkbox')) { | |
| toggleCheckbox($el, shouldBeChecked); | |
| } else { | |
| const $checkbox = $el.querySelector('.js-reviewed-checkbox'); | |
| if ($checkbox) { | |
| toggleCheckbox($checkbox, shouldBeChecked); | |
| } | |
| } | |
| }); | |
| } | |
| function toggleCheckbox(checkbox, shouldBeChecked) { | |
| if (checkbox && checkbox.checked !== shouldBeChecked) { | |
| checkbox.click(); | |
| } | |
| } | |
| // Fonction de création du dropdown | |
| function createDropdown({ label, title, buttonClassNames = [], buttonStyle = '', items }) { | |
| const containerId = `${slugify(label)}-details`; | |
| const buttonToRegister = []; | |
| const htmlString = ` | |
| <details class="details-reset details-overlay f5 position-relative ${buttonClassNames.join(' ')}" style="${buttonStyle}" id="${containerId}"> | |
| <summary class="btn-sm btn" aria-haspopup="menu" role="button"> | |
| <span>${escapeHtml(label)} </span> | |
| <span class="dropdown-caret"></span> | |
| </summary> | |
| <details-menu class="SelectMenu" role="menu"> | |
| <div class="SelectMenu-modal notifications-component-menu-modal"> | |
| <header class="SelectMenu-header"> | |
| <h3 class="SelectMenu-title">${escapeHtml(title)}</h3> | |
| </header> | |
| <div class="SelectMenu-list"> | |
| ${items.map((item) => { | |
| const buttonId = `${slugify(item.title)}-details__button`; | |
| buttonToRegister.push({ id: buttonId, onClick: item.onClick }); | |
| return `<button class="SelectMenu-item flex-items-start" id="${buttonId}"> | |
| <div> | |
| <div class="f5 text-bold">${escapeHtml(item.title)}</div> | |
| <div class="text-small color-fg-muted text-normal pb-1">${escapeHtml(item.subtitle)}</div> | |
| </div> | |
| </button>`; | |
| }).join('')} | |
| </div> | |
| </div> | |
| </details-menu> | |
| </details> | |
| `; | |
| const $dropdown = stringToHTML(htmlString); | |
| buttonToRegister.forEach(({ id, onClick }) => { | |
| const button = $dropdown.querySelector(`#${id}`); | |
| if (button) { | |
| button.addEventListener('click', () => { | |
| $dropdown.removeAttribute('open'); | |
| onClick(); | |
| }); | |
| } | |
| }); | |
| return $dropdown; | |
| } | |
| // Fonction pour convertir une chaîne en slug | |
| function slugify(text) { | |
| return text.toString() | |
| .normalize('NFKD') | |
| .toLowerCase() | |
| .trim() | |
| .replace(/\s+/g, '-') | |
| .replace(/[^\w\-]+/g, '') | |
| .replace(/\_/g, '-') | |
| .replace(/\-\-+/g, '-') | |
| .replace(/\-$/g, ''); | |
| } | |
| // Fonction optimisée pour convertir une chaîne HTML en élément DOM | |
| function stringToHTML(str) { | |
| const template = document.createElement('template'); | |
| template.innerHTML = str.trim(); | |
| return template.content.firstChild; | |
| } | |
| // Fonction pour échapper les caractères HTML spéciaux | |
| function escapeHtml(str) { | |
| if (!str) return ''; | |
| return str | |
| .replace(/&/g, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>') | |
| .replace(/"/g, '"') | |
| .replace(/'/g, '''); | |
| } | |
| // Initialisation | |
| function init() { | |
| try { | |
| observeUrlChanges(); | |
| handlePathChange(); // Injection initiale si la page est déjà sur /files | |
| } catch (error) { | |
| console.error('Erreur lors de l\'initialisation du script:', error); | |
| } | |
| } | |
| // Lancement seulement quand le DOM est complètement chargé | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', init); | |
| } else { | |
| init(); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment