feat: add Google code retrieval across desktop CLI and MCP
Core CI / core (macos-latest) (push) Canceled after 0s
Core CI / core (windows-latest) (push) Canceled after 0s
Core CI / desktop (macos-latest) (push) Canceled after 0s
Core CI / desktop (ubuntu-latest) (push) Canceled after 0s
Core CI / desktop (windows-latest) (push) Canceled after 0s
Core CI / core (ubuntu-latest) (push) Canceled after 2m23s

This commit is contained in:
karl committed 2026-10-12 21:22:11 +02:00
1 parent 8b9b079469
commit 81846b3e98
32 files changed
+1303 -59

No files matched your search

+50
View File
@@ -119,6 +119,8 @@ fn set_cpu_threads(threads: usize, state: State<'_, AppState>) -> Result<Setting
#[derive(Default, Deserialize)]
struct Filters {
#[serde(default)]
code_only: bool,
folder: Option<PathBuf>,
extension: Option<String>,
min_size: Option<u64>,
@@ -132,6 +134,7 @@ struct Filters {
impl From<Filters> for SearchOptions {
fn from(value: Filters) -> Self {
Self {
code_only: value.code_only,
folder: value.folder,
extension: value.extension,
min_size: value.min_size,
@@ -500,6 +503,53 @@ fn main() {
mod tests {
use super::*;
#[test]
fn code_scope_defaults_off_and_combines_with_other_filters() {
let legacy: Filters = serde_json::from_str(r#"{"extension":"rs"}"#).unwrap();
let legacy: SearchOptions = legacy.into();
assert!(!legacy.code_only);
let scoped: Filters =
serde_json::from_str(r#"{"code_only":true,"extension":"rs","min_size":10}"#).unwrap();
let scoped: SearchOptions = scoped.into();
assert!(scoped.code_only);
assert_eq!(scoped.extension.as_deref(), Some("rs"));
assert_eq!(scoped.min_size, Some(10));
assert!(serde_json::from_str::<Filters>(r#"{"code_only":"true"}"#).is_err());
}
#[test]
fn code_scope_reaches_real_keyword_search_without_model_readiness() {
let root = tempfile::tempdir().unwrap();
std::fs::write(
root.path().join("parse.rs"),
"fn parse_configuration() { /* validate config */ }",
)
.unwrap();
std::fs::write(
root.path().join("notes.md"),
"parse_configuration validate config",
)
.unwrap();
let mut index = SearchIndex::open(":memory:").unwrap();
index.index_folder(root.path()).unwrap();
let all: SearchOptions = Filters::default().into();
assert_eq!(
index
.search_with_options("parse_configuration", 10, &all)
.unwrap()
.len(),
2
);
let scoped: SearchOptions = serde_json::from_str::<Filters>(r#"{"code_only":true}"#)
.unwrap()
.into();
let hits = index
.search_with_options("parse_configuration", 10, &scoped)
.unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].filename, "parse.rs");
}
#[test]
fn measured_failure_retains_closed_trace_without_inventing_results() {
let mut trace = TraceCollector::new(MetricsMode::Debug);
+14 -5
View File
@@ -12,9 +12,10 @@ import {
emptySearchState,
LatestLane,
semanticAvailable,
scopedFilters,
SEMANTIC_SETTLE_MS,
} from "./search-state";
import type { SemanticPhase } from "./search-state";
import type { SemanticPhase, SearchFilters } from "./search-state";
import { fileIcon, icon } from "./icons";
import {
SearchMeasurement,
@@ -108,13 +109,13 @@ interface SemanticStatus {
interface SearchRequest {
id: number;
query: string;
filters: Record<string, string | number | null>;
filters: SearchFilters;
started: number;
measurement: SearchMeasurement;
}
document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
<header><div class="brand"><img class="brand-symbol" src="/searchi-symbol.png" alt=""><span class="wordmark">searchi</span></div><span id="index-status" class="index-status">Loading your index…</span><div class="header-actions"><button id="add-folder" class="quiet-button" type="button">${icon("folderAdd")}<span>Add folder</span></button><details id="filters" class="popover"><summary>${icon("filter")}<span>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>Minimum duration (seconds)<input id="filter-duration-min" type="number" min="0" step="0.1"></label><label>Maximum duration (seconds)<input id="filter-duration-max" type="number" min="0" step="0.1"></label><label>Modified from<input id="filter-after" type="date"></label><label>Modified through<input id="filter-before" type="date"></label></div></div></details><details id="settings" class="popover"><summary class="icon-button" aria-label="Settings" title="Settings">${icon("settings")}</summary><div class="popover-panel"><h2>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><label class="performance-toggle"><input id="performance-toggle" type="checkbox">Performance details</label><p class="hint">Local timing diagnostics for new searches. Queries and file paths are excluded from copied reports.</p><details id="ai-settings" class="ai-settings"><summary>Optional AI answers</summary><label class="performance-toggle"><input id="ai-enabled" type="checkbox">Enable summaries and questions</label><p class="hint">Disabled by default. Only an explicit action sends the shown passage to your configured endpoint.</p><label>OpenAI-compatible base URL<input id="ai-endpoint" type="url" placeholder="http://localhost:1234/v1" autocomplete="off" maxlength="2048"></label><label>Model<input id="ai-model" placeholder="Your model name" autocomplete="off" maxlength="200"></label><label>API key (optional, this session only)<input id="ai-key" type="password" autocomplete="off" maxlength="2048"></label><button id="ai-clear-key" type="button">Clear key</button><p class="hint">Endpoint, model and key stay in this app session. The key authenticates requests to your endpoint.</p></details></div></details></div></header>
<header><div class="brand"><img class="brand-symbol" src="/searchi-symbol.png" alt=""><span class="wordmark">searchi</span></div><span id="index-status" class="index-status">Loading your index…</span><div class="header-actions"><button id="add-folder" class="quiet-button" type="button">${icon("folderAdd")}<span>Add folder</span></button><details id="filters" class="popover"><summary>${icon("filter")}<span id="scope-label">All files</span>${icon("chevron")}</summary><div class="popover-panel"><h2>Filter your search</h2><div class="filters-grid"><label>Search scope<select id="filter-scope"><option value="all">All files</option><option value="code">Code</option></select></label><label>Within folder<input id="filter-folder" placeholder="Absolute folder path"></label><label>Extension<input id="filter-extension" placeholder="pdf, md, txt…"></label><label>Minimum size (bytes)<input id="filter-min" type="number" min="0" step="1"></label><label>Maximum size (bytes)<input id="filter-max" type="number" min="0" step="1"></label><label>Minimum duration (seconds)<input id="filter-duration-min" type="number" min="0" step="0.1"></label><label>Maximum duration (seconds)<input id="filter-duration-max" type="number" min="0" step="0.1"></label><label>Modified from<input id="filter-after" type="date"></label><label>Modified through<input id="filter-before" type="date"></label></div></div></details><details id="settings" class="popover"><summary class="icon-button" aria-label="Settings" title="Settings">${icon("settings")}</summary><div class="popover-panel"><h2>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><label class="performance-toggle"><input id="performance-toggle" type="checkbox">Performance details</label><p class="hint">Local timing diagnostics for new searches. Queries and file paths are excluded from copied reports.</p><details id="ai-settings" class="ai-settings"><summary>Optional AI answers</summary><label class="performance-toggle"><input id="ai-enabled" type="checkbox">Enable summaries and questions</label><p class="hint">Disabled by default. Only an explicit action sends the shown passage to your configured endpoint.</p><label>OpenAI-compatible base URL<input id="ai-endpoint" type="url" placeholder="http://localhost:1234/v1" autocomplete="off" maxlength="2048"></label><label>Model<input id="ai-model" placeholder="Your model name" autocomplete="off" maxlength="200"></label><label>API key (optional, this session only)<input id="ai-key" type="password" autocomplete="off" maxlength="2048"></label><button id="ai-clear-key" type="button">Clear key</button><p class="hint">Endpoint, model and key stay in this app session. The key authenticates requests to your endpoint.</p></details></div></details></div></header>
<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>
@@ -135,6 +136,7 @@ try {
}
let results: SearchResult[] = [];
let displayedLiteralQuery = literalQuery("");
let displayedCodeScope = false;
let explainedSelection = "";
let selected = -1;
let requestId = 0;
@@ -726,6 +728,7 @@ function select(index: number, focus = false) {
: media
? "Preview the matched media below."
: "No extracted text is available for this file.");
element("preview-text").classList.toggle("code-passage", displayedCodeScope);
renderHighlighted(
element("preview-text"),
passageText,
@@ -833,6 +836,7 @@ function render() {
"semantic_video",
"semantic_audio",
].includes(result.match_kind);
entry.snippet.classList.toggle("code-snippet", displayedCodeScope);
renderHighlighted(
entry.snippet,
displayExcerpt(result.snippet, displayedLiteralQuery),
@@ -879,7 +883,7 @@ function captureFilters() {
throw new Error("Enter a valid modification date.");
return stamp;
};
return {
return scopedFilters(element<HTMLSelectElement>("filter-scope").value, {
folder: input("filter-folder") || null,
extension: input("filter-extension") || null,
min_duration_ms: duration("filter-duration-min"),
@@ -888,7 +892,7 @@ function captureFilters() {
max_size: size("filter-max"),
modified_after_unix_ms: date("filter-after", false),
modified_before_unix_ms: date("filter-before", true),
};
});
}
function applyResults(
@@ -907,6 +911,7 @@ function applyResults(
shownLane = lane;
displayedRequestId = request.id;
displayedLiteralQuery = literalQuery(request.query);
displayedCodeScope = request.filters.code_only === true;
results = found;
selected = -1;
render();
@@ -1209,6 +1214,10 @@ element("browse-folder").addEventListener("click", async () => {
}
});
element("filters").addEventListener("change", () => {
element("scope-label").textContent =
element<HTMLSelectElement>("filter-scope").value === "code"
? "Code"
: "All files";
void search();
});
element("retry-model").addEventListener("click", async () => {
+3 -1
View File
@@ -195,7 +195,9 @@ export function matchExplanation(result: MatchResult) {
? ` on page ${source.number}`
: source?.kind === "paragraph"
? ` in paragraph ${source.number}`
: "";
: source?.kind === "line"
? ` at line ${source.number}`
: "";
const time =
source?.kind === "timestamp_ms"
? ` near ${timestampLabel(source.number)}`
+13
View File
@@ -1,3 +1,16 @@
/** Scope is captured with filters; changing controls cannot mutate active work. */
export type SearchFilters = Readonly<
Record<string, string | number | boolean | null>
>;
export function scopedFilters(
scope: string,
filters: Record<string, string | number | boolean | null>,
): SearchFilters {
if (scope !== "all" && scope !== "code")
throw new Error("Choose a valid search scope.");
return Object.freeze({ ...filters, code_only: scope === "code" });
}
/** Let rapid typing settle before starting the model lane. */
export const SEMANTIC_SETTLE_MS = 120;
+12
View File
@@ -937,3 +937,15 @@ mark[data-match="literal"] {
border-color: Highlight;
}
}
/* Preserve actual source indentation without introducing generated annotations. */
.snippet.code-snippet,
aside pre.code-passage {
font-family:
ui-monospace, "Cascadia Code", "SFMono-Regular", Consolas, monospace;
white-space: pre-wrap;
tab-size: 4;
}
aside pre.code-passage {
line-height: 1.6;
}
@@ -80,3 +80,8 @@ test('optional reasons use real source routes and never claim semantic word conf
assert.equal(matchExplanation({match_kind:'hybrid',source:{kind:'page',number:2}}).semanticPassage,false);
assert.doesNotMatch(JSON.stringify(text),/confidence|percent|importance/);
});
test('code passage reasons retain actual indexed source line', () => {
assert.match(matchExplanation({match_kind:'semantic', source:{kind:'line',number:97}}).reason, /at line 97/);
assert.match(matchExplanation({match_kind:'hybrid', source:{kind:'line',number:97}}).reason, /showing the passage at line 97/);
});
+30 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { LatestLane, AutomaticIndexGate, emptySearchState, semanticAvailable, SEMANTIC_SETTLE_MS } from '../src/search-state.ts';
import { LatestLane, AutomaticIndexGate, emptySearchState, semanticAvailable, SEMANTIC_SETTLE_MS, scopedFilters } from '../src/search-state.ts';
const settle = () => new Promise(resolve => setImmediate(resolve));
test('coalesces queued keystrokes and discards stale responses', async () => {
@@ -78,3 +78,32 @@ test('old committed semantic generation stays available during rebuild, activati
assert.equal(semanticAvailable({ready: true, generation: 2, embedding: false}), true);
assert.equal(SEMANTIC_SETTLE_MS, 120);
});
test('scope snapshots freeze all filters independently of later control changes', () => {
const controls = { extension: 'rs', folder: '/project' };
const all = scopedFilters('all', controls);
const code = scopedFilters('code', controls);
controls.extension = 'pdf';
assert.equal(all.code_only, false);
assert.equal(code.code_only, true);
assert.equal(code.extension, 'rs');
assert.equal(code.folder, '/project');
assert.throws(() => { code.code_only = false; }, TypeError);
assert.throws(() => scopedFilters('unknown', {}), /scope/);
});
test('same-query scope change discards old lane output and runs latest scoped request', async () => {
let revision = 1; let release; const seen = []; const accepted = [];
const lane = new LatestLane(async request => {
seen.push(request.filters.code_only);
if (request.id === 1) await new Promise(resolve => { release = resolve; });
if (request.id === revision) accepted.push(request);
}, request => request.id === revision, () => assert.fail('Unexpected failure'));
lane.submit({id: 1, query: 'parse configuration', filters: scopedFilters('all', {})});
revision = 2;
lane.submit({id: 2, query: 'parse configuration', filters: scopedFilters('code', {extension: 'rs'})});
release(); await settle();
assert.deepEqual(seen, [false, true]);
assert.equal(accepted.length, 1);
assert.equal(accepted[0].filters.code_only, true);
assert.equal(accepted[0].filters.extension, 'rs');
});