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
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:
1 parent
8b9b079469
commit
81846b3e98
32 files changed
+1303
-59
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)}`
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
Reference in new issue
Block a user