feat: integrate Google semantic search and branded desktop launcher
Core CI / core (ubuntu-latest) (push) Failing after 3m27s
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 5m45s
Core CI / desktop (macos-latest) (push) Canceled after 0s
Core CI / desktop (windows-latest) (push) Canceled after 0s

This commit is contained in:
karl committed 2026-10-12 17:44:56 +02:00
1 parent a90df15503
commit caf6a2f82e
80 files changed
+14971 -837

No files matched your search

+64
View File
@@ -0,0 +1,64 @@
// Optimized library assets only. No handcrafted SVG or runtime icon requests.
import search from "@fluentui/svg-icons/icons/search_24_regular.svg?raw";
import settings from "@fluentui/svg-icons/icons/settings_20_regular.svg?raw";
import filter from "@fluentui/svg-icons/icons/filter_20_regular.svg?raw";
import dismiss from "@fluentui/svg-icons/icons/dismiss_20_regular.svg?raw";
import folder from "@fluentui/svg-icons/icons/folder_24_regular.svg?raw";
import folderAdd from "@fluentui/svg-icons/icons/folder_add_20_regular.svg?raw";
import document from "@fluentui/svg-icons/icons/document_24_regular.svg?raw";
import pdf from "@fluentui/svg-icons/icons/document_pdf_24_regular.svg?raw";
import code from "@fluentui/svg-icons/icons/code_24_regular.svg?raw";
import lock from "@fluentui/svg-icons/icons/lock_closed_20_regular.svg?raw";
import arrowUp from "@fluentui/svg-icons/icons/arrow_up_16_regular.svg?raw";
import arrowDown from "@fluentui/svg-icons/icons/arrow_down_16_regular.svg?raw";
import open from "@fluentui/svg-icons/icons/open_20_regular.svg?raw";
import chevron from "@fluentui/svg-icons/icons/chevron_down_16_regular.svg?raw";
import sparkles from "@fluentui/svg-icons/icons/sparkle_20_regular.svg?raw";
const assets = {
search,
settings,
filter,
dismiss,
folder,
folderAdd,
document,
pdf,
code,
lock,
arrowUp,
arrowDown,
open,
chevron,
sparkles,
};
export function icon(name: keyof typeof assets) {
return `<span class="icon" aria-hidden="true">${assets[name]}</span>`;
}
export function fileIcon(filename: string) {
const extension = filename.split(".").pop()?.toLowerCase();
if (extension === "pdf") return { html: icon("pdf"), type: "pdf" };
if (
extension &&
[
"rs",
"js",
"ts",
"tsx",
"py",
"json",
"html",
"css",
"toml",
"yaml",
"yml",
].includes(extension)
)
return { html: icon("code"), type: "code" };
return {
html: icon("document"),
type: ["doc", "docx"].includes(extension || "")
? "document-word"
: "document",
};
}
+583 -88
View File
@@ -1,4 +1,13 @@
import { invoke, isTauri } from "@tauri-apps/api/core";
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";
import "./style.css";
interface SearchResult {
@@ -20,16 +29,54 @@ interface IndexStats {
files: number;
content_files: number;
}
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;
}
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>
<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>
<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>`;
<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>`;
const element = <T extends HTMLElement>(id: string) =>
document.getElementById(id) as T;
@@ -40,6 +87,246 @@ let results: SearchResult[] = [];
let selected = -1;
let requestId = 0;
let timer: ReturnType<typeof setTimeout>;
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;
}
function showError(error: unknown) {
notice.textContent = String(error);
@@ -51,10 +338,14 @@ function select(index: number, focus = false) {
const result = results[selected];
list.querySelectorAll<HTMLButtonElement>("button").forEach((button, i) => {
button.classList.toggle("selected", i === selected);
button.setAttribute("aria-pressed", String(i === selected));
button.setAttribute("aria-current", String(i === selected));
button.tabIndex = i === selected ? 0 : -1;
if (i === selected && focus) button.focus();
});
element("preview").hidden = false;
const selectedIcon = fileIcon(result.filename);
element("preview-icon").innerHTML = selectedIcon.html;
element("preview-icon").dataset.type = selectedIcon.type;
element("preview-title").textContent = result.filename;
element("preview-path").textContent = result.path;
element("preview-kind").textContent =
@@ -64,102 +355,218 @@ function select(index: number, focus = false) {
(result.extraction_error
? `Content could not be extracted: ${result.extraction_error}`
: "No extracted text is available for this file.");
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;
}
}
function render() {
list.replaceChildren();
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);
}
}
element("empty").hidden = results.length > 0;
element("preview").hidden = true;
if (!results.length) {
element("preview").hidden = true;
previewPath = null;
}
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);
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);
});
if (results.length) select(0);
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;
}
async function search() {
const id = ++requestId;
invalidateSearch();
querySemanticFailed = false;
shownLane = "lexical";
const value = query.value.trim();
if (!value) {
results = [];
displayedRequestId = null;
selected = -1;
render();
element("result-summary").textContent = "READY WHEN YOU ARE";
element("empty").querySelector("h2")!.textContent =
"Your next discovery starts here.";
"Find what matters. Locally.";
element("empty").querySelector("p")!.textContent =
"Index a folder, then search a filename or a phrase from a document.";
"Index a folder, then search a filename, phrase, or what you remember.";
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", {
const request: SearchRequest = {
id: requestId,
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.";
}
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);
} catch (error) {
if (id === requestId) {
showError(error);
element("result-summary").textContent = "SEARCH FAILED";
}
showError(error);
}
}
@@ -170,15 +577,36 @@ async function refreshStats() {
}
query.addEventListener("input", () => {
++requestId;
clearTimeout(timer);
timer = setTimeout(search, 100);
invalidateSearch();
timer = setTimeout(search, 60);
});
element("search-form").addEventListener("submit", (event) => {
event.preventDefault();
clearTimeout(timer);
void search();
});
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;
});
});
element("add-folder").addEventListener("click", () => {
element("folder-form").hidden = false;
element("folder").focus();
@@ -196,10 +624,26 @@ element("browse-folder").addEventListener("click", async () => {
}
});
element("filters").addEventListener("change", () => {
++requestId;
clearTimeout(timer);
void search();
});
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();
});
async function openSelected(reveal: boolean) {
if (selected < 0 || !results[selected]) return;
try {
@@ -214,6 +658,18 @@ element("open-result").addEventListener("click", () => {
element("reveal-result").addEventListener("click", () => {
void openSelected(true);
});
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);
}
});
element("folder-form").addEventListener("submit", async (event) => {
event.preventDefault();
const input = element<HTMLInputElement>("folder");
@@ -223,6 +679,10 @@ element("folder-form").addEventListener("submit", async (event) => {
button.disabled = true;
});
element<HTMLButtonElement>("add-folder").disabled = true;
element<HTMLSelectElement>("cpu-threads").disabled = true;
indexingActive = true;
element("index-status").textContent =
"Indexing active · Search remains available";
notice.className = "";
notice.textContent = "Indexing folder… Search remains available.";
try {
@@ -231,6 +691,8 @@ element("folder-form").addEventListener("submit", async (event) => {
});
notice.textContent = `Indexed ${report.indexed} files. ${report.removed} removed; ${report.skipped} skipped.`;
element("folder-form").hidden = true;
folderRevision++;
localSemanticError = null;
await refreshStats();
await search();
} catch (error) {
@@ -240,6 +702,9 @@ element("folder-form").addEventListener("submit", async (event) => {
button.disabled = false;
});
element<HTMLButtonElement>("add-folder").disabled = false;
await refreshDesktopStatus().catch(showError);
await refreshStats().catch(showError);
queueSemanticRefresh(0);
}
});
document.addEventListener("keydown", (event) => {
@@ -249,12 +714,25 @@ document.addEventListener("keydown", (event) => {
query.select();
}
if (event.key === "Escape") {
document
.querySelectorAll<HTMLDetailsElement>(".popover[open]")
.forEach((popover) => {
popover.open = false;
});
++requestId;
query.value = "";
void search();
query.focus();
}
if (event.target === query || list.contains(event.target as Node)) {
if (
event.key === "Enter" &&
results[selected] &&
displayedRequestId === requestId
) {
event.preventDefault();
void openSelected(false);
}
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
select(selected + (event.key === "ArrowDown" ? 1 : -1), true);
@@ -263,8 +741,25 @@ document.addEventListener("keydown", (event) => {
});
if (isTauri()) {
void refreshStats().catch(showError);
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);
query.focus();
} else {
element("index-status").textContent = "Desktop preview";
element("semantic-status").textContent =
"Native semantic search runs in the desktop app.";
element("semantic-progress").hidden = true;
element("semantic-detail").textContent =
"This browser preview does not download models or return simulated search results.";
showError("Run the Tauri desktop app to index and search real files.");
}
+133
View File
@@ -0,0 +1,133 @@
/** One active request per lane; intermediate keystrokes collapse to the latest.
* Native work already running cannot be cancelled, so revisions guard every
* result and retry. Busy responses receive at most one delayed retry. */
export class LatestLane<T> {
private pending: T | undefined;
private running = false;
private run: (value: T) => Promise<void>;
private current: (value: T) => boolean;
private fail: (value: T, error: unknown) => void;
private delay: () => Promise<void>;
constructor(
run: (value: T) => Promise<void>,
current: (value: T) => boolean,
fail: (value: T, error: unknown) => void,
delay = () => new Promise<void>((resolve) => setTimeout(resolve, 400)),
) {
this.run = run;
this.current = current;
this.fail = fail;
this.delay = delay;
}
submit(value: T) {
this.pending = value;
if (!this.running) void this.drain();
}
clear() {
this.pending = undefined;
}
private async drain() {
this.running = true;
try {
while (this.pending !== undefined) {
const value = this.pending;
this.pending = undefined;
if (!this.current(value)) continue;
for (let attempt = 0; attempt < 2; attempt++) {
try {
await this.run(value);
break;
} catch (error) {
if (!this.current(value)) break;
if (
attempt === 0 &&
/busy|in progress|currently running|already running/i.test(
String(error),
)
) {
await this.delay();
if (!this.current(value) || this.pending !== undefined) break;
} else {
this.fail(value, error);
break;
}
}
}
}
} finally {
this.running = false;
}
}
}
/** An unsuccessful automatic build is not retried on every status poll. */
export class AutomaticIndexGate {
private attempted = new Set<string>();
claim(key: string) {
if (this.attempted.has(key)) return false;
this.attempted.add(key);
// Bound memory even across a very long desktop session.
if (this.attempted.size > 128)
this.attempted.delete(this.attempted.values().next().value!);
return true;
}
}
export type SemanticPhase =
| "preparing"
| "indexing"
| "pending"
| "unavailable"
| "no-content"
| "complete";
/** Zero keyword matches are provisional until the semantic lane completes. */
export function emptySearchState(
lane: "lexical" | "hybrid",
phase: SemanticPhase,
) {
const title =
lane === "lexical"
? "No keyword matches yet."
: "No matches in the current index yet.";
if (phase === "preparing")
return {
title:
lane === "lexical" ? title : "No matches in the current index yet.",
detail:
"Google semantic search is still preparing. This query will also search by meaning when it is ready.",
};
if (phase === "indexing")
return {
title:
lane === "lexical" ? title : "No matches in the current index yet.",
detail:
"Semantic indexing is underway. Results may change when your documents finish indexing.",
};
if (phase === "pending")
return {
title,
detail:
"Searching by meaning… Semantic results will appear here when this query finishes.",
};
if (phase === "unavailable")
return {
title,
detail:
"Semantic results are currently unavailable; see the model status above. Try a filename or words from your document, or retry the search.",
};
if (phase === "no-content")
return {
title,
detail:
"No document content is indexed for semantic search yet. Index a folder with readable documents, or search a filename.",
};
return {
title: lane === "hybrid" ? "No matches for this search." : title,
detail:
"Try another phrase, broaden your filters, or index another folder.",
};
}
+639 -214
View File
@@ -1,13 +1,38 @@
:root {
font-family:
Inter,
Segoe UI,
"Inter Variable",
system-ui,
-apple-system,
"Segoe UI",
sans-serif;
color: #20332e;
background: #f6f7f2;
font-synthesis: none;
color-scheme: light;
color-scheme: light dark;
--brand: #2563ff;
--canvas: #f8fafc;
--surface: #fff;
--subtle: #f1f5f9;
--selected: #e8efff;
--ink: #0b0f14;
--muted: #64748b;
--line: #e2e8f0;
--error: #b42318;
--shadow: 0 12px 36px #0b0f1418;
color: var(--ink);
background: var(--canvas);
font-size: 14px;
}
@media (prefers-color-scheme: dark) {
:root {
--canvas: #0b0f14;
--surface: #111720;
--subtle: #18212e;
--selected: #182c53;
--ink: #f8fafc;
--muted: #9cacc2;
--line: #263141;
--error: #ffaaa2;
--shadow: 0 12px 36px #00000050;
}
}
* {
box-sizing: border-box;
@@ -15,250 +40,626 @@
body {
margin: 0;
}
button,
input {
font: inherit;
#app {
height: 100dvh;
min-height: 500px;
display: flex;
flex-direction: column;
}
button {
button,
input,
select {
font: inherit;
color: inherit;
}
button,
summary {
cursor: pointer;
}
button:disabled {
cursor: wait;
opacity: 0.6;
}
header,
footer {
display: flex;
justify-content: space-between;
button {
display: inline-flex;
align-items: center;
padding: 24px 40px;
justify-content: center;
gap: 7px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 7px;
padding: 8px 12px;
transition:
background-color 120ms ease,
border-color 120ms ease;
}
button:hover,
summary:hover {
background: var(--subtle);
}
button:disabled {
opacity: 0.5;
cursor: default;
}
input,
select {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 6px;
padding: 9px 10px;
min-width: 0;
}
input::placeholder {
color: var(--muted);
}
input[type="search"]::-webkit-search-cancel-button {
display: none;
}
button:focus-visible,
input:focus-visible,
select:focus-visible,
summary:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 3px;
}
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
.icon svg {
width: 20px;
height: 20px;
fill: currentColor;
}
header {
display: flex;
align-items: center;
gap: 20px;
padding: 16px 22px 12px;
min-height: 64px;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
flex: 0 0 auto;
}
.brand-symbol {
width: 29px;
height: 29px;
object-fit: contain;
}
.wordmark {
font-size: 28px;
font-size: 20px;
font-weight: 750;
letter-spacing: -1.5px;
letter-spacing: -0.65px;
}
.dot {
color: #87a77f;
}
.privacy {
.index-status {
font-size: 12px;
color: #58705c;
color: var(--muted);
border-left: 1px solid var(--line);
padding-left: 18px;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
flex: 0 0 auto;
}
.quiet-button {
border-color: transparent;
background: transparent;
font-size: 12px;
}
.icon-button {
padding: 7px;
border-color: transparent;
background: transparent;
}
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;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
padding: 0 18px;
}
#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;
gap: 12px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 10px;
padding: 12px 15px;
min-height: 56px;
box-shadow: 0 2px 8px #0b0f1404;
}
#search-form:focus-within {
border-color: #588663;
box-shadow: 0 0 0 3px #65866922;
border-color: var(--brand);
box-shadow: 0 0 0 2px #2563ff15;
}
.search-icon {
font-size: 30px;
color: #6a8770;
#search-form > .icon {
color: var(--muted);
}
#search-form > .icon svg {
width: 24px;
height: 24px;
}
#query {
flex: 1;
min-width: 0;
padding: 0;
border: 0;
outline: none;
font-size: 18px;
background: transparent;
letter-spacing: -0.25px;
}
#query:focus-visible {
outline: none;
}
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;
display: inline-flex;
align-items: center;
margin: 18px 0;
font-size: 12px;
color: #78847b;
justify-content: center;
font-size: 11px;
line-height: 1.2;
padding: 4px 6px;
min-height: 23px;
border: 1px solid var(--line);
border-radius: 5px;
background: var(--surface);
color: var(--muted);
white-space: nowrap;
}
.toolbar button,
.folder-row button {
background: #e8ede2;
border: 1px solid #d9e0d2;
padding: 9px 14px;
kbd .icon svg {
width: 13px;
height: 13px;
}
.popover {
position: relative;
}
summary {
display: flex;
align-items: center;
gap: 8px;
list-style: none;
}
summary::-webkit-details-marker {
display: none;
}
.popover > summary {
border: 1px solid var(--line);
border-radius: 7px;
color: #36583c;
padding: 8px 10px;
background: var(--surface);
font-size: 12px;
}
#settings > summary {
border: 0;
background: transparent;
padding: 8px;
}
.popover-panel {
position: absolute;
right: 0;
top: calc(100% + 9px);
width: 360px;
max-width: calc(100vw - 36px);
z-index: 20;
padding: 20px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--surface);
box-shadow: var(--shadow);
max-height: calc(100vh - 120px);
overflow: auto;
animation: panel-in 160ms ease-out;
}
.popover-panel h2 {
font-size: 15px;
margin: 0 0 15px;
}
.filters-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 13px;
}
.filters-grid label {
font-size: 12px;
color: var(--muted);
}
.filters-grid input {
display: block;
width: 100%;
margin-top: 6px;
font-size: 12px;
}
.resource-label {
display: block;
font-size: 12px;
margin-bottom: 8px;
color: var(--muted);
}
#cpu-threads {
width: 100%;
}
#roots {
list-style: none;
padding: 0;
margin-bottom: 0;
}
#roots li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 9px 0;
border-top: 1px solid var(--line);
}
#roots button {
font-size: 11px;
padding: 6px 9px;
}
#semantic-panel {
margin: 8px 0 0;
font-size: 12px;
}
#semantic-panel > summary {
min-height: 34px;
padding: 5px 7px;
border-radius: 6px;
color: var(--muted);
}
#semantic-panel > summary > .icon:first-child {
color: var(--brand);
}
#semantic-status {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.semantic-content {
padding: 12px 15px;
margin: 6px 0 8px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface);
animation: panel-in 160ms ease-out;
}
.semantic-content strong {
font-size: 12px;
}
#semantic-progress {
display: block;
width: 100%;
height: 6px;
margin-top: 12px;
accent-color: var(--brand);
}
.semantic-actions {
display: flex;
gap: 8px;
}
.semantic-actions button {
font-size: 12px;
}
.hint {
font-size: 12px;
color: var(--muted);
line-height: 1.6;
}
#folder-form {
background: #eef2e9;
padding: 18px;
padding: 14px 16px;
margin: 8px 0;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 10px;
animation: panel-in 160ms ease-out;
}
#folder-form label {
font-size: 13px;
font-size: 12px;
font-weight: 600;
}
.folder-row {
display: flex;
gap: 8px;
margin-top: 10px;
margin-top: 8px;
}
.folder-row input {
min-width: 0;
flex: 1;
padding: 9px;
border: 1px solid #c9d3c3;
border-radius: 6px;
}
.hint {
color: #74806e;
.folder-row button {
font-size: 12px;
}
#notice {
font-size: 12px;
color: var(--muted);
line-height: 1.5;
margin: 4px 8px;
}
#notice:empty {
display: none;
}
.error,
#semantic-status.error {
color: var(--error);
}
.workspace {
display: grid;
grid-template-columns: minmax(0, 1.3fr) minmax(250px, 1fr);
gap: 28px;
border-top: 1px solid #e1e6db;
padding-top: 20px;
grid-template-columns: minmax(0, 0.96fr) minmax(0, 1.04fr);
flex: 1;
min-height: 0;
margin-top: 6px;
border: 1px solid var(--line);
border-radius: 10px 10px 0 0;
overflow: hidden;
background: var(--surface);
}
.workspace:has(aside[hidden]) {
grid-template-columns: 1fr;
}
.results-pane {
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
background: var(--canvas);
}
.eyebrow {
font-size: 10px;
font-weight: 600;
letter-spacing: 1px;
color: var(--muted);
}
#result-summary {
margin: 0;
padding: 12px 15px 9px;
flex: 0 0 auto;
}
#results {
list-style: none;
padding: 0;
margin: 0;
padding: 0 8px 8px;
overflow-y: auto;
flex: 1;
min-height: 0;
scrollbar-gutter: stable;
}
#results:empty {
display: none;
}
#results li {
padding: 3px 0;
}
.result {
display: grid;
grid-template-columns: 1fr auto;
grid-template-columns: 42px minmax(0, 1fr) auto;
text-align: left;
width: 100%;
padding: 16px;
min-height: 96px;
padding: 13px 12px;
background: transparent;
border: 1px solid transparent;
border-radius: 10px;
color: inherit;
gap: 8px;
border-radius: 8px;
gap: 5px 12px;
color: var(--ink);
}
.result:hover {
background: #edf1e7;
background: var(--subtle);
}
.result.selected {
background: white;
border-color: #d1dbc9;
box-shadow: 0 3px 12px #314b2810;
background: var(--selected);
border-color: #2563ff20;
}
.result .file-icon {
grid-row: 1/4;
align-self: start;
}
.file-icon {
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 7px;
background: var(--subtle);
color: var(--brand);
}
.file-icon svg {
width: 28px;
height: 28px;
}
.file-icon[data-type="pdf"] {
color: #e12e3d;
}
.file-icon[data-type="code"] {
color: var(--muted);
}
.result strong {
font-size: 14px;
overflow-wrap: anywhere;
grid-column: 2;
align-self: center;
font-size: 13px;
font-weight: 650;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.path {
font-size: 11px;
color: #79847a;
overflow-wrap: anywhere;
.kind {
font-size: 10px;
color: var(--muted);
white-space: nowrap;
}
.result .path,
.snippet {
grid-column: 1/-1;
.result .kind {
grid-column: 3;
align-self: center;
color: var(--brand);
}
.snippet {
grid-column: 2/4;
font-size: 12px;
color: #61705f;
line-height: 1.55;
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;
.path {
font-size: 11px;
color: var(--muted);
overflow-wrap: anywhere;
}
.result .path {
grid-column: 2/4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.new-row {
animation: row-in 160ms ease-out;
}
#empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 45px 30px;
color: var(--muted);
}
#empty > .icon {
margin-bottom: 12px;
color: var(--brand);
}
#empty > .icon svg {
width: 36px;
height: 36px;
}
#empty h2 {
margin: 7px 0;
color: var(--ink);
font-size: 19px;
letter-spacing: -0.45px;
font-weight: 600;
}
#empty p {
max-width: 420px;
font-size: 13px;
line-height: 1.7;
}
aside {
padding: 18px 22px;
border-left: 1px solid #dce3d5;
display: flex;
flex-direction: column;
border-left: 1px solid var(--line);
min-width: 0;
min-height: 0;
background: var(--canvas);
}
.preview-header {
display: flex;
align-items: center;
gap: 13px;
padding: 18px 20px 10px;
}
.preview-heading {
min-width: 0;
}
aside h2 {
font-size: 20px;
margin: 0 0 5px;
font-size: 14px;
font-weight: 650;
overflow-wrap: anywhere;
}
aside pre {
font: 13px/1.8 inherit;
white-space: pre-wrap;
overflow-wrap: anywhere;
.preview-heading .path {
margin: 0;
line-height: 1.5;
}
.preview-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 0 20px 14px;
}
#preview-kind {
font-size: 12px;
color: #67875f;
font-size: 11px;
color: var(--muted);
}
#empty {
text-align: center;
padding: 38px 18px;
color: #91a088;
.preview-actions {
display: flex;
gap: 6px;
flex: 0 0 auto;
}
#empty > span {
font-size: 55px;
.preview-actions button {
font-size: 11px;
padding: 6px 9px;
}
#empty h2 {
font-size: 20px;
font-weight: 500;
color: #607358;
.preview-actions .icon svg {
width: 17px;
height: 17px;
}
#empty p {
font-size: 13px;
line-height: 1.6;
.passage {
flex: 1;
min-height: 0;
margin: 0 10px 10px;
padding: 22px 25px;
overflow: auto;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 7px;
scrollbar-gutter: stable;
}
#notice {
min-height: 18px;
font-size: 12px;
color: #627a5c;
.passage > .eyebrow {
margin: 0 0 18px;
}
#notice.error {
color: #a54639;
aside pre {
font-family: inherit;
font-size: 14px;
line-height: 1.9;
white-space: pre-wrap;
overflow-wrap: anywhere;
margin: 0;
}
footer {
color: #95a18e;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 13px 20px;
min-height: 50px;
border-top: 1px solid var(--line);
color: var(--muted);
font-size: 11px;
border-top: 1px solid #e1e6db;
margin-top: 32px;
}
button:focus-visible,
input:focus-visible {
outline: 2px solid #5b8357;
outline-offset: 3px;
.footer-privacy {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.footer-privacy > .icon {
color: var(--ink);
}
.privacy {
font-size: 10px;
border-left: 1px solid var(--line);
padding-left: 9px;
white-space: nowrap;
}
.keyboard-hints {
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.keyboard-hints > span:not(.icon) {
margin-right: 8px;
}
.sr-only {
position: absolute;
@@ -274,70 +675,94 @@ input:focus-visible {
[hidden] {
display: none !important;
}
@media (max-width: 760px) {
header,
footer {
padding: 20px;
@keyframes row-in {
from {
opacity: 0;
transform: translateY(3px);
}
main {
padding: 12px 20px;
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes panel-in {
from {
opacity: 0;
transform: translateY(-3px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 820px) {
.index-status {
display: none;
}
.header-actions {
gap: 5px;
}
.quiet-button > span:not(.icon) {
display: none;
}
.workspace {
grid-template-columns: 1fr;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
aside {
border-left: 0;
border-top: 1px solid #dce3d5;
.keyboard-hints > span {
display: none;
}
.folder-row {
flex-wrap: wrap;
.privacy {
display: none;
}
.folder-row input {
flex-basis: 100%;
.preview-actions button > span:not(.icon) {
display: none;
}
footer {
gap: 12px;
flex-wrap: wrap;
.preview-header {
padding: 14px 14px 8px;
}
.preview-toolbar {
padding: 0 14px 12px;
}
.passage {
padding: 18px;
}
}
.filters-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
padding: 16px 0;
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation: none !important;
transition: none !important;
scroll-behavior: auto !important;
}
}
.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));
@media (forced-colors: active) {
:root {
--canvas: Canvas;
--surface: Canvas;
--subtle: ButtonFace;
--ink: CanvasText;
--muted: CanvasText;
--line: ButtonBorder;
--selected: Highlight;
--brand: LinkText;
--error: CanvasText;
}
.result.selected {
color: HighlightText;
border-color: Highlight;
}
.result.selected .path,
.result.selected .kind {
color: HighlightText;
}
.file-icon {
border: 1px solid ButtonBorder;
}
button:focus-visible,
input:focus-visible,
select:focus-visible,
summary:focus-visible {
outline-color: Highlight;
}
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />