diff --git a/README.md b/README.md index 928fe69..3cee6e4 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,11 @@ They can be set in user preferences (`ctrl+,` or `cmd+,`) or workspace settings // Extending this may lead to performance issues "relativePath.searchCountLimit": 1000, +// Max number of files scanned and cached from the workspace. Bounds memory and +// scan time on very large workspaces; the picker indicates when results were +// truncated. Set to 0 for no limit. +"relativePath.maxFilesCached": 100000, + // Removes the leading ./ character when the path is pointing to a parent folder. "relativePath.removeLeadingDot": true, diff --git a/package.json b/package.json index a140884..1cab012 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,11 @@ "type": "integer", "default": 1000, "description": "Max number of files searched in quick filter. Extending this may lead to performance issues." + }, + "relativePath.maxFilesCached": { + "type": "integer", + "default": 100000, + "description": "Max number of files scanned and cached from the workspace. Bounds memory and scan time on very large workspaces. The picker indicates when results were truncated. Set to 0 for no limit." } } } diff --git a/src/extension.ts b/src/extension.ts index 3e8f76a..d3b05ab 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -16,6 +16,7 @@ import { } from "vscode"; import { getClosestMatches } from "./closest-match"; import { buildExcludeGlob, normalizeIncludeGlob } from "./globs"; +import { isTruncated, resolveMaxResults } from "./search-limit"; // this method is called when your extension is activated // your extension is activated the very first time the command is executed @@ -37,6 +38,7 @@ export function activate(context: ExtensionContext) { class RelativePath { private _fileNames: string[]; + private _truncated: boolean; private _watcher: FileSystemWatcher; private _workspacePath: string; private _configuration: WorkspaceConfiguration; @@ -44,6 +46,7 @@ class RelativePath { constructor() { this._fileNames = null; + this._truncated = false; this._tokenSource = null; this._workspacePath = this.getWorkspaceFolder(); this._configuration = workspace.getConfiguration("relativePath"); @@ -120,9 +123,12 @@ class RelativePath { normalizeIncludeGlob(includeGlob) ); const exclude = buildExcludeGlob(this._configuration.get("ignore")); + const maxResults = resolveMaxResults( + this._configuration.get("maxFilesCached") + ); workspace - .findFiles(include, exclude, undefined, tokenSource.token) + .findFiles(include, exclude, maxResults, tokenSource.token) .then((files) => { if (this._tokenSource !== tokenSource) { // A newer search superseded this one @@ -134,6 +140,7 @@ class RelativePath { return; } + this._truncated = isTruncated(files.length, maxResults); this._fileNames = files.map((file) => file.fsPath.replace(/\\/g, "/") ); @@ -188,12 +195,14 @@ class RelativePath { const lastConfig = this._configuration; this._configuration = workspace.getConfiguration("relativePath"); - // Handle updates to the ignored property if there's one + // Handle updates to the properties that shape the cached file + // list if there are any if ( this.ignoreWasUpdated( this._configuration.ignore, lastConfig.ignore - ) + ) || + this._configuration.maxFilesCached !== lastConfig.maxFilesCached ) { this.updateFiles(true); } @@ -319,11 +328,21 @@ class RelativePath { const allowQuickFilter = this._configuration.searchCountLimit > this._fileNames.length; + // When the scan stopped at relativePath.maxFilesCached, say so + // instead of presenting a silently partial list. + const foundLabel = this._truncated + ? `Found the first ${this._fileNames.length} files (capped by 'relativePath.maxFilesCached')` + : `Found ${this._fileNames.length} files`; + if (allowQuickFilter) { - this.showQuickPick(this._fileNames, editor); + this.showQuickPick( + this._fileNames, + editor, + this._truncated ? `${foundLabel}. Type to filter.` : undefined + ); } else { // Don't filter on too many files. Show the input search box instead - const placeHolder = `Found ${this._fileNames.length} files but your limit is ${this._configuration.searchCountLimit}. Start typing or ignore files with 'relativePath.ignore' in settings.`; + const placeHolder = `${foundLabel} but your limit is ${this._configuration.searchCountLimit}. Start typing or ignore files with 'relativePath.ignore' in settings.`; const input = window.showInputBox({ placeHolder }); input.then( (val) => { diff --git a/src/search-limit.ts b/src/search-limit.ts new file mode 100644 index 0000000..9175a83 --- /dev/null +++ b/src/search-limit.ts @@ -0,0 +1,28 @@ +// Bounds the workspace file scan so very large workspaces (huge monorepos, +// folders that slip past the ignore globs) can't pin memory or block the +// picker behind an unbounded disk crawl. See issue #74. + +export const DEFAULT_MAX_FILES_CACHED = 100_000; + +// Sanitizes the relativePath.maxFilesCached setting into a value for +// workspace.findFiles' maxResults parameter. Zero or negative is an explicit +// opt-out (no limit, the pre-#74 behavior); anything non-numeric falls back +// to the default cap. +export function resolveMaxResults(configValue: unknown): number | undefined { + if (typeof configValue !== "number" || !Number.isFinite(configValue)) { + return DEFAULT_MAX_FILES_CACHED; + } + if (configValue <= 0) { + return undefined; + } + return Math.floor(configValue); +} + +// findFiles gives no explicit truncation flag; hitting the cap exactly is the +// signal that more files likely exist. +export function isTruncated( + fileCount: number, + maxResults: number | undefined +): boolean { + return maxResults !== undefined && fileCount >= maxResults; +} diff --git a/test/search-limit.test.ts b/test/search-limit.test.ts new file mode 100644 index 0000000..f343e09 --- /dev/null +++ b/test/search-limit.test.ts @@ -0,0 +1,34 @@ +import * as assert from "node:assert/strict"; +import { test } from "node:test"; +import { + DEFAULT_MAX_FILES_CACHED, + isTruncated, + resolveMaxResults, +} from "../src/search-limit"; + +test("falls back to the default cap when the setting is missing or invalid", () => { + assert.equal(resolveMaxResults(undefined), DEFAULT_MAX_FILES_CACHED); + assert.equal(resolveMaxResults(null), DEFAULT_MAX_FILES_CACHED); + assert.equal(resolveMaxResults("50000"), DEFAULT_MAX_FILES_CACHED); + assert.equal(resolveMaxResults(NaN), DEFAULT_MAX_FILES_CACHED); + assert.equal(resolveMaxResults(Infinity), DEFAULT_MAX_FILES_CACHED); +}); + +test("treats zero or negative as an explicit opt-out (no limit)", () => { + assert.equal(resolveMaxResults(0), undefined); + assert.equal(resolveMaxResults(-1), undefined); +}); + +test("passes positive values through, flooring fractions", () => { + assert.equal(resolveMaxResults(1), 1); + assert.equal(resolveMaxResults(100_000), 100_000); + assert.equal(resolveMaxResults(1234.9), 1234); +}); + +test("reports truncation only when the cap was reached", () => { + assert.equal(isTruncated(100_000, 100_000), true); + assert.equal(isTruncated(99_999, 100_000), false); + assert.equal(isTruncated(0, 100_000), false); + // No cap -> never truncated, regardless of count. + assert.equal(isTruncated(10_000_000, undefined), false); +});