Files
Panama/config/local/share/vicinae/extensions/panama-search/src/search.tsx
T
Gabriel Brown 44124d72fa Take one machine's fingerprints off everyone's desktop
The audit's second tier: values that were measurements of the author's
desktop, shipped to every machine as if they were defaults.

Settings greeted every human as Gabriel; it now greets whoever
accountsservice says is signed in, and nobody when it says nothing. The
weather shipped his home coordinates and confidently reported his forecast
anywhere on earth; it now ships unset, fetches nothing until a location is
chosen, and the location row says so. The GTK bookmarks carried seven
/home/gib paths and his file server into every file dialog; they are now
generated per machine from a template and gitignored -- Nautilus edits the
instance freely, the way settings.ini already worked one file over. Web
search routed through his personal bang redirector; the engine is now the
webSearchUrl preference with a DuckDuckGo default, read by both the script
command and the suggestions extension, which the launcher-search contract
already pins to one another. The GPU vitals path defaulted to his card1 and
lost the readout on any machine enumerated differently; a machine with
exactly one GPU now adopts it. And the Containers and Snapshots pages hide
once a scan proves their backing stack absent, instead of rendering
permanently empty on machines that never had podman or snapper.

Lesser residue swept in the same pass: the DP-2 hyprpaper block one machine
needed, the author's username-typo expansions (moved to his personal seed in
user/, where personal content belongs), a capture fallback into /home/gib,
and a parity table asserting one machine's hardware as fact.

Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
2026-08-23 11:55:43 -04:00

140 lines
5.1 KiB
TypeScript

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<string[]>([]);
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<AbortController | null>(null);
const debounce = useRef<ReturnType<typeof setTimeout> | 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 (
<List
isLoading={loading}
onSearchTextChange={onSearchTextChange}
searchBarPlaceholder="Search, or !bang to jump straight there"
>
{trimmed !== "" && (
<List.Item
title={trimmed}
subtitle={trimmed.startsWith("!") ? "Bang" : "Search"}
icon={Icon.MagnifyingGlass}
actions={
<ActionPanel>
<Action.OpenInBrowser title="Search" url={searchUrl(trimmed)} />
</ActionPanel>
}
/>
)}
<List.Section title="Suggestions">
{suggestions
.filter((suggestion) => suggestion !== trimmed)
.map((suggestion) => (
<List.Item
key={suggestion}
title={suggestion}
icon={Icon.MagnifyingGlass}
actions={
<ActionPanel>
<Action.OpenInBrowser title="Search" url={searchUrl(suggestion)} />
</ActionPanel>
}
/>
))}
</List.Section>
</List>
);
}