GitHub — search star lists

Adds a filter field to the "Add this repository to a list" dialog so you can find lists without scrolling.

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

// ==UserScript==
// @name         GitHub — search star lists
// @namespace    https://github.com/leocaseiro/leocaseiro-userscripts
// @version      1.0.0
// @description  Adds a filter field to the "Add this repository to a list" dialog so you can find lists without scrolling.
// @author       leocaseiro
// @homepageURL  https://github.com/leocaseiro/leocaseiro-userscripts/tree/main/scripts/github-star-list-search
// @supportURL   https://github.com/leocaseiro/leocaseiro-userscripts/issues
// @match        https://github.com/*
// @match        https://gist.github.com/*
// @icon         https://github.githubassets.com/favicons/favicon.svg
// @run-at       document-idle
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    const WRAP_ATTR = 'data-gh-star-list-search';
    const INPUT_ATTR = 'data-gh-star-list-search-input';
    const DEBOUNCE_MS = 200;

    const listObservers = new WeakMap();
    const lifecycleAttached = new WeakSet();

    function debounce(fn, delay) {
        let timer = null;
        return (...args) => {
            if (timer) clearTimeout(timer);
            timer = setTimeout(() => fn(...args), delay);
        };
    }

    /**
     * Star-list picker dialog: id ends with -starred-dialog (repo id is in the middle).
     */
    function isStarListDialog(dialog) {
        if (!(dialog instanceof HTMLDialogElement) || !dialog.open) return false;
        if (!dialog.id || !dialog.id.endsWith('-starred-dialog')) return false;
        const title = dialog.querySelector('h1.Overlay-title');
        return title?.textContent?.trim() === 'Lists';
    }

    function getListRoot(dialog) {
        return dialog.querySelector('ul.ActionListWrap[role="listbox"]');
    }

    function filterListItems(dialog, rawTerm) {
        const ul = getListRoot(dialog);
        if (!ul) return;

        const term = rawTerm.trim().toLowerCase();
        ul.querySelectorAll('li.ActionListItem').forEach((li) => {
            const label = li.querySelector('.ActionListItem-label');
            const text = (label?.textContent ?? '').trim().toLowerCase();
            const show = !term || text.includes(term);
            li.hidden = !show;
        });
    }

    function disconnectListObserver(dialog) {
        const obs = listObservers.get(dialog);
        if (obs) {
            obs.disconnect();
            listObservers.delete(dialog);
        }
    }

    function ensureListMutationObserver(dialog, input) {
        const ul = getListRoot(dialog);
        if (!ul || listObservers.has(dialog)) return;

        const obs = new MutationObserver(() => {
            filterListItems(dialog, input.value);
        });
        obs.observe(ul, { childList: true });
        listObservers.set(dialog, obs);
    }

    function createSearchUI(dialog) {
        const overlayBody = dialog.querySelector('.Overlay-body');
        if (!overlayBody) return null;

        const wrap = document.createElement('div');
        wrap.setAttribute(WRAP_ATTR, '');
        wrap.className = 'px-3 py-2 border-bottom';

        const label = document.createElement('label');
        label.className = 'sr-only';
        label.textContent = 'Filter lists';

        const input = document.createElement('input');
        input.type = 'search';
        input.id = `gh-star-list-search__${dialog.id}`;
        input.setAttribute(INPUT_ATTR, '');
        input.className = 'form-control input-contrast width-full';
        input.setAttribute('autocomplete', 'off');
        input.setAttribute('spellcheck', 'false');
        input.placeholder = 'Search lists…';

        const runFilter = debounce((value) => {
            filterListItems(dialog, value);
        }, DEBOUNCE_MS);

        // Avoid the overlay / focus-group swallowing clicks (same idea as the YouTube reference).
        input.addEventListener('click', (e) => e.stopPropagation());
        input.addEventListener('mousedown', (e) => e.stopPropagation());
        input.addEventListener('input', (e) => {
            e.stopPropagation();
            runFilter(e.target.value);
        });

        label.setAttribute('for', input.id);
        wrap.appendChild(label);
        wrap.appendChild(input);
        overlayBody.insertBefore(wrap, overlayBody.firstChild);

        return wrap;
    }

    function ensureDialogLifecycle(dialog) {
        if (lifecycleAttached.has(dialog)) return;
        lifecycleAttached.add(dialog);
        dialog.addEventListener(
            'close',
            () => {
                disconnectListObserver(dialog);
                delete dialog.dataset.ghStarListSearchSession;
            },
            { passive: true }
        );
    }

    function enhanceDialog(dialog) {
        ensureDialogLifecycle(dialog);

        let wrap = dialog.querySelector(`[${WRAP_ATTR}]`);
        if (!wrap) {
            wrap = createSearchUI(dialog);
        }
        if (!wrap) return;

        const input = wrap.querySelector(`input[${INPUT_ATTR}]`);
        if (!input) return;

        // Only reset/focus once per dialog open so DOM updates do not steal focus.
        if (dialog.dataset.ghStarListSearchSession === '1') {
            ensureListMutationObserver(dialog, input);
            return;
        }
        dialog.dataset.ghStarListSearchSession = '1';

        input.value = '';
        filterListItems(dialog, '');
        ensureListMutationObserver(dialog, input);

        requestAnimationFrame(() => {
            input.focus({ preventScroll: true });
        });
    }

    function scanForOpenDialog() {
        document.querySelectorAll('dialog[id$="-starred-dialog"]').forEach((dialog) => {
            if (isStarListDialog(dialog)) {
                enhanceDialog(dialog);
            }
        });
    }

    const mo = new MutationObserver(() => {
        scanForOpenDialog();
    });

    mo.observe(document.documentElement, {
        childList: true,
        subtree: true,
        attributes: true,
        attributeFilter: ['open'],
    });

    scanForOpenDialog();
})();