Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 93 additions & 38 deletions apps/server/src/workspace/WorkspaceSearchIndex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string>) {
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<FileFinder["fileSearchAsync"]> => 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", () =>
Expand Down
60 changes: 50 additions & 10 deletions apps/server/src/workspace/WorkspaceSearchIndex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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<I> = { readonly items: ReadonlyArray<I>; readonly totalMatched: number };

function mapFileSearchResult(
Expand Down Expand Up @@ -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* <I>(
const searchVisible = Effect.fn("WorkspaceSearchIndex.searchVisible")(function* <I, Metadata>(
query: string,
pageSize: number,
operation: "directorySearch" | "fileSearch" | "mixedSearch",
execute: (pageSize: number) => Promise<Result<SearchPage<I>>>,
execute: (pageSize: number) => Promise<Result<SearchPage<I> & Metadata>>,
pathOf: (item: I) => string,
): Effect.fn.Return<SearchPage<I>, WorkspaceSearchIndexSearchFailed> {
): Effect.fn.Return<SearchPage<I> & Metadata, WorkspaceSearchIndexSearchFailed> {
let size = pageSize;
while (true) {
const fetchSize = size;
Expand All @@ -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),
};
Expand Down Expand Up @@ -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") {
Expand Down
Loading