2026-10-12 19:57:34 +02:00
import {
invoke ,
initializeDesktopSurface ,
isNativeDesktop ,
isDesktopSurface ,
isCaptureSurface ,
prepareMediaURL ,
} from "./desktop-api" ;
2026-10-12 17:44:56 +02:00
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" ;
2026-10-12 19:16:23 +02:00
import {
SearchMeasurement ,
measuredFailure ,
spanDepth ,
briefMs ,
} from "./search-performance" ;
import type { MeasuredResponse , SearchLane } from "./search-performance" ;
2026-10-12 19:57:34 +02:00
import { displayPath } from "./path-display" ;
2026-10-12 20:28:38 +02:00
import {
AiRevision ,
aiConfigurationProblem ,
aiQuestionProblem ,
aiEndpointLabel ,
aiSourceEligible ,
} from "./ai-state" ;
import type { AiConfiguration } from "./ai-state" ;
2026-10-12 19:57:34 +02:00
import {
literalQuery ,
renderHighlighted ,
displayExcerpt ,
matchExplanation ,
} from "./match-display" ;
2026-10-12 17:44:56 +02:00
import "@fontsource-variable/inter/wght.css" ;
2026-10-12 16:07:53 +02:00
import "./style.css" ;
2026-10-12 19:16:23 +02:00
import {
mediaKind ,
matchLabel ,
sourceLabel ,
seekSeconds ,
PreviewRevision ,
} from "./media-display" ;
2026-10-12 16:07:53 +02:00
interface SearchResult {
path : string ;
filename : string ;
snippet : string ;
score : number ;
match_kind : string ;
2026-10-12 19:16:23 +02:00
source ?: { kind : string ; number : number ; matched_query? : string };
2026-10-12 16:07:53 +02:00
extraction_error? : string ;
}
interface IndexReport {
discovered : number ;
indexed : number ;
removed : number ;
skipped : number ;
}
interface IndexStats {
files : number ;
content_files : number ;
}
2026-10-12 17:44:56 +02:00
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 ;
2026-10-12 19:16:23 +02:00
media_tools_ready? : boolean ;
media_setup_error? : string | null ;
media_failures ?: { path : string ; message : string }[];
2026-10-12 17:44:56 +02:00
worker_info ?: {
model? : string ;
device? : string ;
runtime? : string ;
threads? : number ;
2026-10-12 19:16:23 +02:00
dimensions? : number ;
media_supported? : boolean ;
2026-10-12 17:44:56 +02:00
} | null ;
}
interface SearchRequest {
id : number ;
query : string ;
filters : Record < string , string | number | null >;
started : number ;
2026-10-12 19:16:23 +02:00
measurement : SearchMeasurement ;
2026-10-12 17:44:56 +02:00
}
2026-10-12 16:07:53 +02:00
document . querySelector < HTMLDivElement >( "#app" ) ! . innerHTML = `
2026-10-12 20:28:38 +02:00
<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>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>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><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></div></header>
2026-10-12 17:44:56 +02:00
<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>
2026-10-12 16:07:53 +02:00
<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>
2026-10-12 20:28:38 +02:00
<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="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><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>Clear</span></span></footer>` ;
2026-10-12 16:07:53 +02:00
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" );
2026-10-12 19:16:23 +02:00
try {
element < HTMLInputElement >( "performance-toggle" ). checked =
localStorage . getItem ( "searchi-performance-details" ) === "true" ;
} catch {
/* Diagnostics remain opt-in when storage is unavailable. */
}
2026-10-12 16:07:53 +02:00
let results : SearchResult [] = [];
2026-10-12 19:57:34 +02:00
let displayedLiteralQuery = literalQuery ( "" );
let explainedSelection = "" ;
2026-10-12 16:07:53 +02:00
let selected = - 1 ;
let requestId = 0 ;
let timer : ReturnType < typeof setTimeout >;
2026-10-12 17:44:56 +02:00
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 ;
2026-10-12 19:16:23 +02:00
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 ();
}
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 = () => {
if ( ! previewRevision . accepts ( request . revision )) return ;
status . hidden = false ;
status . textContent =
"This format cannot be previewed here. 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 });
2026-10-12 19:57:34 +02:00
image . src = prepareMediaURL ( prepared . path );
2026-10-12 19:16:23 +02:00
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 },
);
2026-10-12 19:57:34 +02:00
player . src = prepareMediaURL ( prepared . path );
2026-10-12 19:16:23 +02:00
host . append ( player );
}
host . hidden = false ;
status . hidden = true ;
},
( request ) => previewRevision . accepts ( request . revision ),
( request , error ) => {
if ( ! previewRevision . accepts ( request . revision )) return ;
element ( "media-preview-status" ). hidden = false ;
element ( "media-preview-status" ). textContent =
`Preview unavailable: ${ String ( error ) } . Use Open file.` ;
},
);
2026-10-12 17:44:56 +02:00
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 =
2026-10-12 19:16:23 +02:00
`Semantic search indexed ${ report . chunks . toLocaleString () } content segments.` ;
2026-10-12 17:44:56 +02:00
} 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 )
2026-10-12 19:16:23 +02:00
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` : "" } ` ;
2026-10-12 17:44:56 +02:00
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." ;
2026-10-12 19:16:23 +02:00
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."
: 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_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.` ;
}
2026-10-12 17:44:56 +02:00
const retry = element < HTMLButtonElement >( "retry-model" );
retry . hidden = ! error || status . ready ;
2026-10-12 19:57:34 +02:00
retry . disabled = working || isCaptureSurface ();
2026-10-12 17:44:56 +02:00
const rebuild = element < HTMLButtonElement >( "rebuild-semantic" );
rebuild . hidden = ! status . ready || ( ! status . chunks && ! status . pending );
2026-10-12 19:57:34 +02:00
rebuild . disabled = working || indexingActive || isCaptureSurface ();
2026-10-12 17:44:56 +02:00
element < HTMLSelectElement >( "cpu-threads" ). disabled =
2026-10-12 19:57:34 +02:00
working || indexingActive || isCaptureSurface ();
2026-10-12 17:44:56 +02:00
if (
2026-10-12 19:57:34 +02:00
! isCaptureSurface () &&
2026-10-12 17:44:56 +02:00
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
)
2026-10-12 19:16:23 +02:00
submitSearch ( latestSearch , "hybrid" );
2026-10-12 17:44:56 +02:00
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 =
2026-10-12 19:57:34 +02:00
isCaptureSurface () ||
2026-10-12 17:44:56 +02:00
status . indexing ||
semanticJobActive ||
Boolean (
semanticStatus ? . downloading ||
semanticStatus ? . warming ||
semanticStatus ? . embedding ,
);
2026-10-12 19:57:34 +02:00
element < HTMLButtonElement >( "add-folder" ). disabled =
status . indexing || isCaptureSurface ();
2026-10-12 17:44:56 +02:00
const roots = element ( "roots" );
roots . replaceChildren ();
for ( const root of status . settings . roots ) {
const item = document . createElement ( "li" );
const path = document . createElement ( "span" );
2026-10-12 19:57:34 +02:00
path . textContent = displayPath ( root );
2026-10-12 17:44:56 +02:00
path . className = "path" ;
const reindex = document . createElement ( "button" );
reindex . type = "button" ;
reindex . textContent = "Reindex" ;
2026-10-12 19:57:34 +02:00
reindex . disabled = status . indexing || isCaptureSurface ();
2026-10-12 17:44:56 +02:00
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 ;
}
2026-10-12 16:07:53 +02:00
function showError ( error : unknown ) {
notice . textContent = String ( error );
notice . className = "error" ;
}
2026-10-12 20:28:38 +02:00
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" ,
});
});
2026-10-12 16:07:53 +02:00
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 );
2026-10-12 17:44:56 +02:00
button . setAttribute ( "aria-current" , String ( i === selected ));
button . tabIndex = i === selected ? 0 : - 1 ;
2026-10-12 16:07:53 +02:00
if ( i === selected && focus ) button . focus ();
});
element ( "preview" ). hidden = false ;
2026-10-12 17:44:56 +02:00
const selectedIcon = fileIcon ( result . filename );
element ( "preview-icon" ). innerHTML = selectedIcon . html ;
element ( "preview-icon" ). dataset . type = selectedIcon . type ;
2026-10-12 19:57:34 +02:00
renderHighlighted (
element ( "preview-title" ),
result . filename ,
displayedLiteralQuery ,
);
renderHighlighted (
element ( "preview-path" ),
displayPath ( result . path ),
displayedLiteralQuery ,
);
2026-10-12 16:07:53 +02:00
element ( "preview-kind" ). textContent =
2026-10-12 19:16:23 +02:00
` ${ matchLabel ( result . match_kind ) }${ result . source ? ` · ${ sourceLabel ( result . source ) } ` : "" } ` ;
const media = mediaKind ( result . filename );
2026-10-12 19:57:34 +02:00
const explanation = matchExplanation ( result );
const semanticMedia = [
"semantic_image" ,
"semantic_video" ,
"semantic_audio" ,
]. includes ( result . match_kind );
element ( "passage-label" ). textContent = semanticMedia
? "MATCHED MEDIA"
: explanation . semanticPassage
? "MATCHED PASSAGE"
: "EXTRACTED PASSAGE" ;
const passageText =
2026-10-12 16:07:53 +02:00
result . snippet ||
( result . extraction_error
? `Content could not be extracted: ${ result . extraction_error } `
2026-10-12 19:16:23 +02:00
: media
2026-10-12 19:57:34 +02:00
? "Preview the matched media below."
2026-10-12 19:16:23 +02:00
: "No extracted text is available for this file." );
2026-10-12 19:57:34 +02:00
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 ;
2026-10-12 20:28:38 +02:00
updateAiSelection ( explanationKey );
2026-10-12 17:44:56 +02:00
element < HTMLButtonElement >( "open-result" ). title = result . source
2026-10-12 19:16:23 +02:00
? `Open in your default application. The matched location ${ sourceLabel ( result . source ) } is shown in Searchi; default application opening starts at its own location.`
2026-10-12 17:44:56 +02:00
: "Open in your default application" ;
2026-10-12 19:16:23 +02:00
const selection = previewRevision . select (
JSON . stringify ([ result . path , result . source ? . kind , result . source ? . number ]),
);
if ( selection . changed ) {
mediaLane . clear ();
unloadMedia ();
2026-10-12 19:57:34 +02:00
if ( media && isDesktopSurface ()) {
2026-10-12 19:16:23 +02:00
element ( "media-preview-status" ). hidden = false ;
element ( "media-preview-status" ). textContent = "Preparing media preview…" ;
mediaLane . submit ({ revision : selection.revision , result });
}
}
2026-10-12 17:44:56 +02:00
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 ;
}
2026-10-12 16:07:53 +02:00
}
function render() {
2026-10-12 17:44:56 +02:00
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 );
}
}
2026-10-12 16:07:53 +02:00
element ( "empty" ). hidden = results . length > 0 ;
2026-10-12 17:44:56 +02:00
if ( ! results . length ) {
element ( "preview" ). hidden = true ;
previewPath = null ;
2026-10-12 19:16:23 +02:00
clearMedia ();
2026-10-12 20:28:38 +02:00
updateAiSelection ( "" );
2026-10-12 17:44:56 +02:00
}
2026-10-12 16:07:53 +02:00
results . forEach (( result , i ) => {
2026-10-12 17:44:56 +02:00
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 );
2026-10-12 19:57:34 +02:00
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 ( result . match_kind );
renderHighlighted (
entry . snippet ,
displayExcerpt ( result . snippet , displayedLiteralQuery ),
semanticMedia ? literalQuery ( "" ) : displayedLiteralQuery ,
);
2026-10-12 19:16:23 +02:00
entry . kind . textContent = ` ${ matchLabel ( result . match_kind ) }${ result . source ? ` · ${ sourceLabel ( result . source ) } ` : "" } ` ;
2026-10-12 17:44:56 +02:00
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 );
2026-10-12 16:07:53 +02:00
});
2026-10-12 17:44:56 +02:00
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 ;
};
2026-10-12 19:16:23 +02:00
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 ;
};
2026-10-12 17:44:56 +02:00
// 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 ,
2026-10-12 19:16:23 +02:00
min_duration_ms : duration ( "filter-duration-min" ),
max_duration_ms : duration ( "filter-duration-max" ),
2026-10-12 17:44:56 +02:00
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 ;
2026-10-12 19:16:23 +02:00
const renderStarted = performance . now ();
2026-10-12 17:44:56 +02:00
const selectedPath = results [ selected ] ? . path ;
const resultsHadFocus = list . contains ( document . activeElement );
shownLane = lane ;
displayedRequestId = request . id ;
2026-10-12 19:57:34 +02:00
displayedLiteralQuery = literalQuery ( request . query );
2026-10-12 17:44:56 +02:00
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 );
if ( ! found . length ) refreshEmptySearchState ();
2026-10-12 19:16:23 +02:00
request . measurement . displayed (
lane ,
renderStarted ,
performance . now (),
found . length ,
);
const metric = request . measurement . latestNative ( lane );
const trace = request . measurement . export ();
element ( "result-summary" ). textContent =
2026-10-12 19:57:34 +02:00
` ${ found . length } RESULTS · ${ lane === "hybrid" ? "KEYWORD + MEANING" : "KEYWORD" } · SEARCH ${ briefMs ( metric ? . total_ms ?? 0 ) } MS · SHOWN ${ briefMs ( trace . latest_displayed_ms ?? 0 ) } MS` ;
2026-10-12 19:16:23 +02:00
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 350 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 );
2026-10-12 17:44:56 +02:00
}
const isCurrent = ( request : SearchRequest ) => request . id === requestId ;
const lexicalLane = new LatestLane < SearchRequest >(
async ( request ) => {
2026-10-12 19:16:23 +02:00
const found = await invokeMeasured ( request , "lexical" );
2026-10-12 17:44:56 +02:00
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 ;
2026-10-12 19:16:23 +02:00
const found = await invokeMeasured ( request , "hybrid" );
2026-10-12 17:44:56 +02:00
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 ;
2026-10-12 16:07:53 +02:00
}
2026-10-12 19:16:23 +02:00
async function search ( inputAt = performance . now ()) {
2026-10-12 17:44:56 +02:00
invalidateSearch ();
querySemanticFailed = false ;
shownLane = "lexical" ;
2026-10-12 16:07:53 +02:00
const value = query . value . trim ();
if ( ! value ) {
2026-10-12 19:16:23 +02:00
element ( "performance-panel" ). hidden = true ;
2026-10-12 16:07:53 +02:00
results = [];
2026-10-12 19:57:34 +02:00
displayedLiteralQuery = literalQuery ( "" );
2026-10-12 17:44:56 +02:00
displayedRequestId = null ;
selected = - 1 ;
2026-10-12 16:07:53 +02:00
render ();
element ( "result-summary" ). textContent = "READY WHEN YOU ARE" ;
element ( "empty" ). querySelector ( "h2" ) ! . textContent =
2026-10-12 17:44:56 +02:00
"Find what matters. Locally." ;
2026-10-12 16:07:53 +02:00
element ( "empty" ). querySelector ( "p" ) ! . textContent =
2026-10-12 17:44:56 +02:00
"Index a folder, then search a filename, phrase, or what you remember." ;
2026-10-12 16:07:53 +02:00
return ;
}
try {
2026-10-12 17:44:56 +02:00
const request : SearchRequest = {
id : requestId ,
2026-10-12 16:07:53 +02:00
query : value ,
2026-10-12 17:44:56 +02:00
filters : captureFilters (),
started : performance.now (),
2026-10-12 19:16:23 +02:00
measurement : new SearchMeasurement (
inputAt ,
performance . now (),
element < HTMLInputElement >( "performance-toggle" ). checked ,
{
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 (),
indexed_chunks : semanticStatus?.chunks ?? null ,
},
),
2026-10-12 17:44:56 +02:00
};
latestSearch = request ;
2026-10-12 19:16:23 +02:00
refreshPerformance ( request );
2026-10-12 17:44:56 +02:00
element ( "result-summary" ). textContent = "SEARCHING…" ;
2026-10-12 19:16:23 +02:00
submitSearch ( request , "lexical" );
2026-10-12 17:44:56 +02:00
hybridTimer = setTimeout (() => {
if ( isCurrent ( request ) && canSearchSemantically ())
2026-10-12 19:16:23 +02:00
submitSearch ( request , "hybrid" );
2026-10-12 17:44:56 +02:00
}, 350 );
2026-10-12 16:07:53 +02:00
} catch ( error ) {
2026-10-12 17:44:56 +02:00
showError ( error );
2026-10-12 16:07:53 +02:00
}
}
async function refreshStats() {
const stats = await invoke < IndexStats >( "stats" );
element ( "index-status" ). textContent =
2026-10-12 19:16:23 +02:00
` ${ stats . files . toLocaleString () } files · ${ stats . content_files . toLocaleString () } with extracted text` ;
2026-10-12 16:07:53 +02:00
}
query . addEventListener ( "input" , () => {
2026-10-12 19:16:23 +02:00
const inputAt = performance . now ();
2026-10-12 17:44:56 +02:00
invalidateSearch ();
2026-10-12 19:16:23 +02:00
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." ;
},
);
2026-10-12 16:07:53 +02:00
});
element ( "search-form" ). addEventListener ( "submit" , ( event ) => {
event . preventDefault ();
clearTimeout ( timer );
void search ();
});
2026-10-12 17:44:56 +02:00
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 ;
});
});
2026-10-12 16:07:53 +02:00
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 ();
});
2026-10-12 17:44:56 +02:00
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 ();
});
2026-10-12 16:07:53 +02:00
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 );
});
2026-10-12 17:44:56 +02:00
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 );
}
});
2026-10-12 16:07:53 +02:00
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 ;
2026-10-12 17:44:56 +02:00
element < HTMLSelectElement >( "cpu-threads" ). disabled = true ;
indexingActive = true ;
element ( "index-status" ). textContent =
"Indexing active · Search remains available" ;
2026-10-12 16:07:53 +02:00
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 ;
2026-10-12 17:44:56 +02:00
folderRevision ++ ;
localSemanticError = null ;
2026-10-12 16:07:53 +02:00
await refreshStats ();
await search ();
} catch ( error ) {
showError ( error );
} finally {
buttons . forEach (( button ) => {
button . disabled = false ;
});
element < HTMLButtonElement >( "add-folder" ). disabled = false ;
2026-10-12 17:44:56 +02:00
await refreshDesktopStatus (). catch ( showError );
await refreshStats (). catch ( showError );
queueSemanticRefresh ( 0 );
2026-10-12 16:07:53 +02:00
}
});
document . addEventListener ( "keydown" , ( event ) => {
if (( event . ctrlKey || event . metaKey ) && event . key . toLowerCase () === "k" ) {
event . preventDefault ();
query . focus ();
query . select ();
}
if ( event . key === "Escape" ) {
2026-10-12 19:16:23 +02:00
clearMedia ();
2026-10-12 17:44:56 +02:00
document
. querySelectorAll < HTMLDetailsElement >( ".popover[open]" )
. forEach (( popover ) => {
popover . open = false ;
});
2026-10-12 16:07:53 +02:00
++ requestId ;
query . value = "" ;
void search ();
query . focus ();
}
if ( event . target === query || list . contains ( event . target as Node )) {
2026-10-12 17:44:56 +02:00
if (
event . key === "Enter" &&
results [ selected ] &&
displayedRequestId === requestId
) {
event . preventDefault ();
void openSelected ( false );
}
2026-10-12 16:07:53 +02:00
if ( event . key === "ArrowDown" || event . key === "ArrowUp" ) {
event . preventDefault ();
select ( selected + ( event . key === "ArrowDown" ? 1 : - 1 ), true );
}
}
});
2026-10-12 19:57:34 +02:00
async function initializeSurface() {
await initializeDesktopSurface ();
if ( isDesktopSurface ()) {
if ( isCaptureSurface ()) {
for ( const id of [
"add-folder" ,
"open-result" ,
"reveal-result" ,
"browse-folder" ,
"retry-model" ,
"rebuild-semantic" ,
"cpu-threads" ,
2026-10-12 20:28:38 +02:00
"ai-enabled" ,
"ai-endpoint" ,
"ai-model" ,
"ai-key" ,
2026-10-12 19:57:34 +02:00
]) {
const control = element < HTMLButtonElement >( id );
control . disabled = true ;
control . title = "Unavailable in the read-only documentation capture" ;
}
}
void refreshStats (). catch ( showError );
2026-10-12 17:44:56 +02:00
void refreshDesktopStatus (). catch ( showError );
2026-10-12 19:57:34 +02:00
void refreshSemanticStatus ();
if ( isNativeDesktop ())
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." );
}
2026-10-12 16:07:53 +02:00
}
2026-10-12 19:57:34 +02:00
void initializeSurface (). catch (( error ) => {
element ( "index-status" ). textContent = "Capture unavailable" ;
element ( "semantic-status" ). textContent =
"The real local capture engine could not initialize." ;
showError ( error );
});