import { useCallback, useEffect, useRef, useState } from "react"; import { Action, ActionPanel, Icon, List } from "@vicinae/api"; // Where a search goes. A client-side bang redirector: it resolves // DuckDuckGo-style bangs in the browser rather than round-tripping through a // search engine to be bounced, and falls through to an ordinary search when // there is no bang. Bang support is a property of this URL, not of this // extension, which is why there is no bang parsing below. // The engine is Panama's webSearchUrl preference (Applications page), the // same key the fallback script command reads -- launcher-search-contract pins // the two to one default. Read once per command launch; DuckDuckGo when the // preference is unset or unreadable. const DEFAULT_ENGINE = "https://duckduckgo.com/?q="; const ENGINE = (() => { try { const configHome = process.env.XDG_CONFIG_HOME || `${process.env.HOME}/.config`; const stored = JSON.parse( require("fs").readFileSync(`${configHome}/panama/settings.json`, "utf8"), ).webSearchUrl; return typeof stored === "string" && stored.startsWith("https://") ? stored : DEFAULT_ENGINE; } catch { return DEFAULT_ENGINE; } })(); // The suggestion endpoint Firefox's address bar uses. Answers with // [query, [suggestion, ...], ...] and needs no key. const SUGGEST = "https://suggestqueries.google.com/complete/search?client=firefox&q="; // Long enough that typing a word is one request rather than one per keystroke, // short enough that the list still feels attached to the keyboard. const DEBOUNCE_MS = 150; const searchUrl = (query: string) => ENGINE + encodeURIComponent(query); export default function SearchCommand() { const [query, setQuery] = useState(""); const [suggestions, setSuggestions] = useState([]); const [loading, setLoading] = useState(false); // Both are refs rather than state: changing them must not re-render, and the // cleanup below needs whatever the latest one is, not the one captured when // an effect happened to run. const inFlight = useRef(null); const debounce = useRef | null>(null); // Typing is faster than the network, so responses can arrive out of order. An // older, slower answer landing after a newer one would leave the list showing // suggestions for a query that is no longer on screen -- so each new keystroke // aborts the request before it. useEffect(() => () => { inFlight.current?.abort(); if (debounce.current) clearTimeout(debounce.current); }, []); const onSearchTextChange = useCallback((text: string) => { setQuery(text); if (debounce.current) clearTimeout(debounce.current); inFlight.current?.abort(); const trimmed = text.trim(); // A bang says *where* to search rather than what for, so Google's guesses // about it are noise: "!yt" suggests nothing anybody wants. if (trimmed === "" || trimmed.startsWith("!")) { setSuggestions([]); setLoading(false); return; } setLoading(true); debounce.current = setTimeout(async () => { const controller = new AbortController(); inFlight.current = controller; try { const response = await fetch(SUGGEST + encodeURIComponent(trimmed), { signal: controller.signal, }); const body = (await response.json()) as unknown; const returned = Array.isArray(body) ? body[1] : undefined; setSuggestions( Array.isArray(returned) ? returned.filter((entry): entry is string => typeof entry === "string") : [], ); } catch { // Offline, rate-limited, or aborted by the next keystroke. The typed // query is still searchable, so this costs suggestions rather than the // command -- which is the right way round for something you reach for // when you already know what you want. setSuggestions([]); } finally { if (inFlight.current === controller) setLoading(false); } }, DEBOUNCE_MS); }, []); const trimmed = query.trim(); return ( {trimmed !== "" && ( } /> )} {suggestions .filter((suggestion) => suggestion !== trimmed) .map((suggestion) => ( } /> ))} ); }