GitHub — search star lists

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

Bu betiği kurabilmeniz için Tampermonkey, Greasemonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği yüklemek için Tampermonkey gibi bir uzantı yüklemeniz gerekir.

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Userscripts gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği indirebilmeniz için ayrıca Tampermonkey gibi bir eklenti kurmanız gerekmektedir.

Bu komut dosyasını yüklemek için bir kullanıcı komut dosyası yöneticisi uzantısı yüklemeniz gerekecek.

(Zaten bir kullanıcı komut dosyası yöneticim var, kurmama izin verin!)

Bu stili yüklemek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için Stylus gibi bir uzantı kurmanız gerekir.

Bu stili yükleyebilmek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı kurmanız gerekir.

Bu stili yükleyebilmek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

(Zateb bir user-style yöneticim var, yükleyeyim!)

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