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
5 changes: 4 additions & 1 deletion apps/server/src/workspace/WorkspaceEntries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -963,10 +963,13 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => {
if (created.ok) {
const finder = created.value;
finders += 1;
// The synchronous rescan waits on fff's picker lock on the event loop.
// The synchronous rescan and grep wait on fff's picker lock on the event loop.
finder.scanFiles = () => {
throw new Error("refresh must not rescan on the JS thread");
};
finder.grep = () => {
throw new Error("search must not grep on the JS thread");
};
const scanFilesAsync = finder.scanFilesAsync.bind(finder);
finder.scanFilesAsync = () => {
rescans += 1;
Expand Down
51 changes: 35 additions & 16 deletions apps/server/src/workspace/WorkspaceSearchIndex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ it.effect("filters image searches before applying the result limit", () =>
...Array.from({ length: 200 }, (_, index) => fileItem(`src/file-${index}.ts`)),
fileItem("public/icon.svg"),
];
const fileSearch = vi.fn(() => ({
const fileSearch = vi.fn(async () => ({
ok: true as const,
value: {
items,
Expand All @@ -59,9 +59,12 @@ it.effect("filters image searches before applying the result limit", () =>
const finder = {
destroy: vi.fn(),
waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })),
isScanning: () => false,
getScanProgressAsync: async () => ({
ok: true as const,
value: { isScanning: false },
}),
watch: () => ({ ok: true as const, value: () => undefined }),
fileSearch,
fileSearchAsync: fileSearch,
} as unknown as FileFinder;
vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder });

Expand Down Expand Up @@ -95,7 +98,7 @@ it.effect("fetches past a first page of Git-ignored results", () =>
fileItem("hidden/c.ts"),
fileItem("src/a.ts"),
];
const fileSearch = vi.fn((_query: string, options?: { pageSize?: number }) => ({
const fileSearch = vi.fn(async (_query: string, options?: { pageSize?: number }) => ({
ok: true as const,
value: {
items: items.slice(0, options?.pageSize),
Expand All @@ -107,9 +110,12 @@ it.effect("fetches past a first page of Git-ignored results", () =>
const finder = {
destroy: vi.fn(),
waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })),
isScanning: () => false,
getScanProgressAsync: async () => ({
ok: true as const,
value: { isScanning: false },
}),
watch: () => ({ ok: true as const, value: () => undefined }),
fileSearch,
fileSearchAsync: fileSearch,
} as unknown as FileFinder;
vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder });

Expand All @@ -131,9 +137,12 @@ it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")(
const finder = {
destroy: vi.fn(),
waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })),
isScanning: () => false,
getScanProgressAsync: async () => ({
ok: true as const,
value: { isScanning: false },
}),
watch: () => ({ ok: true as const, value: () => undefined }),
fileSearch: vi.fn(() => ({
fileSearchAsync: vi.fn(async () => ({
ok: true as const,
value: { items, scores: [], totalMatched: 1, totalFiles: 1 },
})),
Expand Down Expand Up @@ -184,7 +193,7 @@ it.effect("continues whole-word searches after a filtered grep page", () =>
filteredFileCount: 1,
nextCursor: cursor,
});
const grep = vi.fn((_query: string, options?: GrepOptions) =>
const grep = vi.fn(async (_query: string, options?: GrepOptions) =>
options?.cursor
? { ok: true as const, value: grepResult("needle", [[0, 6]], null) }
: {
Expand All @@ -195,9 +204,12 @@ it.effect("continues whole-word searches after a filtered grep page", () =>
const finder = {
destroy: vi.fn(),
waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })),
isScanning: () => false,
getScanProgressAsync: async () => ({
ok: true as const,
value: { isScanning: false },
}),
watch: () => ({ ok: true as const, value: () => undefined }),
grep,
grepAsync: grep,
} as unknown as FileFinder;
vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder });

Expand Down Expand Up @@ -241,7 +253,7 @@ function fakeSymlinkFinder(
let watchFailures = options.watchFailures ?? 0;
// fff's pageIndex is an offset into the results, not a page number.
const directorySearch = vi.fn(
(_query: string, searchOptions?: { pageIndex?: number; pageSize?: number }) => {
async (_query: string, searchOptions?: { pageIndex?: number; pageSize?: number }) => {
const offset = searchOptions?.pageIndex ?? 0;
const items = directories.slice(offset, offset + (searchOptions?.pageSize ?? 100));
return {
Expand All @@ -260,7 +272,14 @@ function fakeSymlinkFinder(
const finder = {
destroy: vi.fn(),
waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })),
isScanning: () => false,
// fff's isScanning waits on the picker lock on the JS thread.
isScanning: () => {
throw new Error("isScanning must not run on the JS thread");
},
getScanProgressAsync: async () => ({
ok: true as const,
value: { isScanning: false },
}),
watch: (callback: (events: ReadonlyArray<WatchEvent>) => void) => {
if (watchFailures > 0) {
watchFailures -= 1;
Expand All @@ -269,12 +288,12 @@ function fakeSymlinkFinder(
watchers.push(callback);
return { ok: true as const, value: () => undefined };
},
directorySearch,
fileSearch: () => {
directorySearchAsync: directorySearch,
fileSearchAsync: async () => {
const items = indexedItems.flatMap((entry) => (entry.type === "file" ? [entry.item] : []));
return { ok: true as const, value: { items, scores: [], totalMatched: items.length } };
},
mixedSearch: () => ({
mixedSearchAsync: async () => ({
ok: true as const,
value: { items: indexedItems, totalMatched: indexedItems.length },
}),
Expand Down
44 changes: 28 additions & 16 deletions apps/server/src/workspace/WorkspaceSearchIndex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -579,22 +579,32 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (
}),
);

const hasIncompleteInitialScan = () => initialScanTimedOut && finder.isScanning();
// fff's isScanning takes the picker lock too, so progress is read off the JS thread.
const isScanning = Effect.promise(async () => {
try {
const progress = await finder.getScanProgressAsync();
return !progress.ok || progress.value.isScanning;
} catch {
return true;
}
});
const hasIncompleteInitialScan = () => (initialScanTimedOut ? isScanning : Effect.succeed(false));

const runSearch = Effect.fn("WorkspaceSearchIndex.runSearch")(function* <A>(
query: string,
pageSize: number,
operation: "directorySearch" | "fileSearch" | "grep" | "mixedSearch",
execute: () => Result<A>,
execute: () => Promise<Result<A>>,
): Effect.fn.Return<A, WorkspaceSearchIndexSearchFailed> {
const result = yield* Effect.try({
// fff's searches wait on its picker lock, which its watcher can hold for minutes.
const result = yield* Effect.tryPromise({
try: execute,
catch: (cause) =>
new WorkspaceSearchIndexSearchFailed({
cwd,
queryLength: query.length,
pageSize,
reason: `FileFinder.${operation} threw unexpectedly.`,
reason: `FileFinder.${operation} rejected unexpectedly.`,
cause,
}),
});
Expand All @@ -616,9 +626,12 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (
const directories: string[] = [];
// fff's pageIndex is an offset into the results, not a page number.
for (let pageIndex = 0; ; pageIndex += SYMLINK_DIRECTORY_PAGE_SIZE) {
const result = yield* Effect.sync(() => {
const result = yield* Effect.promise(async () => {
try {
return finder.directorySearch("", { pageIndex, pageSize: SYMLINK_DIRECTORY_PAGE_SIZE });
return await finder.directorySearchAsync("", {
pageIndex,
pageSize: SYMLINK_DIRECTORY_PAGE_SIZE,
});
} catch {
return undefined;
}
Expand All @@ -629,7 +642,6 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (
if (directory) directories.push(directory);
}
if (result.value.items.length < SYMLINK_DIRECTORY_PAGE_SIZE) return directories;
yield* Effect.yieldNow;
}
},
);
Expand Down Expand Up @@ -682,7 +694,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (
});

const scanSymlinkEntries = Effect.fn("WorkspaceSearchIndex.scanSymlinkEntries")(function* () {
const indexComplete = !finder.isScanning();
const indexComplete = !(yield* isScanning);
const directories = yield* listIndexedDirectories();
const scanned = yield* Effect.forEach(
["", ...directories],
Expand Down Expand Up @@ -833,7 +845,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (
query: string,
pageSize: number,
operation: "directorySearch" | "fileSearch" | "mixedSearch",
execute: (pageSize: number) => Result<SearchPage<I>>,
execute: (pageSize: number) => Promise<Result<SearchPage<I>>>,
pathOf: (item: I) => string,
): Effect.fn.Return<SearchPage<I>, WorkspaceSearchIndexSearchFailed> {
let size = pageSize;
Expand Down Expand Up @@ -895,12 +907,12 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (

const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")(
function* () {
const incompleteBeforeQuery = hasIncompleteInitialScan();
const incompleteBeforeQuery = yield* hasIncompleteInitialScan();
const result = yield* searchVisible(
"",
WORKSPACE_INDEX_PAGE_SIZE,
"mixedSearch",
(pageSize) => finder.mixedSearch("", { pageSize }),
(pageSize) => finder.mixedSearchAsync("", { pageSize }),
mixedItemPath,
);
const mapped = mapMixedSearchResult(result, WORKSPACE_INDEX_MAX_ENTRIES, hostIsWindows);
Expand Down Expand Up @@ -941,7 +953,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (
query,
pageSize,
"fileSearch",
(size) => finder.fileSearch(query, { pageSize: size }),
(size) => finder.fileSearchAsync(query, { pageSize: size }),
(item) => item.relativePath,
);
return merge(mapFileSearchResult(result, indexLimit, hostIsWindows, imageOnly));
Expand All @@ -951,7 +963,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (
query,
pageSize,
"directorySearch",
(size) => finder.directorySearch(query, { pageSize: size }),
(size) => finder.directorySearchAsync(query, { pageSize: size }),
(item) => item.relativePath,
);
return merge(mapDirectorySearchResult(result, indexLimit, hostIsWindows));
Expand All @@ -960,7 +972,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (
query,
pageSize,
"mixedSearch",
(size) => finder.mixedSearch(query, { pageSize: size }),
(size) => finder.mixedSearchAsync(query, { pageSize: size }),
mixedItemPath,
);
return merge(mapMixedSearchResult(result, indexLimit, hostIsWindows));
Expand All @@ -973,7 +985,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (
// matching is subsequence-based, so dropping them still matches "[id]" and "a{b}" paths.
// "{" is dropped only when the query finds nothing as a glob, so "*.{ts,tsx}" keeps working.
const query = rawQuery.replaceAll("[", "");
const incompleteBeforeQuery = hasIncompleteInitialScan();
const incompleteBeforeQuery = yield* hasIncompleteInitialScan();
let mapped = yield* searchNames(query, limit, kind, imageOnly);
if (mapped.entries.length === 0 && query.includes("{")) {
mapped = yield* searchNames(query.replaceAll("{", ""), limit, kind, imageOnly);
Expand All @@ -998,7 +1010,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (
do {
const remainingTimeBudgetMs = Math.max(1, Math.ceil(deadline - performance.now()));
const result = yield* runSearch(input.query, input.limit, "grep", () =>
finder.grep(searchQuery, {
finder.grepAsync(searchQuery, {
mode: regexMode ? "regex" : "plain",
smartCase: !input.caseSensitive && !regexMode,
maxMatchesPerFile: Math.min(CONTENT_SEARCH_MAX_MATCHES_PER_FILE, rawPageSize),
Expand Down
Loading
Loading