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

270 lines
11 KiB
TypeScript
Raw Normal View History

import { invoke, isTauri } from "@tauri-apps/api/core";
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;
}
document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
<header><div class="wordmark">searchi<span class="dot">.</span></div><span class="privacy">● Local · CPU only</span></header>
<main><section class="intro"><p class="eyebrow">YOUR FILES, WITHIN REACH</p><h1>Find the thing you remember.</h1><p>Search filenames and document content in your selected folders.</p></section>
<form id="search-form" role="search"><label class="sr-only" for="query">Search your files</label><span class="search-icon" aria-hidden="true">⌕</span><input id="query" type="search" placeholder="A filename or words from a document…" autocomplete="off" spellcheck="false" maxlength="1000"><kbd>Ctrl K</kbd></form>
<div class="toolbar"><span id="index-status">Loading your index…</span><button id="add-folder" type="button">+ Index a folder</button></div>
<details id="filters"><summary>Filter results</summary><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></details>
<form id="folder-form" hidden><label for="folder">Folder to index</label><div class="folder-row"><input id="folder" placeholder="Absolute folder path" required><button id="browse-folder" type="button">Browse…</button><button type="submit">Index folder</button><button id="cancel-folder" type="button">Cancel</button></div><p class="hint">Only choose folders you want searched. Large folders may take a while.</p></form>
<p id="notice" role="status" aria-live="polite"></p><div class="workspace"><section 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"><span aria-hidden="true">⌕</span><h2>Your next discovery starts here.</h2><p>Index a folder, then search a filename or a phrase from a document.</p></div></section><aside id="preview" aria-label="Result preview" hidden><p class="eyebrow">CONTENT PREVIEW</p><h2 id="preview-title"></h2><p id="preview-path" class="path"></p><p id="preview-kind"></p><div class="preview-actions"><button id="open-result" type="button">Open file</button><button id="reveal-result" type="button">Show in folder</button></div><pre id="preview-text"></pre></aside></div>
</main><footer>Offline by default<span>↑ ↓ Navigate · Enter Preview · Esc Clear</span></footer>`;
const element = <T extends HTMLElement>(id: string) =>
document.getElementById(id) as T;
const query = element<HTMLInputElement>("query");
const list = element<HTMLUListElement>("results");
const notice = element<HTMLParagraphElement>("notice");
let results: SearchResult[] = [];
let selected = -1;
let requestId = 0;
let timer: ReturnType<typeof setTimeout>;
function showError(error: unknown) {
notice.textContent = String(error);
notice.className = "error";
}
function select(index: number, focus = false) {
if (!results.length) return;
selected = Math.max(0, Math.min(index, results.length - 1));
const result = results[selected];
list.querySelectorAll<HTMLButtonElement>("button").forEach((button, i) => {
button.classList.toggle("selected", i === selected);
button.setAttribute("aria-pressed", String(i === selected));
if (i === selected && focus) button.focus();
});
element("preview").hidden = false;
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.");
}
function render() {
list.replaceChildren();
element("empty").hidden = results.length > 0;
element("preview").hidden = true;
results.forEach((result, i) => {
const row = document.createElement("li");
const button = document.createElement("button");
button.className = "result";
button.type = "button";
const title = document.createElement("strong");
title.textContent = result.filename;
const path = document.createElement("span");
path.className = "path";
path.textContent = result.path;
const snippet = document.createElement("span");
snippet.className = "snippet";
snippet.textContent = result.snippet;
const kind = document.createElement("span");
kind.className = "kind";
kind.textContent = result.match_kind;
button.append(title, kind, path, snippet);
button.addEventListener("click", () => select(i));
row.append(button);
list.append(row);
});
if (results.length) select(0);
}
async function search() {
const id = ++requestId;
const value = query.value.trim();
if (!value) {
results = [];
render();
element("result-summary").textContent = "READY WHEN YOU ARE";
element("empty").querySelector("h2")!.textContent =
"Your next discovery starts here.";
element("empty").querySelector("p")!.textContent =
"Index a folder, then search a filename or a phrase from a document.";
return;
}
element("result-summary").textContent = "SEARCHING…";
const started = performance.now();
try {
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;
};
const found = await invoke<SearchResult[]>("search", {
query: value,
filters: {
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),
},
});
if (id !== requestId) return;
results = found;
selected = -1;
render();
element("result-summary").textContent =
`${found.length} RESULTS · ${Math.round(performance.now() - started)} MS`;
if (!found.length) {
element("empty").querySelector("h2")!.textContent =
"No matching files yet.";
element("empty").querySelector("p")!.textContent =
"Try a shorter phrase, an exact filename, or index another folder.";
}
} catch (error) {
if (id === requestId) {
showError(error);
element("result-summary").textContent = "SEARCH FAILED";
}
}
}
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", () => {
++requestId;
clearTimeout(timer);
timer = setTimeout(search, 100);
});
element("search-form").addEventListener("submit", (event) => {
event.preventDefault();
clearTimeout(timer);
void search();
});
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", () => {
++requestId;
clearTimeout(timer);
void search();
});
async function openSelected(reveal: boolean) {
if (selected < 0 || !results[selected]) return;
try {
await invoke("open_result", { path: results[selected].path, reveal });
} catch (error) {
showError(error);
}
}
element("open-result").addEventListener("click", () => {
void openSelected(false);
});
element("reveal-result").addEventListener("click", () => {
void openSelected(true);
});
element("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;
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;
await refreshStats();
await search();
} catch (error) {
showError(error);
} finally {
buttons.forEach((button) => {
button.disabled = false;
});
element<HTMLButtonElement>("add-folder").disabled = false;
}
});
document.addEventListener("keydown", (event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
query.focus();
query.select();
}
if (event.key === "Escape") {
++requestId;
query.value = "";
void search();
query.focus();
}
if (event.target === query || list.contains(event.target as Node)) {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
select(selected + (event.key === "ArrowDown" ? 1 : -1), true);
}
}
});
if (isTauri()) {
void refreshStats().catch(showError);
query.focus();
} else {
element("index-status").textContent = "Desktop preview";
showError("Run the Tauri desktop app to index and search real files.");
}