GitHub Advanced Search

Build GitHub search queries without memorising qualifier syntax, save them as presets, and hide results you never want to see.

Terá de instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

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

Terá de instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Terá de instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Terá de instalar uma extensão como Tampermonkey para instalar este script.

Terá de instalar uma extensão de gestão de scripts de utilizador para instalar este script.

(Já tenho um gestor de scripts de utilizador, deixe-me instalá-lo!)

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

(Já tenho um gestor de estilos de utilizador, deixe-me instalá-lo!)

// ==UserScript==
// @name         GitHub Advanced Search
// @namespace    https://github.com/quantavil/userscript/github-filter
// @version      8.2.0
// @author       quantavil (https://github.com/quantavil)
// @description  Build GitHub search queries without memorising qualifier syntax, save them as presets, and hide results you never want to see.
// @license      MIT
// @icon         https://github.githubassets.com/favicons/favicon.svg
// @homepage     https://github.com/quantavil/userscript
// @homepageURL  https://github.com/quantavil/userscript
// @match        https://github.com/*
// @grant        GM_registerMenuCommand
// @run-at       document-idle
// ==/UserScript==

(function() {
	"use strict";
	var IDS = {
		panel: "ghf-panel",
		style: "ghf-style",
		fab: "ghf-fab"
	};
	var STORE = {
		presets: "ghf:presets",
		theme: "ghf:theme"
	};
	var PARAM = { hide: "ghf_hide" };
	var RESULTS_LIST = "[data-testid=\"results-list\"]";
	var LEGACY_ROW = ".repo-list-item, .Box-row";
	var NAV_EVENTS = [
		"turbo:render",
		"turbo:load",
		"pjax:end"
	];
	var NUMERIC = new Set([
		"stars",
		"forks",
		"size"
	]);
	var SECTIONS = [
		{
			title: "Search",
			fields: [{
				id: "type",
				label: "Type",
				kind: "select",
				options: [
					{
						value: "repositories",
						label: "Repositories"
					},
					{
						value: "code",
						label: "Code"
					},
					{
						value: "issues",
						label: "Issues"
					},
					{
						value: "pullrequests",
						label: "Pull requests"
					},
					{
						value: "discussions",
						label: "Discussions"
					},
					{
						value: "users",
						label: "Users"
					}
				]
			}, {
				id: "sort",
				label: "Sort by",
				kind: "select",
				options: [
					{
						value: "",
						label: "Best match"
					},
					{
						value: "stars",
						label: "Most stars"
					},
					{
						value: "forks",
						label: "Most forks"
					},
					{
						value: "updated",
						label: "Recently updated"
					}
				]
			}]
		},
		{
			title: "Terms",
			fields: [
				{
					id: "and",
					label: "All of these",
					kind: "text",
					placeholder: "rust async"
				},
				{
					id: "or",
					label: "Any of these",
					kind: "text",
					placeholder: "react, vue"
				},
				{
					id: "hide",
					label: "Hide results containing",
					kind: "text",
					placeholder: "spam, bot",
					full: true
				}
			]
		},
		{
			title: "Qualifiers",
			fields: [
				{
					id: "repo",
					label: "Repo",
					kind: "text",
					placeholder: "facebook/react",
					qualifier: "repo"
				},
				{
					id: "lang",
					label: "Language",
					kind: "text",
					placeholder: "python, -html",
					qualifier: "language",
					aliases: ["lang"]
				},
				{
					id: "ext",
					label: "Extension",
					kind: "text",
					placeholder: "md",
					qualifier: "extension",
					aliases: ["ext"]
				},
				{
					id: "stars",
					label: "Stars",
					kind: "text",
					placeholder: ">500",
					qualifier: "stars"
				},
				{
					id: "forks",
					label: "Forks",
					kind: "text",
					placeholder: ">100",
					qualifier: "forks"
				},
				{
					id: "size",
					label: "Size (KB)",
					kind: "text",
					placeholder: "<5000",
					qualifier: "size"
				},
				{
					id: "created",
					label: "Created",
					kind: "text",
					placeholder: ">2023-01",
					qualifier: "created"
				},
				{
					id: "pushed",
					label: "Pushed",
					kind: "text",
					placeholder: ">2024-01-01",
					qualifier: "pushed"
				}
			]
		}
	];
	var QUALIFIERS = SECTIONS.flatMap((s) => s.fields).filter((f) => f.kind === "text" && !!f.qualifier);
	var emptyState = () => ({
		type: "repositories",
		sort: "",
		and: "",
		or: "",
		hide: "",
		q: {}
	});
	var tokenize = (input) => input.match(/-?"[^"]*"|[^\s,]+/g) ?? [];
	var splitNegation = (token) => token.startsWith("-") ? [token.slice(1), "-"] : [token, ""];
	function buildUrl(state) {
		const terms = tokenize(state.and);
		const anyOf = tokenize(state.or);
		if (anyOf.length) terms.push(anyOf.length === 1 ? anyOf[0] : `(${anyOf.join(" OR ")})`);
		for (const field of QUALIFIERS) for (const token of tokenize(state.q[field.id] ?? "")) {
			const [bare, negate] = splitNegation(token);
			if (!bare) continue;
			const value = NUMERIC.has(field.qualifier) && /^\d+$/.test(bare) ? `>=${bare}` : bare;
			terms.push(`${negate}${field.qualifier}:${value}`);
		}
		const url = new URL("https://github.com/search");
		url.searchParams.set("q", terms.join(" "));
		url.searchParams.set("type", state.type);
		if (state.sort) {
			url.searchParams.set("s", state.sort);
			url.searchParams.set("o", "desc");
		}
		if (state.hide.trim()) url.searchParams.set(PARAM.hide, state.hide.trim());
		return url.toString();
	}
	function parseUrl(search) {
		const params = new URLSearchParams(search);
		const state = emptyState();
		state.type = (params.get("type") || "repositories").toLowerCase();
		state.sort = params.get("s") ?? "";
		state.hide = params.get(PARAM.hide) ?? "";
		let q = params.get("q") ?? "";
		for (const field of QUALIFIERS) {
			const keys = [field.qualifier, ...field.aliases ?? []].sort((a, b) => b.length - a.length).join("|");
			const found = [];
			q = q.replace(new RegExp(`(^|\\s)(-?)(?:${keys}):("[^"]*"|\\S+)`, "gi"), (_m, _sp, negate, raw) => {
				let value = raw.replace(/^"|"$/g, "");
				if (NUMERIC.has(field.qualifier) && /^>=\d+$/.test(value)) value = value.slice(2);
				found.push(negate + value);
				return " ";
			});
			if (found.length) state.q[field.id] = found.join(", ");
		}
		const group = q.match(/\(([^()]+)\)/);
		if (group?.[1]?.includes(" OR ")) {
			state.or = group[1].split(" OR ").map((s) => s.trim()).filter(Boolean).join(", ");
			q = q.replace(group[0], " ");
		}
		state.and = q.replace(/\s+/g, " ").trim();
		return state;
	}
	var escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
	function buildHideMatcher(input) {
		const words = tokenize(input).map((w) => w.replace(/^-?"|"$/g, "").trim()).filter(Boolean);
		if (!words.length) return null;
		const alt = words.sort((a, b) => b.length - a.length).map(escapeRe).join("|");
		return new RegExp(`(?<![\\p{L}\\p{N}])(?:${alt})(?![\\p{L}\\p{N}])`, "iu");
	}
	var resultRows = () => {
		const list = document.querySelector(RESULTS_LIST);
		return list ? Array.from(list.children) : Array.from(document.querySelectorAll(LEGACY_ROW));
	};
	function rowText(row) {
		const walker = document.createTreeWalker(row, NodeFilter.SHOW_TEXT);
		let text = "";
		while (walker.nextNode()) text += `${walker.currentNode.nodeValue} `;
		return text;
	}
	function scanResults() {
		if (location.pathname !== "/search") return;
		const matcher = buildHideMatcher(new URLSearchParams(location.search).get(PARAM.hide) ?? "");
		if (!matcher) return;
		for (const row of resultRows()) if (matcher.test(rowText(row))) row.classList.add("ghf-hidden");
	}
	function watchResults() {
		scanResults();
		let debounce;
		new MutationObserver(() => {
			if (location.pathname !== "/search") return;
			clearTimeout(debounce);
			debounce = setTimeout(scanResults, 150);
		}).observe(document.body, {
			childList: true,
			subtree: true
		});
		for (const event of NAV_EVENTS) document.addEventListener(event, () => scanResults());
	}
	var read = (key, fallback) => {
		try {
			const raw = localStorage.getItem(key);
			return raw === null ? fallback : JSON.parse(raw);
		} catch {
			return fallback;
		}
	};
	var write = (key, value) => {
		try {
			localStorage.setItem(key, JSON.stringify(value));
		} catch {}
	};
	var getPresets = () => read(STORE.presets, []).filter((p) => p?.id && p?.name);
	var addPreset = (name, state) => {
		const presets = [...getPresets(), {
			id: `p${Date.now().toString(36)}`,
			name,
			state
		}];
		write(STORE.presets, presets);
		return presets;
	};
	var removePreset = (id) => {
		const presets = getPresets().filter((p) => p.id !== id);
		write(STORE.presets, presets);
		return presets;
	};
	var getStoredTheme = () => localStorage.getItem(STORE.theme);
	var setStoredTheme = (theme) => localStorage.setItem(STORE.theme, theme);
	function migrateLegacyStorage() {
		const legacyPresets = localStorage.getItem("gh-adv-presets");
		if (legacyPresets && !localStorage.getItem(STORE.presets)) try {
			const migrated = JSON.parse(legacyPresets).filter((p) => p?.name).map((p, i) => ({
				id: p.id ?? `p${i}`,
				name: p.name,
				state: {
					...emptyState(),
					type: p.fields?.type ?? "repositories",
					sort: p.fields?.sort ?? "",
					and: p.fields?.and ?? "",
					or: p.fields?.or ?? "",
					hide: p.fields?.hideKeys ?? "",
					q: p.fields?.meta ?? {}
				}
			}));
			write(STORE.presets, migrated);
		} catch {}
		localStorage.removeItem("gh-adv-presets");
		const legacyTheme = localStorage.getItem("gh-adv-theme");
		if (legacyTheme && !getStoredTheme()) setStoredTheme(legacyTheme);
		localStorage.removeItem("gh-adv-theme");
		for (const key of Object.keys(localStorage)) if (key.startsWith("gh-rel-")) localStorage.removeItem(key);
		localStorage.removeItem("gh-adv-scan");
		localStorage.removeItem("ghf:scan");
		localStorage.removeItem("ghf:releases");
	}
	var P = `#${IDS.panel}`;
	var F = `#${IDS.fab}`;
	var CSS = `
${P}, ${F} {
  --ghf-canvas: var(--overlay-bgColor, var(--bgColor-default, var(--color-canvas-default, #ffffff)));
  --ghf-subtle: var(--bgColor-muted, var(--color-canvas-subtle, #f6f8fa));
  --ghf-inset: var(--bgColor-inset, var(--color-canvas-inset, #f6f8fa));
  --ghf-fg: var(--fgColor-default, var(--color-fg-default, #1f2328));
  --ghf-fg-muted: var(--fgColor-muted, var(--color-fg-muted, #59636e));
  --ghf-accent: var(--fgColor-accent, var(--color-accent-fg, #0969da));
  --ghf-danger: var(--fgColor-danger, var(--color-danger-fg, #d1242f));
  --ghf-border: var(--borderColor-default, var(--color-border-default, #d1d9e0));
  --ghf-border-muted: var(--borderColor-muted, var(--color-border-muted, #d8dee4));
  --ghf-btn-bg: var(--button-default-bgColor-rest, var(--color-btn-bg, #f6f8fa));
  --ghf-btn-hover: var(--button-default-bgColor-hover, var(--color-btn-hover-bg, #eef1f4));
  --ghf-primary: var(--button-primary-bgColor-rest, var(--color-btn-primary-bg, #1f883d));
  --ghf-primary-hover: var(--button-primary-bgColor-hover, var(--color-btn-primary-hover-bg, #1a7f37));
  --ghf-shadow: var(--shadow-floating-large, 0 8px 24px rgba(31, 35, 40, 0.16));
  --ghf-radius: 6px;
  --ghf-font: var(--fontStack-sansSerif, -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif);
  --ghf-mono: var(--fontStack-monospace, ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace);
}

${P}[data-theme="light"], ${F}[data-theme="light"] {
  color-scheme: light;
  /* Three deliberate steps: body → header/footer → inset controls. */
  --ghf-canvas: #ffffff; --ghf-subtle: #f6f8fa; --ghf-inset: #eaeef2;
  --ghf-fg: #1f2328; --ghf-fg-muted: #59636e; --ghf-accent: #0969da; --ghf-danger: #d1242f;
  --ghf-border: #d1d9e0; --ghf-border-muted: #d8dee4;
  --ghf-btn-bg: #f6f8fa; --ghf-btn-hover: #eef1f4;
  --ghf-primary: #1f883d; --ghf-primary-hover: #1a7f37;
  --ghf-shadow: 0 8px 24px rgba(31, 35, 40, 0.16);
}

${P}[data-theme="dark"], ${F}[data-theme="dark"] {
  color-scheme: dark;
  /* Header sits above the body, matching light mode's direction of elevation.
     #010409 against #151b23 was a hard black band. */
  --ghf-canvas: #151b23; --ghf-subtle: #1c2128; --ghf-inset: #0d1117;
  --ghf-fg: #f0f6fc; --ghf-fg-muted: #9198a1; --ghf-accent: #4493f8; --ghf-danger: #f85149;
  --ghf-border: #3d444d; --ghf-border-muted: #2f353d;
  --ghf-btn-bg: #212830; --ghf-btn-hover: #262c36;
  --ghf-primary: #238636; --ghf-primary-hover: #29903b;
  /* No 1px ring — the panel already has a border, and the two drew a double line. */
  --ghf-shadow: 0 16px 32px rgba(1, 4, 9, 0.85);
}

/* ---------- panel ---------- */

${P} {
  width: min(400px, 100vw);
  max-width: 100vw;
  height: 100dvh;
  max-height: 100dvh;
  margin: 0 0 0 auto;
  padding: 0;
  border: none;
  border-left: 1px solid var(--ghf-border);
  background: var(--ghf-canvas);
  color: var(--ghf-fg);
  font-family: var(--ghf-font);
  font-size: 14px;
  line-height: 1.5;
  box-shadow: var(--ghf-shadow);
  box-sizing: border-box;
  overflow: hidden;
  transform: translateX(100%);
  opacity: 0;
  transition: transform .22s cubic-bezier(.2, 0, 0, 1), opacity .22s ease,
              display .22s allow-discrete, overlay .22s allow-discrete;
}
${P}[open] { transform: translateX(0); opacity: 1; }
@starting-style { ${P}[open] { transform: translateX(100%); opacity: 0; } }

${P}::backdrop {
  background: rgba(1, 4, 9, .45);
  opacity: 0;
  transition: opacity .22s ease, display .22s allow-discrete, overlay .22s allow-discrete;
}
${P}[open]::backdrop { opacity: 1; }
@starting-style { ${P}[open]::backdrop { opacity: 0; } }

@media (max-width: 767px) {
  ${P} {
    width: 100vw;
    height: auto;
    max-height: 88dvh;
    margin: auto 0 0;
    border-left: none;
    border-top: 1px solid var(--ghf-border);
    border-radius: 12px 12px 0 0;
    transform: translateY(100%);
  }
  ${P}[open] { transform: translateY(0); }
  @starting-style { ${P}[open] { transform: translateY(100%); opacity: 0; } }
}

@media (prefers-reduced-motion: reduce) {
  ${P}, ${P}::backdrop, ${F}, ${P} * { transition-duration: .01ms !important; }
}

${P} .ghf-form { display: flex; flex-direction: column; height: 100%; min-height: 0; }

/* ---------- header ---------- */

${P} header {
  display: flex; flex-direction: column; gap: 12px;
  padding: 12px 16px;
  border-bottom: 1px solid var(--ghf-border);
  background: var(--ghf-subtle);
}
${P} .ghf-titlebar { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
${P} h2 {
  display: flex; align-items: center; gap: 8px; margin: 0;
  font-size: 14px; font-weight: 600; letter-spacing: -.005em;
}
${P} h2 svg { fill: var(--ghf-fg-muted); }
${P} .ghf-titlebar nav { display: flex; gap: 2px; }

${P} .ghf-icon-btn {
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; padding: 0;
  border: 1px solid transparent; border-radius: var(--ghf-radius);
  background: transparent; color: var(--ghf-fg-muted); cursor: pointer;
  transition: background .12s ease, color .12s ease, transform .06s ease;
}
${P} .ghf-icon-btn svg { fill: currentColor; }
${P} .ghf-icon-btn:hover { background: var(--ghf-btn-hover); color: var(--ghf-fg); }
${P} .ghf-icon-btn:active { transform: scale(.94); }
${P} .ghf-icon-btn.ghf-danger:hover { background: var(--ghf-danger); color: #fff; }

${P} .ghf-tabs {
  display: grid; grid-template-columns: 1fr 1fr; gap: 2px;
  padding: 2px; border: 1px solid var(--ghf-border);
  border-radius: var(--ghf-radius); background: var(--ghf-inset);
}
${P} .ghf-tabs button {
  padding: 5px 8px; border: none; border-radius: 4px;
  background: transparent; color: var(--ghf-fg-muted);
  font: inherit; font-size: 12px; font-weight: 500; cursor: pointer;
  transition: background .12s ease, color .12s ease;
}
${P} .ghf-tabs button:hover { color: var(--ghf-fg); }
${P} .ghf-tabs button[aria-selected="true"] {
  background: var(--ghf-canvas); color: var(--ghf-fg); font-weight: 600;
}

/* ---------- body ---------- */

${P} .ghf-body { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 4px 16px 16px; }
${P} [role="tabpanel"][hidden] { display: none; }

${P} fieldset { margin: 16px 0 0; padding: 0; border: none; }
${P} legend {
  padding: 0; margin-bottom: 8px;
  font-size: 12px; font-weight: 600; color: var(--ghf-fg-muted);
}
${P} .ghf-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 12px; }
${P} .ghf-grid > .ghf-wide { grid-column: 1 / -1; }

${P} .ghf-field label { display: block; margin-bottom: 4px; font-size: 12px; font-weight: 500; }
${P} .ghf-input {
  width: 100%; padding: 5px 10px;
  border: 1px solid var(--ghf-border); border-radius: var(--ghf-radius);
  background: var(--ghf-canvas); color: var(--ghf-fg);
  font: inherit; font-size: 13px; box-sizing: border-box;
  transition: border-color .12s ease, box-shadow .12s ease;
}
${P} .ghf-input::placeholder { color: var(--ghf-fg-muted); opacity: .7; }
${P} .ghf-input:hover { border-color: var(--ghf-fg-muted); }
${P} select.ghf-input { padding-right: 6px; cursor: pointer; }

${P} :is(.ghf-input, button, [role="tab"]):focus-visible {
  outline: 2px solid var(--ghf-accent); outline-offset: -1px; border-color: var(--ghf-accent);
}

/* ---------- footer ---------- */

${P} footer {
  display: flex; gap: 8px; padding: 12px 16px;
  border-top: 1px solid var(--ghf-border); background: var(--ghf-subtle);
}
${P} .ghf-btn {
  flex: 1; padding: 5px 12px;
  border: 1px solid var(--ghf-border); border-radius: var(--ghf-radius);
  background: var(--ghf-btn-bg); color: var(--ghf-fg);
  font: inherit; font-size: 13px; font-weight: 500; cursor: pointer;
  transition: background .12s ease, transform .06s ease;
}
${P} .ghf-btn:hover { background: var(--ghf-btn-hover); }
${P} .ghf-btn:active { transform: translateY(1px); }
${P} .ghf-btn.ghf-primary {
  border-color: transparent; background: var(--ghf-primary); color: #fff;
}
${P} .ghf-btn.ghf-primary:hover { background: var(--ghf-primary-hover); }
${P} .ghf-btn.ghf-compact { flex: 0 0 auto; }

/* ---------- presets ---------- */

${P} .ghf-save { display: flex; gap: 8px; margin: 16px 0 20px; }
${P} .ghf-save .ghf-input { flex: 1; }
${P} .ghf-error { margin: -14px 0 16px; font-size: 12px; color: var(--ghf-danger); }
${P} .ghf-input[aria-invalid="true"] { border-color: var(--ghf-danger); }

${P} .ghf-presets { display: flex; flex-direction: column; gap: 8px; list-style: none; margin: 0; padding: 0; }
${P} .ghf-preset {
  padding: 10px 12px;
  border: 1px solid var(--ghf-border); border-radius: var(--ghf-radius);
  background: var(--ghf-canvas);
  transition: border-color .12s ease;
}
${P} .ghf-preset:hover { border-color: var(--ghf-fg-muted); }
${P} .ghf-preset-head { display: flex; align-items: start; justify-content: space-between; gap: 8px; }
${P} .ghf-preset-name { font-weight: 600; font-size: 13px; overflow-wrap: anywhere; }
${P} .ghf-chips { display: flex; flex-wrap: wrap; gap: 4px; margin: 6px 0 10px; }
${P} .ghf-chip {
  padding: 0 6px; border: 1px solid var(--ghf-border-muted); border-radius: 20px;
  background: var(--ghf-subtle); color: var(--ghf-fg-muted);
  font-family: var(--ghf-mono); font-size: 10px; line-height: 18px;
  max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
${P} .ghf-preset-actions { display: flex; gap: 6px; }
${P} .ghf-preset-actions .ghf-btn { padding: 3px 10px; font-size: 12px; }
${P} .ghf-empty {
  padding: 28px 16px; border: 1px dashed var(--ghf-border); border-radius: var(--ghf-radius);
  text-align: center; color: var(--ghf-fg-muted); font-size: 13px;
}
${P} .ghf-empty strong { display: block; margin-bottom: 2px; color: var(--ghf-fg); font-size: 13px; }

/* ---------- launcher ---------- */

${F} {
  position: fixed;
  right: calc(16px + env(safe-area-inset-right, 0px));
  bottom: calc(16px + env(safe-area-inset-bottom, 0px));
  display: flex; align-items: center; justify-content: center;
  width: 40px; height: 40px; padding: 0;
  border: 1px solid var(--ghf-border); border-radius: 50%;
  background: var(--ghf-canvas); color: var(--ghf-fg-muted);
  box-shadow: var(--ghf-shadow); cursor: pointer;
  z-index: 9999;
  transition: color .12s ease, border-color .12s ease, transform .12s cubic-bezier(.2, 0, 0, 1);
}
${F} svg { fill: currentColor; }
${F}:hover { color: var(--ghf-accent); border-color: var(--ghf-accent); transform: scale(1.06); }
${F}:active { transform: scale(.96); }
${F}:focus-visible { outline: 2px solid var(--ghf-accent); outline-offset: 2px; }

/* Applied to result rows in GitHub's own DOM, hence no panel prefix. */
.ghf-hidden { display: none !important; }
`;
	function injectStyles() {
		if (document.getElementById(IDS.style)) return;
		const style = document.createElement("style");
		style.id = IDS.style;
		style.textContent = CSS;
		document.head.append(style);
	}
	function el(tag, props = {}, ...children) {
		const node = document.createElement(tag);
		for (const [key, value] of Object.entries(props)) {
			if (value == null) continue;
			if (key === "class") node.className = String(value);
			else if (key === "dataset") Object.assign(node.dataset, value);
			else if (key === "role" || key.includes("-")) node.setAttribute(key, String(value));
			else node[key] = value;
		}
		node.append(...children.filter((c) => c != null));
		return node;
	}
	var svg = (paths, size = 16) => {
		const node = document.createElementNS("http://www.w3.org/2000/svg", "svg");
		node.setAttribute("viewBox", "0 0 16 16");
		node.setAttribute("width", String(size));
		node.setAttribute("height", String(size));
		node.setAttribute("aria-hidden", "true");
		for (const d of paths) {
			const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
			path.setAttribute("d", d);
			node.appendChild(path);
		}
		return node;
	};
	var ICON = {
		search: "M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z",
		x: "M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z",
		trash: "M11 1.75V3h2.25a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75ZM4.496 6.675l.66 6.6a.25.25 0 0 0 .249.225h5.19a.25.25 0 0 0 .249-.225l.66-6.6a.75.75 0 0 1 1.492.149l-.66 6.6A1.748 1.748 0 0 1 10.595 15h-5.19a1.75 1.75 0 0 1-1.741-1.576l-.66-6.6a.75.75 0 1 1 1.492-.149ZM6.5 1.75V3h3V1.75a.25.25 0 0 0-.25-.25h-2.5a.25.25 0 0 0-.25.25Z",
		sun: "M8 12a4 4 0 1 1 0-8 4 4 0 0 1 0 8Zm0-1.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Zm5.657-8.157a.75.75 0 0 1 0 1.061l-1.061 1.06a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l1.06-1.06a.75.75 0 0 1 1.06 0Zm-9.193 9.193a.75.75 0 0 1 0 1.06l-1.06 1.061a.75.75 0 1 1-1.061-1.06l1.06-1.061a.75.75 0 0 1 1.061 0ZM8 0a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0V.75A.75.75 0 0 1 8 0ZM3 8a.75.75 0 0 1-.75.75H.75a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 3 8Zm13 0a.75.75 0 0 1-.75.75h-1.5a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 16 8Zm-8 5a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 8 13ZM2.343 2.343a.75.75 0 0 1 1.061 0l1.06 1.061a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018l-1.06-1.06a.75.75 0 0 1 0-1.06Zm9.193 9.193a.75.75 0 0 1 1.06 0l1.061 1.06a.75.75 0 0 1-1.06 1.061l-1.061-1.06a.75.75 0 0 1 0-1.061Z",
		moon: "M9.598 1.591a.749.749 0 0 1 .785-.175 7.001 7.001 0 1 1-8.967 8.967.75.75 0 0 1 .961-.96 5.5 5.5 0 0 0 7.046-7.046.75.75 0 0 1 .175-.786Zm1.616 1.945a7 7 0 0 1-7.678 7.678 5.499 5.499 0 1 0 7.678-7.678Z",
		desktop: "M0 2.75C0 1.784.784 1 1.75 1h12.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 14.25 12h-3.727c.099 1.041.52 1.872 1.292 2.757A.752.752 0 0 1 11.25 16h-6.5a.752.752 0 0 1-.565-1.243c.772-.885 1.192-1.716 1.292-2.757H1.75A1.75 1.75 0 0 1 0 10.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"
	};
	var CYCLE = [
		"auto",
		"light",
		"dark"
	];
	var LABEL = {
		auto: "Theme: matches GitHub",
		light: "Theme: light",
		dark: "Theme: dark"
	};
	var THEME_ICON = {
		auto: ICON.desktop,
		light: ICON.sun,
		dark: ICON.moon
	};
	function getTheme() {
		const stored = getStoredTheme();
		return CYCLE.includes(stored) ? stored : "auto";
	}
	function applyTheme(theme) {
		setStoredTheme(theme);
		for (const id of [IDS.panel, IDS.fab]) {
			const node = document.getElementById(id);
			if (node) node.dataset.theme = theme;
		}
	}
	var nextTheme = (theme) => CYCLE[(CYCLE.indexOf(theme) + 1) % CYCLE.length];
	var panel = null;
	var inputs = new Map();
	var field = (id) => inputs.get(id);
	var textOf = (id) => field(id)?.value ?? "";
	function readForm() {
		const q = {};
		for (const f of QUALIFIERS) {
			const value = textOf(f.id).trim();
			if (value) q[f.id] = value;
		}
		return {
			type: textOf("type") || "repositories",
			sort: textOf("sort"),
			and: textOf("and"),
			or: textOf("or"),
			hide: textOf("hide"),
			q
		};
	}
	function writeForm(state) {
		for (const [id, input] of inputs) input.value = id in state ? String(state[id] ?? "") : state.q[id] ?? "";
	}
	var search = (state) => location.assign(buildUrl(state));
	function buildField(f) {
		const input = f.kind === "select" ? el("select", {
			class: "ghf-input",
			id: `ghf-${f.id}`
		}, ...f.options.map((o) => el("option", { value: o.value }, o.label))) : el("input", {
			type: "text",
			class: "ghf-input",
			id: `ghf-${f.id}`,
			placeholder: f.placeholder ?? "",
			autocomplete: "off",
			spellcheck: false
		});
		inputs.set(f.id, input);
		return el("div", { class: `ghf-field${f.kind === "text" && f.full ? " ghf-wide" : ""}` }, el("label", { htmlFor: `ghf-${f.id}` }, f.label), input);
	}
	function chipsFor(state) {
		const chips = [state.type];
		if (state.sort) chips.push(`sort:${state.sort}`);
		if (state.and) chips.push(state.and);
		if (state.or) chips.push(`any: ${state.or}`);
		for (const f of QUALIFIERS) if (state.q[f.id]) chips.push(`${f.qualifier}:${state.q[f.id]}`);
		if (state.hide) chips.push(`hide: ${state.hide}`);
		return chips;
	}
	function renderPresets(list, presets, toBuilder) {
		if (!presets.length) {
			list.replaceChildren(el("li", { class: "ghf-empty" }, el("strong", {}, "No presets yet"), "Set up a search in the builder, then name and save it here."));
			return;
		}
		list.replaceChildren(...presets.map((preset) => {
			const remove = el("button", {
				type: "button",
				class: "ghf-icon-btn ghf-danger",
				title: `Delete "${preset.name}"`,
				"aria-label": `Delete preset ${preset.name}`,
				onclick: () => renderPresets(list, removePreset(preset.id), toBuilder)
			});
			remove.append(svg([ICON.trash], 14));
			return el("li", { class: "ghf-preset" }, el("div", { class: "ghf-preset-head" }, el("div", { class: "ghf-preset-name" }, preset.name), remove), el("div", { class: "ghf-chips" }, ...chipsFor(preset.state).map((c) => el("span", { class: "ghf-chip" }, c))), el("div", { class: "ghf-preset-actions" }, el("button", {
				type: "button",
				class: "ghf-btn ghf-primary",
				onclick: () => search(preset.state)
			}, "Search"), el("button", {
				type: "button",
				class: "ghf-btn",
				onclick: () => {
					writeForm(preset.state);
					toBuilder();
				}
			}, "Edit")));
		}));
	}
	function build() {
		const dialog = el("dialog", {
			id: IDS.panel,
			"aria-label": "GitHub advanced search"
		});
		dialog.dataset.theme = getTheme();
		const themeBtn = el("button", {
			type: "button",
			class: "ghf-icon-btn"
		});
		const paintTheme = () => {
			const theme = getTheme();
			themeBtn.title = LABEL[theme];
			themeBtn.setAttribute("aria-label", LABEL[theme]);
			themeBtn.replaceChildren(svg([THEME_ICON[theme]], 15));
		};
		themeBtn.onclick = () => {
			applyTheme(nextTheme(getTheme()));
			paintTheme();
		};
		paintTheme();
		const closeBtn = el("button", {
			type: "button",
			class: "ghf-icon-btn",
			title: "Close",
			"aria-label": "Close",
			onclick: () => dialog.close()
		});
		closeBtn.append(svg([ICON.x], 16));
		const title = el("h2", {}, "Advanced search");
		title.prepend(svg([ICON.search], 15));
		const tabs = {
			builder: el("button", { type: "button" }, "Builder"),
			presets: el("button", { type: "button" }, "Presets")
		};
		const panels = {
			builder: el("div", { role: "tabpanel" }),
			presets: el("div", { role: "tabpanel" })
		};
		const showTab = (name) => {
			for (const key of ["builder", "presets"]) {
				const on = key === name;
				tabs[key].setAttribute("aria-selected", String(on));
				tabs[key].tabIndex = on ? 0 : -1;
				panels[key].hidden = !on;
			}
			footer.hidden = name !== "builder";
		};
		for (const [name, btn] of Object.entries(tabs)) {
			btn.setAttribute("role", "tab");
			btn.onclick = () => showTab(name);
		}
		for (const section of SECTIONS) panels.builder.append(el("fieldset", {}, el("legend", {}, section.title), el("div", { class: "ghf-grid" }, ...section.fields.map(buildField))));
		const nameInput = el("input", {
			type: "text",
			class: "ghf-input",
			placeholder: "Preset name",
			"aria-label": "Preset name",
			maxLength: 60
		});
		const error = el("p", {
			class: "ghf-error",
			role: "alert",
			hidden: true
		});
		const list = el("ul", { class: "ghf-presets" });
		const toBuilder = () => showTab("builder");
		const saveBtn = el("button", {
			type: "button",
			class: "ghf-btn ghf-primary ghf-compact",
			onclick: () => {
				const name = nameInput.value.trim();
				if (!name) {
					error.textContent = "Give the preset a name first.";
					error.hidden = false;
					nameInput.setAttribute("aria-invalid", "true");
					nameInput.focus();
					return;
				}
				error.hidden = true;
				nameInput.removeAttribute("aria-invalid");
				renderPresets(list, addPreset(name, readForm()), toBuilder);
				nameInput.value = "";
			}
		}, "Save");
		nameInput.oninput = () => {
			error.hidden = true;
			nameInput.removeAttribute("aria-invalid");
		};
		nameInput.onkeydown = (event) => {
			if (event.key !== "Enter") return;
			event.preventDefault();
			saveBtn.click();
		};
		panels.presets.append(el("div", { class: "ghf-save" }, nameInput, saveBtn), error, list);
		const footer = el("footer", {}, el("button", {
			type: "button",
			class: "ghf-btn",
			onclick: () => writeForm(emptyState())
		}, "Reset"), el("button", {
			type: "submit",
			class: "ghf-btn ghf-primary"
		}, "Search"));
		const form = el("form", {
			class: "ghf-form",
			method: "dialog"
		}, el("header", {}, el("div", { class: "ghf-titlebar" }, title, el("nav", {}, themeBtn, closeBtn)), el("div", {
			class: "ghf-tabs",
			role: "tablist",
			"aria-label": "Search panel sections"
		}, tabs.builder, tabs.presets)), el("div", { class: "ghf-body" }, panels.builder, panels.presets), footer);
		form.onsubmit = (event) => {
			event.preventDefault();
			search(readForm());
		};
		dialog.append(form);
		dialog.addEventListener("click", (event) => {
			if (event.target === dialog) dialog.close();
		});
		showTab("builder");
		renderPresets(list, getPresets(), toBuilder);
		document.body.append(dialog);
		return dialog;
	}
	function openPanel() {
		panel ??= build();
		if (panel.open) {
			panel.close();
			return;
		}
		writeForm(parseUrl(location.search));
		panel.showModal();
		field("and")?.focus();
	}
	function syncLauncher() {
		if (document.getElementById(IDS.fab)) return;
		const btn = el("button", {
			id: IDS.fab,
			type: "button",
			title: "Advanced search",
			"aria-label": "Open advanced search",
			dataset: { theme: getTheme() },
			onclick: openPanel
		});
		btn.append(svg([ICON.search], 18));
		document.body.append(btn);
	}
	migrateLegacyStorage();
	injectStyles();
	syncLauncher();
	watchResults();
	for (const event of NAV_EVENTS) document.addEventListener(event, syncLauncher);
	GM_registerMenuCommand("Advanced search", openPanel);
})();