1992 lines
77 KiB
TypeScript
1992 lines
77 KiB
TypeScript
import {
|
|
invoke,
|
|
initializeDesktopSurface,
|
|
isNativeDesktop,
|
|
isDesktopSurface,
|
|
isCaptureSurface,
|
|
prepareMediaURL,
|
|
} from "./desktop-api";
|
|
import { appearanceView, AppearanceUpdates } from "./appearance-state";
|
|
import type { AppearanceStatus, BackgroundMaterial } from "./appearance-state";
|
|
import { listen } from "@tauri-apps/api/event";
|
|
import {
|
|
AutomaticIndexGate,
|
|
emptySearchState,
|
|
LatestLane,
|
|
semanticAvailable,
|
|
scopedFilters,
|
|
hasSearchInput,
|
|
SEMANTIC_SETTLE_MS,
|
|
} from "./search-state";
|
|
import type { SemanticPhase, SearchFilters } from "./search-state";
|
|
import {
|
|
semanticIndexReady,
|
|
semanticIndexKey,
|
|
semanticRetryVisible,
|
|
semanticMediaReady,
|
|
} from "./semantic-setup-state";
|
|
import type { SemanticCoverage } from "./semantic-setup-state";
|
|
import { fileIcon, icon } from "./icons";
|
|
import {
|
|
SearchMeasurement,
|
|
measuredFailure,
|
|
spanDepth,
|
|
briefMs,
|
|
} from "./search-performance";
|
|
import type {
|
|
MeasuredResponse,
|
|
SearchLane,
|
|
SearchRefreshTrigger,
|
|
} from "./search-performance";
|
|
import { displayPath } from "./path-display";
|
|
import { ResultSelection } from "./selection-state";
|
|
import {
|
|
AiRevision,
|
|
aiConfigurationProblem,
|
|
aiQuestionProblem,
|
|
aiEndpointLabel,
|
|
aiSourceEligible,
|
|
} from "./ai-state";
|
|
import type { AiConfiguration } from "./ai-state";
|
|
import {
|
|
literalQuery,
|
|
renderHighlighted,
|
|
displayExcerpt,
|
|
matchExplanation,
|
|
baseMatchKind,
|
|
partitionResults,
|
|
} from "./match-display";
|
|
import "@fontsource-variable/inter/wght.css";
|
|
import "./style.css";
|
|
import {
|
|
mediaKind,
|
|
matchLabel,
|
|
sourceLabel,
|
|
seekSeconds,
|
|
PreviewRevision,
|
|
previewSelectionKey,
|
|
} from "./media-display";
|
|
|
|
interface SearchResult {
|
|
path: string;
|
|
filename: string;
|
|
snippet: string;
|
|
score: number;
|
|
match_kind: string;
|
|
source?: { kind: string; number: number; matched_query?: string };
|
|
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;
|
|
automatic_indexing?: boolean;
|
|
background_material?: BackgroundMaterial;
|
|
background_opacity?: number;
|
|
}
|
|
interface DesktopStatus {
|
|
settings: Settings;
|
|
indexing: boolean;
|
|
shortcut_status: string;
|
|
appearance?: AppearanceStatus;
|
|
watching?: {
|
|
root: string;
|
|
attached: boolean;
|
|
pending: boolean;
|
|
error: string | null;
|
|
watch_error: string | null;
|
|
}[];
|
|
}
|
|
interface SpeechStatus {
|
|
worker_ready: boolean;
|
|
ready: boolean;
|
|
downloading: boolean;
|
|
downloaded_bytes: number;
|
|
total_bytes: number;
|
|
error: string | null;
|
|
requested_policy: string | null;
|
|
pending: boolean;
|
|
corpus_revision: number;
|
|
failures: number;
|
|
}
|
|
interface SemanticStatus {
|
|
speech?: SpeechStatus;
|
|
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;
|
|
text_model_ready?: boolean;
|
|
media_model_ready?: boolean;
|
|
media_model_error?: string | null;
|
|
indexing_ready?: boolean;
|
|
requested_coverage?: SemanticCoverage;
|
|
coverage?: SemanticCoverage | null;
|
|
media_tools_ready?: boolean;
|
|
media_setup_error?: string | null;
|
|
media_failures?: { path: string; message: string }[];
|
|
worker_info?: {
|
|
model?: string;
|
|
device?: string;
|
|
runtime?: string;
|
|
threads?: number;
|
|
dimensions?: number;
|
|
media_supported?: boolean;
|
|
} | null;
|
|
}
|
|
interface SearchRequest {
|
|
id: number;
|
|
query: string;
|
|
filters: SearchFilters;
|
|
started: number;
|
|
measurement: SearchMeasurement;
|
|
}
|
|
|
|
document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
|
|
<header id="launcher-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"><details id="filters" class="popover"><summary>${icon("filter")}<span id="scope-label">All files</span>${icon("chevron")}</summary><div class="popover-panel"><h2>Filter your search</h2><div class="filters-grid"><label>Search scope<select id="filter-scope"><option value="all">All files</option><option value="code">Code</option></select></label><label>Within folder<input id="filter-folder" placeholder="Absolute folder path"></label><label>Extension<input id="filter-extension" placeholder="pdf, md, txt…"></label><label>Artist (exact)<input id="filter-artist" placeholder="Artist from music tags" maxlength="256"></label><label>Track title (exact)<input id="filter-title" placeholder="Title from music tags" maxlength="256"></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>Minimum duration (seconds)<input id="filter-duration-min" type="number" min="0" step="0.1"></label><label>Maximum duration (seconds)<input id="filter-duration-max" type="number" min="0" step="0.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>Settings</h2><section class="appearance-settings" aria-labelledby="appearance-heading"><h3 id="appearance-heading">Appearance</h3><label for="background-material">Window background</label><select id="background-material"><option value="solid">Solid</option><option value="glass">Glass</option><option value="frosted">Frosted glass</option></select><div class="appearance-opacity"><label for="background-opacity">Background opacity</label><output id="background-opacity-value" for="background-opacity">90%</output></div><input id="background-opacity" type="range" min="40" max="100" step="1" value="90" aria-describedby="appearance-status"><p id="appearance-status" class="hint" role="status">A subtle window background. Search results and passages stay clear.</p></section><h3>Folders & resources</h3><label class="performance-toggle"><input id="automatic-indexing" type="checkbox" checked>Search my device automatically</label><p class="hint">Your local drives are indexed in the background. Existing results remain searchable when this is off.</p><button id="add-folder" class="quiet-button" type="button">${icon("folderAdd")}<span>Add folder</span></button><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 === 1 ? "threads (default)" : i ? "threads" : "thread (low power)"}</option>`).join("")}</select><p class="hint">Search automatically uses supported acceleration with CPU fallback. Two or more threads let search run alongside indexing; one thread can delay semantic search during indexing.</p><ul id="roots" aria-label="Selected folders"></ul><label class="performance-toggle"><input id="performance-toggle" type="checkbox">Performance details</label><p class="hint">Local timing diagnostics for new searches. Queries and file paths are excluded from copied reports.</p><details id="ai-settings" class="ai-settings"><summary>Optional AI answers</summary><label class="performance-toggle"><input id="ai-enabled" type="checkbox">Enable summaries and questions</label><p class="hint">Disabled by default. Only an explicit action sends the shown passage to your configured endpoint.</p><label>OpenAI-compatible base URL<input id="ai-endpoint" type="url" placeholder="http://localhost:1234/v1" autocomplete="off" maxlength="2048"></label><label>Model<input id="ai-model" placeholder="Your model name" autocomplete="off" maxlength="200"></label><label>API key (optional, this session only)<input id="ai-key" type="password" autocomplete="off" maxlength="2048"></label><button id="ai-clear-key" type="button">Clear key</button><p class="hint">Endpoint, model and key stay in this app session. The key authenticates requests to your endpoint.</p></details></div></details><button id="setup-details" class="icon-button" type="button" aria-label="Local search setup" title="Local search setup">${icon("sparkles")}</button><button id="hide-launcher" class="icon-button" type="button" aria-label="Hide searchi" title="Hide searchi">${icon("dismiss")}</button></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><p id="speech-status" class="hint" role="status" hidden></p><progress id="speech-progress" aria-label="Local speech model setup progress" hidden></progress><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><details id="performance-panel" hidden><summary>Performance details</summary><div id="performance-detail"></div><button id="copy-performance" type="button">Copy local timing report</button><span id="copy-performance-status" role="status"></span></details><div class="workspace"><section class="results-pane" aria-label="Search results"><p id="result-summary" class="eyebrow">READY WHEN YOU ARE</p><ul id="application-results" aria-label="Applications" hidden></ul><ul id="results" aria-label="Matching files"></ul><details id="related-suggestions" hidden><summary>Related suggestions</summary><ul id="related-results" aria-label="Related file suggestions"></ul></details><div id="empty">${icon("search")}<h2>Find what matters. Locally.</h2><p>Your device is indexed in the background. Search an application, filename, phrase, or what you remember.</p></div></section><aside id="application-preview" aria-label="Application preview" hidden><div class="application-detail"><img src="/searchi-symbol.png" alt="" width="48" height="48"><h2 id="application-name"></h2><p class="hint">Installed application</p><button id="launch-application" type="button">${icon("open")} Launch application</button></div></aside><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><details id="match-details" class="match-details"><summary>Why this result?</summary><p id="match-reason"></p><p id="match-legend"></p></details><div id="media-preview" class="media-preview" hidden></div><p id="media-preview-status" class="hint" role="status" hidden></p><details id="ai-panel" class="ai-panel" hidden><summary>Summarize or ask</summary><p id="ai-disclosure" class="hint"></p><p class="eyebrow">PASSAGE THAT WILL BE SENT</p><pre id="ai-source-text"></pre><div class="ai-actions"><button id="ai-summarize" type="button" disabled>Summarize this passage</button><label class="sr-only" for="ai-question">Question about this passage</label><input id="ai-question" placeholder="Ask about this passage…" maxlength="2048"><button id="ai-ask" type="button" disabled>Ask</button></div><p id="ai-status" class="hint" role="status"></p><pre id="ai-answer"></pre><p id="ai-answer-label" class="hint"></p><button id="ai-citation" type="button" hidden>Selected source</button></details><div class="passage"><p id="passage-label" class="eyebrow">EXTRACTED PASSAGE</p><pre id="preview-text"></pre></div></aside></div>
|
|
</main><footer><span class="footer-privacy">${icon("lock")}<span id="privacy-summary">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>${isNativeDesktop() ? "Hide" : "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");
|
|
// Window sizing never delays a search request. Serialize changes so rapid typing
|
|
// cannot leave the native window in an older compact/expanded state.
|
|
let requestedExpansion = false;
|
|
let nativeExpansion: boolean | undefined;
|
|
let resizingLauncher = false;
|
|
function syncLauncher() {
|
|
let filtered = false;
|
|
try {
|
|
filtered = hasSearchInput(query.value, captureFilters());
|
|
} catch {
|
|
/* Invalid filters stay editable. */
|
|
}
|
|
requestedExpansion =
|
|
filtered ||
|
|
document.querySelector(".popover[open]") !== null ||
|
|
element<HTMLDetailsElement>("semantic-panel").open ||
|
|
!element("folder-form").hidden;
|
|
document.documentElement.dataset.launcherLayout = requestedExpansion
|
|
? "expanded"
|
|
: "compact";
|
|
if (isNativeDesktop() && !resizingLauncher) void resizeLauncher();
|
|
}
|
|
async function resizeLauncher() {
|
|
resizingLauncher = true;
|
|
try {
|
|
while (nativeExpansion !== requestedExpansion) {
|
|
const expanded = requestedExpansion;
|
|
await invoke("set_search_layout", { expanded });
|
|
nativeExpansion = expanded;
|
|
}
|
|
} catch (error) {
|
|
showError(error);
|
|
} finally {
|
|
resizingLauncher = false;
|
|
}
|
|
}
|
|
element("launcher-header").addEventListener("pointerdown", (event) => {
|
|
if (
|
|
isNativeDesktop() &&
|
|
event.button === 0 &&
|
|
!(event.target as Element).closest("button,summary,input,select,details")
|
|
) {
|
|
void invoke("drag_search").catch(showError);
|
|
}
|
|
});
|
|
element("hide-launcher").addEventListener("click", () => {
|
|
if (isNativeDesktop()) void invoke("hide_search").catch(showError);
|
|
else {
|
|
query.value = "";
|
|
void search();
|
|
query.focus();
|
|
}
|
|
});
|
|
element("setup-details").addEventListener("click", () => {
|
|
const panel = element<HTMLDetailsElement>("semantic-panel");
|
|
panel.open = !panel.open;
|
|
});
|
|
document
|
|
.querySelectorAll<HTMLDetailsElement>(".popover, #semantic-panel")
|
|
.forEach((panel) => {
|
|
panel.addEventListener("toggle", syncLauncher);
|
|
});
|
|
new MutationObserver(syncLauncher).observe(element("folder-form"), {
|
|
attributes: true,
|
|
attributeFilter: ["hidden"],
|
|
});
|
|
|
|
try {
|
|
element<HTMLInputElement>("performance-toggle").checked =
|
|
localStorage.getItem("searchi-performance-details") === "true";
|
|
} catch {
|
|
/* Diagnostics remain opt-in when storage is unavailable. */
|
|
}
|
|
let results: SearchResult[] = [];
|
|
let allResults: SearchResult[] = [];
|
|
const relatedDisclosure = element<HTMLDetailsElement>("related-suggestions");
|
|
const relatedList = element<HTMLUListElement>("related-results");
|
|
relatedDisclosure
|
|
.querySelector("summary")!
|
|
.addEventListener("keydown", (event) => {
|
|
if (event.key !== "ArrowDown") return;
|
|
event.preventDefault();
|
|
relatedDisclosure.open = true;
|
|
results = partitionResults(allResults, true).visible;
|
|
render();
|
|
const index = partitionResults(allResults).primary.length;
|
|
if (results[index]) select(index, true);
|
|
});
|
|
relatedDisclosure.addEventListener("toggle", () => {
|
|
const previous = results[selected]?.path;
|
|
results = partitionResults(allResults, relatedDisclosure.open).visible;
|
|
selected = -1;
|
|
render();
|
|
const retained = previous
|
|
? results.findIndex((r) => r.path === previous)
|
|
: -1;
|
|
if (retained >= 0) select(retained, false, false);
|
|
else if (results.length && selectedApplication < 0) select(0, false, false);
|
|
else if (selectedApplication < 0) element("preview").hidden = true;
|
|
});
|
|
interface ApplicationResult {
|
|
id: string;
|
|
name: string;
|
|
}
|
|
let applications: ApplicationResult[] = [];
|
|
let selectedApplication = -1;
|
|
let applicationRevision = 0;
|
|
let settledApplicationRevision = -1;
|
|
let applicationQuery = "";
|
|
function selectApplication(index: number, focus = false) {
|
|
if (!applications.length) return;
|
|
selectedApplication = Math.max(0, Math.min(index, applications.length - 1));
|
|
const app = applications[selectedApplication];
|
|
clearMedia();
|
|
element("preview").hidden = true;
|
|
element("application-preview").hidden = false;
|
|
element("application-name").textContent = app.name;
|
|
list.querySelectorAll("button").forEach((button) => {
|
|
button.classList.remove("selected");
|
|
button.setAttribute("aria-current", "false");
|
|
});
|
|
element("application-results")
|
|
.querySelectorAll<HTMLButtonElement>("button")
|
|
.forEach((button, i) => {
|
|
button.classList.toggle("selected", i === selectedApplication);
|
|
button.setAttribute("aria-current", String(i === selectedApplication));
|
|
button.tabIndex = i === selectedApplication ? 0 : -1;
|
|
if (focus && i === selectedApplication) button.focus();
|
|
});
|
|
}
|
|
async function launchApplication() {
|
|
const app = applications[selectedApplication];
|
|
if (
|
|
!app ||
|
|
!isNativeDesktop() ||
|
|
applicationQuery !== query.value.trim() ||
|
|
settledApplicationRevision !== applicationRevision
|
|
)
|
|
return;
|
|
try {
|
|
await invoke("launch_application", { id: app.id });
|
|
await invoke("hide_search");
|
|
} catch (error) {
|
|
showError(error);
|
|
}
|
|
}
|
|
async function refreshApplications() {
|
|
if (!isNativeDesktop()) return;
|
|
const value = query.value.trim();
|
|
const revision = ++applicationRevision;
|
|
const changed = value !== applicationQuery;
|
|
applicationQuery = value;
|
|
if (changed) selectedApplication = -1;
|
|
let filtersActive = false;
|
|
try {
|
|
const filters = captureFilters();
|
|
filtersActive = Boolean(filters.code_only) || hasSearchInput("", filters);
|
|
} catch {
|
|
filtersActive = true;
|
|
}
|
|
const found =
|
|
value && !filtersActive
|
|
? await invoke<ApplicationResult[]>("search_applications", {
|
|
query: value,
|
|
})
|
|
: [];
|
|
if (revision !== applicationRevision || query.value.trim() !== value) return;
|
|
settledApplicationRevision = revision;
|
|
applications = found;
|
|
const appList = element("application-results");
|
|
appList.replaceChildren();
|
|
appList.hidden = !found.length;
|
|
found.forEach((app, i) => {
|
|
const row = document.createElement("li");
|
|
const button = document.createElement("button");
|
|
button.className = "application-result";
|
|
button.type = "button";
|
|
const label = document.createElement("strong");
|
|
label.textContent = app.name;
|
|
const kind = document.createElement("span");
|
|
kind.className = "kind";
|
|
kind.textContent = "Application";
|
|
button.append(label, kind);
|
|
button.addEventListener("click", () => selectApplication(i));
|
|
button.addEventListener("dblclick", () => {
|
|
selectApplication(i);
|
|
void launchApplication();
|
|
});
|
|
row.append(button);
|
|
appList.append(row);
|
|
});
|
|
if (found.length && (changed || selectedApplication >= 0))
|
|
selectApplication(selectedApplication < 0 ? 0 : selectedApplication);
|
|
else if (!found.length) {
|
|
selectedApplication = -1;
|
|
element("application-preview").hidden = true;
|
|
}
|
|
element("empty").hidden = Boolean(found.length || results.length);
|
|
updateRelatedEmptyState();
|
|
const summary = element("result-summary");
|
|
const fileSummary = (summary.textContent ?? "").replace(
|
|
/^\d+ APPLICATIONS? � /,
|
|
"",
|
|
);
|
|
summary.textContent = found.length
|
|
? `${found.length} APPLICATION${found.length === 1 ? "" : "S"} � ${fileSummary}`
|
|
: fileSummary;
|
|
}
|
|
element("launch-application").addEventListener(
|
|
"click",
|
|
() => void launchApplication(),
|
|
);
|
|
element("automatic-indexing").addEventListener("change", async () => {
|
|
const checkbox = element<HTMLInputElement>("automatic-indexing");
|
|
checkbox.disabled = true;
|
|
try {
|
|
await invoke("set_automatic_indexing", { enabled: checkbox.checked });
|
|
await refreshDesktopStatus();
|
|
} catch (error) {
|
|
showError(error);
|
|
} finally {
|
|
checkbox.disabled = !isNativeDesktop();
|
|
}
|
|
});
|
|
|
|
let displayedLiteralQuery = literalQuery("");
|
|
let displayedCodeScope = false;
|
|
let explainedSelection = "";
|
|
let selected = -1;
|
|
const resultSelection = new ResultSelection();
|
|
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 localModelSetupError: string | null = null;
|
|
let querySemanticFailed = false;
|
|
let previewPath: string | null = null;
|
|
const previewRevision = new PreviewRevision();
|
|
interface PreviewRequest {
|
|
revision: number;
|
|
result: SearchResult;
|
|
}
|
|
function unloadMedia() {
|
|
element("media-preview")
|
|
.querySelectorAll<HTMLMediaElement>("audio,video")
|
|
.forEach((player) => {
|
|
player.pause();
|
|
player.removeAttribute("src");
|
|
player.load();
|
|
});
|
|
element("media-preview").replaceChildren();
|
|
element("media-preview").hidden = true;
|
|
element("media-preview-status").hidden = true;
|
|
}
|
|
function clearMedia() {
|
|
previewRevision.clear();
|
|
mediaLane.clear();
|
|
unloadMedia();
|
|
}
|
|
function showMediaFailure(request: PreviewRequest, message: string): void {
|
|
if (!previewRevision.accepts(request.revision)) return;
|
|
const status = element("media-preview-status");
|
|
status.hidden = false;
|
|
status.textContent = message;
|
|
const retry = document.createElement("button");
|
|
retry.type = "button";
|
|
retry.textContent = "Retry preview";
|
|
retry.addEventListener("click", () => {
|
|
const revision = previewRevision.retry(request.revision);
|
|
if (revision === undefined) return;
|
|
unloadMedia();
|
|
status.hidden = false;
|
|
status.textContent = "Preparing media preview…";
|
|
mediaLane.submit({ revision, result: request.result });
|
|
});
|
|
status.append(" ", retry);
|
|
}
|
|
const mediaLane = new LatestLane<PreviewRequest>(
|
|
async (request) => {
|
|
const prepared = await invoke<{
|
|
path: string;
|
|
kind: "image" | "audio" | "video";
|
|
}>("prepare_media_preview", { path: request.result.path });
|
|
if (!previewRevision.accepts(request.revision)) return;
|
|
const host = element("media-preview");
|
|
const status = element("media-preview-status");
|
|
const failed = () => {
|
|
showMediaFailure(
|
|
request,
|
|
"This preview could not be loaded. Retry or use Open file to view it in your default application.",
|
|
);
|
|
};
|
|
if (prepared.kind === "image") {
|
|
const image = document.createElement("img");
|
|
image.alt = request.result.filename;
|
|
image.addEventListener("error", failed, { once: true });
|
|
image.src = prepareMediaURL(prepared.path);
|
|
host.append(image);
|
|
} else {
|
|
const player = document.createElement(prepared.kind);
|
|
player.controls = true;
|
|
player.preload = "metadata";
|
|
player.addEventListener("error", failed, { once: true });
|
|
player.addEventListener(
|
|
"loadedmetadata",
|
|
() => {
|
|
if (!previewRevision.accepts(request.revision)) return;
|
|
const target = seekSeconds(request.result.source);
|
|
if (target !== undefined) {
|
|
if (!Number.isFinite(player.duration) || target > player.duration) {
|
|
status.hidden = false;
|
|
status.textContent =
|
|
"The matched timestamp is unavailable in this preview. Use Open file to check the recording.";
|
|
} else {
|
|
try {
|
|
player.currentTime = target;
|
|
} catch {
|
|
failed();
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{ once: true },
|
|
);
|
|
player.src = prepareMediaURL(prepared.path);
|
|
host.append(player);
|
|
}
|
|
host.hidden = false;
|
|
status.hidden = true;
|
|
},
|
|
(request) => previewRevision.accepts(request.revision),
|
|
(request, error) =>
|
|
showMediaFailure(
|
|
request,
|
|
`Preview unavailable: ${String(error)}. Use Open file.`,
|
|
),
|
|
);
|
|
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 ||
|
|
allResults.length ||
|
|
applications.length
|
|
)
|
|
return;
|
|
if (!latestSearch.query.trim()) {
|
|
element("empty").querySelector("h2")!.textContent =
|
|
"No matching music tags.";
|
|
element("empty").querySelector("p")!.textContent =
|
|
"Try the complete artist or track title from your files. Missing tags cannot match these filters.";
|
|
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 semanticAvailable(semanticStatus);
|
|
}
|
|
|
|
function queueSemanticRefresh(delay = 1000) {
|
|
clearTimeout(semanticTimer);
|
|
semanticTimer = setTimeout(() => {
|
|
void refreshSemanticStatus();
|
|
}, delay);
|
|
}
|
|
|
|
async function buildSemanticIndex() {
|
|
if (semanticJobActive || indexingActive) return;
|
|
if (semanticStatus && !semanticIndexReady(semanticStatus)) 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()} content segments.`;
|
|
} 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 setupError =
|
|
status.ready && status.media_model_error ? null : localModelSetupError;
|
|
const error = localSemanticError || status.error || setupError;
|
|
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.ready && status.text_model_ready
|
|
? `${semanticAvailable(status) ? "Text search ready" : "Text model ready"} · Preparing media model · ${Math.round(status.downloaded_bytes / 1_000_000)} of ${Math.round(status.total_bytes / 1_000_000)} MB`
|
|
: 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 (semanticAvailable(status) && status.chunks > 0)
|
|
message = `Semantic search ready · ${status.chunks.toLocaleString()} content segments${status.stale ? " · Updates pending" : ""}${status.media_failures?.length ? ` · ${status.media_failures.length} files could not be indexed` : ""}`;
|
|
else if (status.ready)
|
|
message =
|
|
"Google model ready. Your files become searchable by meaning as background indexing completes.";
|
|
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 mediaDetail = status.media_setup_error
|
|
? `Media decoder setup needs attention: ${status.media_setup_error}. Text search remains available.`
|
|
: status.worker_info && !status.worker_info.media_supported
|
|
? "The installed worker needs a media-capable runtime update. Text search remains available."
|
|
: semanticMediaReady(status) &&
|
|
status.media_tools_ready &&
|
|
status.worker_info?.media_supported
|
|
? "Image, audio, and video encoders are available locally."
|
|
: "Preparing local image, audio, and video support.";
|
|
element("semantic-detail").textContent += ` ${mediaDetail}`;
|
|
if (status.media_model_error)
|
|
element("semantic-detail").textContent +=
|
|
` Media model setup needs attention: ${status.media_model_error}. Text search remains available.`;
|
|
if (status.media_failures?.length) {
|
|
element("semantic-detail").textContent +=
|
|
` ${status.media_failures.length} files could not be indexed. The first issue: ${status.media_failures[0].path}: ${status.media_failures[0].message}. Filename search remains available; rebuild after fixing the file.`;
|
|
}
|
|
const speech = status.speech;
|
|
const speechStatus = element("speech-status");
|
|
speechStatus.hidden = !speech;
|
|
const speechProgress = element<HTMLProgressElement>("speech-progress");
|
|
speechProgress.hidden = !speech?.downloading;
|
|
if (speech) {
|
|
speechProgress.max = speech.total_bytes;
|
|
speechProgress.value = speech.downloaded_bytes;
|
|
speechStatus.textContent = speech.downloading
|
|
? `Preparing local speech passages · ${Math.round(speech.downloaded_bytes / 1_000_000)} of ${Math.round(speech.total_bytes / 1_000_000)} MB`
|
|
: speech.error
|
|
? `Speech passages: ${speech.error}`
|
|
: speech.ready
|
|
? speech.failures
|
|
? `Speech passages incomplete for ${speech.failures} files. Other search results remain available.`
|
|
: speech.pending
|
|
? "Speech models ready · Spoken passages are waiting to be indexed."
|
|
: "Local speech passages ready. Recognized words and timings can contain errors."
|
|
: "Local speech passages are preparing. Filename and existing content search remain available.";
|
|
speechStatus.textContent = `Experimental speech search · ${speechStatus.textContent}`;
|
|
}
|
|
const retry = element<HTMLButtonElement>("retry-model");
|
|
retry.hidden =
|
|
!semanticRetryVisible({ ...status, error }) && !speech?.error;
|
|
retry.disabled =
|
|
working || Boolean(speech?.downloading) || isCaptureSurface();
|
|
const rebuild = element<HTMLButtonElement>("rebuild-semantic");
|
|
rebuild.hidden = !status.ready || (!status.chunks && !status.pending);
|
|
rebuild.disabled =
|
|
!semanticIndexReady(status) ||
|
|
semanticJobActive ||
|
|
indexingActive ||
|
|
isCaptureSurface();
|
|
element<HTMLSelectElement>("cpu-threads").disabled =
|
|
working || indexingActive || isCaptureSurface();
|
|
if (
|
|
!isCaptureSurface() &&
|
|
status.ready &&
|
|
((status.stale && status.pending > 0) ||
|
|
(Boolean(speech?.pending) &&
|
|
Boolean(status.generation !== null || status.pending))) &&
|
|
semanticIndexReady(status) &&
|
|
!semanticJobActive &&
|
|
!indexingActive
|
|
) {
|
|
const key = semanticIndexKey(status, folderRevision);
|
|
if (automaticIndex.claim(key)) {
|
|
void buildSemanticIndex();
|
|
return;
|
|
}
|
|
}
|
|
if (
|
|
(!previousReady || previousGeneration !== status.generation) &&
|
|
canSearchSemantically() &&
|
|
latestSearch?.id === requestId
|
|
)
|
|
refreshIndexedSearch("semantic_activation");
|
|
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;
|
|
}
|
|
}
|
|
|
|
const highContrast = window.matchMedia("(forced-colors: active)");
|
|
const appearanceUpdates = new AppearanceUpdates(
|
|
(update) =>
|
|
invoke<AppearanceStatus>(
|
|
update.preference ? "set_appearance" : "apply_appearance",
|
|
{ ...update.preference, highContrast: update.highContrast },
|
|
),
|
|
(status) => renderAppearance(status),
|
|
showError,
|
|
() => {
|
|
renderAppearance();
|
|
void refreshDesktopStatus(true).catch(showError);
|
|
},
|
|
);
|
|
let lastAppearance: AppearanceStatus | undefined;
|
|
function renderAppearance(
|
|
status?: AppearanceStatus,
|
|
settings?: Settings,
|
|
restoreControls = false,
|
|
) {
|
|
if (status) lastAppearance = status;
|
|
const material = element<HTMLSelectElement>("background-material");
|
|
const opacity = element<HTMLInputElement>("background-opacity");
|
|
if (
|
|
!appearanceUpdates.busy &&
|
|
settings &&
|
|
(restoreControls || document.activeElement !== opacity)
|
|
) {
|
|
material.value = settings.background_material ?? "frosted";
|
|
opacity.value = String(settings.background_opacity ?? 90);
|
|
}
|
|
const view = appearanceView(lastAppearance, highContrast.matches);
|
|
document.documentElement.dataset.backgroundMaterial = view.material;
|
|
document.documentElement.style.setProperty(
|
|
"--window-opacity",
|
|
String(view.opacity / 100),
|
|
);
|
|
element("background-opacity-value").textContent = `${opacity.value}%`;
|
|
material.disabled = !isNativeDesktop() || appearanceUpdates.busy;
|
|
opacity.disabled =
|
|
!isNativeDesktop() ||
|
|
appearanceUpdates.busy ||
|
|
material.value === "solid" ||
|
|
highContrast.matches;
|
|
element("appearance-status").textContent = view.message;
|
|
}
|
|
function saveAppearance() {
|
|
if (!isNativeDesktop()) return;
|
|
appearanceUpdates.submit(highContrast.matches, {
|
|
material: element<HTMLSelectElement>("background-material")
|
|
.value as BackgroundMaterial,
|
|
opacity: Number(element<HTMLInputElement>("background-opacity").value),
|
|
});
|
|
renderAppearance();
|
|
}
|
|
element("background-material").addEventListener("change", () => {
|
|
void saveAppearance();
|
|
});
|
|
element("background-opacity").addEventListener("input", () => {
|
|
const opacity = element<HTMLInputElement>("background-opacity").value;
|
|
element("background-opacity-value").textContent = `${opacity}%`;
|
|
// Preview only an already confirmed compositor material. Persist on release.
|
|
if (!highContrast.matches && lastAppearance?.native_effect) {
|
|
document.documentElement.style.setProperty(
|
|
"--window-opacity",
|
|
String(Number(opacity) / 100),
|
|
);
|
|
}
|
|
});
|
|
element("background-opacity").addEventListener("change", () => {
|
|
void saveAppearance();
|
|
});
|
|
highContrast.addEventListener("change", () => {
|
|
renderAppearance(); // Fail closed immediately, before the native effect changes.
|
|
if (isNativeDesktop()) appearanceUpdates.submit(highContrast.matches);
|
|
});
|
|
|
|
async function refreshDesktopStatus(restoreAppearanceControls = false) {
|
|
const appearanceRevision = appearanceUpdates.revision;
|
|
const status = await invoke<DesktopStatus>("desktop_status");
|
|
if (
|
|
!appearanceUpdates.busy &&
|
|
appearanceRevision === appearanceUpdates.revision
|
|
)
|
|
renderAppearance(
|
|
status.appearance,
|
|
status.settings,
|
|
restoreAppearanceControls,
|
|
);
|
|
indexingActive = status.indexing;
|
|
element<HTMLInputElement>("automatic-indexing").checked =
|
|
status.settings.automatic_indexing ?? true;
|
|
element<HTMLInputElement>("automatic-indexing").disabled = !isNativeDesktop();
|
|
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 =
|
|
isCaptureSurface() ||
|
|
status.indexing ||
|
|
semanticJobActive ||
|
|
Boolean(
|
|
semanticStatus?.downloading ||
|
|
semanticStatus?.warming ||
|
|
semanticStatus?.embedding,
|
|
);
|
|
element<HTMLButtonElement>("add-folder").disabled =
|
|
status.indexing || isCaptureSurface();
|
|
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 = displayPath(root);
|
|
path.className = "path";
|
|
const watched = status.watching?.find((entry) => entry.root === root);
|
|
if (watched) {
|
|
path.title = watched.error
|
|
? `Automatic updates need attention: ${watched.error}`
|
|
: watched.pending
|
|
? "Checking for file changes…"
|
|
: watched.attached
|
|
? "Watching for file changes"
|
|
: "Checking for changes automatically";
|
|
}
|
|
const reindex = document.createElement("button");
|
|
reindex.type = "button";
|
|
reindex.textContent = "Reindex";
|
|
reindex.disabled = status.indexing || isCaptureSurface();
|
|
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 =
|
|
"Searching your device · Ready to search";
|
|
return status;
|
|
}
|
|
|
|
function showError(error: unknown) {
|
|
notice.textContent = String(error);
|
|
notice.className = "error";
|
|
}
|
|
interface AiPreview {
|
|
id: string;
|
|
text: string;
|
|
location: string;
|
|
}
|
|
interface AiAnswer {
|
|
text: string;
|
|
citations: string[];
|
|
model: string;
|
|
}
|
|
const aiRevision = new AiRevision();
|
|
let aiSelectionKey = "";
|
|
let aiPrepared: AiPreview | null = null;
|
|
let aiRunning = false;
|
|
function aiConfiguration(): AiConfiguration {
|
|
return {
|
|
enabled: element<HTMLInputElement>("ai-enabled").checked,
|
|
base_url: element<HTMLInputElement>("ai-endpoint").value.trim(),
|
|
model: element<HTMLInputElement>("ai-model").value.trim(),
|
|
api_key: element<HTMLInputElement>("ai-key").value || null,
|
|
};
|
|
}
|
|
function clearAiAnswer() {
|
|
element("ai-answer").textContent = "";
|
|
element("ai-answer-label").textContent = "";
|
|
element("ai-citation").hidden = true;
|
|
}
|
|
function updateAiControls() {
|
|
const config = aiConfiguration();
|
|
const panel = element<HTMLDetailsElement>("ai-panel");
|
|
panel.hidden = !config.enabled;
|
|
const result = results[selected];
|
|
const eligible = aiSourceEligible(
|
|
result?.source,
|
|
Boolean(result && mediaKind(result.filename)),
|
|
);
|
|
const problem = aiConfigurationProblem(config);
|
|
const blocked =
|
|
!isNativeDesktop() ||
|
|
Boolean(problem) ||
|
|
!eligible ||
|
|
!aiPrepared ||
|
|
aiRunning;
|
|
element<HTMLButtonElement>("ai-summarize").disabled = blocked;
|
|
element<HTMLButtonElement>("ai-ask").disabled =
|
|
blocked ||
|
|
Boolean(aiQuestionProblem(element<HTMLInputElement>("ai-question").value));
|
|
element("ai-disclosure").textContent = !isNativeDesktop()
|
|
? "Optional AI actions are available only in the desktop app."
|
|
: !eligible
|
|
? "Select a result with an indexed text passage to summarize or ask a question."
|
|
: problem
|
|
? `${problem} Configuration stays in this app session.`
|
|
: `Clicking Summarize or Ask sends the exact passage shown below and, for Ask, your question to ${aiEndpointLabel(config.base_url)}. File names and folder paths aren't added. Your session key authenticates that endpoint.`;
|
|
element("privacy-summary").textContent =
|
|
aiRunning && !config.enabled
|
|
? "Local search. An earlier optional AI request is finishing."
|
|
: config.enabled
|
|
? "Local search. Optional AI shares the shown passage only when requested."
|
|
: "Private search. Everything stays on your device.";
|
|
}
|
|
const aiPreparation = new LatestLane<{
|
|
revision: number;
|
|
path: string;
|
|
source: { kind: string; number: number };
|
|
}>(
|
|
async (request) => {
|
|
const preview = await invoke<AiPreview>("prepare_ai_source", {
|
|
enabled: true,
|
|
path: request.path,
|
|
source: request.source,
|
|
revision: request.revision,
|
|
});
|
|
if (!aiRevision.accepts(request.revision)) return;
|
|
aiPrepared = preview;
|
|
element("ai-source-text").textContent = preview.text;
|
|
element("ai-status").textContent =
|
|
`Review the passage beginning from ${preview.location}. Only this shown excerpt will be sent.`;
|
|
updateAiControls();
|
|
},
|
|
(request) => aiRevision.accepts(request.revision),
|
|
(_request, error) => {
|
|
element("ai-status").textContent = String(error);
|
|
updateAiControls();
|
|
},
|
|
);
|
|
function prepareAiSource() {
|
|
const result = results[selected];
|
|
if (
|
|
!aiConfiguration().enabled ||
|
|
!isNativeDesktop() ||
|
|
!result?.source ||
|
|
!aiSourceEligible(result.source, Boolean(mediaKind(result.filename)))
|
|
)
|
|
return;
|
|
element("ai-status").textContent =
|
|
"Preparing the selected local passage. Nothing is sent to the endpoint.";
|
|
aiPreparation.submit({
|
|
revision: aiRevision.current(),
|
|
path: result.path,
|
|
source: { kind: result.source.kind, number: result.source.number },
|
|
});
|
|
}
|
|
function updateAiSelection(key: string) {
|
|
if (aiSelectionKey !== key) {
|
|
aiSelectionKey = key;
|
|
aiRevision.select(key);
|
|
aiPreparation.clear();
|
|
aiPrepared = null;
|
|
element("ai-source-text").textContent = "";
|
|
element<HTMLInputElement>("ai-question").value = "";
|
|
element("ai-status").textContent = "";
|
|
clearAiAnswer();
|
|
updateAiControls();
|
|
if (element<HTMLDetailsElement>("ai-panel").open) prepareAiSource();
|
|
}
|
|
}
|
|
async function requestAi(mode: "summary" | "question") {
|
|
const config = aiConfiguration();
|
|
const question = element<HTMLInputElement>("ai-question").value.trim();
|
|
const problem =
|
|
aiConfigurationProblem(config) ||
|
|
(mode === "question" ? aiQuestionProblem(question) : null);
|
|
if (problem || !aiPrepared || !isNativeDesktop() || aiRunning) {
|
|
element("ai-status").textContent =
|
|
problem || "Prepare and review the current selected passage first.";
|
|
return;
|
|
}
|
|
const revision = aiRevision.current();
|
|
const source = aiPrepared;
|
|
aiRunning = true;
|
|
clearAiAnswer();
|
|
updateAiControls();
|
|
element("ai-status").textContent =
|
|
`Sending the shown passage${mode === "question" ? " and question" : ""} to ${aiEndpointLabel(config.base_url)}…`;
|
|
const started = performance.now();
|
|
try {
|
|
const answer = await invoke<AiAnswer>("generate_ai", {
|
|
input: {
|
|
enabled: true,
|
|
config: {
|
|
base_url: config.base_url,
|
|
model: config.model,
|
|
api_key: config.api_key,
|
|
},
|
|
source_id: source.id,
|
|
mode,
|
|
question: mode === "question" ? question : null,
|
|
},
|
|
});
|
|
if (!aiRevision.accepts(revision)) return;
|
|
element("ai-answer").textContent = answer.text;
|
|
element("ai-answer-label").textContent =
|
|
`Model-generated ${answer.citations.length ? "draft" : "uncited draft"} · ${answer.model} · ${briefMs(performance.now() - started)} ms. Verify against the passage.`;
|
|
element("ai-status").textContent = "";
|
|
const citation = element<HTMLButtonElement>("ai-citation");
|
|
citation.hidden = !answer.citations.includes("selected");
|
|
citation.textContent = `Source: ${source.location}`;
|
|
} catch (error) {
|
|
if (aiRevision.accepts(revision))
|
|
element("ai-status").textContent = String(error);
|
|
} finally {
|
|
aiRunning = false;
|
|
updateAiControls();
|
|
}
|
|
}
|
|
element("ai-panel").addEventListener("toggle", () => {
|
|
if (element<HTMLDetailsElement>("ai-panel").open && !aiPrepared)
|
|
prepareAiSource();
|
|
});
|
|
for (const id of ["ai-enabled", "ai-endpoint", "ai-model", "ai-key"]) {
|
|
element(id).addEventListener("input", () => {
|
|
aiRevision.invalidate();
|
|
aiPreparation.clear();
|
|
aiPrepared = null;
|
|
clearAiAnswer();
|
|
element("ai-source-text").textContent = "";
|
|
updateAiControls();
|
|
if (element<HTMLDetailsElement>("ai-panel").open) prepareAiSource();
|
|
});
|
|
}
|
|
element("ai-question").addEventListener("input", () => {
|
|
aiRevision.invalidate();
|
|
clearAiAnswer();
|
|
updateAiControls();
|
|
if (!aiPrepared && element<HTMLDetailsElement>("ai-panel").open)
|
|
prepareAiSource();
|
|
});
|
|
element("ai-clear-key").addEventListener("click", () => {
|
|
element<HTMLInputElement>("ai-key").value = "";
|
|
element("ai-key").dispatchEvent(new Event("input"));
|
|
});
|
|
element("ai-summarize").addEventListener("click", () => {
|
|
void requestAi("summary");
|
|
});
|
|
element("ai-ask").addEventListener("click", () => {
|
|
void requestAi("question");
|
|
});
|
|
element("ai-citation").addEventListener("click", () => {
|
|
element("ai-source-text").scrollIntoView({
|
|
behavior: reducedMotion.matches ? "auto" : "smooth",
|
|
block: "nearest",
|
|
});
|
|
});
|
|
function select(index: number, focus = false, userChoice = true) {
|
|
if (!results.length) return;
|
|
selectedApplication = -1;
|
|
element("application-preview").hidden = true;
|
|
element("application-results")
|
|
.querySelectorAll("button")
|
|
.forEach((button) => {
|
|
button.classList.remove("selected");
|
|
button.setAttribute("aria-current", "false");
|
|
});
|
|
selected = Math.max(0, Math.min(index, results.length - 1));
|
|
const result = results[selected];
|
|
if (userChoice) resultSelection.choose(result.path);
|
|
document
|
|
.querySelectorAll<HTMLButtonElement>(
|
|
"#results button, #related-results 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;
|
|
renderHighlighted(
|
|
element("preview-title"),
|
|
result.filename,
|
|
displayedLiteralQuery,
|
|
);
|
|
renderHighlighted(
|
|
element("preview-path"),
|
|
displayPath(result.path),
|
|
displayedLiteralQuery,
|
|
);
|
|
element("preview-kind").textContent =
|
|
`${matchLabel(baseMatchKind(result.match_kind))}${result.source ? ` · ${sourceLabel(result.source)}` : ""}`;
|
|
const media = mediaKind(result.filename);
|
|
const explanation = matchExplanation(result);
|
|
const semanticMedia = [
|
|
"semantic_image",
|
|
"semantic_video",
|
|
"semantic_audio",
|
|
].includes(baseMatchKind(result.match_kind));
|
|
element("passage-label").textContent = semanticMedia
|
|
? "MATCHED MEDIA"
|
|
: explanation.semanticPassage
|
|
? "MATCHED PASSAGE"
|
|
: "EXTRACTED PASSAGE";
|
|
const passageText =
|
|
result.snippet ||
|
|
(result.extraction_error
|
|
? `Content could not be extracted: ${result.extraction_error}`
|
|
: media
|
|
? "Preview the matched media below."
|
|
: "No extracted text is available for this file.");
|
|
element("preview-text").classList.toggle("code-passage", displayedCodeScope);
|
|
renderHighlighted(
|
|
element("preview-text"),
|
|
passageText,
|
|
semanticMedia ? literalQuery("") : displayedLiteralQuery,
|
|
);
|
|
document
|
|
.querySelector<HTMLElement>(".passage")!
|
|
.classList.toggle("semantic-passage", explanation.semanticPassage);
|
|
element("match-reason").textContent = explanation.reason;
|
|
element("match-legend").textContent = explanation.legend;
|
|
const explanationKey = JSON.stringify([
|
|
result.path,
|
|
result.match_kind,
|
|
result.source?.kind,
|
|
result.source?.number,
|
|
displayedLiteralQuery.query,
|
|
]);
|
|
if (explanationKey !== explainedSelection)
|
|
element<HTMLDetailsElement>("match-details").open = false;
|
|
explainedSelection = explanationKey;
|
|
updateAiSelection(explanationKey);
|
|
element<HTMLButtonElement>("open-result").title = result.source
|
|
? `Open in your default application. The matched location ${sourceLabel(result.source)} is shown in Searchi; default application opening starts at its own location.`
|
|
: "Open in your default application";
|
|
const selection = previewRevision.select(
|
|
previewSelectionKey(result.path, result.source, folderRevision),
|
|
);
|
|
if (selection.changed) {
|
|
mediaLane.clear();
|
|
unloadMedia();
|
|
if (media && isDesktopSurface()) {
|
|
element("media-preview-status").hidden = false;
|
|
element("media-preview-status").textContent = "Preparing media preview…";
|
|
mediaLane.submit({ revision: selection.revision, result });
|
|
}
|
|
}
|
|
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 updateRelatedEmptyState() {
|
|
const partition = partitionResults(allResults);
|
|
if (
|
|
!partition.primary.length &&
|
|
partition.related.length &&
|
|
!applications.length
|
|
) {
|
|
element("empty").querySelector("h2")!.textContent = "No strong matches";
|
|
element("empty").querySelector("p")!.textContent =
|
|
"You can explore the related suggestions.";
|
|
}
|
|
}
|
|
|
|
function render() {
|
|
const scrollTop = list.scrollTop;
|
|
const partition = partitionResults(allResults, relatedDisclosure.open);
|
|
relatedDisclosure.hidden = partition.related.length === 0;
|
|
relatedDisclosure.querySelector("summary")!.textContent =
|
|
`Related suggestions (${partition.related.length})`;
|
|
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 || applications.length > 0;
|
|
updateRelatedEmptyState();
|
|
if (!results.length) {
|
|
element("preview").hidden = true;
|
|
previewPath = null;
|
|
clearMedia();
|
|
updateAiSelection("");
|
|
}
|
|
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 = displayPath(result.path);
|
|
renderHighlighted(entry.title, result.filename, displayedLiteralQuery);
|
|
renderHighlighted(
|
|
entry.path,
|
|
displayPath(result.path),
|
|
displayedLiteralQuery,
|
|
);
|
|
const semanticMedia = [
|
|
"semantic_image",
|
|
"semantic_video",
|
|
"semantic_audio",
|
|
].includes(baseMatchKind(result.match_kind));
|
|
entry.snippet.classList.toggle("code-snippet", displayedCodeScope);
|
|
renderHighlighted(
|
|
entry.snippet,
|
|
displayExcerpt(result.snippet, displayedLiteralQuery),
|
|
semanticMedia ? literalQuery("") : displayedLiteralQuery,
|
|
);
|
|
entry.kind.textContent = `${matchLabel(baseMatchKind(result.match_kind))}${result.source ? ` · ${sourceLabel(result.source)}` : ""}`;
|
|
const asset = fileIcon(result.filename);
|
|
if (entry.file.dataset.type !== asset.type) {
|
|
entry.file.innerHTML = asset.html;
|
|
entry.file.dataset.type = asset.type;
|
|
}
|
|
const target = result.match_kind.endsWith("_related") ? relatedList : list;
|
|
const position = result.match_kind.endsWith("_related")
|
|
? i - partition.primary.length
|
|
: i;
|
|
const positioned = target.children.item(position);
|
|
if (positioned !== entry.row) target.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;
|
|
};
|
|
const duration = (id: string) => {
|
|
const text = input(id);
|
|
if (!text) return null;
|
|
const ms = Math.round(Number(text) * 1000);
|
|
if (!Number.isFinite(ms) || ms < 0 || ms > 4_294_967_295)
|
|
throw new Error("Enter a valid nonnegative media duration in seconds.");
|
|
return ms;
|
|
};
|
|
// 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 scopedFilters(element<HTMLSelectElement>("filter-scope").value, {
|
|
folder: input("filter-folder") || null,
|
|
extension: input("filter-extension") || null,
|
|
media_artist: input("filter-artist") || null,
|
|
media_title: input("filter-title") || null,
|
|
min_duration_ms: duration("filter-duration-min"),
|
|
max_duration_ms: duration("filter-duration-max"),
|
|
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 renderStarted = performance.now();
|
|
const resultsHadFocus =
|
|
list.contains(document.activeElement) ||
|
|
relatedList.contains(document.activeElement);
|
|
shownLane = lane;
|
|
displayedRequestId = request.id;
|
|
displayedLiteralQuery = literalQuery(request.query);
|
|
displayedCodeScope = request.filters.code_only === true;
|
|
allResults = found;
|
|
results = partitionResults(found, relatedDisclosure.open).visible;
|
|
selected = -1;
|
|
render();
|
|
const preserved = resultSelection.resolve(
|
|
results.map((result) => result.path),
|
|
);
|
|
if (preserved >= 0 && selectedApplication < 0)
|
|
select(preserved, resultsHadFocus, false);
|
|
if (!found.length) refreshEmptySearchState();
|
|
request.measurement.displayed(
|
|
lane,
|
|
renderStarted,
|
|
performance.now(),
|
|
results.length,
|
|
);
|
|
const metric = request.measurement.latestNative(lane);
|
|
const trace = request.measurement.export();
|
|
element("result-summary").textContent =
|
|
`${partitionResults(found).primary.length ? `${partitionResults(found).primary.length} RESULTS` : found.length ? "NO STRONG MATCHES" : "0 RESULTS"} · ${lane === "hybrid" ? "KEYWORD + MEANING" : "KEYWORD"} · SEARCH ${briefMs(metric?.total_ms ?? 0)} MS · SHOWN ${briefMs(trace.latest_displayed_ms ?? 0)} MS`;
|
|
refreshPerformance(request);
|
|
}
|
|
|
|
function refreshPerformance(request: SearchRequest) {
|
|
if (request.id !== requestId) return;
|
|
const panel = element<HTMLDetailsElement>("performance-panel");
|
|
panel.hidden = !request.measurement.debug;
|
|
if (!request.measurement.debug) return;
|
|
const body = element("performance-detail");
|
|
body.replaceChildren();
|
|
const report = request.measurement.export();
|
|
const append = (text: string, className = "") => {
|
|
const row = document.createElement("p");
|
|
row.textContent = text;
|
|
row.className = className;
|
|
body.append(row);
|
|
return row;
|
|
};
|
|
append(
|
|
"Times below use separate UI and native clocks. Nested spans and parallel lanes must not be added together. DOM update measures synchronous changes, not paint or deferred media preview loading.",
|
|
"hint",
|
|
);
|
|
append(
|
|
`Input debounce ${report.debounce_ms.toFixed(1)} ms · First displayed response ${report.first_results_ms?.toFixed(1) ?? "pending"} ms · Latest displayed ${report.latest_displayed_ms?.toFixed(1) ?? "pending"} ms · Hybrid final ${report.hybrid_final_results_ms?.toFixed(1) ?? "pending / unavailable"} ms`,
|
|
);
|
|
for (const [lane, delay] of Object.entries(report.lane_schedule_ms))
|
|
append(
|
|
`${lane}: scheduled ${delay.toFixed(1)} ms after debounce${lane === "hybrid" ? ` (includes intentional ${SEMANTIC_SETTLE_MS} ms semantic delay or waiting for readiness)` : ""}`,
|
|
);
|
|
for (const attempt of report.attempts) {
|
|
append(
|
|
`${attempt.lane} attempt ${attempt.attempt}${attempt.failed ? " · FAILED" : ""}: pending queue ${attempt.queued_ms.toFixed(1)} ms · retry wait ${attempt.retry_wait_ms.toFixed(1)} ms · invoke ${attempt.invoke_ms.toFixed(1)} ms`,
|
|
"performance-attempt",
|
|
);
|
|
if (!attempt.native) {
|
|
append("Native timing unavailable for this failure.", "hint");
|
|
continue;
|
|
}
|
|
append(
|
|
`Native command ${attempt.native.total_ms.toFixed(1)} ms · ${attempt.native.result_count} results · estimated transport / serialization / scheduling ${attempt.transport_estimate_ms?.toFixed(1)} ms`,
|
|
"hint",
|
|
);
|
|
if (attempt.native.trace === null)
|
|
append("Detailed native trace unavailable for this attempt.", "hint");
|
|
const spans = attempt.native.trace ?? [];
|
|
spans.forEach((span, index) => {
|
|
const counts = Object.entries(span.counters)
|
|
.map(([name, value]) =>
|
|
name.endsWith("_us")
|
|
? `${name.slice(0, -3).replaceAll("_", " ")}: ${(value / 1000).toFixed(3)} ms`
|
|
: `${name.replaceAll("_", " ")}: ${value}`,
|
|
)
|
|
.join(" · ");
|
|
const row = append(
|
|
`${span.name}: ${span.duration_ms.toFixed(2)} ms · starts +${span.start_ms.toFixed(2)} ms${counts ? ` · ${counts}` : ""}`,
|
|
"performance-span",
|
|
);
|
|
row.style.paddingLeft = `${spanDepth(spans, index) * 14}px`;
|
|
});
|
|
if (attempt.native.dropped_spans)
|
|
append(`${attempt.native.dropped_spans} native spans dropped.`, "hint");
|
|
}
|
|
for (const display of report.displays)
|
|
append(
|
|
`${display.lane} synchronous DOM update ${display.dom_update_ms.toFixed(2)} ms · ${display.count} displayed results`,
|
|
"hint",
|
|
);
|
|
if (report.dropped_attempts || report.dropped_displays)
|
|
append(
|
|
`History capped: ${report.dropped_attempts} attempts and ${report.dropped_displays} displays dropped.`,
|
|
"hint",
|
|
);
|
|
}
|
|
|
|
async function invokeMeasured(request: SearchRequest, lane: SearchLane) {
|
|
const attempt = request.measurement.begin(lane, performance.now());
|
|
try {
|
|
const response = await invoke<MeasuredResponse<SearchResult>>(
|
|
lane === "lexical" ? "search_measured" : "hybrid_search_measured",
|
|
{
|
|
query: request.query,
|
|
filters: request.filters,
|
|
debugPerformance: request.measurement.debug,
|
|
},
|
|
);
|
|
request.measurement.completed(
|
|
attempt,
|
|
performance.now(),
|
|
response.metrics,
|
|
false,
|
|
);
|
|
return response.results;
|
|
} catch (error) {
|
|
const failure = measuredFailure(error);
|
|
request.measurement.completed(
|
|
attempt,
|
|
performance.now(),
|
|
failure?.metrics ?? null,
|
|
true,
|
|
);
|
|
refreshPerformance(request);
|
|
throw failure ? new Error(failure.message) : error;
|
|
}
|
|
}
|
|
|
|
function submitSearch(request: SearchRequest, lane: SearchLane) {
|
|
request.measurement.submitted(lane, performance.now());
|
|
(lane === "lexical" ? lexicalLane : hybridLane).submit(request);
|
|
}
|
|
|
|
function searchMeasurementContext() {
|
|
return {
|
|
model: semanticStatus?.worker_info?.model ?? null,
|
|
device: semanticStatus?.worker_info?.device ?? null,
|
|
runtime: semanticStatus?.worker_info?.runtime ?? null,
|
|
threads: semanticStatus?.worker_info?.threads ?? null,
|
|
dimensions: semanticStatus?.worker_info?.dimensions ?? null,
|
|
semantic_ready: canSearchSemantically(),
|
|
semantic_generation: semanticStatus?.generation ?? null,
|
|
indexed_chunks: semanticStatus?.chunks ?? null,
|
|
};
|
|
}
|
|
|
|
function refreshIndexedSearch(
|
|
trigger: SearchRefreshTrigger = "filesystem_refresh",
|
|
) {
|
|
const previous = latestSearch;
|
|
if (
|
|
!previous ||
|
|
previous.id !== requestId ||
|
|
query.value.trim() !== previous.query
|
|
)
|
|
return;
|
|
const refreshStarted = performance.now();
|
|
clearTimeout(hybridTimer);
|
|
lexicalLane.clear();
|
|
hybridLane.clear();
|
|
querySemanticFailed = false;
|
|
shownLane = "lexical";
|
|
const refreshed: SearchRequest = {
|
|
...previous,
|
|
id: ++requestId,
|
|
started: refreshStarted,
|
|
measurement: previous.measurement.freshForRefresh(
|
|
refreshStarted,
|
|
trigger,
|
|
searchMeasurementContext(),
|
|
),
|
|
};
|
|
latestSearch = refreshed;
|
|
submitSearch(refreshed, "lexical");
|
|
if (refreshed.query.trim() && canSearchSemantically()) {
|
|
hybridTimer = setTimeout(() => {
|
|
if (isCurrent(refreshed)) submitSearch(refreshed, "hybrid");
|
|
}, SEMANTIC_SETTLE_MS);
|
|
}
|
|
}
|
|
|
|
const isCurrent = (request: SearchRequest) => request.id === requestId;
|
|
const lexicalLane = new LatestLane<SearchRequest>(
|
|
async (request) => {
|
|
const found = await invokeMeasured(request, "lexical");
|
|
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 invokeMeasured(request, "hybrid");
|
|
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(inputAt = performance.now()) {
|
|
syncLauncher();
|
|
void refreshApplications().catch(showError);
|
|
invalidateSearch();
|
|
relatedDisclosure.open = false;
|
|
querySemanticFailed = false;
|
|
shownLane = "lexical";
|
|
const value = query.value.trim();
|
|
try {
|
|
const filters = captureFilters();
|
|
if (!hasSearchInput(value, filters)) {
|
|
resultSelection.begin("");
|
|
element("performance-panel").hidden = true;
|
|
allResults = [];
|
|
relatedDisclosure.open = false;
|
|
results = [];
|
|
displayedLiteralQuery = literalQuery("");
|
|
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 =
|
|
"Your device is indexed in the background. Search an application, filename, phrase, or what you remember.";
|
|
return;
|
|
}
|
|
resultSelection.begin(JSON.stringify([value, filters]));
|
|
const request: SearchRequest = {
|
|
id: requestId,
|
|
query: value,
|
|
filters,
|
|
started: performance.now(),
|
|
measurement: new SearchMeasurement(
|
|
inputAt,
|
|
performance.now(),
|
|
element<HTMLInputElement>("performance-toggle").checked,
|
|
searchMeasurementContext(),
|
|
),
|
|
};
|
|
latestSearch = request;
|
|
refreshPerformance(request);
|
|
element("result-summary").textContent = "SEARCHING…";
|
|
submitSearch(request, "lexical");
|
|
hybridTimer = setTimeout(() => {
|
|
if (value && isCurrent(request) && canSearchSemantically())
|
|
submitSearch(request, "hybrid");
|
|
}, SEMANTIC_SETTLE_MS);
|
|
} 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 extracted text`;
|
|
}
|
|
|
|
query.addEventListener("input", () => {
|
|
++applicationRevision;
|
|
selectedApplication = -1;
|
|
applications = [];
|
|
element("application-results").replaceChildren();
|
|
element("application-results").hidden = true;
|
|
element("application-preview").hidden = true;
|
|
syncLauncher();
|
|
const inputAt = performance.now();
|
|
invalidateSearch();
|
|
timer = setTimeout(() => void search(inputAt), 60);
|
|
});
|
|
element("performance-toggle").addEventListener("change", () => {
|
|
const enabled = element<HTMLInputElement>("performance-toggle").checked;
|
|
try {
|
|
localStorage.setItem("searchi-performance-details", String(enabled));
|
|
} catch {
|
|
/* Session preference still works. */
|
|
}
|
|
void search();
|
|
});
|
|
element("copy-performance").addEventListener("click", () => {
|
|
if (!latestSearch?.measurement.debug) return;
|
|
if (!navigator.clipboard) {
|
|
element("copy-performance-status").textContent =
|
|
"Clipboard unavailable. Timings remain visible here.";
|
|
return;
|
|
}
|
|
const report = JSON.stringify(latestSearch.measurement.export(), null, 2);
|
|
void navigator.clipboard.writeText(report).then(
|
|
() => {
|
|
element("copy-performance-status").textContent =
|
|
"Timing report copied. No query or file paths included.";
|
|
},
|
|
() => {
|
|
element("copy-performance-status").textContent =
|
|
"Clipboard unavailable. Timings remain visible here.";
|
|
},
|
|
);
|
|
});
|
|
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", () => {
|
|
element("scope-label").textContent =
|
|
element<HTMLSelectElement>("filter-scope").value === "code"
|
|
? "Code"
|
|
: "All files";
|
|
void search();
|
|
});
|
|
element("retry-model").addEventListener("click", async () => {
|
|
const button = element<HTMLButtonElement>("retry-model");
|
|
button.disabled = true;
|
|
localSemanticError = null;
|
|
localModelSetupError = null;
|
|
element("semantic-status").textContent = "Retrying Google model setup…";
|
|
queueSemanticRefresh();
|
|
try {
|
|
await invoke("setup_model");
|
|
} catch (error) {
|
|
localModelSetupError = 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") {
|
|
if (isNativeDesktop()) {
|
|
event.preventDefault();
|
|
const popovers =
|
|
document.querySelectorAll<HTMLDetailsElement>(".popover[open]");
|
|
if (popovers.length) {
|
|
popovers.forEach((popover) => {
|
|
popover.open = false;
|
|
});
|
|
} else {
|
|
void invoke("hide_search").catch(showError);
|
|
}
|
|
return;
|
|
}
|
|
clearMedia();
|
|
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) ||
|
|
relatedList.contains(event.target as Node) ||
|
|
element("application-results").contains(event.target as Node)
|
|
) {
|
|
if (event.key === "Enter" && selectedApplication >= 0) {
|
|
event.preventDefault();
|
|
void launchApplication();
|
|
return;
|
|
}
|
|
if (
|
|
(event.key === "ArrowDown" || event.key === "ArrowUp") &&
|
|
applications.length
|
|
) {
|
|
event.preventDefault();
|
|
const step = event.key === "ArrowDown" ? 1 : -1;
|
|
const position =
|
|
selectedApplication >= 0
|
|
? selectedApplication
|
|
: applications.length + selected;
|
|
const next = Math.max(
|
|
0,
|
|
Math.min(position + step, applications.length + results.length - 1),
|
|
);
|
|
if (next < applications.length) selectApplication(next);
|
|
else select(next - applications.length);
|
|
query.focus();
|
|
return;
|
|
}
|
|
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));
|
|
query.focus();
|
|
}
|
|
}
|
|
});
|
|
async function initializeSurface() {
|
|
await initializeDesktopSurface();
|
|
document.documentElement.dataset.nativeLauncher = String(isNativeDesktop());
|
|
syncLauncher();
|
|
renderAppearance();
|
|
if (isNativeDesktop()) {
|
|
appearanceUpdates.submit(highContrast.matches);
|
|
}
|
|
if (isDesktopSurface()) {
|
|
if (isCaptureSurface()) {
|
|
for (const id of [
|
|
"add-folder",
|
|
"open-result",
|
|
"reveal-result",
|
|
"browse-folder",
|
|
"retry-model",
|
|
"rebuild-semantic",
|
|
"cpu-threads",
|
|
"ai-enabled",
|
|
"ai-endpoint",
|
|
"ai-model",
|
|
"ai-key",
|
|
]) {
|
|
const control = element<HTMLButtonElement>(id);
|
|
control.disabled = true;
|
|
control.title = "Unavailable in the read-only documentation capture";
|
|
}
|
|
}
|
|
void refreshStats().catch(showError);
|
|
void refreshDesktopStatus().catch(showError);
|
|
void refreshSemanticStatus();
|
|
if (isNativeDesktop())
|
|
void listen("focus-search", () => {
|
|
query.focus();
|
|
query.select();
|
|
void refreshDesktopStatus().catch(showError);
|
|
queueSemanticRefresh(0);
|
|
}).catch(showError);
|
|
if (isNativeDesktop()) {
|
|
void listen("search-hidden", () => {
|
|
element("media-preview")
|
|
.querySelectorAll<HTMLMediaElement>("audio,video")
|
|
.forEach((player) => player.pause());
|
|
}).catch(showError);
|
|
void listen("folder-indexing-changed", () => {
|
|
void refreshDesktopStatus().catch(showError);
|
|
queueSemanticRefresh(0);
|
|
}).catch(showError);
|
|
void listen("search-index-changed", () => {
|
|
folderRevision++;
|
|
void refreshStats().catch(showError);
|
|
void refreshDesktopStatus().catch(showError);
|
|
queueSemanticRefresh(0);
|
|
refreshIndexedSearch();
|
|
}).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.");
|
|
}
|
|
}
|
|
void initializeSurface().catch((error) => {
|
|
element("index-status").textContent = "Capture unavailable";
|
|
element("semantic-status").textContent =
|
|
"The real local capture engine could not initialize.";
|
|
showError(error);
|
|
});
|