diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index 1c50b94a7cbcc..92d8bc882c951 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -1014,6 +1014,68 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { expect({ finders, rescans }).toEqual({ finders: 1, rescans: 1 }); }), ); + + it.effect("serves content search once a first scan that outlasted the wait finishes", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-slow-scan-" }); + yield* writeTextFile(cwd, "src/notes.ts", "const needle = 1;\n"); + // Real finders; only the initial wait and the progress it reads report a scan still running. + let scanning = true; + let finders = 0; + let settle = (): Promise => Promise.resolve(); + const createFinder = FileFinder.create.bind(FileFinder); + vi.spyOn(FileFinder, "create").mockImplementation((options) => { + const created = createFinder(options); + if (!created.ok) return created; + const finder = created.value; + finders += 1; + const waitForIndexReady = finder.waitForIndexReady.bind(finder); + settle = () => waitForIndexReady(15_000); + finder.waitForIndexReady = (timeoutMs) => + scanning ? Promise.resolve({ ok: true, value: false }) : waitForIndexReady(timeoutMs); + const getScanProgress = finder.getScanProgressAsync.bind(finder); + finder.getScanProgressAsync = async () => { + const progress = await getScanProgress(); + return scanning && progress.ok + ? { + ok: true, + value: { ...progress.value, isScanning: true, isWarmupComplete: false }, + } + : progress; + }; + return created; + }); + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const searchNeedle = workspaceEntries + .searchContents({ + cwd, + query: "needle", + limit: 100, + caseSensitive: true, + wholeWord: false, + useRegex: false, + }) + .pipe( + Effect.map((result): unknown => result.matches.map((match) => match.path)), + Effect.catchTags({ + WorkspaceSearchIndexScanTimedOut: (error) => Effect.succeed(error._tag), + }), + ); + + const whileScanning = yield* Effect.all([searchNeedle, searchNeedle], { + concurrency: "unbounded", + }); + scanning = false; + yield* Effect.promise(() => settle()); + const afterScan = yield* searchNeedle; + + expect({ whileScanning, afterScan, finders }).toEqual({ + whileScanning: ["WorkspaceSearchIndexScanTimedOut", "WorkspaceSearchIndexScanTimedOut"], + afterScan: ["src/notes.ts"], + finders: 1, + }); + }), + ); }); describe("browse", () => { diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index 51b1720126536..ad1d2c2ae0ebe 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -179,7 +179,6 @@ export const make = Effect.gen(function* () { function* ( cause: | WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed - | WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut | WorkspaceSearchIndex.WorkspaceSearchIndexRefreshFailed, ) { yield* Effect.logWarning("Failed to refresh workspace search index", { @@ -197,7 +196,6 @@ export const make = Effect.gen(function* () { Effect.provide(workspaceSearchIndexes.get(indexKey)), Effect.catchTags({ WorkspaceSearchIndexCreateFailed: recoverRefreshFailure, - WorkspaceSearchIndexScanTimedOut: recoverRefreshFailure, WorkspaceSearchIndexRefreshFailed: recoverRefreshFailure, }), ); diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index 8d9798baadfd7..fd92420396e48 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -142,7 +142,10 @@ export class WorkspaceSearchIndex extends Context.Service< ) => Effect.Effect; readonly searchContents: ( input: Omit, - ) => Effect.Effect; + ) => Effect.Effect< + ProjectSearchContentsResult, + WorkspaceSearchIndexScanTimedOut | WorkspaceSearchIndexSearchFailed + >; readonly refresh: () => Effect.Effect; } >()("t3/workspace/WorkspaceSearchIndex") {} @@ -585,13 +588,12 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( cause, }), ).pipe( + // The scan keeps running on this finder; failing here would destroy it and cache the failure. Effect.catchTags({ - WorkspaceSearchIndexScanTimedOut: (error) => - variant === "paths" - ? Effect.sync(() => { - initialScanTimedOut = true; - }) - : Effect.fail(error), + WorkspaceSearchIndexScanTimedOut: () => + Effect.sync(() => { + initialScanTimedOut = true; + }), }), ); @@ -609,7 +611,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( const runSearch = Effect.fn("WorkspaceSearchIndex.runSearch")(function* ( query: string, pageSize: number, - operation: "directorySearch" | "fileSearch" | "grep" | "mixedSearch", + operation: "directorySearch" | "fileSearch" | "getScanProgress" | "grep" | "mixedSearch", execute: () => Promise>, ): Effect.fn.Return { // fff's searches wait on its picker lock, which its watcher can hold for minutes. @@ -1036,6 +1038,20 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( const searchContents: WorkspaceSearchIndex["Service"]["searchContents"] = Effect.fn( "WorkspaceSearchIndex.searchContents", )(function* (input) { + if (initialScanTimedOut) { + const progress = yield* runSearch(input.query, input.limit, "getScanProgress", () => + finder.getScanProgressAsync(), + ); + // Files the first scan has not indexed yet would read as "No results found". + if (!progress.isWarmupComplete) { + return yield* new WorkspaceSearchIndexScanTimedOut({ + cwd, + timeout: WORKSPACE_INDEX_SCAN_TIMEOUT, + }); + } + initialScanTimedOut = false; + } + let { searchQuery, regexMode } = buildContentSearchQuery(input); const deadline = performance.now() + CONTENT_SEARCH_TIME_BUDGET_MS; // Grep cursors advance per file; whole-word filtering needs enough raw candidates before advancing. diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index aa97d5f17aa66..8afc9d7ba9217 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -13,6 +13,7 @@ import { type ThreadSearchScope, } from "@t3tools/client-runtime/state/thread-search"; import { + ProjectSearchContentsError, PROJECT_SEARCH_CONTENTS_QUERY_MAX_LENGTH, THREAD_SEARCH_QUERY_MAX_LENGTH, } from "@t3tools/contracts"; @@ -30,6 +31,7 @@ import type { VcsRef, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import * as Schema from "effect/Schema"; import { AsyncResult, Atom } from "effect/reactivity"; import { useEffect, useState } from "react"; @@ -46,6 +48,8 @@ const PROJECT_PATH_SEARCH_DEBOUNCE_MS = 120; const COMPOSER_PATH_SEARCH_LIMIT = 80; const PROJECT_CONTENT_SEARCH_DEBOUNCE_MS = 120; const PROJECT_CONTENT_SEARCH_LIMIT = 500; +const PROJECT_CONTENT_SEARCH_SCAN_RETRY_MS = 30_000; +const isProjectSearchContentsError = Schema.is(ProjectSearchContentsError); const THREAD_SEARCH_DEBOUNCE_MS = 120; const VCS_REF_LIST_LIMIT = 100; const EMPTY_REFS: ReadonlyArray = []; @@ -390,6 +394,22 @@ export function useProjectContentSearch(target: ProjectContentSearchTarget) { }) : null, ); + // Only once the timeout has come back: a refresh restarts a search still in flight. + const settledScanTimeout = + !result.isPending && + isProjectSearchContentsError(result.failure) && + result.failure.failure === "search_index_scan_timed_out" + ? result.failure + : null; + const { refresh } = result; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- retry timer: nothing tells the client when the server's first scan ends + useEffect(() => { + if (settledScanTimeout === null) return; + const timer = window.setTimeout(refresh, PROJECT_CONTENT_SEARCH_SCAN_RETRY_MS); + return () => { + window.clearTimeout(timer); + }; + }, [refresh, settledScanTimeout]); return { canReadFiles,