Skip to content

Instantly share code, notes, and snippets.

@mathiasdotdev
Created October 30, 2025 09:42
Show Gist options
  • Save mathiasdotdev/a7e6d57cfda9a80a19c2916bcc4fe397 to your computer and use it in GitHub Desktop.
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.
// 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)}&nbsp;</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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
// 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