feat: add desktop document search and verified native CPU embeddings
Core CI / core (ubuntu-latest) (push) Failing after 1m22s
Core CI / core (macos-latest) (push) Canceled after 0s
Core CI / core (windows-latest) (push) Canceled after 0s
Core CI / desktop (ubuntu-latest) (push) Successful in 3m46s
Core CI / desktop (macos-latest) (push) Canceled after 0s
Core CI / desktop (windows-latest) (push) Canceled after 0s
Core CI / core (ubuntu-latest) (push) Failing after 1m22s
Core CI / core (macos-latest) (push) Canceled after 0s
Core CI / core (windows-latest) (push) Canceled after 0s
Core CI / desktop (ubuntu-latest) (push) Successful in 3m46s
Core CI / desktop (macos-latest) (push) Canceled after 0s
Core CI / desktop (windows-latest) (push) Canceled after 0s
This commit is contained in:
1 parent
f77112db97
commit
a9b6592330
46 files changed
+10794
-77
No files matched your search
@@ -0,0 +1,270 @@
|
||||
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.");
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
:root {
|
||||
font-family:
|
||||
Inter,
|
||||
Segoe UI,
|
||||
system-ui,
|
||||
sans-serif;
|
||||
color: #20332e;
|
||||
background: #f6f7f2;
|
||||
font-synthesis: none;
|
||||
color-scheme: light;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.6;
|
||||
}
|
||||
header,
|
||||
footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24px 40px;
|
||||
}
|
||||
.wordmark {
|
||||
font-size: 28px;
|
||||
font-weight: 750;
|
||||
letter-spacing: -1.5px;
|
||||
}
|
||||
.dot {
|
||||
color: #87a77f;
|
||||
}
|
||||
.privacy {
|
||||
font-size: 12px;
|
||||
color: #58705c;
|
||||
}
|
||||
main {
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 40px;
|
||||
}
|
||||
.intro {
|
||||
margin: 12px 0 30px;
|
||||
}
|
||||
.eyebrow {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.8px;
|
||||
color: #78887c;
|
||||
}
|
||||
.intro h1 {
|
||||
font-size: clamp(28px, 4vw, 42px);
|
||||
letter-spacing: -1.5px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.intro > p:last-child {
|
||||
color: #738077;
|
||||
margin: 0;
|
||||
}
|
||||
#search-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
background: white;
|
||||
border: 1px solid #d2dccf;
|
||||
border-radius: 14px;
|
||||
padding: 18px 22px;
|
||||
box-shadow: 0 8px 30px #243b2910;
|
||||
}
|
||||
#query {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 19px;
|
||||
color: inherit;
|
||||
outline: none;
|
||||
}
|
||||
#search-form:focus-within {
|
||||
border-color: #588663;
|
||||
box-shadow: 0 0 0 3px #65866922;
|
||||
}
|
||||
.search-icon {
|
||||
font-size: 30px;
|
||||
color: #6a8770;
|
||||
}
|
||||
kbd {
|
||||
white-space: nowrap;
|
||||
color: #829086;
|
||||
font-size: 11px;
|
||||
border: 1px solid #dce3d9;
|
||||
padding: 5px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: 18px 0;
|
||||
font-size: 12px;
|
||||
color: #78847b;
|
||||
}
|
||||
.toolbar button,
|
||||
.folder-row button {
|
||||
background: #e8ede2;
|
||||
border: 1px solid #d9e0d2;
|
||||
padding: 9px 14px;
|
||||
border-radius: 7px;
|
||||
color: #36583c;
|
||||
}
|
||||
#folder-form {
|
||||
background: #eef2e9;
|
||||
padding: 18px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
#folder-form label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.folder-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.folder-row input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
padding: 9px;
|
||||
border: 1px solid #c9d3c3;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.hint {
|
||||
color: #74806e;
|
||||
font-size: 12px;
|
||||
}
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.3fr) minmax(250px, 1fr);
|
||||
gap: 28px;
|
||||
border-top: 1px solid #e1e6db;
|
||||
padding-top: 20px;
|
||||
}
|
||||
.workspace:has(aside[hidden]) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
#results {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.result {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
color: inherit;
|
||||
gap: 8px;
|
||||
}
|
||||
.result:hover {
|
||||
background: #edf1e7;
|
||||
}
|
||||
.result.selected {
|
||||
background: white;
|
||||
border-color: #d1dbc9;
|
||||
box-shadow: 0 3px 12px #314b2810;
|
||||
}
|
||||
.result strong {
|
||||
font-size: 14px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.path {
|
||||
font-size: 11px;
|
||||
color: #79847a;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.result .path,
|
||||
.snippet {
|
||||
grid-column: 1/-1;
|
||||
}
|
||||
.snippet {
|
||||
font-size: 12px;
|
||||
color: #61705f;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.kind {
|
||||
font-size: 10px;
|
||||
padding: 3px 7px;
|
||||
border-radius: 5px;
|
||||
background: #e6eddf;
|
||||
color: #638054;
|
||||
}
|
||||
aside {
|
||||
padding: 18px 22px;
|
||||
border-left: 1px solid #dce3d5;
|
||||
min-width: 0;
|
||||
}
|
||||
aside h2 {
|
||||
font-size: 20px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
aside pre {
|
||||
font: 13px/1.8 inherit;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
#preview-kind {
|
||||
font-size: 12px;
|
||||
color: #67875f;
|
||||
}
|
||||
#empty {
|
||||
text-align: center;
|
||||
padding: 38px 18px;
|
||||
color: #91a088;
|
||||
}
|
||||
#empty > span {
|
||||
font-size: 55px;
|
||||
}
|
||||
#empty h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: #607358;
|
||||
}
|
||||
#empty p {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
#notice {
|
||||
min-height: 18px;
|
||||
font-size: 12px;
|
||||
color: #627a5c;
|
||||
}
|
||||
#notice.error {
|
||||
color: #a54639;
|
||||
}
|
||||
footer {
|
||||
color: #95a18e;
|
||||
font-size: 11px;
|
||||
border-top: 1px solid #e1e6db;
|
||||
margin-top: 32px;
|
||||
}
|
||||
button:focus-visible,
|
||||
input:focus-visible {
|
||||
outline: 2px solid #5b8357;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
header,
|
||||
footer {
|
||||
padding: 20px;
|
||||
}
|
||||
main {
|
||||
padding: 12px 20px;
|
||||
}
|
||||
.workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
aside {
|
||||
border-left: 0;
|
||||
border-top: 1px solid #dce3d5;
|
||||
}
|
||||
.folder-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.folder-row input {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
footer {
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
.filters-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
.filters-grid label {
|
||||
font-size: 11px;
|
||||
color: #68785f;
|
||||
}
|
||||
.filters-grid input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
padding: 8px;
|
||||
border: 1px solid #d5decd;
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
}
|
||||
details summary {
|
||||
font-size: 12px;
|
||||
color: #61795a;
|
||||
cursor: pointer;
|
||||
}
|
||||
.preview-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.preview-actions button {
|
||||
font-size: 12px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #d3decb;
|
||||
border-radius: 6px;
|
||||
background: #e9efdf;
|
||||
color: #36583c;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.filters-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user