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