164 lines
4.9 KiB
TypeScript
164 lines
4.9 KiB
TypeScript
/** 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;
|
|
|
|
/** A rebuild retains the previous committed generation. Backend readiness is
|
|
* withdrawn only while a replacement query graph is being prepared. */
|
|
export function semanticAvailable(
|
|
status: {
|
|
ready: boolean;
|
|
generation: number | null;
|
|
coverage?: string | null;
|
|
} | null,
|
|
) {
|
|
return Boolean(
|
|
status?.ready && status.generation !== null && status.coverage !== null,
|
|
);
|
|
}
|
|
|
|
/** 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.",
|
|
};
|
|
}
|