Last active
October 7, 2026 13:07
-
-
Save Gabrielcarvfer/bbd3e4826148934449d4e963e1ea79d3 to your computer and use it in GitHub Desktop.
GitLab: Open/Closed items instead of Work items
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
| // ==UserScript== | |
| // @name GitLab: Open/Closed items instead of Work items | |
| // @namespace gitlab-open-closed-items | |
| // @version 1.2.1 | |
| // @description Replaces "Work items" in the left sidebar with "Open issues" and "Closed issues" links | |
| // @match https://gitlab.com/* | |
| // @match https://gitlab.example.org/* | |
| // @run-at document-idle | |
| // @grant none | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| /* ====================================================================== | |
| * SETTINGS | |
| * ==================================================================== */ | |
| // Which list the new buttons open. '/-/issues' = classic issue list, | |
| // '/-/work_items' = the work items list, just pre-filtered by state. | |
| const LIST_PATH = '/-/issues'; | |
| // count: show that state's issue count in the badge (false = no badge) | |
| const BUTTONS = [ | |
| { label: 'Open issues', state: 'opened', count: true }, | |
| { label: 'Closed issues', state: 'closed', count: true }, | |
| ]; | |
| const DEBUG = false; | |
| /* ====================================================================== */ | |
| const MARK = 'data-oci-clone'; | |
| const HIDDEN = 'data-oci-hidden'; | |
| const WORK_ITEMS_RE = /\/-\/work_items\/?(?:[?#].*)?$/; | |
| const log = (...a) => DEBUG && console.info('[open-closed-items]', ...a); | |
| // original sidebar item (li) -> { clones: [li, li], urls: [URL, URL] } | |
| const registry = new Map(); | |
| function findWorkItemsLinks() { | |
| const root = document.getElementById('super-sidebar') || document; | |
| return [...root.querySelectorAll('a[href]')].filter((a) => { | |
| if (a.closest(`[${MARK}]`)) return false; | |
| return WORK_ITEMS_RE.test(a.getAttribute('href')); | |
| }); | |
| } | |
| function setLabel(a, text) { | |
| const walker = document.createTreeWalker(a, NodeFilter.SHOW_TEXT); | |
| let done = false; | |
| for (let n; (n = walker.nextNode()); ) { | |
| if (/work items/i.test(n.data)) { | |
| n.data = n.data.replace(/work items/i, text); | |
| done = true; | |
| } | |
| } | |
| if (!done) { | |
| const label = a.querySelector('[data-testid="nav-item-link-label"]'); | |
| if (label) label.textContent = text; | |
| } | |
| for (const el of [a, ...a.querySelectorAll('[title], [aria-label], [data-qa-submenu-item]')]) { | |
| if (el.hasAttribute('title')) el.setAttribute('title', text); | |
| if (el.hasAttribute('aria-label')) el.setAttribute('aria-label', text); | |
| if (el.hasAttribute('data-qa-submenu-item')) el.setAttribute('data-qa-submenu-item', text); | |
| } | |
| } | |
| const BADGE_SEL = '[data-testid="pill-badge"], .nav-item-link-badge, .badge, .gl-badge'; | |
| function removeBadge(a) { | |
| a.querySelectorAll(BADGE_SEL).forEach((b) => b.remove()); | |
| } | |
| function setBadge(a, text) { | |
| let badge = a.querySelector(BADGE_SEL); | |
| if (!badge) { | |
| badge = document.createElement('span'); | |
| badge.className = 'nav-item-link-badge gl-mr-3 gl-text-sm'; | |
| badge.setAttribute('data-testid', 'pill-badge'); | |
| const slot = document.createElement('span'); | |
| slot.className = 'gl-nav-item-slot'; | |
| slot.setAttribute('data-testid', 'nav-item-end'); | |
| slot.appendChild(badge); | |
| a.appendChild(slot); | |
| } | |
| badge.textContent = text; | |
| } | |
| // 1234 -> "1.2k", like GitLab's own badges | |
| function formatCount(n) { | |
| if (n < 1000) return String(n); | |
| if (n < 1e6) return `${+(n / 1000).toFixed(1)}k`; | |
| return `${+(n / 1e6).toFixed(1)}m`; | |
| } | |
| /* ---------- issue counts (GraphQL, one request per project/group per page) ---------- */ | |
| const countCache = new Map(); // "kind:path:state" -> Promise<number | null> | |
| // "/group/sub/project/-/work_items" -> { kind: 'project', path } | |
| // "/groups/g/sub/-/work_items" -> { kind: 'group', path } | |
| function namespaceOf(workItemsHref) { | |
| const root = (window.gon && gon.relative_url_root) || ''; | |
| let path = new URL(workItemsHref, location.origin).pathname.replace(/\/-\/work_items\/?$/, ''); | |
| if (root && path.startsWith(root)) path = path.slice(root.length); | |
| const m = path.match(/^\/groups\/(.+)$/); | |
| return m ? { kind: 'group', path: m[1] } : { kind: 'project', path: path.replace(/^\//, '') }; | |
| } | |
| function getCount(workItemsHref, state) { | |
| const { kind, path } = namespaceOf(workItemsHref); | |
| const key = `${kind}:${path}:${state}`; | |
| if (!countCache.has(key)) { | |
| const sub = kind === 'group' ? ', includeSubgroups: true' : ''; | |
| const query = `query($path: ID!) { ns: ${kind}(fullPath: $path) { issues(state: ${state}${sub}) { count } } }`; | |
| const root = (window.gon && gon.relative_url_root) || ''; | |
| const csrf = document.querySelector('meta[name="csrf-token"]'); | |
| countCache.set(key, fetch(`${root}/api/graphql`, { | |
| method: 'POST', | |
| credentials: 'same-origin', | |
| headers: { 'Content-Type': 'application/json', ...(csrf ? { 'X-CSRF-Token': csrf.content } : {}) }, | |
| body: JSON.stringify({ query, variables: { path } }), | |
| }) | |
| .then((r) => (r.ok ? r.json() : null)) | |
| .then((j) => (j && j.data && j.data.ns && j.data.ns.issues ? j.data.ns.issues.count : null)) | |
| .catch(() => null)); | |
| } | |
| return countCache.get(key); | |
| } | |
| // Active state: GitLab marks the current item with aria-current and | |
| // "...-current"/"...-active" classes. Only those are touched. | |
| const ACTIVE_CLASS_RE = /(^|-)(current|active)$/i; | |
| function setActive(a, active) { | |
| if (active) { | |
| a.setAttribute('aria-current', 'page'); | |
| return; | |
| } | |
| a.removeAttribute('aria-current'); | |
| for (const el of [a, ...a.querySelectorAll('[class]')]) { | |
| for (const c of [...el.classList]) if (ACTIVE_CLASS_RE.test(c)) el.classList.remove(c); | |
| } | |
| } | |
| function buildUrl(workItemsHref, state) { | |
| const url = new URL(workItemsHref, location.origin); | |
| url.pathname = url.pathname.replace(/\/-\/work_items\/?$/, LIST_PATH); | |
| url.search = ''; | |
| url.hash = ''; | |
| url.searchParams.set('state', state); | |
| return url; | |
| } | |
| function isCurrent(url) { | |
| const here = new URL(location.href); | |
| const samePath = here.pathname.replace(/\/$/, '') === url.pathname.replace(/\/$/, ''); | |
| const hereState = here.searchParams.get('state') || 'opened'; | |
| return samePath && hereState === url.searchParams.get('state'); | |
| } | |
| function makeClones(item, link) { | |
| const href = link.getAttribute('href'); | |
| const clones = []; | |
| const urls = []; | |
| for (const btn of BUTTONS) { | |
| const clone = item.cloneNode(true); | |
| clone.setAttribute(MARK, btn.state); | |
| clone.removeAttribute(HIDDEN); | |
| clone.style.removeProperty('display'); | |
| clone.classList.remove('js-draggable-item'); // not part of pin reordering | |
| clone.querySelectorAll('[id]').forEach((el) => el.removeAttribute('id')); | |
| const a = clone.matches('a') ? clone : clone.querySelector('a[href]'); | |
| a.classList.remove('shortcuts-issues'); // keep GitLab's "g i" shortcut on the original | |
| const url = buildUrl(href, btn.state); | |
| a.setAttribute('href', url.pathname + url.search); | |
| setLabel(a, btn.label); | |
| setActive(a, isCurrent(url)); | |
| if (btn.count) { | |
| getCount(href, btn.state).then((n) => { | |
| if (n != null) setBadge(a, formatCount(n)); | |
| }); | |
| } else { | |
| removeBadge(a); | |
| } | |
| clones.push(clone); | |
| urls.push(url); | |
| } | |
| return { clones, urls }; | |
| } | |
| /** Make sure every original is hidden and followed by its clones, in order. */ | |
| function ensure() { | |
| // forget originals Vue threw away | |
| for (const [item, entry] of registry) { | |
| if (!item.isConnected) { | |
| entry.clones.forEach((c) => c.remove()); | |
| registry.delete(item); | |
| } | |
| } | |
| // register new originals | |
| for (const link of findWorkItemsLinks()) { | |
| const item = link.closest('li') || link; | |
| if (registry.has(item)) continue; | |
| registry.set(item, makeClones(item, link)); | |
| log('replaced', link.getAttribute('href')); | |
| } | |
| // enforce placement / visibility (cheap no-op when already right) | |
| for (const [item, { clones, urls }] of registry) { | |
| if (item.style.display !== 'none') item.style.display = 'none'; | |
| if (!item.hasAttribute(HIDDEN)) item.setAttribute(HIDDEN, ''); | |
| let prev = item; | |
| clones.forEach((clone, i) => { | |
| if (prev.nextElementSibling !== clone) prev.after(clone); | |
| const a = clone.matches('a') ? clone : clone.querySelector('a[href]'); | |
| const cur = isCurrent(urls[i]); | |
| if (cur !== (a.getAttribute('aria-current') === 'page')) setActive(a, cur); | |
| prev = clone; | |
| }); | |
| } | |
| } | |
| let pending = false; | |
| new MutationObserver(() => { | |
| if (pending) return; | |
| pending = true; | |
| requestAnimationFrame(() => { | |
| pending = false; | |
| ensure(); | |
| }); | |
| }).observe(document.documentElement, { childList: true, subtree: true }); | |
| ensure(); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment