GitHub — search star lists

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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();
})();