Ship floating launcher, automatic discovery and bounded media search improvements
This commit is contained in:
1 parent
01279a6b25
commit
521966a2ac
43 files changed
+3165
-267
No files matched your search
+271
-13
@@ -88,6 +88,7 @@ interface Settings {
|
||||
version: number;
|
||||
roots: string[];
|
||||
cpu_threads: number;
|
||||
automatic_indexing?: boolean;
|
||||
background_material?: BackgroundMaterial;
|
||||
background_opacity?: number;
|
||||
}
|
||||
@@ -160,19 +161,86 @@ interface SearchRequest {
|
||||
}
|
||||
|
||||
document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
|
||||
<header><div class="brand"><img class="brand-symbol" src="/searchi-symbol.png" alt=""><span class="wordmark">searchi</span></div><span id="index-status" class="index-status">Loading your index…</span><div class="header-actions"><button id="add-folder" class="quiet-button" type="button">${icon("folderAdd")}<span>Add folder</span></button><details id="filters" class="popover"><summary>${icon("filter")}<span 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 tint</label><output id="background-opacity-value" for="background-opacity">90%</output></div><input id="background-opacity" type="range" min="70" 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><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></div></header>
|
||||
<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="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>`;
|
||||
<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><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";
|
||||
@@ -180,6 +248,130 @@ try {
|
||||
/* Diagnostics remain opt-in when storage is unavailable. */
|
||||
}
|
||||
let results: SearchResult[] = [];
|
||||
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);
|
||||
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 = "";
|
||||
@@ -318,7 +510,13 @@ const resultRows = new Map<
|
||||
const automaticIndex = new AutomaticIndexGate();
|
||||
|
||||
function refreshEmptySearchState() {
|
||||
if (!latestSearch || latestSearch.id !== requestId || results.length) return;
|
||||
if (
|
||||
!latestSearch ||
|
||||
latestSearch.id !== requestId ||
|
||||
results.length ||
|
||||
applications.length
|
||||
)
|
||||
return;
|
||||
if (!latestSearch.query.trim()) {
|
||||
element("empty").querySelector("h2")!.textContent =
|
||||
"No matching music tags.";
|
||||
@@ -439,7 +637,8 @@ async function refreshSemanticStatus() {
|
||||
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. Index a folder to search by meaning.";
|
||||
message =
|
||||
"Google model ready. Your files become searchable by meaning as background indexing completes.";
|
||||
else
|
||||
message =
|
||||
"Preparing local semantic search… Keyword search remains available.";
|
||||
@@ -592,8 +791,15 @@ element("background-material").addEventListener("change", () => {
|
||||
void saveAppearance();
|
||||
});
|
||||
element("background-opacity").addEventListener("input", () => {
|
||||
element("background-opacity-value").textContent =
|
||||
`${element<HTMLInputElement>("background-opacity").value}%`;
|
||||
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();
|
||||
@@ -616,6 +822,9 @@ async function refreshDesktopStatus(restoreAppearanceControls = false) {
|
||||
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",
|
||||
@@ -671,7 +880,7 @@ async function refreshDesktopStatus(restoreAppearanceControls = false) {
|
||||
}
|
||||
if (status.indexing)
|
||||
element("index-status").textContent =
|
||||
"Indexing active · Search remains available";
|
||||
"Searching your device · Ready to search";
|
||||
return status;
|
||||
}
|
||||
|
||||
@@ -885,6 +1094,14 @@ element("ai-citation").addEventListener("click", () => {
|
||||
});
|
||||
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);
|
||||
@@ -992,7 +1209,7 @@ function render() {
|
||||
resultRows.delete(key);
|
||||
}
|
||||
}
|
||||
element("empty").hidden = results.length > 0;
|
||||
element("empty").hidden = results.length > 0 || applications.length > 0;
|
||||
if (!results.length) {
|
||||
element("preview").hidden = true;
|
||||
previewPath = null;
|
||||
@@ -1120,7 +1337,8 @@ function applyResults(
|
||||
const preserved = resultSelection.resolve(
|
||||
results.map((result) => result.path),
|
||||
);
|
||||
if (preserved >= 0) select(preserved, resultsHadFocus, false);
|
||||
if (preserved >= 0 && selectedApplication < 0)
|
||||
select(preserved, resultsHadFocus, false);
|
||||
if (!found.length) refreshEmptySearchState();
|
||||
request.measurement.displayed(
|
||||
lane,
|
||||
@@ -1330,6 +1548,8 @@ function invalidateSearch() {
|
||||
}
|
||||
|
||||
async function search(inputAt = performance.now()) {
|
||||
syncLauncher();
|
||||
void refreshApplications().catch(showError);
|
||||
invalidateSearch();
|
||||
querySemanticFailed = false;
|
||||
shownLane = "lexical";
|
||||
@@ -1348,7 +1568,7 @@ async function search(inputAt = performance.now()) {
|
||||
element("empty").querySelector("h2")!.textContent =
|
||||
"Find what matters. Locally.";
|
||||
element("empty").querySelector("p")!.textContent =
|
||||
"Index a folder, then search a filename, phrase, or what you remember.";
|
||||
"Your device is indexed in the background. Search an application, filename, phrase, or what you remember.";
|
||||
return;
|
||||
}
|
||||
resultSelection.begin(JSON.stringify([value, filters]));
|
||||
@@ -1384,6 +1604,13 @@ async function refreshStats() {
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -1579,7 +1806,35 @@ document.addEventListener("keydown", (event) => {
|
||||
void search();
|
||||
query.focus();
|
||||
}
|
||||
if (event.target === query || list.contains(event.target as Node)) {
|
||||
if (
|
||||
event.target === query ||
|
||||
list.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] &&
|
||||
@@ -1590,12 +1845,15 @@ document.addEventListener("keydown", (event) => {
|
||||
}
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
select(selected + (event.key === "ArrowDown" ? 1 : -1), true);
|
||||
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);
|
||||
|
||||
Reference in new issue
Block a user