diff --git a/CHANGELOG.md b/CHANGELOG.md index 9688dd3..ce95d53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## Unreleased + +- The picker now also honors VS Code's built-in `search.exclude` by default, so you no longer have to duplicate those globs in `relativePath.ignore`. (`files.exclude` was already applied to the file scan by VS Code; it is now also applied to files created after the scan, keeping the cache consistent.) Opt out with the new `relativePath.respectSearchExclude: false`. (#31) + ## 1.7.0 Big-workspace release: scanning is now bounded, filters are enforced everywhere, and defaults got a refresh. diff --git a/README.md b/README.md index 6e7ca23..726e9f3 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,12 @@ They can be set in user preferences (`ctrl+,` or `cmd+,`) or workspace settings "**/objd/**" ], +// Also hide files matched by VS Code's built-in `search.exclude`, so you don't +// have to duplicate those globs in `relativePath.ignore`. (`files.exclude` is +// always respected -- VS Code applies it to the file scan regardless.) Set to +// false to use only `relativePath.ignore` plus `files.exclude`. +"relativePath.respectSearchExclude": true, + // Excludes the extension from the relative path url (Useful for systemjs imports). "relativePath.removeExtension": false, diff --git a/package.json b/package.json index 9d3608d..5518a74 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,11 @@ "type": "string" } }, + "relativePath.respectSearchExclude": { + "type": "boolean", + "default": true, + "description": "Also hide files matched by VS Code's built-in 'search.exclude' setting, so you don't have to duplicate those globs in 'relativePath.ignore'. 'files.exclude' is always respected (VS Code applies it to the file scan regardless). Set to false to use only 'relativePath.ignore' plus 'files.exclude'." + }, "relativePath.removeExtension": { "type": "boolean", "default": false, diff --git a/src/extension.ts b/src/extension.ts index 66805f2..5c49285 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -17,7 +17,11 @@ import { } from "vscode"; import { getClosestMatches } from "./closest-match"; import { shouldAddToCache } from "./glob-match"; -import { buildExcludeGlob, normalizeIncludeGlob } from "./globs"; +import { + buildExcludeGlob, + collectExcludeGlobs, + normalizeIncludeGlob, +} from "./globs"; import { partitionByRecency, recordRecentPath } from "./recent-paths"; import { replaceSelections } from "./replace-selections"; import { isTruncated, resolveMaxResults } from "./search-limit"; @@ -105,7 +109,7 @@ class RelativePath { shouldAddToCache( filePath.slice(workspacePrefix.length), normalizeIncludeGlob(includeGlob), - this._configuration.get("ignore") + this.buildIgnoreGlobs() ) ) { this._fileNames.push(filePath); @@ -167,7 +171,7 @@ class RelativePath { this._workspacePath, normalizeIncludeGlob(includeGlob) ); - const exclude = buildExcludeGlob(this._configuration.get("ignore")); + const exclude = buildExcludeGlob(this.buildIgnoreGlobs()); const maxResults = resolveMaxResults( this._configuration.get("maxFilesCached") ); @@ -220,6 +224,34 @@ class RelativePath { this.updateFiles(skipOpen); } + // The globs excluded from both the findFiles scan and the file-creation + // watcher: the user's `relativePath.ignore` plus VS Code's own built-in + // excludes so those don't have to be duplicated (issue #31). + // + // `files.exclude` is always merged in: findFiles applies it to the scan no + // matter what we pass (short of a `null` exclude, which would also drop our + // own ignore globs), so we add it to keep the creation watcher consistent + // with that. `search.exclude` is opt-in via + // `relativePath.respectSearchExclude` (default true), because findFiles + // never applies it on its own. + private buildIgnoreGlobs(): string[] { + const ignore: string[] = this._configuration.get("ignore") ?? []; + + const filesExclude = workspace + .getConfiguration("files") + .get>("exclude"); + const globs = [...ignore, ...collectExcludeGlobs(filesExclude)]; + + if (this._configuration.get("respectSearchExclude")) { + const searchExclude = workspace + .getConfiguration("search") + .get>("exclude"); + globs.push(...collectExcludeGlobs(searchExclude)); + } + + return globs; + } + // Compares the ignore property of _configuration to lastConfig private ignoreWasUpdated( currentIgnore: Array, @@ -240,6 +272,13 @@ class RelativePath { const lastConfig = this._configuration; this._configuration = workspace.getConfiguration("relativePath"); + // VS Code's own `files.exclude` always shapes our cache, and + // `search.exclude` does too while it's respected, so a change to + // either must trigger a rescan. + const respectSearch = this._configuration.get( + "respectSearchExclude" + ); + // Handle updates to the properties that shape the cached file // list if there are any if ( @@ -249,7 +288,11 @@ class RelativePath { ) || this._configuration.maxFilesCached !== lastConfig.maxFilesCached || - this._configuration.includeGlob !== lastConfig.includeGlob + this._configuration.includeGlob !== lastConfig.includeGlob || + this._configuration.respectSearchExclude !== + lastConfig.respectSearchExclude || + e.affectsConfiguration("files.exclude") || + (respectSearch && e.affectsConfiguration("search.exclude")) ) { this.updateFiles(true); } diff --git a/src/globs.ts b/src/globs.ts index 9540f6d..0ac89b7 100644 --- a/src/globs.ts +++ b/src/globs.ts @@ -7,7 +7,49 @@ export function buildExcludeGlob(ignore: string[] | undefined): string | null { return null; } - return `{${ignore.map(escapeBraceSegment).join(",")}}`; + const unique = [...new Set(ignore)]; + // A single-item brace (`{a}`) is not alternation, so the glob engine would + // read it as the literal characters `{a}`. Emit the bare pattern instead + // so its own `{a,b}` groups keep working. + if (unique.length === 1) { + return unique[0]; + } + + return `{${unique.map(escapeBraceSegment).join(",")}}`; +} + +// Collect the enabled globs from one of VS Code's built-in exclude settings +// (`files.exclude` or `search.exclude`) so they can be merged into the exclude +// passed to findFiles and the file-creation watcher. Each setting is a map of +// glob -> value, where the value is `true`, `false`, or a `{ when }` sibling +// clause. We honor only the globs explicitly enabled with `true`: `false` (a +// user re-including a path) and `when` clauses (which need sibling files we +// can't cheaply resolve here) are skipped. +export function collectExcludeGlobs( + exclude: Record | undefined +): string[] { + if (!exclude) { + return []; + } + + const globs: string[] = []; + for (const glob of Object.keys(exclude)) { + if (exclude[glob] !== true) { + continue; + } + + globs.push(glob); + // The built-in settings write folders as `**/node_modules` (no trailing + // `/**`). findFiles prunes that folder's contents, but our file-creation + // watcher matches file paths directly, so it also needs the descendant + // form to keep files created later inside an excluded folder out of the + // cache. Appending `/**` to a file-name pattern is harmless: it simply + // never matches. + if (!glob.endsWith("/**")) { + globs.push(`${glob}/**`); + } + } + return globs; } function escapeBraceSegment(pattern: string): string { diff --git a/test/globs.test.ts b/test/globs.test.ts index 9b3ac22..f9218cb 100644 --- a/test/globs.test.ts +++ b/test/globs.test.ts @@ -1,7 +1,10 @@ import * as assert from "node:assert/strict"; import { test } from "node:test"; - -import { buildExcludeGlob, normalizeIncludeGlob } from "../src/globs"; +import { + buildExcludeGlob, + collectExcludeGlobs, + normalizeIncludeGlob, +} from "../src/globs"; test("normalizes leading slash from include glob for RelativePattern", () => { assert.equal(normalizeIncludeGlob("/**/*.*"), "**/*.*"); @@ -19,3 +22,53 @@ test("combines ignore globs without changing comma or brace literals", () => { "{**/node_modules/**,**/fixtures/\\{a\\,b\\}/**}" ); }); + +test("emits a single ignore glob bare so its own {a,b} groups keep working", () => { + assert.equal( + buildExcludeGlob(["**/fixtures/{a,b}/**"]), + "**/fixtures/{a,b}/**" + ); +}); + +test("dedupes repeated ignore globs", () => { + assert.equal( + buildExcludeGlob(["**/node_modules/**", "**/node_modules/**"]), + "**/node_modules/**" + ); +}); + +test("collects nothing from an undefined or empty exclude map", () => { + assert.deepEqual(collectExcludeGlobs(undefined), []); + assert.deepEqual(collectExcludeGlobs({}), []); +}); + +test("collects each enabled glob with a folder-descendant form", () => { + assert.deepEqual( + collectExcludeGlobs({ "**/.git": true, "**/.DS_Store": true }), + ["**/.git", "**/.git/**", "**/.DS_Store", "**/.DS_Store/**"] + ); +}); + +test("honors only globs explicitly enabled with true", () => { + assert.deepEqual( + collectExcludeGlobs({ + "**/.git": true, + "**/kept": false, + "**/when-clause": { when: "$(basename).ts" }, + }), + ["**/.git", "**/.git/**"] + ); +}); + +test("does not append a descendant form to globs already ending in /**", () => { + assert.deepEqual(collectExcludeGlobs({ "**/node_modules/**": true }), [ + "**/node_modules/**", + ]); +}); + +test("keeps file-name patterns and adds a harmless descendant form", () => { + assert.deepEqual(collectExcludeGlobs({ "**/*.pyc": true }), [ + "**/*.pyc", + "**/*.pyc/**", + ]); +});