diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts index 45102e4c1cfea..6c4e873f12b47 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts @@ -9,6 +9,7 @@ import { import * as NodeServices from "@effect/platform-node/NodeServices"; import { afterEach, expect, it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { PROJECT_FAVICON_EXTENSIONS, isProjectFaviconPath } from "@t3tools/shared/projectFavicon"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -40,44 +41,98 @@ function fileItem(relativePath: string): FileItem { }; } -it.effect("filters image searches before applying the result limit", () => - Effect.scoped( - Effect.gen(function* () { - const items = [ - ...Array.from({ length: 200 }, (_, index) => fileItem(`src/file-${index}.ts`)), - fileItem("public/icon.svg"), - ]; - const fileSearch = vi.fn(async () => ({ - ok: true as const, - value: { - items, - scores: [], - totalMatched: items.length, - totalFiles: items.length, - }, - })); - const finder = { - destroy: vi.fn(), - waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), - getScanProgressAsync: async () => ({ - ok: true as const, - value: { isScanning: false }, - }), - watch: () => ({ ok: true as const, value: () => undefined }), - fileSearchAsync: fileSearch, - } as unknown as FileFinder; - vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); - - const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project"); - const resultWithoutKind = yield* searchIndex.search("", 200, undefined, true); - const resultWithDirectoryKind = yield* searchIndex.search("", 200, "directory", true); - - expect(resultWithoutKind.entries).toEqual([{ kind: "file", path: "public/icon.svg" }]); - expect(resultWithDirectoryKind.entries).toEqual([{ kind: "file", path: "public/icon.svg" }]); - expect(fileSearch).toHaveBeenCalledTimes(2); - expect(fileSearch).toHaveBeenCalledWith("", { pageSize: 25_002 }); - }), - ).pipe(Effect.provide(testLayer)), +it.live("image searches match the image subset of a file search without fetching every file", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const vcsProcess = yield* VcsProcess.VcsProcess; + const writeFiles = Effect.fn(function* (root: string, relativePaths: ReadonlyArray) { + for (const relativePath of relativePaths) { + yield* fileSystem.makeDirectory(path.dirname(path.join(root, relativePath)), { + recursive: true, + }); + yield* fileSystem.writeFileString(path.join(root, relativePath), ""); + yield* fileSystem.utimes(path.join(root, relativePath), 0, 0); + } + }); + + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-image-search-" }); + yield* vcsProcess.run({ operation: "test.gitInit", command: "git", args: ["init"], cwd }); + yield* fileSystem.writeFileString(path.join(cwd, ".git/info/exclude"), "hidden/\n"); + yield* writeFiles(cwd, [ + ...Array.from({ length: 220 }, (_, index) => `assets/source-${index}.ts`), + ...PROJECT_FAVICON_EXTENSIONS.flatMap((extension) => [ + `assets/logo${extension}`, + `other/logo${extension.toUpperCase()}`, + ]), + "assets/logo.ts", + "assets/logotype.png", + "assetss/logotype.png", + "assets/images/logo.PNG", + "assets/foo{bar}.png", + "assets/[id].png", + "hidden/logo.png", + ]); + + const fileSearch = vi.spyOn(FileFinder.prototype, "fileSearchAsync"); + const searchIndex = yield* WorkspaceSearchIndex.make(cwd); + yield* searchIndex.search("", 200, undefined, true); + const nativeResult = yield* Effect.promise( + (): ReturnType => fileSearch.mock.results[0]?.value, + ); + if (!nativeResult.ok) return yield* Effect.die(nativeResult.error); + expect(nativeResult.value.items.length).toBeLessThanOrEqual(201); + + // The image glob must not change fff's parse of fuzzy paths, globs, negations or locations. + for (const query of [ + "", + "logo", + "lgo", + "*.png", + "!*.png", + "assets/**", + "!assets/", + "assets*/", + "/assets", + "assets/", + "logo:12", + "/assets/logotype.png", + "assets/images/", + "logo *.svg !other", + "foo{bar}.png", + "[id].png", + "*.{png,svg}", + "hidden/", + ]) { + const ordinary = yield* searchIndex.search(query, 1000, "file"); + const expected = ordinary.entries.filter((entry) => isProjectFaviconPath(entry.path)); + for (const limit of [1, 200]) { + const images = yield* searchIndex.search(query, limit, "directory", true); + expect(images, `query=${query} limit=${limit}`).toEqual({ + entries: expected.slice(0, limit), + truncated: expected.length > limit, + }); + } + } + + // Past 15,000 matches fff drops its filename fallback, which the image glob would otherwise + // reorder: "x/logo/logo.png" must keep its place relative to "a/xxlogo.png". + const largeCwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-image-rank-" }); + yield* writeFiles(largeCwd, [ + ...Array.from({ length: 14_998 }, (_, index) => `b/logo-${index}.png`), + "x/logo/source.ts", + "a/xxlogo.png", + "x/logo/logo.png", + ]); + const largeIndex = yield* WorkspaceSearchIndex.make(largeCwd); + const pairPaths = ["a/xxlogo.png", "x/logo/logo.png"]; + const ordinary = yield* largeIndex.search("logo", 20_000, "file"); + const images = yield* largeIndex.search("logo", 20_000, "file", true); + expect(images.entries).toHaveLength(15_000); + expect(images.entries.filter((entry) => pairPaths.includes(entry.path))).toEqual( + ordinary.entries.filter((entry) => pairPaths.includes(entry.path)), + ); + }).pipe(Effect.scoped, Effect.provide(testLayer)), ); it.effect("fetches past a first page of Git-ignored results", () => diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index 22e307c182616..8d9798baadfd7 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -32,7 +32,7 @@ import type { ProjectSearchEntriesResult, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import { isProjectFaviconPath } from "@t3tools/shared/projectFavicon"; +import { PROJECT_FAVICON_EXTENSIONS, isProjectFaviconPath } from "@t3tools/shared/projectFavicon"; import { scoreSubsequenceMatch } from "@t3tools/shared/searchRanking"; import * as VcsProcess from "../vcs/VcsProcess.ts"; @@ -42,6 +42,14 @@ import { type IgnoreDecision, type IgnoreFile, parseIgnoreFile } from "./IgnoreP const requireForFff = NodeModule.createRequire(import.meta.url); const { FileFinder } = requireForFff("@ff-labs/fff-node") as typeof import("@ff-labs/fff-node"); +// A glob intersects existing extension constraints; fff ORs bare `*.ext` constraints together. +const IMAGE_SEARCH_CONSTRAINT = `**/*.{${PROJECT_FAVICON_EXTENSIONS.map((extension) => + extension.slice(1).replace(/[a-z]/g, (letter) => `[${letter}${letter.toUpperCase()}]`), +).join(",")}}`; + +// fff 0.11 disables filename fallback above this match count per base/overflow segment. +const FFF_FILENAME_FALLBACK_MAX_MATCHES = 15_000; + const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; const WORKSPACE_INDEX_PAGE_SIZE = WORKSPACE_INDEX_MAX_ENTRIES + 2; const WORKSPACE_INDEX_SCAN_TIMEOUT = "15 seconds"; @@ -175,6 +183,14 @@ function toDirectoryEntry(item: DirItem, hostIsWindows: boolean): ProjectEntry | return normalizedPath ? { path: normalizedPath, kind: "directory" } : null; } +function isLoneFuzzyPath(query: string): boolean { + const trimmed = query.trim(); + return ( + !/\s/u.test(trimmed) && + (trimmed.startsWith("/") || (trimmed.endsWith("/") && !/^[*!\\]/u.test(trimmed))) + ); +} + type SearchPage = { readonly items: ReadonlyArray; readonly totalMatched: number }; function mapFileSearchResult( @@ -841,13 +857,13 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( }); // Grows the first page until hidden entries can no longer crowd visible ones out of it. - const searchVisible = Effect.fn("WorkspaceSearchIndex.searchVisible")(function* ( + const searchVisible = Effect.fn("WorkspaceSearchIndex.searchVisible")(function* ( query: string, pageSize: number, operation: "directorySearch" | "fileSearch" | "mixedSearch", - execute: (pageSize: number) => Promise>>, + execute: (pageSize: number) => Promise & Metadata>>, pathOf: (item: I) => string, - ): Effect.fn.Return, WorkspaceSearchIndexSearchFailed> { + ): Effect.fn.Return & Metadata, WorkspaceSearchIndexSearchFailed> { let size = pageSize; while (true) { const fetchSize = size; @@ -862,6 +878,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( fetchSize >= WORKSPACE_INDEX_PAGE_SIZE ) { return { + ...result, items, totalMatched: result.totalMatched - (result.items.length - items.length), }; @@ -945,17 +962,40 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( ); // Index results that are filtered-out links get dropped in the merge, so fetch past them. const indexLimit = limit + linkPaths.size - symlinks.length; - const pageSize = imageOnly ? WORKSPACE_INDEX_PAGE_SIZE : Math.max(1, indexLimit + 1); + const pageSize = Math.max(1, indexLimit + 1); const merge = (indexed: ProjectSearchEntriesResult) => mergeSymlinkMatches(indexed, linkPaths, symlinks, query, limit); if (kind === "file" || imageOnly) { - const result = yield* searchVisible( - query, + const searchFiles = (fileQuery: string, size: number) => + searchVisible( + query, + size, + "fileSearch", + (fetchSize) => finder.fileSearchAsync(fileQuery, { pageSize: fetchSize }), + (item: FileItem) => item.relativePath, + ); + // Adding a constraint changes fff's lone-path parse; offset pages can skip tied matches. + if (imageOnly && isLoneFuzzyPath(query)) { + const result = yield* searchFiles(query, WORKSPACE_INDEX_PAGE_SIZE); + return merge(mapFileSearchResult(result, indexLimit, hostIsWindows, true)); + } + let result = yield* searchFiles( + imageOnly ? `${query} ${IMAGE_SEARCH_CONSTRAINT}` : query, pageSize, - "fileSearch", - (size) => finder.fileSearchAsync(query, { pageSize: size }), - (item) => item.relativePath, ); + if ( + imageOnly && + query.trim() && + result.totalFiles > FFF_FILENAME_FALLBACK_MAX_MATCHES && + result.totalMatched > 0 + ) { + const unfiltered = yield* runSearch(query, 1, "fileSearch", () => + finder.fileSearchAsync(query, { pageSize: 1 }), + ); + if (unfiltered.totalMatched > FFF_FILENAME_FALLBACK_MAX_MATCHES) { + result = yield* searchFiles(query, WORKSPACE_INDEX_PAGE_SIZE); + } + } return merge(mapFileSearchResult(result, indexLimit, hostIsWindows, imageOnly)); } if (kind === "directory") {