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
140 lines
5.1 KiB
TypeScript
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>
|
|
);
|
|
}
|