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
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:
1 parent
a90df15503
commit
caf6a2f82e
80 files changed
+14971
-837
No files matched your search
@@ -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
@@ -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.");
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in new issue
Block a user