GitHub Advanced Search

Advanced filter modal for GitHub search

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         GitHub Advanced Search 
// @namespace    https://github.com/quantavil/userscript
// @version      4.2
// @description  Advanced filter modal for GitHub search 
// @match        https://github.com/*
// @license      MIT
// @icon         https://github.githubassets.com/favicons/favicon.svg
// @grant        GM_registerMenuCommand
// ==/UserScript==

(function () {
    'use strict';

    /* =========================================================================
       CONSTANTS & CONFIG
       ========================================================================= */
    const CONFIG = {
        ids: {
            modal: 'gh-adv-search-modal',
            style: 'gh-adv-search-style',
            toggleBtn: 'gh-adv-toggle-btn'
        },
        selectors: {
            results: '[data-testid="results-list"]',
            resultItem: '[data-testid="results-list"] > div, .repo-list-item, .Box-row',
            resultLink: '.search-title a, a[href^="/"]'
        }
    };

    const FIELDS = [
        {
            section: 'CORE',
            items: [
                {
                    id: 'type', label: 'TYPE', type: 'select', options: [
                        { v: 'repositories', l: 'Repositories' },
                        { v: 'code', l: 'Code' },
                        { v: 'issues', l: 'Issues' },
                        { v: 'pullrequests', l: 'Pull Requests' },
                        { v: 'discussions', l: 'Discussions' },
                        { v: 'users', l: 'Users' }
                    ]
                },
                {
                    id: 'sort', label: 'SORT', type: 'select', options: [
                        { v: '', l: 'Best Match' },
                        { v: 'stars', l: 'Most Stars' },
                        { v: 'forks', l: 'Most Forks' },
                        { v: 'updated', l: 'Recently Updated' }
                    ]
                }
            ]
        },
        {
            section: 'LOGIC',
            items: [
                { id: 'and', label: 'AND', placeholder: 'rust async', type: 'text' },
                { id: 'or', label: 'OR', placeholder: 'react, vue', type: 'text' },
            ]
        },
        {
            section: 'META',
            items: [
                { id: 'user', label: 'Owner', placeholder: 'facebook', meta: 'user' },
                { id: 'repo', label: 'Repository', placeholder: 'react', meta: 'repo' },
                { id: 'lang', label: 'Language', placeholder: 'python', meta: 'language' },
                { id: 'ext', label: 'Extension', placeholder: 'md', meta: 'extension' },
                { id: 'path', label: 'Path', placeholder: 'src/', meta: 'path' },
                { id: 'stars', label: 'Stars', placeholder: '>500', meta: 'stars' },
                { id: 'forks', label: 'Forks', placeholder: '>100', meta: 'forks' },
                { id: 'size', label: 'Size (KB)', placeholder: '>1000', meta: 'size' },
                { id: 'created', label: 'Created', placeholder: '>2023-01', meta: 'created' },
                { id: 'pushed', label: 'Pushed', placeholder: '>2024-01-01', meta: 'pushed' }
            ]
        }
    ];

    /* =========================================================================
       THEME & STYLES — MIMIMAL BRUTAL
       ========================================================================= */
    function injectStyles() {
        if (document.getElementById(CONFIG.ids.style)) return;

        const css = `
            /* Light theme by default */
            :root {
                --brutal-bg: #ffffff;
                --brutal-fg: #1a1a1a;
                --brutal-accent: #e11d48;
                --brutal-border: #1a1a1a;
                --brutal-muted: #6b7280;
                --brutal-shadow: 4px 4px 0 #1a1a1a;
                --brutal-font: system-ui, -apple-system, sans-serif;
            }

            /* --- Floating Toggle Button --- */
            #${CONFIG.ids.toggleBtn} {
                position: fixed;
                top: 50%;
                right: 0;
                transform: translateY(-50%);
                width: 28px;
                height: 44px;
                background: var(--brutal-fg);
                border: none;
                border-radius: 6px 0 0 6px;
                cursor: pointer;
                z-index: 9997;
                display: flex;
                align-items: center;
                justify-content: center;
                opacity: 0.25;
                transition: opacity 0.15s, width 0.15s;
            }
            #${CONFIG.ids.toggleBtn}:hover,
            #${CONFIG.ids.toggleBtn}:focus {
                opacity: 1;
                width: 36px;
            }
            #${CONFIG.ids.toggleBtn} svg {
                width: 16px;
                height: 16px;
                fill: var(--brutal-bg);
            }
            @media (hover: none) {
                #${CONFIG.ids.toggleBtn} {
                    opacity: 0.5;
                }
            }

            /* --- Modal Panel (Right Side) --- */
            #${CONFIG.ids.modal} {
                position: fixed;
                top: 60px;
                right: 16px;
                bottom: auto;
                left: auto;
                transform: none;
                width: 420px;
                max-width: calc(100vw - 32px);
                max-height: calc(100vh - 80px);
                background: var(--brutal-bg);
                border: 2px solid var(--brutal-border);
                box-shadow: var(--brutal-shadow);
                z-index: 9999;
                display: none;
                flex-direction: column;
                font-family: var(--brutal-font);
                font-size: 13px;
            }

            #${CONFIG.ids.modal}[data-visible="true"] {
                display: flex;
                animation: brutal-slide 0.12s ease-out;
            }

            @keyframes brutal-slide {
                from { opacity: 0; transform: translateX(8px); }
                to { opacity: 1; transform: translateX(0); }
            }

            .brutal-header {
                background: var(--brutal-border);
                color: var(--brutal-bg);
                padding: 8px 12px;
                font-weight: 700;
                text-transform: uppercase;
                letter-spacing: 1px;
                display: flex;
                justify-content: space-between;
                align-items: center;
                font-size: 11px;
            }

            .brutal-close {
                cursor: pointer;
                font-size: 18px;
                line-height: 1;
                opacity: 0.7;
                font-weight: 400;
            }
            .brutal-close:hover { opacity: 1; }

            .brutal-body {
                padding: 12px;
                overflow-y: auto;
                scrollbar-width: none;
                -ms-overflow-style: none;
            }
            .brutal-body::-webkit-scrollbar { display: none; }

            .brutal-section {
                margin-bottom: 12px;
            }
            .brutal-section:last-child { margin-bottom: 0; }

            .brutal-section-title {
                font-weight: 700;
                font-size: 9px;
                text-transform: uppercase;
                letter-spacing: 1.5px;
                color: var(--brutal-muted);
                margin-bottom: 8px;
                padding-bottom: 4px;
                border-bottom: 1px solid var(--brutal-border);
            }

            .brutal-grid {
                display: grid;
                grid-template-columns: 1fr 1fr;
                gap: 8px;
            }
            .brutal-grid.full { grid-template-columns: 1fr; }

            .brutal-field label {
                display: block;
                font-size: 9px;
                font-weight: 700;
                text-transform: uppercase;
                letter-spacing: 0.5px;
                margin-bottom: 3px;
                color: var(--brutal-fg);
            }

            .brutal-input {
                width: 100%;
                background: var(--brutal-bg);
                border: 2px solid var(--brutal-border);
                padding: 6px 8px;
                color: var(--brutal-fg);
                font-family: inherit;
                font-size: 12px;
                box-sizing: border-box;
            }
            .brutal-input:focus {
                outline: none;
                border-color: var(--brutal-accent);
            }
            .brutal-input::placeholder {
                color: var(--brutal-muted);
            }

            .brutal-footer {
                padding: 10px 12px;
                border-top: 2px solid var(--brutal-border);
                display: flex;
                gap: 8px;
            }

            .brutal-btn {
                flex: 1;
                padding: 8px;
                border: 2px solid var(--brutal-border);
                background: var(--brutal-bg);
                font-family: inherit;
                font-weight: 700;
                font-size: 10px;
                text-transform: uppercase;
                letter-spacing: 0.5px;
                cursor: pointer;
                color: var(--brutal-fg);
                transition: transform 0.05s, box-shadow 0.05s;
            }
            .brutal-btn:hover {
                transform: translate(-2px, -2px);
                box-shadow: 4px 4px 0 var(--brutal-border);
            }
            .brutal-btn:active {
                transform: translate(0, 0);
                box-shadow: none;
            }
            .brutal-btn.primary {
                background: var(--brutal-border);
                color: var(--brutal-bg);
            }

            /* --- Release Badge (Brutal Enhanced) --- */
            .gh-release-tag {
                display: inline-flex;
                align-items: center;
                padding: 4px 8px;
                margin-top: 6px;
                font-size: 11px;
                font-family: var(--brutal-font);
                font-weight: 700;
                border: 2px solid var(--brutal-border);
                background: var(--brutal-bg);
                color: var(--brutal-fg) !important;
                text-decoration: none !important;
                box-shadow: 2px 2px 0 var(--brutal-border);
                transition: transform 0.1s;
            }
            .gh-release-tag:hover {
                transform: translate(-1px, -1px);
                box-shadow: 3px 3px 0 var(--brutal-border);
                text-decoration: none !important;
            }
            .gh-release-tag.loading { opacity: 0.5; cursor: wait; border-style: dashed; }
            
            /* Success (Has Release) - GREEN */
            .gh-release-tag.has-release {
                color: #15803d !important;
                border-color: #15803d;
                box-shadow: 2px 2px 0 #15803d;
            }
            .gh-release-tag.has-release:hover {
                background: #f0fdf4;
                box-shadow: 3px 3px 0 #15803d;
            }

            /* Failure (No Release) - RED */
            .gh-release-tag.no-release {
                color: #b91c1c !important;
                border-color: #b91c1c;
                box-shadow: 2px 2px 0 #b91c1c;
            }
            .gh-release-tag.no-release:hover {
                background: #fef2f2;
                box-shadow: 3px 3px 0 #b91c1c;
            }

            /* --- Checkbox --- */
            .brutal-check-row {
                display: flex;
                align-items: center;
                gap: 8px;
            }
            .brutal-check {
                width: 16px;
                height: 16px;
                accent-color: var(--brutal-accent);
                cursor: pointer;
                border: 2px solid var(--brutal-border);
            }
            .brutal-check-label {
                font-size: 11px;
                font-weight: 700;
                text-transform: uppercase;
                cursor: pointer;
            }

            /* --- Overlay --- */
            .brutal-overlay {
                position: fixed;
                inset: 0;
                background: rgba(0, 0, 0, 0.4);
                backdrop-filter: blur(2px);
                z-index: 9998;
                display: none;
            }
            .brutal-overlay[data-visible="true"] { display: block; }

            /* Mobile responsive */
            @media (max-width: 480px) {
                #${CONFIG.ids.modal} {
                    right: 0;
                    left: 0;
                    bottom: 0;
                    width: 100%;
                    max-width: none;
                    top: auto;
                    max-height: 85vh;
                    border-left: none;
                    border-right: none;
                    border-bottom: none;
                }
            }
        `;

        const style = document.createElement('style');
        style.id = CONFIG.ids.style;
        style.textContent = css;
        document.head.appendChild(style);
    }

    /* =========================================================================
       LOGIC: QUERY BUILDER 
       ========================================================================= */
    class QueryBuilder {
        static clean(str) {
            if (!str) return [];
            // Parse tokens respecting quotes: matches quoted strings OR non-space/comma sequences
            const matches = str.match(/("[^"]*"|[^, ]+)/g);
            return matches ? matches : [];
        }

        static buildUrl(data) {
            const parts = [];

            const andTerms = this.clean(data.and);
            if (andTerms.length) parts.push(...andTerms);

            const orTerms = this.clean(data.or);
            if (orTerms.length) {
                parts.push(orTerms.length === 1 ? orTerms[0] : `(${orTerms.join(' OR ')})`);
            }

            data.meta.forEach(m => {
                let val = m.value.trim();
                if (!val) return;
                // Auto-add >= for specific numeric fields if missing operator
                if (['stars', 'forks', 'size'].includes(m.key)) {
                    if (!val.match(/^[<>=]/) && !val.includes('..')) val = `>=${val}`;
                }
                parts.push(`${m.key}:${val}`);
            });

            const query = encodeURIComponent(parts.join(' '));
            let url = `https://github.com/search?q=${query}&type=${data.type}`;
            if (data.sort) url += `&s=${data.sort}&o=desc`;
            if (data.releasesOnly) url += '&userscript_has_release=1';

            return url;
        }

        static parseCurrent() {
            const params = new URLSearchParams(window.location.search);
            const rawQ = params.get('q') || '';
            const type = params.get('type') || '';
            const sort = params.get('s') || '';
            const releasesOnly = params.get('userscript_has_release') === '1';

            const state = { type, sort, releasesOnly, and: '', or: '', meta: {} };
            let q = rawQ;

            // 1. Extract Meta fields (key:value) - supports quoted values
            FIELDS.find(s => s.section === 'META').items.forEach(item => {
                const regex = new RegExp(`\\b${item.meta}:("[^"]*"|\\S+)`, 'gi');
                q = q.replace(regex, (match, val) => {
                    // Strip auto >= for display
                    if (['stars', 'forks', 'size'].includes(item.meta) && val.startsWith('>=')) {
                        val = val.substring(2);
                    }
                    state.meta[item.id] = val;
                    return ''; // Remove from q
                });
            });

            // 2. Extract OR groups: (a OR b)
            const orMatch = q.match(/\(([^)]+)\)/);
            if (orMatch && orMatch[1].includes(' OR ')) {
                state.or = orMatch[1].split(' OR ').join(', ');
                q = q.replace(orMatch[0], '');
            }

            // 3. Remaining is AND
            state.and = q.replace(/\s+/g, ' ').trim();

            return state;
        }
    }

    /* =========================================================================
       LOGIC: RELEASE DETECTION
       ========================================================================= */
    const CACHE_PREFIX = 'gh-release-cache-';
    const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours

    function getReleaseCache(owner, repo) {
        try {
            const key = `${CACHE_PREFIX}${owner}-${repo}`;
            const cached = localStorage.getItem(key);
            if (!cached) return null;

            const data = JSON.parse(cached);
            if (Date.now() - data.timestamp > CACHE_TTL) {
                localStorage.removeItem(key);
                return null;
            }
            return data.info;
        } catch (e) {
            return null;
        }
    }

    function setReleaseCache(owner, repo, info) {
        try {
            const key = `${CACHE_PREFIX}${owner}-${repo}`;
            const data = {
                timestamp: Date.now(),
                info: info
            };
            localStorage.setItem(key, JSON.stringify(data));
        } catch (e) { }
    }

    function formatRelativeDate(dateStr) {
        const date = new Date(dateStr);
        const now = new Date();
        const diffMs = now - date;
        const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));

        if (diffDays === 0) return 'today';
        if (diffDays === 1) return 'yesterday';
        if (diffDays < 7) return `${diffDays}d ago`;
        if (diffDays < 30) return `${Math.floor(diffDays / 7)}w ago`;
        if (diffDays < 365) return `${Math.floor(diffDays / 30)}mo ago`;
        return `${Math.floor(diffDays / 365)}y ago`;
    }

    function createReleaseBadge(status, data = null) {
        const badge = document.createElement('a');
        badge.className = `gh-release-tag`;

        if (status === 'checking') {
            badge.textContent = 'Checking...';
            badge.classList.add('loading');
            badge.href = '#';
            badge.onclick = (e) => e.preventDefault();
        } else if (status === 'has-release' && data) {
            const dateText = data.date ? ` (${formatRelativeDate(data.date)})` : '';
            badge.textContent = `${data.tag}${dateText}`;
            badge.href = data.url;
            badge.target = '_blank';
            badge.title = data.date ? `Released: ${new Date(data.date).toLocaleDateString()}` : data.tag;
            badge.classList.add('has-release');
        } else {
            badge.textContent = 'NO RELEASE';
            badge.classList.add('no-release');
            badge.href = '#';
            badge.onclick = (e) => e.preventDefault();
        }

        return badge;
    }

    async function fetchReleaseInfo(owner, repo) {
        const cached = getReleaseCache(owner, repo);
        if (cached) return cached;

        try {
            const controller = new AbortController();
            const timeoutId = setTimeout(() => controller.abort(), 10000);

            const res = await fetch(`https://github.com/${owner}/${repo}/releases/latest`, {
                method: 'GET',
                redirect: 'follow',
                signal: controller.signal
            });
            clearTimeout(timeoutId);

            if (res.status === 404 || !res.ok) {
                setReleaseCache(owner, repo, null);
                return null;
            }

            const htmlText = await res.text();
            const parser = new DOMParser();
            const doc = parser.parseFromString(htmlText, 'text/html');

            let tag = null;
            const finalUrl = res.url;
            const tagMatch = finalUrl.match(/\/releases\/tag\/([^/?#]+)/);

            if (tagMatch) {
                tag = decodeURIComponent(tagMatch[1]);
            } else {
                const title = doc.title;
                const titleMatch = title.match(/Release (.+?) ·/);
                if (titleMatch) tag = titleMatch[1];
            }

            if (!tag) {
                const header = doc.querySelector('h1.d-inline');
                if (header) tag = header.textContent.trim();
            }

            if (!tag) return null;

            let date = null;
            const timeEl = doc.querySelector('relative-time');
            if (timeEl) {
                date = timeEl.getAttribute('datetime');
            } else {
                const anyTime = doc.querySelector('time[datetime]');
                if (anyTime) date = anyTime.getAttribute('datetime');
            }

            const info = {
                tag,
                date,
                url: `https://github.com/${owner}/${repo}/releases/tag/${encodeURIComponent(tag)}`
            };

            setReleaseCache(owner, repo, info);
            return info;

        } catch (e) {
            return null;
        }
    }

    async function processQueue(items, concurrency, task) {
        const queue = [...items];
        const workers = [];

        const worker = async () => {
            while (queue.length > 0) {
                const item = queue.shift();
                try {
                    await task(item);
                } catch (e) { }
            }
        };

        for (let i = 0; i < concurrency; i++) {
            workers.push(worker());
        }

        await Promise.all(workers);
    }

    async function processItem(item, filterOnly) {
        const link = item.querySelector(CONFIG.selectors.resultLink);
        if (!link) return;

        const path = link.getAttribute('href');
        const parts = path.split('/').filter(Boolean);
        if (parts.length < 2) return;
        const [owner, repo] = parts;

        const metaList = item.querySelector('ul');
        const insertTarget = metaList || item;

        // Container is still used to keep structure clean, but no specific asset classes needed
        const badgeContainer = document.createElement('div');
        badgeContainer.style.marginTop = '6px';

        const checkingBadge = createReleaseBadge('checking');
        badgeContainer.appendChild(checkingBadge);

        insertTarget.parentNode.insertBefore(badgeContainer, insertTarget.nextSibling);

        const releaseInfo = await fetchReleaseInfo(owner, repo);

        badgeContainer.innerHTML = '';
        if (releaseInfo) {
            const releaseBadge = createReleaseBadge('has-release', releaseInfo);
            badgeContainer.appendChild(releaseBadge);
        } else {
            if (filterOnly) {
                item.style.display = 'none';
                badgeContainer.style.display = 'none';
            } else {
                const noReleaseBadge = createReleaseBadge('no-release');
                badgeContainer.appendChild(noReleaseBadge);
            }
        }
    }

    async function processSearchResults() {
        if (!window.location.pathname.startsWith('/search')) return;

        const params = new URLSearchParams(window.location.search);
        const filterOnly = params.get('userscript_has_release') === '1';

        const allItems = Array.from(document.querySelectorAll(CONFIG.selectors.resultItem));
        const itemsToProcess = allItems.filter(item => !item.dataset.releaseProcessed);

        itemsToProcess.forEach(item => item.dataset.releaseProcessed = 'true');

        await processQueue(itemsToProcess, 3, item => processItem(item, filterOnly));
    }

    /* =========================================================================
       UI: MODAL 
       ========================================================================= */
    let modalEl = null;
    let overlayEl = null;

    function toggleModal(show) {
        if (!modalEl) createUI();
        // If show is undefined, toggle. If defined, set.
        const visible = (show === undefined) ? (modalEl.dataset.visible !== 'true') : show;

        modalEl.dataset.visible = visible;
        overlayEl.dataset.visible = visible;

        if (visible) {
            loadStateToUI();
            const firstInput = modalEl.querySelector('input, select');
            if (firstInput) firstInput.focus();
        }
    }

    function createUI() {
        if (document.getElementById(CONFIG.ids.modal)) return;

        // Overlay
        overlayEl = document.createElement('div');
        overlayEl.className = 'brutal-overlay';
        overlayEl.onclick = () => toggleModal(false);
        document.body.appendChild(overlayEl);

        // Modal
        modalEl = document.createElement('div');
        modalEl.id = CONFIG.ids.modal;

        let html = `
            <div class="brutal-header">
                <span>FILTER</span>
                <span class="brutal-close" data-close>×</span>
            </div>
            <div class="brutal-body">
        `;

        FIELDS.forEach(section => {
            html += `<div class="brutal-section">
                <div class="brutal-section-title">${section.section}</div>
                <div class="brutal-grid ${section.items.length === 1 ? 'full' : ''}">`;

            section.items.forEach(field => {
                const dangerStyle = field.danger ? 'style="color:var(--brutal-accent)"' : '';
                html += `<div class="brutal-field">
                    <label ${dangerStyle}>${field.label}</label>
                    ${field.type === 'select'
                        ? `<select id="gh-field-${field.id}" class="brutal-input">${field.options.map(o => `<option value="${o.v}">${o.l}</option>`).join('')}</select>`
                        : `<input id="gh-field-${field.id}" type="text" class="brutal-input" placeholder="${field.placeholder || ''}">`}
                </div>`;
            });
            html += `</div></div>`;
        });

        html += `
            <div class="brutal-section">
                <div class="brutal-check-row">
                    <input type="checkbox" id="gh-field-releases" class="brutal-check">
                    <label for="gh-field-releases" class="brutal-check-label">Only with releases</label>
                </div>
            </div>
        </div>
        <div class="brutal-footer">
            <button data-clear class="brutal-btn">Clear</button>
            <button data-search class="brutal-btn primary">Search</button>
        </div>`;

        modalEl.innerHTML = html;
        document.body.appendChild(modalEl);

        // Floating toggle button
        if (!document.getElementById(CONFIG.ids.toggleBtn)) {
            const toggleBtn = document.createElement('button');
            toggleBtn.id = CONFIG.ids.toggleBtn;
            toggleBtn.type = 'button';
            toggleBtn.title = 'Search Filter';
            toggleBtn.innerHTML = `<svg viewBox="0 0 16 16"><path d="M10.68 11.74a6 6 0 1 1 1.06-1.06l3.04 3.04a.75.75 0 1 1-1.06 1.06l-3.04-3.04ZM11 6.5a4.5 4.5 0 1 0-9 0 4.5 4.5 0 0 0 9 0Z"/></svg>`;
            toggleBtn.onclick = () => toggleModal();
            document.body.appendChild(toggleBtn);
        }

        // Events
        modalEl.querySelector('[data-close]').onclick = () => toggleModal(false);
        modalEl.querySelector('[data-clear]').onclick = () => {
            modalEl.querySelectorAll('input, select').forEach(el => {
                el.type === 'checkbox' ? el.checked = false : el.value = '';
            });
        };
        modalEl.querySelector('[data-search]').onclick = executeSearch;

        // Keydown
        modalEl.addEventListener('keydown', (e) => {
            if (e.key === 'Enter') executeSearch();
        });
        document.addEventListener('keydown', e => {
            if (e.key === 'Escape' && modalEl.dataset.visible === 'true') toggleModal(false);
        });
    }

    function loadStateToUI() {
        const state = QueryBuilder.parseCurrent();
        const setVal = (id, val) => {
            const el = document.getElementById(`gh-field-${id}`);
            if (el) el.value = val || '';
        };

        setVal('type', state.type);
        setVal('sort', state.sort);
        setVal('and', state.and);
        setVal('or', state.or);
        Object.entries(state.meta).forEach(([id, val]) => setVal(id, val));

        const relCheck = document.getElementById('gh-field-releases');
        if (relCheck) relCheck.checked = state.releasesOnly;
    }

    function executeSearch() {
        const getVal = id => document.getElementById(`gh-field-${id}`).value;
        const data = {
            type: getVal('type'),
            sort: getVal('sort'),
            and: getVal('and'),
            or: getVal('or'),
            meta: [],
            releasesOnly: document.getElementById('gh-field-releases').checked
        };

        FIELDS.find(s => s.section === 'META').items.forEach(item => {
            const val = getVal(item.id);
            if (val) data.meta.push({ key: item.meta, value: val });
        });

        window.location.href = QueryBuilder.buildUrl(data);
    }

    /* =========================================================================
       INIT
       ========================================================================= */
    function init() {
        injectStyles();
        createUI();

        // Register Menu
        if (typeof GM_registerMenuCommand === 'function') {
            GM_registerMenuCommand("Search Filter", () => toggleModal());
        }

        // Process existing results
        processSearchResults();

        // Observers
        let debounceTimer;
        // Watch for page changes (GitHub uses Turbo/PJAX)
        const observer = new MutationObserver(() => {
            clearTimeout(debounceTimer);
            debounceTimer = setTimeout(() => {
                processSearchResults();
            }, 200);
        });
        observer.observe(document.body, { childList: true, subtree: true });

        // Turbo event listener for cleaner navigation handling
        document.addEventListener('turbo:render', () => {
            processSearchResults();
        });
    }

    init();

})();