Files
searchi/apps/desktop/src/main.ts
T

765 lines
29 KiB
TypeScript
Raw Normal View History

import { invoke, isTauri } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import {
AutomaticIndexGate,
emptySearchState,
LatestLane,
} from "./search-state";
import type { SemanticPhase } from "./search-state";
import { fileIcon, icon } from "./icons";
import "@fontsource-variable/inter/wght.css";
import "./style.css";
interface SearchResult {
path: string;
filename: string;
snippet: string;
score: number;
match_kind: string;
source?: { kind: string; number: number };
extraction_error?: string;
}
interface IndexReport {
discovered: number;
indexed: number;
removed: number;
skipped: number;
}
interface IndexStats {
files: number;
content_files: number;
}
interface Settings {
version: number;
roots: string[];
cpu_threads: number;
}
interface DesktopStatus {
settings: Settings;
indexing: boolean;
shortcut_status: string;
}
interface SemanticStatus {
model: string;
ready: boolean;
model_directory: string;
worker_ready: boolean;
downloading: boolean;
embedding: boolean;
device_policy: "auto" | "cpu";
error: string | null;
downloaded_bytes: number;
total_bytes: number;
generation: number | null;
chunks: number;
stale: boolean;
pending: number;
warming: boolean;
worker_info?: {
model?: string;
device?: string;
runtime?: string;
threads?: number;
} | null;
}
interface SearchRequest {
id: number;
query: string;
filters: Record<string, string | number | null>;
started: number;
}
document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
<header><div class="brand"><img class="brand-symbol" src="/searchi-symbol.png" alt=""><span class="wordmark">searchi</span></div><span id="index-status" class="index-status">Loading your index…</span><div class="header-actions"><button id="add-folder" class="quiet-button" type="button">${icon("folderAdd")}<span>Add folder</span></button><details id="filters" class="popover"><summary>${icon("filter")}<span>All files</span>${icon("chevron")}</summary><div class="popover-panel"><h2>Filter your search</h2><div class="filters-grid"><label>Within folder<input id="filter-folder" placeholder="Absolute folder path"></label><label>Extension<input id="filter-extension" placeholder="pdf, md, txt…"></label><label>Minimum size (bytes)<input id="filter-min" type="number" min="0" step="1"></label><label>Maximum size (bytes)<input id="filter-max" type="number" min="0" step="1"></label><label>Modified from<input id="filter-after" type="date"></label><label>Modified through<input id="filter-before" type="date"></label></div></div></details><details id="settings" class="popover"><summary class="icon-button" aria-label="Settings" title="Settings">${icon("settings")}</summary><div class="popover-panel"><h2>Folders & resources</h2><p id="shortcut-status" class="hint"></p><label class="resource-label" for="cpu-threads">CPU thread budget</label><select id="cpu-threads">${Array.from({ length: 8 }, (_, i) => `<option value="${i + 1}">${i + 1} ${i ? "threads" : "thread (default)"}</option>`).join("")}</select><p class="hint">Search automatically uses supported acceleration with CPU fallback.</p><ul id="roots" aria-label="Selected folders"></ul></div></details></div></header>
<main>
<form id="search-form" role="search"><label class="sr-only" for="query">Search your files</label>${icon("search")}<input id="query" type="search" placeholder="Search a filename, phrase, or what you remember…" autocomplete="off" spellcheck="false" maxlength="1000"><kbd id="focus-shortcut">Ctrl K</kbd><button id="clear-query" class="icon-button" type="button" aria-label="Clear search" title="Clear search">${icon("dismiss")}</button></form>
<details id="semantic-panel"><summary>${icon("sparkles")}<span id="semantic-status" role="status" aria-live="polite">Preparing local semantic search…</span><span id="semantic-device" class="kind">Local model</span>${icon("chevron")}</summary><div class="semantic-content"><strong>Google EmbeddingGemma 2</strong><progress id="semantic-progress" aria-label="Local model setup progress"></progress><p id="semantic-detail" class="hint">The verified local model downloads automatically on first launch (865 MB). Your files stay on this device.</p><div class="semantic-actions"><button id="retry-model" type="button" hidden>Retry model setup</button><button id="rebuild-semantic" type="button" hidden>Rebuild semantic index</button></div></div></details>
<form id="folder-form" hidden><label for="folder">Folder to index</label><div class="folder-row"><input id="folder" placeholder="Absolute folder path" required><button id="browse-folder" type="button">Browse…</button><button type="submit">Index folder</button><button id="cancel-folder" type="button">Cancel</button></div><p class="hint">Only choose folders you want searched. Large folders may take a while.</p></form>
<p id="notice" role="status" aria-live="polite"></p><div class="workspace"><section class="results-pane" aria-label="Search results"><p id="result-summary" class="eyebrow">READY WHEN YOU ARE</p><ul id="results" aria-label="Matching files"></ul><div id="empty">${icon("search")}<h2>Find what matters. Locally.</h2><p>Index a folder, then search a filename, phrase, or what you remember.</p></div></section><aside id="preview" aria-label="Result preview" hidden><div class="preview-header"><span id="preview-icon" class="file-icon"></span><div class="preview-heading"><h2 id="preview-title"></h2><p id="preview-path" class="path"></p></div></div><div class="preview-toolbar"><span id="preview-kind"></span><div class="preview-actions"><button id="open-result" type="button">${icon("open")}<span>Open file</span></button><button id="reveal-result" type="button" title="Show in folder" aria-label="Show in folder">${icon("folder")}</button></div></div><div class="passage"><p class="eyebrow">EXTRACTED PASSAGE</p><pre id="preview-text"></pre></div></aside></div>
</main><footer><span class="footer-privacy">${icon("lock")}<span>Private search. Everything stays on your device.</span><span id="privacy" class="privacy">Local</span></span><span class="keyboard-hints"><kbd>${icon("arrowUp")}</kbd><kbd>${icon("arrowDown")}</kbd><span>Navigate</span><kbd>Enter</kbd><span>Open</span><kbd>Esc</kbd><span>Clear</span></span></footer>`;
const element = <T extends HTMLElement>(id: string) =>
document.getElementById(id) as T;
const query = element<HTMLInputElement>("query");
const list = element<HTMLUListElement>("results");
const notice = element<HTMLParagraphElement>("notice");
let results: SearchResult[] = [];
let selected = -1;
let requestId = 0;
let timer: ReturnType<typeof setTimeout>;
let indexingActive = false;
let semanticStatus: SemanticStatus | null = null;
let semanticJobActive = false;
let semanticRefreshActive = false;
let semanticTimer: ReturnType<typeof setTimeout> | undefined;
let hybridTimer: ReturnType<typeof setTimeout> | undefined;
let latestSearch: SearchRequest | null = null;
let shownLane: "lexical" | "hybrid" = "lexical";
let folderRevision = 0;
let localSemanticError: string | null = null;
let querySemanticFailed = false;
let previewPath: string | null = null;
let displayedRequestId: number | null = null;
let expandedError: string | null = null;
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)");
const resultRows = new Map<
string,
{
row: HTMLLIElement;
button: HTMLButtonElement;
title: HTMLElement;
path: HTMLElement;
snippet: HTMLElement;
kind: HTMLElement;
file: HTMLElement;
}
>();
const automaticIndex = new AutomaticIndexGate();
function refreshEmptySearchState() {
if (!latestSearch || latestSearch.id !== requestId || results.length) return;
let phase: SemanticPhase;
if (querySemanticFailed || localSemanticError || semanticStatus?.error)
phase = "unavailable";
else if (
semanticStatus?.embedding ||
semanticJobActive ||
indexingActive ||
(semanticStatus?.ready &&
semanticStatus.stale &&
semanticStatus.pending > 0)
)
phase = "indexing";
else if (!semanticStatus?.ready) phase = "preparing";
else if (semanticStatus.generation === null) phase = "no-content";
else phase = shownLane === "hybrid" ? "complete" : "pending";
const state = emptySearchState(shownLane, phase);
element("empty").querySelector("h2")!.textContent = state.title;
element("empty").querySelector("p")!.textContent = state.detail;
}
function canSearchSemantically() {
return Boolean(
semanticStatus?.ready &&
semanticStatus.generation !== null &&
!semanticStatus.embedding &&
!semanticJobActive,
);
}
function queueSemanticRefresh(delay = 1000) {
clearTimeout(semanticTimer);
semanticTimer = setTimeout(() => {
void refreshSemanticStatus();
}, delay);
}
async function buildSemanticIndex() {
if (semanticJobActive || indexingActive) return;
semanticJobActive = true;
localSemanticError = null;
element("semantic-status").textContent =
"Building your semantic index… Keyword search remains available.";
element<HTMLProgressElement>("semantic-progress").removeAttribute("value");
element("semantic-progress").hidden = false;
queueSemanticRefresh();
try {
const report = await invoke<{ passages: number; chunks: number }>(
"index_embeddings",
);
element("semantic-status").textContent =
`Semantic search indexed ${report.chunks.toLocaleString()} passages.`;
} catch (error) {
localSemanticError = String(error);
} finally {
semanticJobActive = false;
queueSemanticRefresh(0);
}
}
async function refreshSemanticStatus() {
if (semanticRefreshActive) {
queueSemanticRefresh();
return;
}
semanticRefreshActive = true;
try {
const previousReady = canSearchSemantically();
const previousGeneration = semanticStatus?.generation;
const status = await invoke<SemanticStatus>("semantic_status");
semanticStatus = status;
refreshEmptySearchState();
const error = localSemanticError || status.error;
if (error && error !== expandedError) {
element<HTMLDetailsElement>("semantic-panel").open = true;
expandedError = error;
}
if (!error) expandedError = null;
const progress = element<HTMLProgressElement>("semantic-progress");
const working =
status.downloading ||
status.warming ||
status.embedding ||
semanticJobActive;
progress.hidden = !working;
progress.removeAttribute("value");
if (
status.downloading &&
status.total_bytes > 0 &&
status.downloaded_bytes < status.total_bytes
) {
progress.max = status.total_bytes;
progress.value = status.downloaded_bytes;
}
const actualDevice =
status.worker_info?.device ||
(status.device_policy === "cpu" ? "CPU" : "Automatic acceleration");
element("privacy").textContent = `Local · ${actualDevice}`;
element("semantic-device").textContent = actualDevice;
let message: string;
if (error) message = error;
else if (status.warming)
message = "Preparing Google semantic search on your device…";
else if (status.downloading)
message =
status.downloaded_bytes >= status.total_bytes
? "Verifying the downloaded Google model…"
: `Downloading Google model · ${Math.round(status.downloaded_bytes / 1_000_000)} of ${Math.round(status.total_bytes / 1_000_000)} MB`;
else if (status.embedding || semanticJobActive)
message =
"Building your semantic index… Keyword search remains available.";
else if (status.ready && status.chunks > 0)
message = `Semantic search ready · ${status.chunks.toLocaleString()} passages${status.stale ? " · Updates pending" : ""}`;
else if (status.ready)
message = "Google model ready. Index a folder to search by meaning.";
else
message =
"Preparing local semantic search… Keyword search remains available.";
element("semantic-status").textContent = message;
element("semantic-status").classList.toggle("error", Boolean(error));
element("semantic-detail").textContent = status.worker_info
? `${status.model} · ${status.worker_info.runtime || "Local inference"} · Your files stay on this device.`
: "The verified Google model downloads automatically once (865 MB). After setup, inference runs locally.";
const retry = element<HTMLButtonElement>("retry-model");
retry.hidden = !error || status.ready;
retry.disabled = working;
const rebuild = element<HTMLButtonElement>("rebuild-semantic");
rebuild.hidden = !status.ready || (!status.chunks && !status.pending);
rebuild.disabled = working || indexingActive;
element<HTMLSelectElement>("cpu-threads").disabled =
working || indexingActive;
if (
status.ready &&
status.stale &&
status.pending > 0 &&
!working &&
!indexingActive
) {
const key = `${status.generation}:${status.pending}:${folderRevision}`;
if (automaticIndex.claim(key)) {
void buildSemanticIndex();
return;
}
}
if (
(!previousReady || previousGeneration !== status.generation) &&
canSearchSemantically() &&
latestSearch?.id === requestId
)
hybridLane.submit(latestSearch);
if (working || (!status.ready && !error)) queueSemanticRefresh();
} catch (error) {
element("semantic-status").textContent =
`Semantic search unavailable: ${String(error)}`;
element("semantic-status").classList.add("error");
element<HTMLButtonElement>("retry-model").hidden = false;
} finally {
semanticRefreshActive = false;
}
}
async function refreshDesktopStatus() {
const status = await invoke<DesktopStatus>("desktop_status");
indexingActive = status.indexing;
element("shortcut-status").textContent = status.shortcut_status;
element("focus-shortcut").textContent = status.shortcut_status.includes(
"Super",
)
? "Cmd K"
: "Ctrl K";
const threads = element<HTMLSelectElement>("cpu-threads");
threads.value = String(status.settings.cpu_threads);
threads.disabled =
status.indexing ||
semanticJobActive ||
Boolean(
semanticStatus?.downloading ||
semanticStatus?.warming ||
semanticStatus?.embedding,
);
element<HTMLButtonElement>("add-folder").disabled = status.indexing;
const roots = element("roots");
roots.replaceChildren();
for (const root of status.settings.roots) {
const item = document.createElement("li");
const path = document.createElement("span");
path.textContent = root;
path.className = "path";
const reindex = document.createElement("button");
reindex.type = "button";
reindex.textContent = "Reindex";
reindex.disabled = status.indexing;
reindex.addEventListener("click", () => {
element("folder-form").hidden = false;
element<HTMLInputElement>("folder").value = root;
element("folder").focus();
});
item.append(path, reindex);
roots.append(item);
}
if (!status.settings.roots.length) {
const item = document.createElement("li");
item.textContent = "No folders selected yet.";
roots.append(item);
}
if (status.indexing)
element("index-status").textContent =
"Indexing active · Search remains available";
return status;
}
function showError(error: unknown) {
notice.textContent = String(error);
notice.className = "error";
}
function select(index: number, focus = false) {
if (!results.length) return;
selected = Math.max(0, Math.min(index, results.length - 1));
const result = results[selected];
list.querySelectorAll<HTMLButtonElement>("button").forEach((button, i) => {
button.classList.toggle("selected", i === selected);
button.setAttribute("aria-current", String(i === selected));
button.tabIndex = i === selected ? 0 : -1;
if (i === selected && focus) button.focus();
});
element("preview").hidden = false;
const selectedIcon = fileIcon(result.filename);
element("preview-icon").innerHTML = selectedIcon.html;
element("preview-icon").dataset.type = selectedIcon.type;
element("preview-title").textContent = result.filename;
element("preview-path").textContent = result.path;
element("preview-kind").textContent =
`${result.match_kind} match${result.source ? ` · ${result.source.kind} ${result.source.number}` : ""}`;
element("preview-text").textContent =
result.snippet ||
(result.extraction_error
? `Content could not be extracted: ${result.extraction_error}`
: "No extracted text is available for this file.");
element<HTMLButtonElement>("open-result").title = result.source
? `Opens this file in your default application. ${result.source.kind} ${result.source.number} is shown here; exact-location opening is not supported by the default application interface.`
: "Open in your default application";
if (previewPath !== result.path) {
const passage = document.querySelector<HTMLElement>(".passage")!;
passage.scrollTop = 0;
if (!reducedMotion.matches) {
passage.getAnimations().forEach((animation) => animation.cancel());
passage.animate(
[
{ opacity: 0.65, transform: "translateY(3px)" },
{ opacity: 1, transform: "translateY(0)" },
],
{ duration: 150, easing: "ease-out" },
);
}
previewPath = result.path;
}
}
function render() {
const scrollTop = list.scrollTop;
const retained = new Set(results.map((result) => result.path));
for (const [key, entry] of resultRows) {
if (!retained.has(key)) {
entry.row.remove();
resultRows.delete(key);
}
}
element("empty").hidden = results.length > 0;
if (!results.length) {
element("preview").hidden = true;
previewPath = null;
}
results.forEach((result, i) => {
let entry = resultRows.get(result.path);
if (!entry) {
const row = document.createElement("li");
if (i < 8) row.className = "new-row";
const button = document.createElement("button");
button.className = "result";
button.type = "button";
const file = document.createElement("span");
file.className = "file-icon";
const title = document.createElement("strong");
const path = document.createElement("span");
path.className = "path";
const snippet = document.createElement("span");
snippet.className = "snippet";
const kind = document.createElement("span");
kind.className = "kind";
button.append(file, title, kind, snippet, path);
button.addEventListener("click", () =>
select(Number(button.dataset.index)),
);
row.append(button);
entry = { row, button, file, title, path, snippet, kind };
resultRows.set(result.path, entry);
}
entry.button.dataset.index = String(i);
entry.button.title = result.path;
entry.title.textContent = result.filename;
entry.path.textContent = result.path;
entry.snippet.textContent = result.snippet;
entry.kind.textContent = result.source
? `${result.source.kind} ${result.source.number}`
: result.match_kind;
const asset = fileIcon(result.filename);
if (entry.file.dataset.type !== asset.type) {
entry.file.innerHTML = asset.html;
entry.file.dataset.type = asset.type;
}
const positioned = list.children.item(i);
if (positioned !== entry.row) list.insertBefore(entry.row, positioned);
});
list.scrollTop = scrollTop;
}
function captureFilters() {
const input = (id: string) => element<HTMLInputElement>(id).value.trim();
const size = (id: string) => {
const text = input(id);
if (!text) return null;
const value = Number(text);
if (!Number.isSafeInteger(value) || value < 0)
throw new Error("File size must be a nonnegative whole number of bytes.");
return value;
};
// Date controls represent the user's local calendar day, including its end.
const date = (id: string, end: boolean) => {
const text = input(id);
if (!text) return null;
const stamp = new Date(
`${text}T${end ? "23:59:59.999" : "00:00:00"}`,
).getTime();
if (!Number.isFinite(stamp))
throw new Error("Enter a valid modification date.");
return stamp;
};
return {
folder: input("filter-folder") || null,
extension: input("filter-extension") || null,
min_size: size("filter-min"),
max_size: size("filter-max"),
modified_after_unix_ms: date("filter-after", false),
modified_before_unix_ms: date("filter-before", true),
};
}
function applyResults(
found: SearchResult[],
request: SearchRequest,
lane: "lexical" | "hybrid",
) {
if (
request.id !== requestId ||
(lane === "lexical" && shownLane === "hybrid")
)
return;
const selectedPath = results[selected]?.path;
const resultsHadFocus = list.contains(document.activeElement);
shownLane = lane;
displayedRequestId = request.id;
results = found;
selected = -1;
render();
const preserved = results.findIndex((result) => result.path === selectedPath);
if (preserved >= 0) select(preserved, resultsHadFocus);
else if (results.length) select(0, resultsHadFocus);
element("result-summary").textContent =
`${found.length} RESULTS · ${lane === "hybrid" ? "KEYWORD + MEANING" : "KEYWORD"} · ${Math.round(performance.now() - request.started)} MS`;
if (!found.length) refreshEmptySearchState();
}
const isCurrent = (request: SearchRequest) => request.id === requestId;
const lexicalLane = new LatestLane<SearchRequest>(
async (request) => {
const found = await invoke<SearchResult[]>("search", {
query: request.query,
filters: request.filters,
});
applyResults(found, request, "lexical");
},
isCurrent,
(_request, error) => {
if (shownLane !== "hybrid") {
showError(error);
element("result-summary").textContent = "KEYWORD SEARCH UNAVAILABLE";
}
},
);
const hybridLane = new LatestLane<SearchRequest>(
async (request) => {
if (!canSearchSemantically()) return;
const found = await invoke<SearchResult[]>("hybrid_search", {
query: request.query,
filters: request.filters,
});
applyResults(found, request, "hybrid");
},
isCurrent,
(_request, error) => {
querySemanticFailed = true;
refreshEmptySearchState();
element("semantic-status").textContent =
`Semantic results unavailable: ${String(error)}. Keyword results remain available.`;
queueSemanticRefresh();
},
);
function invalidateSearch() {
++requestId;
clearTimeout(timer);
clearTimeout(hybridTimer);
lexicalLane.clear();
hybridLane.clear();
latestSearch = null;
}
async function search() {
invalidateSearch();
querySemanticFailed = false;
shownLane = "lexical";
const value = query.value.trim();
if (!value) {
results = [];
displayedRequestId = null;
selected = -1;
render();
element("result-summary").textContent = "READY WHEN YOU ARE";
element("empty").querySelector("h2")!.textContent =
"Find what matters. Locally.";
element("empty").querySelector("p")!.textContent =
"Index a folder, then search a filename, phrase, or what you remember.";
return;
}
try {
const request: SearchRequest = {
id: requestId,
query: value,
filters: captureFilters(),
started: performance.now(),
};
latestSearch = request;
element("result-summary").textContent = "SEARCHING…";
lexicalLane.submit(request);
hybridTimer = setTimeout(() => {
if (isCurrent(request) && canSearchSemantically())
hybridLane.submit(request);
}, 350);
} catch (error) {
showError(error);
}
}
async function refreshStats() {
const stats = await invoke<IndexStats>("stats");
element("index-status").textContent =
`${stats.files.toLocaleString()} files · ${stats.content_files.toLocaleString()} with searchable content`;
}
query.addEventListener("input", () => {
invalidateSearch();
timer = setTimeout(search, 60);
});
element("search-form").addEventListener("submit", (event) => {
event.preventDefault();
clearTimeout(timer);
void search();
});
element("clear-query").addEventListener("click", () => {
query.value = "";
void search();
query.focus();
});
document.querySelectorAll<HTMLDetailsElement>(".popover").forEach((popover) => {
popover.addEventListener("toggle", () => {
if (popover.open)
document
.querySelectorAll<HTMLDetailsElement>(".popover")
.forEach((other) => {
if (other !== popover) other.open = false;
});
});
});
document.addEventListener("pointerdown", (event) => {
document
.querySelectorAll<HTMLDetailsElement>(".popover[open]")
.forEach((popover) => {
if (!popover.contains(event.target as Node)) popover.open = false;
});
});
element("add-folder").addEventListener("click", () => {
element("folder-form").hidden = false;
element("folder").focus();
});
element("cancel-folder").addEventListener("click", () => {
element("folder-form").hidden = true;
query.focus();
});
element("browse-folder").addEventListener("click", async () => {
try {
const folder = await invoke<string | null>("pick_folder");
if (folder) element<HTMLInputElement>("folder").value = folder;
} catch (error) {
showError(error);
}
});
element("filters").addEventListener("change", () => {
void search();
});
element("retry-model").addEventListener("click", async () => {
const button = element<HTMLButtonElement>("retry-model");
button.disabled = true;
localSemanticError = null;
element("semantic-status").textContent = "Retrying Google model setup…";
queueSemanticRefresh();
try {
await invoke("setup_model");
} catch (error) {
localSemanticError = String(error);
} finally {
button.disabled = false;
queueSemanticRefresh(0);
}
});
element("rebuild-semantic").addEventListener("click", () => {
void buildSemanticIndex();
});
async function openSelected(reveal: boolean) {
if (selected < 0 || !results[selected]) return;
try {
await invoke("open_result", { path: results[selected].path, reveal });
} catch (error) {
showError(error);
}
}
element("open-result").addEventListener("click", () => {
void openSelected(false);
});
element("reveal-result").addEventListener("click", () => {
void openSelected(true);
});
element("cpu-threads").addEventListener("change", async () => {
try {
await invoke<Settings>("set_cpu_threads", {
threads: Number(element<HTMLSelectElement>("cpu-threads").value),
});
await refreshDesktopStatus();
queueSemanticRefresh(0);
} catch (error) {
showError(error);
await refreshDesktopStatus().catch(showError);
}
});
element("folder-form").addEventListener("submit", async (event) => {
event.preventDefault();
const input = element<HTMLInputElement>("folder");
const buttons =
element("folder-form").querySelectorAll<HTMLButtonElement>("button");
buttons.forEach((button) => {
button.disabled = true;
});
element<HTMLButtonElement>("add-folder").disabled = true;
element<HTMLSelectElement>("cpu-threads").disabled = true;
indexingActive = true;
element("index-status").textContent =
"Indexing active · Search remains available";
notice.className = "";
notice.textContent = "Indexing folder… Search remains available.";
try {
const report = await invoke<IndexReport>("index_folder", {
root: input.value.trim(),
});
notice.textContent = `Indexed ${report.indexed} files. ${report.removed} removed; ${report.skipped} skipped.`;
element("folder-form").hidden = true;
folderRevision++;
localSemanticError = null;
await refreshStats();
await search();
} catch (error) {
showError(error);
} finally {
buttons.forEach((button) => {
button.disabled = false;
});
element<HTMLButtonElement>("add-folder").disabled = false;
await refreshDesktopStatus().catch(showError);
await refreshStats().catch(showError);
queueSemanticRefresh(0);
}
});
document.addEventListener("keydown", (event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
query.focus();
query.select();
}
if (event.key === "Escape") {
document
.querySelectorAll<HTMLDetailsElement>(".popover[open]")
.forEach((popover) => {
popover.open = false;
});
++requestId;
query.value = "";
void search();
query.focus();
}
if (event.target === query || list.contains(event.target as Node)) {
if (
event.key === "Enter" &&
results[selected] &&
displayedRequestId === requestId
) {
event.preventDefault();
void openSelected(false);
}
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
select(selected + (event.key === "ArrowDown" ? 1 : -1), true);
}
}
});
if (isTauri()) {
void refreshStats().catch(showError);
void refreshDesktopStatus().catch(showError);
void refreshSemanticStatus();
void listen("focus-search", () => {
query.focus();
query.select();
void refreshDesktopStatus().catch(showError);
queueSemanticRefresh(0);
}).catch(showError);
// Poll only during a user-triggered job; idle indexing status does no work.
setInterval(() => {
if (indexingActive) void refreshDesktopStatus().catch(showError);
}, 2000);
query.focus();
} else {
element("index-status").textContent = "Desktop preview";
element("semantic-status").textContent =
"Native semantic search runs in the desktop app.";
element("semantic-progress").hidden = true;
element("semantic-detail").textContent =
"This browser preview does not download models or return simulated search results.";
showError("Run the Tauri desktop app to index and search real files.");
}