GitHub — search star lists

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

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey, Greasemonkey или Violentmonkey.

Для установки этого скрипта вам необходимо установить расширение, такое как Tampermonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Userscripts.

Чтобы установить этот скрипт, сначала вы должны установить расширение браузера, например Tampermonkey.

Чтобы установить этот скрипт, вы должны установить расширение — менеджер скриптов.

(у меня уже есть менеджер скриптов, дайте мне установить скрипт!)

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

(у меня уже есть менеджер стилей, дайте мне установить скрипт!)

// ==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();
})();