Files
searchi/apps/desktop/src/search-state.ts
T

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.",
};
}