Skip to content

Instantly share code, notes, and snippets.

@fgilio
Created September 2, 2024 11:41
Show Gist options
  • Save fgilio/fa2ac736c9453f40551e199343f42c3e to your computer and use it in GitHub Desktop.
Save fgilio/fa2ac736c9453f40551e199343f42c3e to your computer and use it in GitHub Desktop.
GitLab MR "Expand and Collapse all"
.gitlab-mreca-top-0 {
top: 0 !important;
}
.gitlab-mreca-mr-version-controls-sticky {
position: sticky;
top: 72px;
z-index: 999;
padding-bottom: 3px;
background-color: white;
}
/**
* GitLab MR "Expand and Collapse all" Extension Script
*
* This script enhances the GitLab interface by adding a "Collapse all" button
* next to the "Expand all" button in merge request views. Additionally,
* it changes the "Expand all files" button text to "Expand all".
*
* GitLab has not provided this functionality for at least the last 10 years (as of 2024.09.02),
* leaving users to manually collapse file contents after expanding them. This script automates
* that process, improving workflow efficiency.
*
* The script observes the DOM to detect when relevant elements are loaded, since GitLab renders
* the page dynamically. It uses MutationObservers to identify when these elements appear and
* modifies them accordingly.
*
* Note: This script is designed for use as an Arc Browser boost, but should
* be compatible with Tampermonkey or similar browser extensions.
*/
// Function to log debug information
const logDebug = (message) => {
console.log(`[GitLab Extension Debug] ${message}`);
};
// Function to modify the "Expand all files" span text
const modifyExpandAllText = (buttonElement) => {
logDebug("Attempting to modify the text of the 'Expand all files' button.");
const expandAllSpan = Array.from(buttonElement.querySelectorAll('span'))
.find(span => span.textContent.trim() === "Expand all files");
if (expandAllSpan && expandAllSpan.textContent !== "Expand all") {
expandAllSpan.textContent = "Expand all";
logDebug("Text successfully modified to 'Expand all'.");
} else {
logDebug("Span with text 'Expand all files' not found or already modified.");
}
};
// Function to add a "Collapse all" button next to the "Expand all" button
const addCollapseAllButton = (expandAllButton) => {
logDebug("Attempting to add the 'Collapse all' button.");
if (expandAllButton.nextElementSibling && expandAllButton.nextElementSibling.textContent === "Collapse all") {
logDebug("'Collapse all' button already exists.");
return;
}
const collapseButton = document.createElement('button');
collapseButton.className = "btn gl-mr-3 btn-default btn-md gl-button";
collapseButton.textContent = "Collapse all";
collapseButton.addEventListener('click', () => {
logDebug("'Collapse all' button clicked.");
const collapseAllFiles = () => {
const hideFileElements = document.querySelectorAll('[aria-label="Hide file contents"]');
hideFileElements.forEach(element => element.click());
logDebug(`Clicked ${hideFileElements.length} elements to hide file contents.`);
if (hideFileElements.length > 0) {
setTimeout(collapseAllFiles, 50);
}
};
collapseAllFiles();
});
expandAllButton.parentElement.insertBefore(collapseButton, expandAllButton.nextSibling);
logDebug("'Collapse all' button successfully added.");
};
// Debounce function to limit the rate of function execution
const debounce = (func, wait) => {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
};
// Function to observe the mr-version-controls div and the "Expand all" button
const observeElements = () => {
logDebug("Starting observation for mr-version-controls div and 'Expand all' button.");
const observer = new MutationObserver(debounce((mutationsList) => {
const mrVersionControls = document.querySelector('.mr-version-controls');
if (mrVersionControls) {
logDebug("mr-version-controls div found.");
const expandAllButton = Array.from(mrVersionControls.querySelectorAll('button'))
.find(button => button.textContent.trim().includes("Expand all"));
if (expandAllButton) {
logDebug("'Expand all' button found.");
modifyExpandAllText(expandAllButton);
addCollapseAllButton(expandAllButton);
observer.disconnect(); // Stop observing after modifications
logDebug("Observer disconnected after modifications.");
}
}
}, 100));
observer.observe(document.body, { childList: true, subtree: true });
};
// Function to toggle classes based on the visibility of the issue-sticky-header
const toggleStickyHeaderClasses = (isVisible) => {
const topBarFixed = document.querySelector('div.top-bar-fixed.container-fluid');
const contentWrapper = document.querySelector('div.content-wrapper');
const issueStickyHeader = document.querySelector('div.issue-sticky-header.merge-request-sticky-header');
const mrVersionControls = document.querySelector('div.mr-version-controls');
const diffsTabPane = document.querySelector('div.diffs.tab-pane.active');
if (!topBarFixed || !contentWrapper || !issueStickyHeader || !mrVersionControls || !diffsTabPane) {
logDebug("One or more elements not found, skipping toggleStickyHeaderClasses.");
return;
}
if (isVisible) {
topBarFixed.classList.add('gl-invisible');
contentWrapper.classList.add('pt-0');
issueStickyHeader.classList.add('gitlab-mreca-top-0');
mrVersionControls.classList.add('gitlab-mreca-mr-version-controls-sticky');
diffsTabPane.classList.add('pt-8');
} else {
topBarFixed.classList.remove('gl-invisible');
contentWrapper.classList.remove('pt-0');
issueStickyHeader.classList.remove('gitlab-mreca-top-0');
mrVersionControls.classList.remove('gitlab-mreca-mr-version-controls-sticky');
diffsTabPane.classList.remove('pt-8');
}
};
// Function to observe the issue-sticky-header element
const observeStickyHeader = () => {
logDebug("Starting observation for issue-sticky-header visibility changes.");
const observer = new MutationObserver(debounce((mutationsList) => {
const stickyHeader = document.querySelector('div.merge-request div.issue-sticky-header');
if (stickyHeader) {
const isVisible = !stickyHeader.classList.contains('gl-invisible');
toggleStickyHeaderClasses(isVisible);
}
}, 100));
const targetNode = document.querySelector('div.merge-request');
if (targetNode) {
observer.observe(targetNode, { attributes: true, subtree: true, attributeFilter: ['class'] });
}
};
const inMrPage = window.location.pathname.includes('merge_requests');
// Start the initial observer when the DOM content is loaded
document.addEventListener('DOMContentLoaded', () => {
logDebug("DOM fully loaded, initializing observer for mr-version-controls and 'Expand all' button.");
if (inMrPage) {
observeElements();
observeStickyHeader();
}
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment