import * as vscode from "vscode"; import { compileQuery, matchLines } from "../lib/text-search"; import type { LineMatch } from "../lib/text-search"; import type { ForgeApi } from "./forge"; import type { GitforFileSystem } from "./fs"; import type { RoomClient } from "./room-client"; const DEFAULT_FILE_RESULTS = 512; const MAX_TEXT_RESULTS = 2000; const MAX_SKIP = 5000; /** `**\/name` and `**\/name/**` excludes name a directory anywhere; the server can skip those subtrees. */ const DIR_EXCLUDE = /^\*\*\/([^*?/[{}\]]+)(?:\/\*\*)?$/; /** Characters of `pattern` appear in `path` in order (Quick Open style); the workbench ranks the results. */ function fuzzyMatch(path: string, pattern: string): boolean { let index = 0; for (const char of path) { if (char === pattern[index]) index++; if (index === pattern.length) return true; } return index === pattern.length; } function toMatches(uri: vscode.Uri, lines: LineMatch[]): vscode.TextSearchMatch[] { return lines.map((line) => ({ uri, ranges: line.ranges.map(([start, end]) => new vscode.Range(line.line, start, line.line, end)), preview: { text: line.text, matches: line.ranges.map(([start, end]) => new vscode.Range(0, start, 0, end)) }, })); } /** * Search for the `gitfor:` workspace. VS Code for the Web only searches a virtual file system through these * providers; without them Search and Quick Open see nothing but the open editors. Quick Open lists the tree the * file system already loaded. Text search runs on the forge over the head tree, while files the live room holds * (edited, created or deleted in this session) are searched here from their shared text instead. */ export function registerSearch(api: ForgeApi, fs: GitforFileSystem, room: RoomClient): vscode.Disposable { const files: vscode.FileSearchProvider = { async provideFileSearchResults(query, options) { const pattern = query.pattern.toLowerCase().replace(/\s+/g, ""); const limit = options.maxResults ?? DEFAULT_FILE_RESULTS; const out: vscode.Uri[] = []; for (const path of await fs.files()) { if (out.length >= limit) break; if (!pattern || fuzzyMatch(path.toLowerCase(), pattern)) out.push(fs.uri(path)); } return out; }, }; const text: vscode.TextSearchProvider = { async provideTextSearchResults(query, options, progress, token) { let regex: RegExp; try { regex = compileQuery(query); } catch { throw new Error("Invalid regular expression"); } let remaining = Math.min(options.maxResults || MAX_TEXT_RESULTS, MAX_TEXT_RESULTS); await room.whenSynced(); const skip = [...room.files.keys()]; for (const [path, meta] of room.files.entries()) { if (remaining <= 0 || token.isCancellationRequested) break; if (meta.kind !== "text" || meta.state === "deleted" || meta.state === "missing") continue; const lines = matchLines(room.text(path).toString(), regex, remaining); remaining -= lines.length; for (const match of toMatches(fs.uri(path), lines)) progress.report(match); } if (remaining <= 0) return { limitHit: true }; if (token.isCancellationRequested) return {}; const excludeDirs = options.excludes.flatMap((pattern) => DIR_EXCLUDE.exec(pattern)?.[1] ?? []); const abort = new AbortController(); const cancel = token.onCancellationRequested(() => abort.abort()); try { const result = await api.search( { pattern: query.pattern, isRegExp: query.isRegExp, isCaseSensitive: query.isCaseSensitive, isWordMatch: query.isWordMatch, maxResults: remaining, skip: skip.slice(0, MAX_SKIP), excludeDirs: excludeDirs.slice(0, 100), }, abort.signal, ); for (const file of result.files) { for (const match of toMatches(fs.uri(file.path), file.matches)) progress.report(match); } return { limitHit: result.limitHit || skip.length > MAX_SKIP }; } catch (error) { if (token.isCancellationRequested) return {}; throw error; } finally { cancel.dispose(); } }, }; return vscode.Disposable.from( vscode.workspace.registerFileSearchProvider("gitfor", files), vscode.workspace.registerTextSearchProvider("gitfor", text), ); }