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: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
}
Expand Down
29 changes: 24 additions & 5 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -37,13 +38,15 @@ export function activate(context: ExtensionContext) {

class RelativePath {
private _fileNames: string[];
private _truncated: boolean;
private _watcher: FileSystemWatcher;
private _workspacePath: string;
private _configuration: WorkspaceConfiguration;
private _tokenSource: CancellationTokenSource;

constructor() {
this._fileNames = null;
this._truncated = false;
this._tokenSource = null;
this._workspacePath = this.getWorkspaceFolder();
this._configuration = workspace.getConfiguration("relativePath");
Expand Down Expand Up @@ -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
Expand All @@ -134,6 +140,7 @@ class RelativePath {
return;
}

this._truncated = isTruncated(files.length, maxResults);
this._fileNames = files.map((file) =>
file.fsPath.replace(/\\/g, "/")
);
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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) => {
Expand Down
28 changes: 28 additions & 0 deletions src/search-limit.ts
Original file line number Diff line number Diff line change
@@ -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;
}
34 changes: 34 additions & 0 deletions test/search-limit.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});