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