From 984525ba017aff2fe7afa9eff88b44c0b3591886 Mon Sep 17 00:00:00 2001 From: JF Gagne Date: Mon, 9 Nov 2026 12:13:41 -0500 Subject: [PATCH 1/9] Add a bounded workspace file search that skips the home folder findFilesInWorkspace wraps workspace.findFiles with a result cap and a 30 second time limit, linked to the caller's cancellation token. A workspace folder that is the user's home folder, or one of its parents, is not searched and the skip is logged; the other folders are searched through a RelativePattern each. With no home folder open, the call is the same single search as before. findFiles runs ripgrep with --no-ignore and follows symlinks, so a search rooted at the home folder walks the whole profile. Co-Authored-By: Claude Opus 5.5 --- src/common/workspaceSearch.ts | 94 +++++++++++ src/test/common/workspaceSearch.unit.test.ts | 166 +++++++++++++++++++ 2 files changed, 260 insertions(+) create mode 100644 src/common/workspaceSearch.ts create mode 100644 src/test/common/workspaceSearch.unit.test.ts diff --git a/src/common/workspaceSearch.ts b/src/common/workspaceSearch.ts new file mode 100644 index 000000000..d3310ae8f --- /dev/null +++ b/src/common/workspaceSearch.ts @@ -0,0 +1,94 @@ +import { CancellationToken, CancellationTokenSource, GlobPattern, RelativePattern, Uri, WorkspaceFolder } from 'vscode'; +import { traceLog, traceWarn } from './logging'; +import { getUserHomeDir, isSameOrParentPath } from './utils/pathUtils'; +import { findFiles, getWorkspaceFolders } from './workspace.apis'; + +/** + * Time limit for one workspace file search. `findFiles` runs ripgrep without ignore files and + * follows symlinks, so a search of a very large folder can take minutes; an ordinary repository + * takes seconds. + */ +export const WORKSPACE_SEARCH_TIMEOUT_MS = 30_000; + +export interface WorkspaceSearchOptions { + /** Names the feature in log messages. */ + readonly label: string; + readonly include: string; + readonly exclude: string; + /** The most matches to return; the search stops once it has this many. */ + readonly maxResults: number; + /** Cancels the search before its time limit. */ + readonly token?: CancellationToken; +} + +/** + * Returns whether a search of `folder` walks the user's home folder, because it is that folder or + * one of its parents. Paths are compared after normalization (separators, and case on Windows). + */ +function containsHomeFolder(folder: WorkspaceFolder): boolean { + const home = getUserHomeDir(); + return !!home && folder.uri.scheme === 'file' && isSameOrParentPath(folder.uri.fsPath, home); +} + +/** + * Runs `findFiles` with a result cap and a time limit. A workspace folder that is the user's home + * folder, or contains it, is not searched: a search there walks the whole profile (AppData, caches, + * virtual environments) and can run for minutes. The other folders are still searched. + */ +export async function findFilesInWorkspace(options: WorkspaceSearchOptions): Promise { + const folders = getWorkspaceFolders() ?? []; + const homeFolders = folders.filter(containsHomeFolder); + if (homeFolders.length === 0) { + return searchWithTimeLimit(options, [options.include]); + } + for (const folder of homeFolders) { + traceLog( + `${options.label}: not searching workspace folder ${folder.uri.fsPath} for ${options.include}, ` + + "because it is the user's home folder or contains it, and a search there walks the whole profile.", + ); + } + const includes = folders + .filter((folder) => !homeFolders.includes(folder)) + .map((folder) => new RelativePattern(folder, options.include)); + return includes.length > 0 ? searchWithTimeLimit(options, includes) : []; +} + +async function searchWithTimeLimit(options: WorkspaceSearchOptions, includes: GlobPattern[]): Promise { + const source = new CancellationTokenSource(); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + source.cancel(); + }, WORKSPACE_SEARCH_TIMEOUT_MS); + const stop = () => { + clearTimeout(timer); + source.cancel(); + }; + const subscription = options.token?.onCancellationRequested(stop); + if (options.token?.isCancellationRequested) { + stop(); + } + try { + const resultSets = await Promise.all( + includes.map((include) => findFiles(include, options.exclude, options.maxResults, source.token)), + ); + // `?? []` keeps the callers' existing handling of a search that resolves to undefined. + const results = resultSets.flatMap((resultSet) => resultSet ?? []).slice(0, options.maxResults); + if (timedOut) { + traceWarn( + `${options.label}: the search for ${options.include} stopped at its ` + + `${WORKSPACE_SEARCH_TIMEOUT_MS} ms time limit; matching files may be missing.`, + ); + } else if (results.length >= options.maxResults) { + traceWarn( + `${options.label}: the search for ${options.include} stopped at ${options.maxResults} ` + + 'results; matching files past that are left out.', + ); + } + return results; + } finally { + clearTimeout(timer); + subscription?.dispose(); + source.dispose(); + } +} diff --git a/src/test/common/workspaceSearch.unit.test.ts b/src/test/common/workspaceSearch.unit.test.ts new file mode 100644 index 000000000..043266e3c --- /dev/null +++ b/src/test/common/workspaceSearch.unit.test.ts @@ -0,0 +1,166 @@ +import assert from 'assert'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { CancellationToken, CancellationTokenSource, RelativePattern, Uri, WorkspaceFolder } from 'vscode'; +import * as logging from '../../common/logging'; +import * as pathUtils from '../../common/utils/pathUtils'; +import { isWindows } from '../../common/utils/platformUtils'; +import * as wapi from '../../common/workspace.apis'; +import { findFilesInWorkspace, WORKSPACE_SEARCH_TIMEOUT_MS } from '../../common/workspaceSearch'; + +suite('findFilesInWorkspace', () => { + const root = path.parse(process.cwd()).root; + const home = path.join(root, 'home', 'tester'); + const work = path.join(root, 'work', 'repo'); + const options = { label: 'Test search', include: '**/pyproject.toml', exclude: '**/.venv/**', maxResults: 3 }; + let findFilesStub: sinon.SinonStub; + let getWorkspaceFoldersStub: sinon.SinonStub; + let traceLogStub: sinon.SinonStub; + let traceWarnStub: sinon.SinonStub; + + function folder(fsPath: string, index = 0): WorkspaceFolder { + return { uri: Uri.file(fsPath), name: path.basename(fsPath), index }; + } + + /** Makes findFiles return a search that only ends when its token is cancelled. */ + function searchUntilCancelled(): void { + findFilesStub.callsFake( + (_include, _exclude, _maxResults, token: CancellationToken) => + new Promise((resolve) => token.onCancellationRequested(() => resolve([]))), + ); + } + + setup(() => { + findFilesStub = sinon.stub(wapi, 'findFiles').resolves([]); + getWorkspaceFoldersStub = sinon.stub(wapi, 'getWorkspaceFolders').returns([folder(work)]); + sinon.stub(pathUtils, 'getUserHomeDir').returns(home); + traceLogStub = sinon.stub(logging, 'traceLog'); + traceWarnStub = sinon.stub(logging, 'traceWarn'); + }); + + teardown(() => { + sinon.restore(); + }); + + test('runs one search with the given pattern, cap and a live token when no folder is the home folder', async () => { + const marker = Uri.file(path.join(work, 'pyproject.toml')); + findFilesStub.resolves([marker]); + + const result = await findFilesInWorkspace(options); + + assert.deepStrictEqual(result, [marker]); + assert.strictEqual(findFilesStub.callCount, 1); + const [include, exclude, maxResults, token] = findFilesStub.firstCall.args; + assert.strictEqual(include, options.include); + assert.strictEqual(exclude, options.exclude); + assert.strictEqual(maxResults, options.maxResults); + assert.ok(token, 'the search should get a token'); + assert.ok(traceLogStub.notCalled); + }); + + [ + { name: 'is the home folder', fsPath: home }, + { name: 'contains the home folder', fsPath: path.dirname(home) }, + { + name: 'is the home folder written with a trailing separator or another case', + fsPath: (isWindows() ? home.toUpperCase() : home) + path.sep, + }, + ].forEach(({ name, fsPath }) => { + test(`skips a workspace folder that ${name} and logs why`, async () => { + getWorkspaceFoldersStub.returns([folder(fsPath)]); + + const result = await findFilesInWorkspace(options); + + assert.deepStrictEqual(result, []); + assert.ok(findFilesStub.notCalled, 'the folder should not be searched'); + assert.strictEqual(traceLogStub.callCount, 1); + const message = String(traceLogStub.firstCall.args[0]); + assert.ok(message.includes(Uri.file(fsPath).fsPath), message); + assert.ok(message.includes('home folder'), message); + }); + }); + + test('searches a folder inside the home folder', async () => { + getWorkspaceFoldersStub.returns([folder(path.join(home, 'code', 'app'))]); + + await findFilesInWorkspace(options); + + assert.strictEqual(findFilesStub.callCount, 1); + assert.ok(traceLogStub.notCalled); + }); + + test('searches each other workspace folder on its own and caps the merged results', async () => { + const other = path.join(root, 'work', 'other'); + getWorkspaceFoldersStub.returns([folder(home, 0), folder(work, 1), folder(other, 2)]); + findFilesStub.callsFake((include: RelativePattern) => + Promise.resolve([ + Uri.file(path.join(include.baseUri.fsPath, 'a', 'pyproject.toml')), + Uri.file(path.join(include.baseUri.fsPath, 'b', 'pyproject.toml')), + ]), + ); + + const result = await findFilesInWorkspace(options); + + const bases = findFilesStub.getCalls().map((call) => { + const include = call.args[0]; + assert.ok(include instanceof RelativePattern, 'each search should be scoped to one folder'); + assert.strictEqual(include.pattern, options.include); + return pathUtils.normalizePath(include.baseUri.fsPath); + }); + assert.deepStrictEqual(bases, [pathUtils.normalizePath(work), pathUtils.normalizePath(other)]); + assert.strictEqual(result.length, options.maxResults); + assert.ok(traceWarnStub.calledOnce, 'reaching the cap should be logged'); + }); + + test('cancels the search at the time limit and logs it', async () => { + const clock = sinon.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + try { + searchUntilCancelled(); + const pending = findFilesInWorkspace(options); + const token: CancellationToken = findFilesStub.firstCall.args[3]; + + await clock.tickAsync(WORKSPACE_SEARCH_TIMEOUT_MS - 1); + assert.strictEqual(token.isCancellationRequested, false); + await clock.tickAsync(1); + + assert.strictEqual(token.isCancellationRequested, true); + assert.deepStrictEqual(await pending, []); + assert.ok(traceWarnStub.calledOnce, 'the time limit should be logged'); + } finally { + clock.restore(); + } + }); + + test('cancels the search with the caller token and clears the time limit', async () => { + const clock = sinon.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const source = new CancellationTokenSource(); + try { + searchUntilCancelled(); + const pending = findFilesInWorkspace({ ...options, token: source.token }); + const token: CancellationToken = findFilesStub.firstCall.args[3]; + assert.notStrictEqual(token, source.token, 'the time limit needs its own token'); + + source.cancel(); + + assert.strictEqual(token.isCancellationRequested, true); + assert.deepStrictEqual(await pending, []); + assert.strictEqual(clock.countTimers(), 0); + assert.ok(traceWarnStub.notCalled, 'a caller cancel is not a time limit'); + } finally { + source.dispose(); + clock.restore(); + } + }); + + test('passes a cancelled token when the caller token is already cancelled', async () => { + const source = new CancellationTokenSource(); + source.cancel(); + try { + await findFilesInWorkspace({ ...options, token: source.token }); + + assert.strictEqual(findFilesStub.firstCall.args[3].isCancellationRequested, true); + } finally { + source.dispose(); + } + }); +}); From 0a23e47dd60fa75561dcd6c813145e59d48e7b6a Mon Sep 17 00:00:00 2001 From: JF Gagne Date: Mon, 9 Nov 2026 12:13:42 -0500 Subject: [PATCH 2/9] Bound the dependency file search when installing packages getProjectInstallable ran four findFiles calls with no maxResults. In a workspace opened on the home folder each one walked the whole profile for minutes before the package picker or Quick Create could continue. Each search now goes through findFilesInWorkspace with a cap of 10,000 files and the 30 second time limit, and skips a home folder workspace. The progress and caller tokens still cancel every search. Co-Authored-By: Claude Opus 5.5 --- src/managers/builtin/pipUtils.ts | 26 +++- .../managers/builtin/pipUtils.unit.test.ts | 111 ++++++++++++++++-- 2 files changed, 124 insertions(+), 13 deletions(-) diff --git a/src/managers/builtin/pipUtils.ts b/src/managers/builtin/pipUtils.ts index 29983060a..bfd4a303a 100644 --- a/src/managers/builtin/pipUtils.ts +++ b/src/managers/builtin/pipUtils.ts @@ -18,7 +18,7 @@ import { PackageManagement, Pickers, VenvManagerStrings } from '../../common/loc import { traceInfo, traceWarn } from '../../common/logging'; import { isSameOrParentPath, normalizePath } from '../../common/utils/pathUtils'; import { showErrorMessage, showQuickPickWithButtons, showTextDocument, withProgress } from '../../common/window.apis'; -import { findFiles } from '../../common/workspace.apis'; +import { findFilesInWorkspace } from '../../common/workspaceSearch'; import { throwIfCancelled, waitForToolRead } from '../../internal/pythonToolSupport'; import { ListCommand } from '../base/commands/index'; import { selectFromCommonPackagesToInstall, selectFromInstallableToInstall } from '../common/pickers'; @@ -422,6 +422,13 @@ export async function getWorkspacePackagesToInstall( return selectWorkspaceOrCommon(installableResult, common, !!options.showSkipOption, installed ?? []); } +/** + * The most files one dependency search returns. Quick Create installs from every match under the + * project, so the cap sits far above what large monorepos hold; the time limit and the home folder + * check in `findFilesInWorkspace` bound how long the search runs. + */ +const MAX_DEPENDENCY_FILES = 10_000; + export async function getProjectInstallable( api: PythonEnvironmentApi, projects?: PythonProject[], @@ -453,13 +460,22 @@ export async function getProjectInstallable( const shouldYieldForSearchCancellation = (index: number) => index > 0 && index % cancellationBatchSize === 0; + const searchDependencyFiles = (include: string) => + findFilesInWorkspace({ + label: 'Dependency search', + include, + exclude, + maxResults: MAX_DEPENDENCY_FILES, + token: cancellationToken, + }); + throwIfSearchCancelled(); const resultSets = await waitForSearch( Promise.all([ - findFiles('**/*requirements*.txt', exclude, undefined, cancellationToken), - findFiles('*requirements*.txt', exclude, undefined, cancellationToken), - findFiles('**/requirements/*.txt', exclude, undefined, cancellationToken), - findFiles('**/pyproject.toml', exclude, undefined, cancellationToken), + searchDependencyFiles('**/*requirements*.txt'), + searchDependencyFiles('*requirements*.txt'), + searchDependencyFiles('**/requirements/*.txt'), + searchDependencyFiles('**/pyproject.toml'), ]), ); await yieldForSearchCancellation(); diff --git a/src/test/managers/builtin/pipUtils.unit.test.ts b/src/test/managers/builtin/pipUtils.unit.test.ts index 6c9a6df70..cc0d518e3 100644 --- a/src/test/managers/builtin/pipUtils.unit.test.ts +++ b/src/test/managers/builtin/pipUtils.unit.test.ts @@ -1,11 +1,12 @@ import assert from 'assert'; import * as path from 'path'; import * as sinon from 'sinon'; -import { CancellationError, CancellationToken, CancellationTokenSource, LogOutputChannel, Progress, ProgressLocation, ProgressOptions, Uri } from 'vscode'; +import { CancellationError, CancellationToken, CancellationTokenSource, LogOutputChannel, Progress, ProgressLocation, ProgressOptions, RelativePattern, Uri, WorkspaceFolder } from 'vscode'; import * as fse from 'fs-extra'; import * as os from 'os'; import { PythonEnvironment, PythonEnvironmentApi, PythonProject } from '../../../api'; import { PackageManagement } from '../../../common/localize'; +import * as pathUtils from '../../../common/utils/pathUtils'; import { normalizePath } from '../../../common/utils/pathUtils'; import * as winapi from '../../../common/window.apis'; import * as wapi from '../../../common/workspace.apis'; @@ -22,6 +23,7 @@ suite('Pip Utils - getProjectInstallable', () => { setup(() => { findFilesStub = sinon.stub(wapi, 'findFiles'); + sinon.stub(wapi, 'getWorkspaceFolders').returns(undefined); // Stub withProgress to immediately execute the callback withProgressStub = sinon.stub(winapi, 'withProgress'); withProgressStub.callsFake( @@ -78,16 +80,17 @@ suite('Pip Utils - getProjectInstallable', () => { } }); - test('propagates the progress token and promptly rejects cancellation during file search', async () => { + test('propagates progress cancellation to each search and promptly rejects during file search', async () => { const source = new CancellationTokenSource(); let startedCount = 0; + const searchTokens: CancellationToken[] = []; let markSearchesStarted!: () => void; const searchesStarted = new Promise((resolve) => { markSearchesStarted = resolve; }); findFilesStub.callsFake( (_pattern: string, _exclude: string, _maxResults: number | undefined, token: CancellationToken) => { - assert.strictEqual(token, source.token); + searchTokens.push(token); startedCount += 1; if (startedCount === 4) { markSearchesStarted(); @@ -125,6 +128,7 @@ suite('Pip Utils - getProjectInstallable', () => { }), ]); assert.strictEqual(findFilesStub.callCount, 4); + assert.ok(searchTokens.every((token) => token.isCancellationRequested)); } finally { clearTimeout(timeoutHandle); source.dispose(); @@ -139,7 +143,7 @@ suite('Pip Utils - getProjectInstallable', () => { mockApi.getPythonProject = getPythonProject; findFilesStub.callsFake( (_pattern: string, _exclude: string, _maxResults: number | undefined, token: CancellationToken) => { - assert.strictEqual(token, source.token); + assert.ok(token, 'each search should get a token'); return new Promise((resolve) => resolutions.push(resolve)); }, ); @@ -203,6 +207,9 @@ suite('Pip Utils - getProjectInstallable', () => { let cancelled = false; let subscriptionCount = 0; + // Subscriptions 1-4 tie the four file searches to this token, 5 waits for the searches, + // and 6 waits for the TOML read. + const tomlReadSubscription = 6; let markPostReadCheck!: () => void; const postReadCheck = new Promise((resolve) => { markPostReadCheck = resolve; @@ -216,7 +223,7 @@ suite('Pip Utils - getProjectInstallable', () => { }, onCancellationRequested: (listener: () => unknown) => { subscriptionCount += 1; - if (subscriptionCount === 2) { + if (subscriptionCount === tomlReadSubscription) { queueMicrotask(() => { cancelled = true; listener(); @@ -235,7 +242,7 @@ suite('Pip Utils - getProjectInstallable', () => { CancellationError, ); await postReadCheck; - assert.strictEqual(subscriptionCount, 2); + assert.strictEqual(subscriptionCount, tomlReadSubscription); assert.ok(withProgressStub.notCalled); } finally { await fse.remove(temp); @@ -261,16 +268,17 @@ suite('Pip Utils - getProjectInstallable', () => { } }); - test('headless search propagates in-flight cancellation and passes the caller token unchanged', async () => { + test('headless search propagates in-flight cancellation of the caller token to each search', async () => { const source = new CancellationTokenSource(); let startedCount = 0; + const searchTokens: CancellationToken[] = []; let markSearchesStarted!: () => void; const searchesStarted = new Promise((resolve) => { markSearchesStarted = resolve; }); findFilesStub.callsFake( (_pattern: string, _exclude: string, _maxResults: number | undefined, token: CancellationToken) => { - assert.strictEqual(token, source.token); + searchTokens.push(token); startedCount += 1; if (startedCount === 4) { markSearchesStarted(); @@ -291,6 +299,7 @@ suite('Pip Utils - getProjectInstallable', () => { source.cancel(); await assert.rejects(pending, CancellationError); assert.strictEqual(findFilesStub.callCount, 4); + assert.ok(searchTokens.every((token) => token.isCancellationRequested)); assert.ok(withProgressStub.notCalled); } finally { source.dispose(); @@ -781,6 +790,7 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () setup(async () => { findFilesStub = sinon.stub(wapi, 'findFiles').resolves([]); + sinon.stub(wapi, 'getWorkspaceFolders').returns(undefined); withProgressStub = sinon.stub(winapi, 'withProgress'); withProgressStub.callsFake( async ( @@ -1505,3 +1515,88 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () assert.strictEqual(result.filter((item) => item.args?.[0] === '-e').length, 2); }); }); + +suite('Pip Utils - dependency file search bounds', () => { + const root = path.parse(process.cwd()).root; + const home = path.join(root, 'home', 'tester'); + const project = { name: 'app', uri: Uri.file(path.join(home, 'code', 'app')) }; + let findFilesStub: sinon.SinonStub; + let getWorkspaceFoldersStub: sinon.SinonStub; + let mockApi: { getPythonProject: (uri: Uri) => PythonProject | undefined }; + + function folder(fsPath: string, index = 0): WorkspaceFolder { + return { uri: Uri.file(fsPath), name: path.basename(fsPath), index }; + } + + setup(() => { + findFilesStub = sinon.stub(wapi, 'findFiles').resolves([]); + getWorkspaceFoldersStub = sinon.stub(wapi, 'getWorkspaceFolders').returns([folder(project.uri.fsPath)]); + sinon.stub(pathUtils, 'getUserHomeDir').returns(home); + mockApi = { getPythonProject: () => project }; + }); + + teardown(() => { + sinon.restore(); + }); + + test('caps each search and stops it at a time limit', async () => { + const clock = sinon.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const finish: Array<() => void> = []; + findFilesStub.callsFake(() => new Promise((resolve) => finish.push(() => resolve([])))); + try { + const pending = getProjectInstallable(mockApi as PythonEnvironmentApi, [project], { runHeadless: true }); + await new Promise((resolve) => setImmediate(resolve)); + + assert.strictEqual(findFilesStub.callCount, 4); + for (const call of findFilesStub.getCalls()) { + const [include, , maxResults, token] = call.args; + assert.ok(Number.isInteger(maxResults) && maxResults > 0, `${include} should have a result cap`); + assert.ok(token && !token.isCancellationRequested, `${include} should get a live token`); + } + await clock.tickAsync(10 * 60_000); + for (const call of findFilesStub.getCalls()) { + assert.ok(call.args[3].isCancellationRequested, `${call.args[0]} should stop at its time limit`); + } + finish.forEach((done) => done()); + assert.deepStrictEqual((await pending).installables, []); + } finally { + finish.forEach((done) => done()); + clock.restore(); + } + }); + + test('does not search a workspace folder that is the home folder', async () => { + getWorkspaceFoldersStub.returns([folder(home)]); + + const result = await getProjectInstallable(mockApi as PythonEnvironmentApi, [project], { runHeadless: true }); + + assert.ok(findFilesStub.notCalled, 'the home folder should not be searched'); + assert.deepStrictEqual(result.installables, []); + }); + + test('still searches the other workspace folders when one is the home folder', async () => { + const service = path.join(root, 'work', 'service'); + const serviceProject = { name: 'service', uri: Uri.file(service) }; + const requirement = Uri.file(path.join(service, 'requirements.txt')); + getWorkspaceFoldersStub.returns([folder(home, 0), folder(service, 1)]); + mockApi.getPythonProject = () => serviceProject; + findFilesStub.callsFake((include: RelativePattern) => + Promise.resolve(include.pattern === '*requirements*.txt' ? [requirement] : []), + ); + + const result = await getProjectInstallable(mockApi as PythonEnvironmentApi, [serviceProject], { + runHeadless: true, + }); + + assert.strictEqual(findFilesStub.callCount, 4); + for (const call of findFilesStub.getCalls()) { + const include = call.args[0]; + assert.ok(include instanceof RelativePattern, 'each search should be scoped to one folder'); + assert.strictEqual(normalizePath(include.baseUri.fsPath), normalizePath(service)); + } + assert.deepStrictEqual( + result.installables.map((item) => item.uri?.fsPath), + [requirement.fsPath], + ); + }); +}); From 8bc62b96e2acec13f51253ac83acf49f343bd9c0 Mon Sep 17 00:00:00 2001 From: JF Gagne Date: Mon, 9 Nov 2026 12:13:43 -0500 Subject: [PATCH 3/9] Bound the Auto Find projects file search AutoFindProjects.create ran findFiles with no maxResults and no token, so in a workspace opened on the home folder it walked the whole profile before showing the picker. The search now goes through findFilesInWorkspace with a cap of 1,000 marker files and the 30 second time limit, and skips a home folder workspace. Co-Authored-By: Claude Opus 5.5 --- src/features/creators/autoFindProjects.ts | 16 +++++- .../creators/autoFindProjects.unit.test.ts | 51 +++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/src/features/creators/autoFindProjects.ts b/src/features/creators/autoFindProjects.ts index 94c732a03..4b9eb1900 100644 --- a/src/features/creators/autoFindProjects.ts +++ b/src/features/creators/autoFindProjects.ts @@ -4,7 +4,8 @@ import { PythonProject, PythonProjectCreator, PythonProjectCreatorOptions } from import { ProjectCreatorString } from '../../common/localize'; import { traceInfo, traceWarn } from '../../common/logging'; import { showErrorMessage, showQuickPickWithButtons, showWarningMessage } from '../../common/window.apis'; -import { findFiles, getWorkspaceFolders } from '../../common/workspace.apis'; +import { getWorkspaceFolders } from '../../common/workspace.apis'; +import { findFilesInWorkspace } from '../../common/workspaceSearch'; import type { EnvironmentManagers } from '../envManagers'; import { PythonProjectManager, @@ -12,6 +13,12 @@ import { } from '../projectManager'; import { isSameOrParentPath, normalizePath } from '../../common/utils/pathUtils'; +/** + * The most project marker files the search returns. Each folder with a marker becomes one item in + * a multi-select picker, and a picker with a thousand items is already too long to use. + */ +const MAX_PROJECT_MARKERS = 1_000; + function getUniqueUri(uris: Uri[]): { label: string; description: string; @@ -91,7 +98,12 @@ export class AutoFindProjects implements PythonProjectCreator { } async create(_options?: PythonProjectCreatorOptions): Promise { - const found = await findFiles('**/{pyproject.toml,setup.py}', '**/.venv/**'); + const found = await findFilesInWorkspace({ + label: 'Auto Find', + include: '**/{pyproject.toml,setup.py}', + exclude: '**/.venv/**', + maxResults: MAX_PROJECT_MARKERS, + }); // Exclude markers inside selected environments (e.g. installed packages in a custom-named venv). const folders = found && found.length > 0 ? getWorkspaceFolders() ?? [] : []; const prefixes = await this.getSelectedEnvironmentPrefixes(folders); diff --git a/src/test/features/creators/autoFindProjects.unit.test.ts b/src/test/features/creators/autoFindProjects.unit.test.ts index 1e4cdc6ee..ba2897a03 100644 --- a/src/test/features/creators/autoFindProjects.unit.test.ts +++ b/src/test/features/creators/autoFindProjects.unit.test.ts @@ -7,6 +7,7 @@ import { PythonEnvironment, PythonProject } from '../../../api'; import { SYSTEM_MANAGER_ID, VENV_MANAGER_ID } from '../../../common/constants'; import { timeout } from '../../../common/utils/asyncUtils'; import { createDeferred } from '../../../common/utils/deferred'; +import * as pathUtils from '../../../common/utils/pathUtils'; import * as winapi from '../../../common/window.apis'; import * as wapi from '../../../common/workspace.apis'; import { AutoFindProjects } from '../../../features/creators/autoFindProjects'; @@ -477,4 +478,54 @@ suite('Auto Find Project tests', () => { projectManager.verify((pm) => pm.add(typmoq.It.isAny()), typmoq.Times.never()); }); }); + + suite('Search bounds', () => { + const home = path.join(path.parse(process.cwd()).root, 'home', 'tester'); + + setup(() => { + sinon.stub(pathUtils, 'getUserHomeDir').returns(home); + }); + + test('caps the marker search and stops it at a time limit', async () => { + const clock = sinon.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + let finish: (() => void) | undefined; + findFilesStub.callsFake( + () => + new Promise((resolve) => { + finish = () => resolve([]); + }), + ); + try { + const pending = new AutoFindProjects(projectManager.object, envManagers.object).create(); + await new Promise((resolve) => setImmediate(resolve)); + + assert.strictEqual(findFilesStub.callCount, 1); + const [include, exclude, maxResults, token] = findFilesStub.firstCall.args; + assert.strictEqual(include, '**/{pyproject.toml,setup.py}'); + assert.strictEqual(exclude, '**/.venv/**'); + assert.ok(Number.isInteger(maxResults) && maxResults > 0, 'the search should have a result cap'); + assert.ok(token && !token.isCancellationRequested, 'the search should get a live token'); + await clock.tickAsync(10 * 60_000); + assert.ok(token.isCancellationRequested, 'the search should stop at its time limit'); + finish?.(); + assert.strictEqual(await pending, undefined); + } finally { + finish?.(); + clock.restore(); + } + }); + + test('does not search a workspace folder that is the home folder', async () => { + getWorkspaceFoldersStub.returns([{ uri: Uri.file(home), name: 'tester', index: 0 }]); + const shown = createDeferred(); + showErrorMessageStub.callsFake(() => shown.resolve()); + + const result = await new AutoFindProjects(projectManager.object, envManagers.object).create(); + + assert.strictEqual(result, undefined); + assert.ok(findFilesStub.notCalled, 'the home folder should not be searched'); + await Promise.race([shown.promise, timeout(100)]); + assert.ok(showErrorMessageStub.calledOnce, 'No projects found should be shown'); + }); + }); }); From 23f98ae5767483fa67aa3d4926663ccfd6ff7868 Mon Sep 17 00:00:00 2001 From: JF Gagne Date: Mon, 9 Nov 2026 13:13:39 -0500 Subject: [PATCH 4/9] Drop the time limit from the workspace file search VS Code resolves a cancelled findFiles to an empty list, not to the matches found so far (searchService.ts raceCancellationError, mainThreadWorkspace.ts returns null, extHostWorkspace.ts maps null to []). The 30 second limit therefore turned a slow dependency search into "no dependencies", so Quick Create made a venv without them, and a slow Auto Find search into "No projects found". findFilesInWorkspace now passes the caller's token to findFiles unchanged. The dependency search keeps its cancellable progress notification or the caller's token, as before this branch. The tests that checked a linked token go back to checking the caller's token. Co-Authored-By: Claude Opus 5.5 --- src/common/workspaceSearch.ts | 72 ++++++----------- src/managers/builtin/pipUtils.ts | 5 +- src/test/common/workspaceSearch.unit.test.ts | 79 ++++++++++--------- .../creators/autoFindProjects.unit.test.ts | 45 +++++++---- .../managers/builtin/pipUtils.unit.test.ts | 63 ++++++++------- 5 files changed, 126 insertions(+), 138 deletions(-) diff --git a/src/common/workspaceSearch.ts b/src/common/workspaceSearch.ts index d3310ae8f..0b5266864 100644 --- a/src/common/workspaceSearch.ts +++ b/src/common/workspaceSearch.ts @@ -1,15 +1,8 @@ -import { CancellationToken, CancellationTokenSource, GlobPattern, RelativePattern, Uri, WorkspaceFolder } from 'vscode'; +import { CancellationToken, GlobPattern, RelativePattern, Uri, WorkspaceFolder } from 'vscode'; import { traceLog, traceWarn } from './logging'; import { getUserHomeDir, isSameOrParentPath } from './utils/pathUtils'; import { findFiles, getWorkspaceFolders } from './workspace.apis'; -/** - * Time limit for one workspace file search. `findFiles` runs ripgrep without ignore files and - * follows symlinks, so a search of a very large folder can take minutes; an ordinary repository - * takes seconds. - */ -export const WORKSPACE_SEARCH_TIMEOUT_MS = 30_000; - export interface WorkspaceSearchOptions { /** Names the feature in log messages. */ readonly label: string; @@ -17,7 +10,7 @@ export interface WorkspaceSearchOptions { readonly exclude: string; /** The most matches to return; the search stops once it has this many. */ readonly maxResults: number; - /** Cancels the search before its time limit. */ + /** Passed to `findFiles` unchanged. */ readonly token?: CancellationToken; } @@ -31,15 +24,19 @@ function containsHomeFolder(folder: WorkspaceFolder): boolean { } /** - * Runs `findFiles` with a result cap and a time limit. A workspace folder that is the user's home - * folder, or contains it, is not searched: a search there walks the whole profile (AppData, caches, - * virtual environments) and can run for minutes. The other folders are still searched. + * Runs `findFiles` with a result cap. A workspace folder that is the user's home folder, or + * contains it, is not searched: a search there walks the whole profile (AppData, caches, virtual + * environments) and can run for minutes. The other folders are still searched. + * + * There is no time limit: VS Code resolves a cancelled `findFiles` to an empty list, not to the + * matches found so far, so a limit would drop every match. A caller that can wait long passes a + * token the user can cancel. */ export async function findFilesInWorkspace(options: WorkspaceSearchOptions): Promise { const folders = getWorkspaceFolders() ?? []; const homeFolders = folders.filter(containsHomeFolder); if (homeFolders.length === 0) { - return searchWithTimeLimit(options, [options.include]); + return search(options, [options.include]); } for (const folder of homeFolders) { traceLog( @@ -50,45 +47,20 @@ export async function findFilesInWorkspace(options: WorkspaceSearchOptions): Pro const includes = folders .filter((folder) => !homeFolders.includes(folder)) .map((folder) => new RelativePattern(folder, options.include)); - return includes.length > 0 ? searchWithTimeLimit(options, includes) : []; + return includes.length > 0 ? search(options, includes) : []; } -async function searchWithTimeLimit(options: WorkspaceSearchOptions, includes: GlobPattern[]): Promise { - const source = new CancellationTokenSource(); - let timedOut = false; - const timer = setTimeout(() => { - timedOut = true; - source.cancel(); - }, WORKSPACE_SEARCH_TIMEOUT_MS); - const stop = () => { - clearTimeout(timer); - source.cancel(); - }; - const subscription = options.token?.onCancellationRequested(stop); - if (options.token?.isCancellationRequested) { - stop(); - } - try { - const resultSets = await Promise.all( - includes.map((include) => findFiles(include, options.exclude, options.maxResults, source.token)), +async function search(options: WorkspaceSearchOptions, includes: GlobPattern[]): Promise { + const resultSets = await Promise.all( + includes.map((include) => findFiles(include, options.exclude, options.maxResults, options.token)), + ); + // `?? []` keeps the callers' existing handling of a search that resolves to undefined. + const results = resultSets.flatMap((resultSet) => resultSet ?? []).slice(0, options.maxResults); + if (results.length >= options.maxResults) { + traceWarn( + `${options.label}: the search for ${options.include} stopped at ${options.maxResults} ` + + 'results; matching files past that are left out.', ); - // `?? []` keeps the callers' existing handling of a search that resolves to undefined. - const results = resultSets.flatMap((resultSet) => resultSet ?? []).slice(0, options.maxResults); - if (timedOut) { - traceWarn( - `${options.label}: the search for ${options.include} stopped at its ` + - `${WORKSPACE_SEARCH_TIMEOUT_MS} ms time limit; matching files may be missing.`, - ); - } else if (results.length >= options.maxResults) { - traceWarn( - `${options.label}: the search for ${options.include} stopped at ${options.maxResults} ` + - 'results; matching files past that are left out.', - ); - } - return results; - } finally { - clearTimeout(timer); - subscription?.dispose(); - source.dispose(); } + return results; } diff --git a/src/managers/builtin/pipUtils.ts b/src/managers/builtin/pipUtils.ts index bfd4a303a..5afa193e7 100644 --- a/src/managers/builtin/pipUtils.ts +++ b/src/managers/builtin/pipUtils.ts @@ -424,8 +424,9 @@ export async function getWorkspacePackagesToInstall( /** * The most files one dependency search returns. Quick Create installs from every match under the - * project, so the cap sits far above what large monorepos hold; the time limit and the home folder - * check in `findFilesInWorkspace` bound how long the search runs. + * project, so the cap sits far above what large monorepos hold. The home folder check in + * `findFilesInWorkspace` bounds the walk, and the progress notification or the caller's token can + * stop it. */ const MAX_DEPENDENCY_FILES = 10_000; diff --git a/src/test/common/workspaceSearch.unit.test.ts b/src/test/common/workspaceSearch.unit.test.ts index 043266e3c..fc34a7f97 100644 --- a/src/test/common/workspaceSearch.unit.test.ts +++ b/src/test/common/workspaceSearch.unit.test.ts @@ -6,7 +6,7 @@ import * as logging from '../../common/logging'; import * as pathUtils from '../../common/utils/pathUtils'; import { isWindows } from '../../common/utils/platformUtils'; import * as wapi from '../../common/workspace.apis'; -import { findFilesInWorkspace, WORKSPACE_SEARCH_TIMEOUT_MS } from '../../common/workspaceSearch'; +import { findFilesInWorkspace } from '../../common/workspaceSearch'; suite('findFilesInWorkspace', () => { const root = path.parse(process.cwd()).root; @@ -22,12 +22,16 @@ suite('findFilesInWorkspace', () => { return { uri: Uri.file(fsPath), name: path.basename(fsPath), index }; } - /** Makes findFiles return a search that only ends when its token is cancelled. */ - function searchUntilCancelled(): void { - findFilesStub.callsFake( - (_include, _exclude, _maxResults, token: CancellationToken) => - new Promise((resolve) => token.onCancellationRequested(() => resolve([]))), - ); + /** + * Makes a findFiles fake that returns `uris` after `delayMs`. Like VS Code, it returns an empty + * list as soon as its token is cancelled, not the matches found so far. + */ + function resolvesUnlessCancelled(delayMs: number, uris: Uri[]) { + return (_include: unknown, _exclude: unknown, _maxResults: unknown, token?: CancellationToken) => + new Promise((resolve) => { + token?.onCancellationRequested(() => resolve([])); + setTimeout(() => resolve(uris), delayMs); + }); } setup(() => { @@ -42,20 +46,25 @@ suite('findFilesInWorkspace', () => { sinon.restore(); }); - test('runs one search with the given pattern, cap and a live token when no folder is the home folder', async () => { + test('runs one search with the given pattern, cap and token when no folder is the home folder', async () => { const marker = Uri.file(path.join(work, 'pyproject.toml')); findFilesStub.resolves([marker]); + const source = new CancellationTokenSource(); - const result = await findFilesInWorkspace(options); - - assert.deepStrictEqual(result, [marker]); - assert.strictEqual(findFilesStub.callCount, 1); - const [include, exclude, maxResults, token] = findFilesStub.firstCall.args; - assert.strictEqual(include, options.include); - assert.strictEqual(exclude, options.exclude); - assert.strictEqual(maxResults, options.maxResults); - assert.ok(token, 'the search should get a token'); - assert.ok(traceLogStub.notCalled); + try { + const result = await findFilesInWorkspace({ ...options, token: source.token }); + + assert.deepStrictEqual(result, [marker]); + assert.strictEqual(findFilesStub.callCount, 1); + const [include, exclude, maxResults, token] = findFilesStub.firstCall.args; + assert.strictEqual(include, options.include); + assert.strictEqual(exclude, options.exclude); + assert.strictEqual(maxResults, options.maxResults); + assert.strictEqual(token, source.token); + assert.ok(traceLogStub.notCalled); + } finally { + source.dispose(); + } }); [ @@ -112,43 +121,35 @@ suite('findFilesInWorkspace', () => { assert.ok(traceWarnStub.calledOnce, 'reaching the cap should be logged'); }); - test('cancels the search at the time limit and logs it', async () => { + test('returns every match of a search that runs for minutes', async () => { const clock = sinon.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); try { - searchUntilCancelled(); + const marker = Uri.file(path.join(work, 'pyproject.toml')); + findFilesStub.callsFake(resolvesUnlessCancelled(10 * 60_000, [marker])); const pending = findFilesInWorkspace(options); - const token: CancellationToken = findFilesStub.firstCall.args[3]; - await clock.tickAsync(WORKSPACE_SEARCH_TIMEOUT_MS - 1); - assert.strictEqual(token.isCancellationRequested, false); - await clock.tickAsync(1); + await clock.tickAsync(10 * 60_000); - assert.strictEqual(token.isCancellationRequested, true); - assert.deepStrictEqual(await pending, []); - assert.ok(traceWarnStub.calledOnce, 'the time limit should be logged'); + assert.deepStrictEqual(await pending, [marker]); + assert.ok(traceWarnStub.notCalled); } finally { clock.restore(); } }); - test('cancels the search with the caller token and clears the time limit', async () => { - const clock = sinon.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + test('passes the caller token to each search', async () => { const source = new CancellationTokenSource(); try { - searchUntilCancelled(); - const pending = findFilesInWorkspace({ ...options, token: source.token }); - const token: CancellationToken = findFilesStub.firstCall.args[3]; - assert.notStrictEqual(token, source.token, 'the time limit needs its own token'); + getWorkspaceFoldersStub.returns([folder(home, 0), folder(work, 1), folder(path.join(root, 'other'), 2)]); - source.cancel(); + await findFilesInWorkspace({ ...options, token: source.token }); - assert.strictEqual(token.isCancellationRequested, true); - assert.deepStrictEqual(await pending, []); - assert.strictEqual(clock.countTimers(), 0); - assert.ok(traceWarnStub.notCalled, 'a caller cancel is not a time limit'); + assert.strictEqual(findFilesStub.callCount, 2); + for (const call of findFilesStub.getCalls()) { + assert.strictEqual(call.args[3], source.token); + } } finally { source.dispose(); - clock.restore(); } }); diff --git a/src/test/features/creators/autoFindProjects.unit.test.ts b/src/test/features/creators/autoFindProjects.unit.test.ts index ba2897a03..4f06d24a6 100644 --- a/src/test/features/creators/autoFindProjects.unit.test.ts +++ b/src/test/features/creators/autoFindProjects.unit.test.ts @@ -2,7 +2,7 @@ import assert from 'assert'; import * as path from 'path'; import * as sinon from 'sinon'; import * as typmoq from 'typemoq'; -import { Uri, WorkspaceFolder } from 'vscode'; +import { CancellationToken, Uri, WorkspaceFolder } from 'vscode'; import { PythonEnvironment, PythonProject } from '../../../api'; import { SYSTEM_MANAGER_ID, VENV_MANAGER_ID } from '../../../common/constants'; import { timeout } from '../../../common/utils/asyncUtils'; @@ -486,31 +486,42 @@ suite('Auto Find Project tests', () => { sinon.stub(pathUtils, 'getUserHomeDir').returns(home); }); - test('caps the marker search and stops it at a time limit', async () => { + test('caps the marker search', async () => { + findFilesStub.resolves([]); + + await new AutoFindProjects(projectManager.object, envManagers.object).create(); + + assert.strictEqual(findFilesStub.callCount, 1); + const [include, exclude, maxResults] = findFilesStub.firstCall.args; + assert.strictEqual(include, '**/{pyproject.toml,setup.py}'); + assert.strictEqual(exclude, '**/.venv/**'); + assert.ok(Number.isInteger(maxResults) && maxResults > 0, 'the search should have a result cap'); + }); + + test('offers the projects of a marker search that runs for minutes', async () => { const clock = sinon.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); - let finish: (() => void) | undefined; + const marker = Uri.file(path.join(path.parse(process.cwd()).root, 'work', 'app', 'pyproject.toml')); + // Like VS Code, a cancelled search returns an empty list, not the matches found so far. findFilesStub.callsFake( - () => + (_include: string, _exclude: string, _maxResults: number, token?: CancellationToken) => new Promise((resolve) => { - finish = () => resolve([]); + token?.onCancellationRequested(() => resolve([])); + setTimeout(() => resolve([marker]), 10 * 60_000); }), ); + projectManager.setup((pm) => pm.get(typmoq.It.isAny())).returns(() => undefined); try { const pending = new AutoFindProjects(projectManager.object, envManagers.object).create(); - await new Promise((resolve) => setImmediate(resolve)); - - assert.strictEqual(findFilesStub.callCount, 1); - const [include, exclude, maxResults, token] = findFilesStub.firstCall.args; - assert.strictEqual(include, '**/{pyproject.toml,setup.py}'); - assert.strictEqual(exclude, '**/.venv/**'); - assert.ok(Number.isInteger(maxResults) && maxResults > 0, 'the search should have a result cap'); - assert.ok(token && !token.isCancellationRequested, 'the search should get a live token'); + await clock.tickAsync(10 * 60_000); - assert.ok(token.isCancellationRequested, 'the search should stop at its time limit'); - finish?.(); - assert.strictEqual(await pending, undefined); + + const result = await pending; + assert.ok(Array.isArray(result), 'the found project should be offered'); + assert.deepStrictEqual( + result.map((project) => project.uri.fsPath), + [path.dirname(marker.fsPath)], + ); } finally { - finish?.(); clock.restore(); } }); diff --git a/src/test/managers/builtin/pipUtils.unit.test.ts b/src/test/managers/builtin/pipUtils.unit.test.ts index cc0d518e3..a645007d4 100644 --- a/src/test/managers/builtin/pipUtils.unit.test.ts +++ b/src/test/managers/builtin/pipUtils.unit.test.ts @@ -80,17 +80,16 @@ suite('Pip Utils - getProjectInstallable', () => { } }); - test('propagates progress cancellation to each search and promptly rejects during file search', async () => { + test('propagates the progress token and promptly rejects cancellation during file search', async () => { const source = new CancellationTokenSource(); let startedCount = 0; - const searchTokens: CancellationToken[] = []; let markSearchesStarted!: () => void; const searchesStarted = new Promise((resolve) => { markSearchesStarted = resolve; }); findFilesStub.callsFake( (_pattern: string, _exclude: string, _maxResults: number | undefined, token: CancellationToken) => { - searchTokens.push(token); + assert.strictEqual(token, source.token); startedCount += 1; if (startedCount === 4) { markSearchesStarted(); @@ -128,7 +127,6 @@ suite('Pip Utils - getProjectInstallable', () => { }), ]); assert.strictEqual(findFilesStub.callCount, 4); - assert.ok(searchTokens.every((token) => token.isCancellationRequested)); } finally { clearTimeout(timeoutHandle); source.dispose(); @@ -143,7 +141,7 @@ suite('Pip Utils - getProjectInstallable', () => { mockApi.getPythonProject = getPythonProject; findFilesStub.callsFake( (_pattern: string, _exclude: string, _maxResults: number | undefined, token: CancellationToken) => { - assert.ok(token, 'each search should get a token'); + assert.strictEqual(token, source.token); return new Promise((resolve) => resolutions.push(resolve)); }, ); @@ -207,9 +205,6 @@ suite('Pip Utils - getProjectInstallable', () => { let cancelled = false; let subscriptionCount = 0; - // Subscriptions 1-4 tie the four file searches to this token, 5 waits for the searches, - // and 6 waits for the TOML read. - const tomlReadSubscription = 6; let markPostReadCheck!: () => void; const postReadCheck = new Promise((resolve) => { markPostReadCheck = resolve; @@ -223,7 +218,7 @@ suite('Pip Utils - getProjectInstallable', () => { }, onCancellationRequested: (listener: () => unknown) => { subscriptionCount += 1; - if (subscriptionCount === tomlReadSubscription) { + if (subscriptionCount === 2) { queueMicrotask(() => { cancelled = true; listener(); @@ -242,7 +237,7 @@ suite('Pip Utils - getProjectInstallable', () => { CancellationError, ); await postReadCheck; - assert.strictEqual(subscriptionCount, tomlReadSubscription); + assert.strictEqual(subscriptionCount, 2); assert.ok(withProgressStub.notCalled); } finally { await fse.remove(temp); @@ -268,17 +263,16 @@ suite('Pip Utils - getProjectInstallable', () => { } }); - test('headless search propagates in-flight cancellation of the caller token to each search', async () => { + test('headless search propagates in-flight cancellation and passes the caller token unchanged', async () => { const source = new CancellationTokenSource(); let startedCount = 0; - const searchTokens: CancellationToken[] = []; let markSearchesStarted!: () => void; const searchesStarted = new Promise((resolve) => { markSearchesStarted = resolve; }); findFilesStub.callsFake( (_pattern: string, _exclude: string, _maxResults: number | undefined, token: CancellationToken) => { - searchTokens.push(token); + assert.strictEqual(token, source.token); startedCount += 1; if (startedCount === 4) { markSearchesStarted(); @@ -299,7 +293,6 @@ suite('Pip Utils - getProjectInstallable', () => { source.cancel(); await assert.rejects(pending, CancellationError); assert.strictEqual(findFilesStub.callCount, 4); - assert.ok(searchTokens.every((token) => token.isCancellationRequested)); assert.ok(withProgressStub.notCalled); } finally { source.dispose(); @@ -1539,28 +1532,38 @@ suite('Pip Utils - dependency file search bounds', () => { sinon.restore(); }); - test('caps each search and stops it at a time limit', async () => { + test('caps each search', async () => { + await getProjectInstallable(mockApi as PythonEnvironmentApi, [project], { runHeadless: true }); + + assert.strictEqual(findFilesStub.callCount, 4); + for (const call of findFilesStub.getCalls()) { + const [include, , maxResults] = call.args; + assert.ok(Number.isInteger(maxResults) && maxResults > 0, `${include} should have a result cap`); + } + }); + + test('keeps the dependency files of a search that runs for minutes', async () => { const clock = sinon.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); - const finish: Array<() => void> = []; - findFilesStub.callsFake(() => new Promise((resolve) => finish.push(() => resolve([])))); + const requirement = Uri.file(path.join(project.uri.fsPath, 'requirements.txt')); + // Like VS Code, a cancelled search returns an empty list, not the matches found so far. + findFilesStub.callsFake( + (include: string, _exclude: string, _maxResults: number, token?: CancellationToken) => + new Promise((resolve) => { + token?.onCancellationRequested(() => resolve([])); + setTimeout(() => resolve(include === '**/*requirements*.txt' ? [requirement] : []), 10 * 60_000); + }), + ); try { const pending = getProjectInstallable(mockApi as PythonEnvironmentApi, [project], { runHeadless: true }); - await new Promise((resolve) => setImmediate(resolve)); - assert.strictEqual(findFilesStub.callCount, 4); - for (const call of findFilesStub.getCalls()) { - const [include, , maxResults, token] = call.args; - assert.ok(Number.isInteger(maxResults) && maxResults > 0, `${include} should have a result cap`); - assert.ok(token && !token.isCancellationRequested, `${include} should get a live token`); - } await clock.tickAsync(10 * 60_000); - for (const call of findFilesStub.getCalls()) { - assert.ok(call.args[3].isCancellationRequested, `${call.args[0]} should stop at its time limit`); - } - finish.forEach((done) => done()); - assert.deepStrictEqual((await pending).installables, []); + + const result = await pending; + assert.deepStrictEqual( + result.installables.map((item) => item.uri?.fsPath), + [requirement.fsPath], + ); } finally { - finish.forEach((done) => done()); clock.restore(); } }); From d66c8f27d72b44389d5295c531049f5067a3b01e Mon Sep 17 00:00:00 2001 From: JF Gagne Date: Mon, 9 Nov 2026 13:20:48 -0500 Subject: [PATCH 5/9] Search the project folders inside a home folder workspace getProjectInstallable keeps only files under the projects it is given, but the home folder check skipped the whole workspace folder. With the workspace on the home folder and a project at ~/code/app, Quick Create made the venv without the project's dependencies and the package picker left out its requirements files. findFilesInWorkspace takes neededFolders. In place of a workspace folder that is the home folder or contains it, it searches each needed folder inside it, one RelativePattern each, and still skips a needed folder that is home or contains it. It skips a needed path that is a file or missing, because ripgrep cannot start there and a failed start fails the whole findFiles call. Nested and repeated folders are searched once. The dependency search passes the project folders; Auto Find passes none and keeps skipping the home folder. The vscode mock's RelativePattern now takes a Uri base, as VS Code's does. Co-Authored-By: Claude Opus 5.5 --- src/common/workspaceSearch.ts | 74 ++++++++++++---- src/managers/builtin/pipUtils.ts | 4 + src/test/common/workspaceSearch.unit.test.ts | 87 ++++++++++++++++++- .../managers/builtin/pipUtils.unit.test.ts | 57 ++++++++++-- src/test/mocks/vsc/extHostedTypes.ts | 14 +-- 5 files changed, 207 insertions(+), 29 deletions(-) diff --git a/src/common/workspaceSearch.ts b/src/common/workspaceSearch.ts index 0b5266864..a97ed3dcf 100644 --- a/src/common/workspaceSearch.ts +++ b/src/common/workspaceSearch.ts @@ -1,3 +1,4 @@ +import * as fsapi from 'fs-extra'; import { CancellationToken, GlobPattern, RelativePattern, Uri, WorkspaceFolder } from 'vscode'; import { traceLog, traceWarn } from './logging'; import { getUserHomeDir, isSameOrParentPath } from './utils/pathUtils'; @@ -10,6 +11,11 @@ export interface WorkspaceSearchOptions { readonly exclude: string; /** The most matches to return; the search stops once it has this many. */ readonly maxResults: number; + /** + * The folders whose files the caller uses. A workspace folder that is the user's home folder, + * or contains it, is searched only inside these folders. Without them it is not searched. + */ + readonly neededFolders?: readonly Uri[]; /** Passed to `findFiles` unchanged. */ readonly token?: CancellationToken; } @@ -18,15 +24,16 @@ export interface WorkspaceSearchOptions { * Returns whether a search of `folder` walks the user's home folder, because it is that folder or * one of its parents. Paths are compared after normalization (separators, and case on Windows). */ -function containsHomeFolder(folder: WorkspaceFolder): boolean { +function containsHomeFolder(folder: Uri): boolean { const home = getUserHomeDir(); - return !!home && folder.uri.scheme === 'file' && isSameOrParentPath(folder.uri.fsPath, home); + return !!home && folder.scheme === 'file' && isSameOrParentPath(folder.fsPath, home); } /** * Runs `findFiles` with a result cap. A workspace folder that is the user's home folder, or - * contains it, is not searched: a search there walks the whole profile (AppData, caches, virtual - * environments) and can run for minutes. The other folders are still searched. + * contains it, is never searched whole: a search there walks the whole profile (AppData, caches, + * virtual environments) and can run for minutes. Only the caller's needed folders inside it are + * searched. The other workspace folders are searched whole. * * There is no time limit: VS Code resolves a cancelled `findFiles` to an empty list, not to the * matches found so far, so a limit would drop every match. A caller that can wait long passes a @@ -34,22 +41,59 @@ function containsHomeFolder(folder: WorkspaceFolder): boolean { */ export async function findFilesInWorkspace(options: WorkspaceSearchOptions): Promise { const folders = getWorkspaceFolders() ?? []; - const homeFolders = folders.filter(containsHomeFolder); - if (homeFolders.length === 0) { + if (!folders.some((folder) => containsHomeFolder(folder.uri))) { return search(options, [options.include]); } - for (const folder of homeFolders) { - traceLog( - `${options.label}: not searching workspace folder ${folder.uri.fsPath} for ${options.include}, ` + - "because it is the user's home folder or contains it, and a search there walks the whole profile.", - ); - } - const includes = folders - .filter((folder) => !homeFolders.includes(folder)) - .map((folder) => new RelativePattern(folder, options.include)); + const bases = await Promise.all(folders.map((folder) => foldersToSearch(folder, options))); + const includes = outermostFolders(bases.flat()).map((base) => new RelativePattern(base, options.include)); return includes.length > 0 ? search(options, includes) : []; } +/** + * Returns the folders to search in place of one workspace folder: the folder itself, or, when it + * is the home folder or contains it, the needed folders inside it that do not contain home. + */ +async function foldersToSearch(folder: WorkspaceFolder, options: WorkspaceSearchOptions): Promise { + if (!containsHomeFolder(folder.uri)) { + return [folder.uri]; + } + const candidates = (options.neededFolders ?? []).filter( + (uri) => isSameOrParentPath(folder.uri.fsPath, uri.fsPath) && !containsHomeFolder(uri), + ); + const isFolder = await Promise.all(candidates.map(isDirectory)); + const inside = candidates.filter((_uri, index) => isFolder[index]); + traceLog( + `${options.label}: not searching all of workspace folder ${folder.uri.fsPath} for ${options.include}, ` + + "because it is the user's home folder or contains it, and a search there walks the whole profile." + + (inside.length > 0 ? ` Searching only ${inside.map((uri) => uri.fsPath).join(', ')}.` : ''), + ); + return inside; +} + +/** + * Returns whether `uri` is a folder. ripgrep cannot start in a file or a missing folder, and a + * failed start fails the whole `findFiles` call. Projects can be single files. + */ +async function isDirectory(uri: Uri): Promise { + try { + return (await fsapi.stat(uri.fsPath)).isDirectory(); + } catch { + return false; + } +} + +/** Drops each folder that sits inside another folder of the list, and each repeat. */ +function outermostFolders(folders: readonly Uri[]): Uri[] { + return folders.filter((folder, index) => + folders.every( + (other, otherIndex) => + otherIndex === index || + !isSameOrParentPath(other.fsPath, folder.fsPath) || + (index < otherIndex && isSameOrParentPath(folder.fsPath, other.fsPath)), + ), + ); +} + async function search(options: WorkspaceSearchOptions, includes: GlobPattern[]): Promise { const resultSets = await Promise.all( includes.map((include) => findFiles(include, options.exclude, options.maxResults, options.token)), diff --git a/src/managers/builtin/pipUtils.ts b/src/managers/builtin/pipUtils.ts index 5afa193e7..e365eec35 100644 --- a/src/managers/builtin/pipUtils.ts +++ b/src/managers/builtin/pipUtils.ts @@ -461,12 +461,16 @@ export async function getProjectInstallable( const shouldYieldForSearchCancellation = (index: number) => index > 0 && index % cancellationBatchSize === 0; + // Only files under one of `projects` are kept below, so in a home folder workspace the + // search can stay inside the project folders. + const neededFolders = projects.map((project) => project.uri); const searchDependencyFiles = (include: string) => findFilesInWorkspace({ label: 'Dependency search', include, exclude, maxResults: MAX_DEPENDENCY_FILES, + neededFolders, token: cancellationToken, }); diff --git a/src/test/common/workspaceSearch.unit.test.ts b/src/test/common/workspaceSearch.unit.test.ts index fc34a7f97..a91282f7a 100644 --- a/src/test/common/workspaceSearch.unit.test.ts +++ b/src/test/common/workspaceSearch.unit.test.ts @@ -1,4 +1,6 @@ import assert from 'assert'; +import * as fse from 'fs-extra'; +import * as os from 'os'; import * as path from 'path'; import * as sinon from 'sinon'; import { CancellationToken, CancellationTokenSource, RelativePattern, Uri, WorkspaceFolder } from 'vscode'; @@ -17,6 +19,7 @@ suite('findFilesInWorkspace', () => { let getWorkspaceFoldersStub: sinon.SinonStub; let traceLogStub: sinon.SinonStub; let traceWarnStub: sinon.SinonStub; + let getUserHomeDirStub: sinon.SinonStub; function folder(fsPath: string, index = 0): WorkspaceFolder { return { uri: Uri.file(fsPath), name: path.basename(fsPath), index }; @@ -37,7 +40,7 @@ suite('findFilesInWorkspace', () => { setup(() => { findFilesStub = sinon.stub(wapi, 'findFiles').resolves([]); getWorkspaceFoldersStub = sinon.stub(wapi, 'getWorkspaceFolders').returns([folder(work)]); - sinon.stub(pathUtils, 'getUserHomeDir').returns(home); + getUserHomeDirStub = sinon.stub(pathUtils, 'getUserHomeDir').returns(home); traceLogStub = sinon.stub(logging, 'traceLog'); traceWarnStub = sinon.stub(logging, 'traceWarn'); }); @@ -164,4 +167,86 @@ suite('findFilesInWorkspace', () => { source.dispose(); } }); + + suite('needed folders inside a home folder workspace', () => { + let diskHome: string; + let app: string; + + /** Returns the normalized base folder of each findFiles call. */ + function searchedBases(): string[] { + return findFilesStub.getCalls().map((call) => { + const include = call.args[0]; + assert.ok(include instanceof RelativePattern, 'each search should be scoped to one folder'); + assert.strictEqual(include.pattern, options.include); + return pathUtils.normalizePath(include.baseUri.fsPath); + }); + } + + setup(async () => { + diskHome = await fse.mkdtemp(path.join(os.tmpdir(), 'workspace-search-home-')); + app = path.join(diskHome, 'code', 'app'); + await fse.ensureDir(path.join(app, 'sub')); + getUserHomeDirStub.returns(diskHome); + getWorkspaceFoldersStub.returns([folder(diskHome)]); + }); + + teardown(async () => { + await fse.remove(diskHome); + }); + + test('searches only the needed folders inside it and logs why', async () => { + const marker = Uri.file(path.join(app, 'pyproject.toml')); + findFilesStub.resolves([marker]); + + const result = await findFilesInWorkspace({ ...options, neededFolders: [Uri.file(app)] }); + + assert.deepStrictEqual(result, [marker]); + assert.deepStrictEqual(searchedBases(), [pathUtils.normalizePath(app)]); + assert.strictEqual(traceLogStub.callCount, 1); + const message = String(traceLogStub.firstCall.args[0]); + assert.ok(message.includes(Uri.file(diskHome).fsPath), message); + assert.ok(message.includes(Uri.file(app).fsPath), message); + }); + + test('does not search a needed folder that is the home folder or contains it', async () => { + const result = await findFilesInWorkspace({ + ...options, + neededFolders: [Uri.file(diskHome), Uri.file(path.dirname(diskHome))], + }); + + assert.deepStrictEqual(result, []); + assert.ok(findFilesStub.notCalled); + }); + + test('does not search a needed path that is a file or does not exist', async () => { + const script = path.join(diskHome, 'script.py'); + await fse.outputFile(script, 'print(1)\n'); + + await findFilesInWorkspace({ + ...options, + neededFolders: [Uri.file(script), Uri.file(path.join(diskHome, 'missing'))], + }); + + assert.ok(findFilesStub.notCalled); + }); + + test('searches a folder once when it sits inside another needed folder or two home folders', async () => { + getWorkspaceFoldersStub.returns([folder(path.dirname(diskHome), 0), folder(diskHome, 1)]); + + await findFilesInWorkspace({ + ...options, + neededFolders: [Uri.file(path.join(app, 'sub')), Uri.file(app), Uri.file(app)], + }); + + assert.deepStrictEqual(searchedBases(), [pathUtils.normalizePath(app)]); + }); + + test('searches the other workspace folders whole', async () => { + getWorkspaceFoldersStub.returns([folder(diskHome, 0), folder(work, 1)]); + + await findFilesInWorkspace({ ...options, neededFolders: [Uri.file(path.join(work, 'service'))] }); + + assert.deepStrictEqual(searchedBases(), [pathUtils.normalizePath(work)]); + }); + }); }); diff --git a/src/test/managers/builtin/pipUtils.unit.test.ts b/src/test/managers/builtin/pipUtils.unit.test.ts index a645007d4..548b2574c 100644 --- a/src/test/managers/builtin/pipUtils.unit.test.ts +++ b/src/test/managers/builtin/pipUtils.unit.test.ts @@ -1515,6 +1515,7 @@ suite('Pip Utils - dependency file search bounds', () => { const project = { name: 'app', uri: Uri.file(path.join(home, 'code', 'app')) }; let findFilesStub: sinon.SinonStub; let getWorkspaceFoldersStub: sinon.SinonStub; + let getUserHomeDirStub: sinon.SinonStub; let mockApi: { getPythonProject: (uri: Uri) => PythonProject | undefined }; function folder(fsPath: string, index = 0): WorkspaceFolder { @@ -1524,7 +1525,7 @@ suite('Pip Utils - dependency file search bounds', () => { setup(() => { findFilesStub = sinon.stub(wapi, 'findFiles').resolves([]); getWorkspaceFoldersStub = sinon.stub(wapi, 'getWorkspaceFolders').returns([folder(project.uri.fsPath)]); - sinon.stub(pathUtils, 'getUserHomeDir').returns(home); + getUserHomeDirStub = sinon.stub(pathUtils, 'getUserHomeDir').returns(home); mockApi = { getPythonProject: () => project }; }); @@ -1568,13 +1569,57 @@ suite('Pip Utils - dependency file search bounds', () => { } }); - test('does not search a workspace folder that is the home folder', async () => { - getWorkspaceFoldersStub.returns([folder(home)]); + suite('in a home folder workspace', () => { + let diskHome: string; + let diskProject: { name: string; uri: Uri }; - const result = await getProjectInstallable(mockApi as PythonEnvironmentApi, [project], { runHeadless: true }); + setup(async () => { + diskHome = await fse.mkdtemp(path.join(os.tmpdir(), 'piputils-home-')); + diskProject = { name: 'app', uri: Uri.file(path.join(diskHome, 'code', 'app')) }; + await fse.ensureDir(diskProject.uri.fsPath); + getUserHomeDirStub.returns(diskHome); + getWorkspaceFoldersStub.returns([folder(diskHome)]); + }); - assert.ok(findFilesStub.notCalled, 'the home folder should not be searched'); - assert.deepStrictEqual(result.installables, []); + teardown(async () => { + await fse.remove(diskHome); + }); + + test('searches the project folder and keeps its dependency files', async () => { + const requirement = Uri.file(path.join(diskProject.uri.fsPath, 'requirements.txt')); + mockApi.getPythonProject = () => diskProject; + findFilesStub.callsFake((include: RelativePattern) => + Promise.resolve(include.pattern === '**/*requirements*.txt' ? [requirement] : []), + ); + + const result = await getProjectInstallable(mockApi as PythonEnvironmentApi, [diskProject], { + runHeadless: true, + preferredRoot: diskProject.uri, + }); + + assert.strictEqual(findFilesStub.callCount, 4); + for (const call of findFilesStub.getCalls()) { + const include = call.args[0]; + assert.ok(include instanceof RelativePattern, 'each search should be scoped to the project'); + assert.strictEqual(normalizePath(include.baseUri.fsPath), normalizePath(diskProject.uri.fsPath)); + } + assert.deepStrictEqual( + result.installables.map((item) => item.uri?.fsPath), + [requirement.fsPath], + ); + }); + + test('does not search a project that is the home folder', async () => { + const homeProject = { name: 'home', uri: Uri.file(diskHome) }; + mockApi.getPythonProject = () => homeProject; + + const result = await getProjectInstallable(mockApi as PythonEnvironmentApi, [homeProject], { + runHeadless: true, + }); + + assert.ok(findFilesStub.notCalled, 'the home folder should not be searched'); + assert.deepStrictEqual(result.installables, []); + }); }); test('still searches the other workspace folders when one is the home folder', async () => { diff --git a/src/test/mocks/vsc/extHostedTypes.ts b/src/test/mocks/vsc/extHostedTypes.ts index c929f1db0..f61033879 100644 --- a/src/test/mocks/vsc/extHostedTypes.ts +++ b/src/test/mocks/vsc/extHostedTypes.ts @@ -2063,19 +2063,19 @@ export class RelativePattern implements IRelativePattern { pattern: string; - constructor(base: vscode.WorkspaceFolder | string, pattern: string) { - if (typeof base !== 'string') { - if (!base || !vscUri.URI.isUri(base.uri)) { - throw illegalArgument('base'); - } + constructor(base: vscode.WorkspaceFolder | vscode.Uri | string, pattern: string) { + // Like VS Code's RelativePattern, the base can be a Uri as well as a workspace folder. + const baseUri = typeof base === 'string' || !base || !('uri' in base) ? base : base.uri; + if (typeof baseUri !== 'string' && !vscUri.URI.isUri(baseUri)) { + throw illegalArgument('base'); } if (typeof pattern !== 'string') { throw illegalArgument('pattern'); } - this.baseUri = typeof base === 'string' ? vscUri.URI.parse(base) : base.uri; - this.base = typeof base === 'string' ? base : base.uri.fsPath; + this.baseUri = typeof baseUri === 'string' ? vscUri.URI.parse(baseUri) : baseUri; + this.base = typeof baseUri === 'string' ? baseUri : baseUri.fsPath; this.pattern = pattern; } From 9c4e4697cf63c79f8ef5f2646cbe048046c47056 Mon Sep 17 00:00:00 2001 From: JF Gagne Date: Mon, 9 Nov 2026 14:00:46 -0500 Subject: [PATCH 6/9] Assert the exact result caps at the search call sites The call-site tests checked only that maxResults was a positive integer, so raising MAX_DEPENDENCY_FILES or MAX_PROJECT_MARKERS to Number.MAX_SAFE_INTEGER still passed. The dependency search test now checks each of the four patterns with a cap of 10,000, and the Auto Find test checks a cap of 1,000. With both caps raised to Number.MAX_SAFE_INTEGER in the compiled code, the two tests fail (72 passing, 2 failing); with the real caps they pass (74 passing). Co-Authored-By: Claude Opus 5.5 --- .../creators/autoFindProjects.unit.test.ts | 4 ++-- src/test/managers/builtin/pipUtils.unit.test.ts | 16 ++++++++++------ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/test/features/creators/autoFindProjects.unit.test.ts b/src/test/features/creators/autoFindProjects.unit.test.ts index 4f06d24a6..6cc2ef098 100644 --- a/src/test/features/creators/autoFindProjects.unit.test.ts +++ b/src/test/features/creators/autoFindProjects.unit.test.ts @@ -486,7 +486,7 @@ suite('Auto Find Project tests', () => { sinon.stub(pathUtils, 'getUserHomeDir').returns(home); }); - test('caps the marker search', async () => { + test('caps the marker search at 1,000 files', async () => { findFilesStub.resolves([]); await new AutoFindProjects(projectManager.object, envManagers.object).create(); @@ -495,7 +495,7 @@ suite('Auto Find Project tests', () => { const [include, exclude, maxResults] = findFilesStub.firstCall.args; assert.strictEqual(include, '**/{pyproject.toml,setup.py}'); assert.strictEqual(exclude, '**/.venv/**'); - assert.ok(Number.isInteger(maxResults) && maxResults > 0, 'the search should have a result cap'); + assert.strictEqual(maxResults, 1_000); }); test('offers the projects of a marker search that runs for minutes', async () => { diff --git a/src/test/managers/builtin/pipUtils.unit.test.ts b/src/test/managers/builtin/pipUtils.unit.test.ts index 548b2574c..492d16a20 100644 --- a/src/test/managers/builtin/pipUtils.unit.test.ts +++ b/src/test/managers/builtin/pipUtils.unit.test.ts @@ -1533,14 +1533,18 @@ suite('Pip Utils - dependency file search bounds', () => { sinon.restore(); }); - test('caps each search', async () => { + test('caps each search at 10,000 files', async () => { await getProjectInstallable(mockApi as PythonEnvironmentApi, [project], { runHeadless: true }); - assert.strictEqual(findFilesStub.callCount, 4); - for (const call of findFilesStub.getCalls()) { - const [include, , maxResults] = call.args; - assert.ok(Number.isInteger(maxResults) && maxResults > 0, `${include} should have a result cap`); - } + assert.deepStrictEqual( + findFilesStub.getCalls().map((call) => [call.args[0], call.args[2]]), + [ + ['**/*requirements*.txt', 10_000], + ['*requirements*.txt', 10_000], + ['**/requirements/*.txt', 10_000], + ['**/pyproject.toml', 10_000], + ], + ); }); test('keeps the dependency files of a search that runs for minutes', async () => { From faa0ae070023f71c2788e55d9fbfba0a43b1e7df Mon Sep 17 00:00:00 2001 From: JF Gagne Date: Mon, 9 Nov 2026 14:13:38 -0500 Subject: [PATCH 7/9] Say why Auto Find finds nothing in a home folder workspace In a workspace opened on the home folder, Auto Find searched nothing and then showed "No projects found". The reason went only to traceLog, so the user read that no projects exist when none were looked for. findFilesInWorkspace's module now exports getHomeWorkspaceFolders, which returns the workspace folders it never searches whole. When Auto Find finds nothing and one of those folders exists, the error names the folder, says it is the home folder or contains it, and says to open the project folder instead. The plain message now comes from ProjectCreatorString.noProjectsFound, which held the same text but was unused. Before the change, the Auto Find test that checks the message and the two getHomeWorkspaceFolders tests failed; after it they pass. Co-Authored-By: Claude Opus 5.5 --- src/common/localize.ts | 5 +++++ src/common/workspaceSearch.ts | 9 ++++++++ src/features/creators/autoFindProjects.ts | 17 +++++++++++++-- src/test/common/workspaceSearch.unit.test.ts | 19 ++++++++++++++++- .../creators/autoFindProjects.unit.test.ts | 21 +++++++++++++++++-- 5 files changed, 66 insertions(+), 5 deletions(-) diff --git a/src/common/localize.ts b/src/common/localize.ts index 19a3bcd84..1cf5c2d84 100644 --- a/src/common/localize.ts +++ b/src/common/localize.ts @@ -290,6 +290,11 @@ export namespace ProjectCreatorString { ); export const noProjectsFound = l10n.t('No projects found'); + export const noProjectsFoundInHomeFolder = (folders: string) => + l10n.t( + 'No projects found. Auto Find does not search {0}, because it is your home folder or contains it. Open your project folder instead.', + folders, + ); } export namespace EnvViewStrings { diff --git a/src/common/workspaceSearch.ts b/src/common/workspaceSearch.ts index a97ed3dcf..e35d49feb 100644 --- a/src/common/workspaceSearch.ts +++ b/src/common/workspaceSearch.ts @@ -49,6 +49,15 @@ export async function findFilesInWorkspace(options: WorkspaceSearchOptions): Pro return includes.length > 0 ? search(options, includes) : []; } +/** + * Returns the workspace folders that `findFilesInWorkspace` never searches whole, because each is + * the user's home folder or contains it. A caller that finds nothing names them in its message, so + * the user learns the folder was skipped rather than searched. + */ +export function getHomeWorkspaceFolders(): WorkspaceFolder[] { + return (getWorkspaceFolders() ?? []).filter((folder) => containsHomeFolder(folder.uri)); +} + /** * Returns the folders to search in place of one workspace folder: the folder itself, or, when it * is the home folder or contains it, the needed folders inside it that do not contain home. diff --git a/src/features/creators/autoFindProjects.ts b/src/features/creators/autoFindProjects.ts index 4b9eb1900..04178c1dd 100644 --- a/src/features/creators/autoFindProjects.ts +++ b/src/features/creators/autoFindProjects.ts @@ -5,7 +5,7 @@ import { ProjectCreatorString } from '../../common/localize'; import { traceInfo, traceWarn } from '../../common/logging'; import { showErrorMessage, showQuickPickWithButtons, showWarningMessage } from '../../common/window.apis'; import { getWorkspaceFolders } from '../../common/workspace.apis'; -import { findFilesInWorkspace } from '../../common/workspaceSearch'; +import { findFilesInWorkspace, getHomeWorkspaceFolders } from '../../common/workspaceSearch'; import type { EnvironmentManagers } from '../envManagers'; import { PythonProjectManager, @@ -42,6 +42,18 @@ function getUniqueUri(uris: Uri[]): { .sort((a, b) => a.label.localeCompare(b.label)); } +/** + * Returns the message for a search that found no project. When the search skipped a workspace + * folder because it is the home folder, the message names the folder and says to open the project + * folder, because no project there was looked for. + */ +function noProjectsFoundMessage(): string { + const skipped = getHomeWorkspaceFolders().map((folder) => folder.uri.fsPath); + return skipped.length > 0 + ? ProjectCreatorString.noProjectsFoundInHomeFolder(skipped.join(', ')) + : ProjectCreatorString.noProjectsFound; +} + async function pickProjects(uris: Uri[]): Promise { const items = getUniqueUri(uris); @@ -120,8 +132,9 @@ export class AutoFindProjects implements PythonProjectCreator { ), ); if (!files || files.length === 0) { + const message = noProjectsFoundMessage(); setImmediate(() => { - showErrorMessage('No projects found'); + showErrorMessage(message); }); return; } diff --git a/src/test/common/workspaceSearch.unit.test.ts b/src/test/common/workspaceSearch.unit.test.ts index a91282f7a..a066cae4e 100644 --- a/src/test/common/workspaceSearch.unit.test.ts +++ b/src/test/common/workspaceSearch.unit.test.ts @@ -8,7 +8,7 @@ import * as logging from '../../common/logging'; import * as pathUtils from '../../common/utils/pathUtils'; import { isWindows } from '../../common/utils/platformUtils'; import * as wapi from '../../common/workspace.apis'; -import { findFilesInWorkspace } from '../../common/workspaceSearch'; +import { findFilesInWorkspace, getHomeWorkspaceFolders } from '../../common/workspaceSearch'; suite('findFilesInWorkspace', () => { const root = path.parse(process.cwd()).root; @@ -92,6 +92,23 @@ suite('findFilesInWorkspace', () => { }); }); + test('names the workspace folders it does not search whole', () => { + getWorkspaceFoldersStub.returns([folder(home, 0), folder(work, 1), folder(path.dirname(home), 2)]); + + const skipped = getHomeWorkspaceFolders(); + + assert.deepStrictEqual( + skipped.map((workspaceFolder) => workspaceFolder.uri.fsPath), + [Uri.file(home).fsPath, Uri.file(path.dirname(home)).fsPath], + ); + }); + + test('names no folder when no workspace folder is open', () => { + getWorkspaceFoldersStub.returns(undefined); + + assert.deepStrictEqual(getHomeWorkspaceFolders(), []); + }); + test('searches a folder inside the home folder', async () => { getWorkspaceFoldersStub.returns([folder(path.join(home, 'code', 'app'))]); diff --git a/src/test/features/creators/autoFindProjects.unit.test.ts b/src/test/features/creators/autoFindProjects.unit.test.ts index 6cc2ef098..43c331374 100644 --- a/src/test/features/creators/autoFindProjects.unit.test.ts +++ b/src/test/features/creators/autoFindProjects.unit.test.ts @@ -526,7 +526,7 @@ suite('Auto Find Project tests', () => { } }); - test('does not search a workspace folder that is the home folder', async () => { + test('does not search a workspace folder that is the home folder and tells the user why', async () => { getWorkspaceFoldersStub.returns([{ uri: Uri.file(home), name: 'tester', index: 0 }]); const shown = createDeferred(); showErrorMessageStub.callsFake(() => shown.resolve()); @@ -536,7 +536,24 @@ suite('Auto Find Project tests', () => { assert.strictEqual(result, undefined); assert.ok(findFilesStub.notCalled, 'the home folder should not be searched'); await Promise.race([shown.promise, timeout(100)]); - assert.ok(showErrorMessageStub.calledOnce, 'No projects found should be shown'); + sinon.assert.calledOnceWithExactly( + showErrorMessageStub, + `No projects found. Auto Find does not search ${Uri.file(home).fsPath}, because it is your ` + + 'home folder or contains it. Open your project folder instead.', + ); + }); + + test('shows the plain message when it searched every workspace folder', async () => { + const app = path.join(path.parse(process.cwd()).root, 'work', 'app'); + getWorkspaceFoldersStub.returns([{ uri: Uri.file(app), name: 'app', index: 0 }]); + findFilesStub.resolves([]); + const shown = createDeferred(); + showErrorMessageStub.callsFake(() => shown.resolve()); + + await new AutoFindProjects(projectManager.object, envManagers.object).create(); + + await Promise.race([shown.promise, timeout(100)]); + sinon.assert.calledOnceWithExactly(showErrorMessageStub, 'No projects found'); }); }); }); From 846f2c2b49d8b4b6ccfeee8c74addbe84fa01ef4 Mon Sep 17 00:00:00 2001 From: JF Gagne Date: Mon, 9 Nov 2026 14:13:38 -0500 Subject: [PATCH 8/9] Skip a home folder workspace in the inline script file search The Set Up Environments for Inline Script Files command ran findFiles('**/*.{py,Py,pY,PY}') over the whole workspace. In a workspace opened on the home folder that walk covers the whole profile, and its first 1,000 matches can all come from Python installs under AppData, whose headers the command then reads. The search now goes through findFilesInWorkspace with the same pattern, exclude and cap of 1,000 (MAX_INLINE_SCRIPT_FILES), so it skips a workspace folder that is the home folder or contains it and still searches every other folder. When nothing is found and a folder was skipped, the message names the folder and says to open the folder that holds the scripts. The new test failed before the change (findFiles ran on the home folder) and passes after it. The test that checks the pattern now reads the first three arguments, since the helper also passes the token slot. Co-Authored-By: Claude Opus 5.5 --- src/features/inlineScript/setupEnvironment.ts | 34 ++++++++++++++----- .../setupEnvironment.unit.test.ts | 27 ++++++++++++--- 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/src/features/inlineScript/setupEnvironment.ts b/src/features/inlineScript/setupEnvironment.ts index aff36cd36..480189da7 100644 --- a/src/features/inlineScript/setupEnvironment.ts +++ b/src/features/inlineScript/setupEnvironment.ts @@ -22,7 +22,8 @@ import { showQuickPickWithButtons, showWarningMessage, } from '../../common/window.apis'; -import { asRelativePath, findFiles, getOpenTextDocuments } from '../../common/workspace.apis'; +import { asRelativePath, getOpenTextDocuments } from '../../common/workspace.apis'; +import { findFilesInWorkspace, getHomeWorkspaceFolders } from '../../common/workspaceSearch'; import type { EnvironmentManagers } from '../envManagers'; import { registerInlineScriptCodeLens } from './codeLens'; import { promptUpdateExtensionsForInlineScripts } from './extensionVersionCheck'; @@ -277,6 +278,22 @@ interface InlineScriptQuickPickItem extends QuickPickItem { readonly configured: boolean; } +/** + * Returns the message for a workspace search that found no Python file. When the search skipped a + * workspace folder because it is the home folder, the message names the folder and says to open + * the folder that holds the scripts, because no script there was looked for. + */ +function noPythonFilesMessage(): string { + const skipped = getHomeWorkspaceFolders().map((folder) => folder.uri.fsPath); + if (skipped.length === 0) { + return l10n.t('No Python files were found in the workspace.'); + } + return l10n.t( + 'No Python files were found in the workspace. {0} is not searched, because it is your home folder or contains it. Open the folder that holds your scripts instead.', + skipped.join(', '), + ); +} + export async function setUpInlineScriptEnvironmentsInWorkspace( em: EnvironmentManagers, routing: InlineScriptRoutingRegistry, @@ -285,13 +302,14 @@ export async function setUpInlineScriptEnvironmentsInWorkspace( showErrorMessage(l10n.t('The inline script environment manager is not available yet. Try again shortly.')); return; } - const files = await findFiles( - '**/*.{py,Py,pY,PY}', - '{**/.venv/**,**/node_modules/**}', - MAX_INLINE_SCRIPT_FILES, - ); - if (!files || files.length === 0) { - showInformationMessage(l10n.t('No Python files were found in the workspace.')); + const files = await findFilesInWorkspace({ + label: 'Inline script setup', + include: '**/*.{py,Py,pY,PY}', + exclude: '{**/.venv/**,**/node_modules/**}', + maxResults: MAX_INLINE_SCRIPT_FILES, + }); + if (files.length === 0) { + showInformationMessage(noPythonFilesMessage()); return; } const candidates = await filterInlineScriptFiles(files); diff --git a/src/test/features/inlineScript/setupEnvironment.unit.test.ts b/src/test/features/inlineScript/setupEnvironment.unit.test.ts index d08be4bf5..4f5480a19 100644 --- a/src/test/features/inlineScript/setupEnvironment.unit.test.ts +++ b/src/test/features/inlineScript/setupEnvironment.unit.test.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import assert from 'assert'; +import * as path from 'path'; import * as sinon from 'sinon'; import * as typemoq from 'typemoq'; import { Disposable, Uri } from 'vscode'; @@ -15,6 +16,7 @@ import { } from '../../../common/inlineScript/routingRegistry'; import { InlineScriptStrings, PythonInstallStrings } from '../../../common/localize'; import { createDeferred } from '../../../common/utils/deferred'; +import * as pathUtils from '../../../common/utils/pathUtils'; import * as winapi from '../../../common/window.apis'; import * as wapi from '../../../common/workspace.apis'; import { @@ -184,6 +186,7 @@ suite('setUpInlineScriptEnvironmentsInWorkspace', () => { let manager: typemoq.IMock; let routing: InlineScriptRoutingRegistry; let findFilesStub: sinon.SinonStub; + let getWorkspaceFoldersStub: sinon.SinonStub; let readMetadataStub: sinon.SinonStub; let quickPickStub: sinon.SinonStub; let infoStub: sinon.SinonStub; @@ -195,6 +198,7 @@ suite('setUpInlineScriptEnvironmentsInWorkspace', () => { em.setup((m) => m.getEnvironmentManager(INLINE_SCRIPT_MANAGER_ID)).returns(() => manager.object); findFilesStub = sinon.stub(wapi, 'findFiles'); + getWorkspaceFoldersStub = sinon.stub(wapi, 'getWorkspaceFolders').returns(undefined); sinon.stub(wapi, 'asRelativePath').callsFake((p) => (p instanceof Uri ? p.fsPath : String(p))); sinon.stub(wapi, 'getOpenTextDocuments').returns([]); readMetadataStub = sinon.stub(metadataApi, 'readInlineScriptMetadataFromFile'); @@ -222,11 +226,26 @@ suite('setUpInlineScriptEnvironmentsInWorkspace', () => { await setUpInlineScriptEnvironmentsInWorkspace(em.object, routing); + sinon.assert.calledOnce(findFilesStub); + const [include, exclude, maxResults] = findFilesStub.firstCall.args; + assert.strictEqual(include, '**/*.{py,Py,pY,PY}'); + assert.strictEqual(exclude, '{**/.venv/**,**/node_modules/**}'); + assert.strictEqual(maxResults, 1000); + }); + + test('does not search a workspace folder that is the home folder and tells the user why', async () => { + const home = path.join(path.parse(process.cwd()).root, 'home', 'tester'); + sinon.stub(pathUtils, 'getUserHomeDir').returns(home); + getWorkspaceFoldersStub.returns([{ uri: Uri.file(home), name: 'tester', index: 0 }]); + findFilesStub.resolves([]); + + await setUpInlineScriptEnvironmentsInWorkspace(em.object, routing); + + sinon.assert.notCalled(findFilesStub); sinon.assert.calledOnceWithExactly( - findFilesStub, - '**/*.{py,Py,pY,PY}', - '{**/.venv/**,**/node_modules/**}', - 1000, + infoStub, + `No Python files were found in the workspace. ${Uri.file(home).fsPath} is not searched, because it ` + + 'is your home folder or contains it. Open the folder that holds your scripts instead.', ); }); From 869763e29e0e86d42ef5c29e178c07c5e482da9f Mon Sep 17 00:00:00 2001 From: JF Gagne Date: Mon, 9 Nov 2026 14:19:27 -0500 Subject: [PATCH 9/9] Format the files this branch touches with Prettier Runs Prettier with the repository's .prettierrc.js on the ten files the branch changes, so npx prettier --check passes on each. The branch itself added one deviation, the vscode import line in pipUtils.unit.test.ts, which now wraps. The other changes were already there before this branch: they are what format on save in the workspace settings would produce. This commit changes formatting only. Co-Authored-By: Claude Opus 5.5 --- src/common/localize.ts | 17 +- src/features/creators/autoFindProjects.ts | 7 +- src/features/inlineScript/setupEnvironment.ts | 5 +- src/managers/builtin/pipUtils.ts | 40 +--- .../creators/autoFindProjects.unit.test.ts | 33 ++- .../setupEnvironment.unit.test.ts | 6 +- .../managers/builtin/pipUtils.unit.test.ts | 209 +++++++++--------- 7 files changed, 163 insertions(+), 154 deletions(-) diff --git a/src/common/localize.ts b/src/common/localize.ts index 1cf5c2d84..316d2f678 100644 --- a/src/common/localize.ts +++ b/src/common/localize.ts @@ -55,9 +55,7 @@ export namespace InlineScriptStrings { export function environmentReady(version: string | undefined): string { const shown = version?.trim(); - return shown - ? l10n.t('Script environment ready (Python {0})', shown) - : l10n.t('Script environment ready'); + return shown ? l10n.t('Script environment ready (Python {0})', shown) : l10n.t('Script environment ready'); } export const unterminatedBlock = l10n.t( @@ -73,10 +71,7 @@ export namespace InlineScriptStrings { "Lines inside a '# /// script' block must be exactly '#' or start with '# '. This line is blank; use '#' for a blank metadata line.", ); } - return l10n.t( - "Lines inside a '# /// script' block must be exactly '#' or start with '# '. Found: {0}", - found, - ); + return l10n.t("Lines inside a '# /// script' block must be exactly '#' or start with '# '. Found: {0}", found); } export function invalidBlockMarker(marker: string): string { return l10n.t( @@ -326,14 +321,16 @@ export namespace PythonInstallStrings { export const fetchingVersions = l10n.t('Finding available Python versions...'); export const selectVersion = l10n.t('Select Python version to install'); export const installed = l10n.t('installed'); - export const installFailed = l10n.t('Python installation did not complete. See the Python Environments output for details.'); + export const installFailed = l10n.t( + 'Python installation did not complete. See the Python Environments output for details.', + ); export const managerUnusable = l10n.t( 'Python Install Manager is unavailable or could not be verified. Check its installation and configuration, then try again. uv was not started.', ); export const catalogueFailed = l10n.t( 'Could not find Python versions using Python Install Manager. Check its configured source or network access and try again. uv was not started.', ); - export const noCompatiblePython = l10n.t('No compatible Python was found in the selected installer\'s catalogue.'); + export const noCompatiblePython = l10n.t("No compatible Python was found in the selected installer's catalogue."); export const verificationFailed = l10n.t( 'A usable Python interpreter matching the request could not be verified. See the Python Environments output.', ); @@ -349,7 +346,7 @@ export namespace PythonInstallStrings { export function installPrompt(version: string, requirement?: string): string { return requirement ? l10n.t( - 'No installed Python satisfies this script\'s requirement ({0}). Install Python {1} using Python Install Manager? An older Python Install Manager runtime in the same version family may be updated, affecting environments that use it. Newer runtimes will not be downgraded.', + "No installed Python satisfies this script's requirement ({0}). Install Python {1} using Python Install Manager? An older Python Install Manager runtime in the same version family may be updated, affecting environments that use it. Newer runtimes will not be downgraded.", requirement, version, ) diff --git a/src/features/creators/autoFindProjects.ts b/src/features/creators/autoFindProjects.ts index 04178c1dd..911aeccb6 100644 --- a/src/features/creators/autoFindProjects.ts +++ b/src/features/creators/autoFindProjects.ts @@ -7,10 +7,7 @@ import { showErrorMessage, showQuickPickWithButtons, showWarningMessage } from ' import { getWorkspaceFolders } from '../../common/workspace.apis'; import { findFilesInWorkspace, getHomeWorkspaceFolders } from '../../common/workspaceSearch'; import type { EnvironmentManagers } from '../envManagers'; -import { - PythonProjectManager, - PythonProjectsImpl, -} from '../projectManager'; +import { PythonProjectManager, PythonProjectsImpl } from '../projectManager'; import { isSameOrParentPath, normalizePath } from '../../common/utils/pathUtils'; /** @@ -117,7 +114,7 @@ export class AutoFindProjects implements PythonProjectCreator { maxResults: MAX_PROJECT_MARKERS, }); // Exclude markers inside selected environments (e.g. installed packages in a custom-named venv). - const folders = found && found.length > 0 ? getWorkspaceFolders() ?? [] : []; + const folders = found && found.length > 0 ? (getWorkspaceFolders() ?? []) : []; const prefixes = await this.getSelectedEnvironmentPrefixes(folders); const files = found?.filter( (uri) => diff --git a/src/features/inlineScript/setupEnvironment.ts b/src/features/inlineScript/setupEnvironment.ts index 480189da7..016c22f00 100644 --- a/src/features/inlineScript/setupEnvironment.ts +++ b/src/features/inlineScript/setupEnvironment.ts @@ -439,10 +439,7 @@ export function registerInlineScriptUx(em: EnvironmentManagers, routing: InlineS disposables.push(codeLens.disposable); disposables.push(registerInlineScriptSetupCodeAction(routing, SETUP_INLINE_SCRIPT_ENV_COMMAND)); disposables.push( - registerCommand( - SETUP_INLINE_SCRIPT_ENV_COMMAND, - setupInlineScriptEnvironmentHandler(em, routing), - ), + registerCommand(SETUP_INLINE_SCRIPT_ENV_COMMAND, setupInlineScriptEnvironmentHandler(em, routing)), ); disposables.push( registerCommand(SETUP_INLINE_SCRIPT_ENVS_COMMAND, () => diff --git a/src/managers/builtin/pipUtils.ts b/src/managers/builtin/pipUtils.ts index e365eec35..11c4f9e10 100644 --- a/src/managers/builtin/pipUtils.ts +++ b/src/managers/builtin/pipUtils.ts @@ -64,9 +64,7 @@ export function validatePyprojectToml(toml: PyprojectToml): string | undefined { return l10n.t('Missing required field "requires" in [build-system] section of pyproject.toml.'); } if (!isStringArray(buildSystemValue.requires)) { - return l10n.t( - 'The "requires" field in [build-system] must be an array of strings in pyproject.toml.', - ); + return l10n.t('The "requires" field in [build-system] must be an array of strings in pyproject.toml.'); } } @@ -311,13 +309,10 @@ async function selectWorkspaceOrCommon( return undefined; } - const shouldProceed = await shouldProceedAfterPyprojectValidation( - installableResult.validationErrors, - { - allPyprojectTomlsSelected: installable.length === 0, - selectedPyprojectTomls: selectedInstallables.selectedUris, - }, - ); + const shouldProceed = await shouldProceedAfterPyprojectValidation(installableResult.validationErrors, { + allPyprojectTomlsSelected: installable.length === 0, + selectedPyprojectTomls: selectedInstallables.selectedUris, + }); if (!shouldProceed) { return undefined; } @@ -458,8 +453,7 @@ export async function getProjectInstallable( throwIfSearchCancelled(); }; const cancellationBatchSize = 64; - const shouldYieldForSearchCancellation = (index: number) => - index > 0 && index % cancellationBatchSize === 0; + const shouldYieldForSearchCancellation = (index: number) => index > 0 && index % cancellationBatchSize === 0; // Only files under one of `projects` are kept below, so in a home folder workspace the // search can stay inside the project folders. @@ -532,9 +526,7 @@ export async function getProjectInstallable( continue; } const relative = path.relative(projectRoot, uri.fsPath); - const depth = relative - ? relative.split(path.sep).filter((segment) => segment.length > 0).length - : 0; + const depth = relative ? relative.split(path.sep).filter((segment) => segment.length > 0).length : 0; sortKeys.set(uri.fsPath, { depth, normalizedPath: normalizePath(uri.fsPath), @@ -635,8 +627,7 @@ export async function getProjectInstallable( await yieldForSearchCancellation(); const shouldSkipDisjointToml = (uri: Uri): boolean => - !!options?.deduplicateProjectPackages && - !isTomlRelevantToPreferredRoot(uri, options.preferredRoot); + !!options?.deduplicateProjectPackages && !isTomlRelevantToPreferredRoot(uri, options.preferredRoot); const selectedTomls = new Map(); const ambiguousProjectNames = new Set(); @@ -693,13 +684,10 @@ export async function getProjectInstallable( const relative = path.relative(projectDir, preferredRoot); const containsPreferredRoot = relative === '' || - (relative !== '..' && - !relative.startsWith(`..${path.sep}`) && - !path.isAbsolute(relative)); + (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)); if ( containsPreferredRoot && - (!preferredCandidate || - projectDir.length > path.dirname(preferredCandidate.fsPath).length) + (!preferredCandidate || projectDir.length > path.dirname(preferredCandidate.fsPath).length) ) { preferredCandidate = candidate; } @@ -811,9 +799,7 @@ function validationMatchesSelectedToml( selectedPyprojectTomls: readonly Uri[] | undefined, ): boolean { const validationPath = normalizePath(path.resolve(validationError.fileUri.fsPath)); - return (selectedPyprojectTomls ?? []).some( - (uri) => normalizePath(path.resolve(uri.fsPath)) === validationPath, - ); + return (selectedPyprojectTomls ?? []).some((uri) => normalizePath(path.resolve(uri.fsPath)) === validationPath); } /** @@ -826,9 +812,7 @@ export async function shouldProceedAfterPyprojectValidation( ): Promise { const validationError = options?.allPyprojectTomlsSelected ? validationErrors[0] - : validationErrors.find((error) => - validationMatchesSelectedToml(error, options?.selectedPyprojectTomls), - ); + : validationErrors.find((error) => validationMatchesSelectedToml(error, options?.selectedPyprojectTomls)); if (!validationError) { return true; } diff --git a/src/test/features/creators/autoFindProjects.unit.test.ts b/src/test/features/creators/autoFindProjects.unit.test.ts index 43c331374..fb25e2e58 100644 --- a/src/test/features/creators/autoFindProjects.unit.test.ts +++ b/src/test/features/creators/autoFindProjects.unit.test.ts @@ -289,7 +289,10 @@ suite('Auto Find Project tests', () => { return { uri: Uri.file(fsPath), name: path.basename(fsPath), index }; } - function setupEnvironments(prefixes: Map, managerId = VENV_MANAGER_ID): void { + function setupEnvironments( + prefixes: Map, + managerId = VENV_MANAGER_ID, + ): void { getWorkspaceFoldersStub.returns(Array.from(prefixes.keys()).map((p, i) => folder(p, i))); envManagers .setup((em) => em.getEnvironment(typmoq.It.isAny())) @@ -356,7 +359,12 @@ suite('Auto Find Project tests', () => { }); test('Keeps workspace projects when a selected prefix contains or equals the workspace', async () => { - setupEnvironments(new Map([[root, broadPrefix], [root2, root2]])); + setupEnvironments( + new Map([ + [root, broadPrefix], + [root2, root2], + ]), + ); findFilesStub.resolves([ Uri.file(path.join(root, 'app', 'pyproject.toml')), Uri.file(path.join(root2, 'setup.py')), @@ -370,7 +378,12 @@ suite('Auto Find Project tests', () => { test('Keeps a nested workspace root selected as the parent workspace environment prefix', async () => { const nested = path.join(root, 'nested'); const nestedEnv = path.join(nested, 'custom-env'); - setupEnvironments(new Map([[root, nested], [nested, nestedEnv]])); + setupEnvironments( + new Map([ + [root, nested], + [nested, nestedEnv], + ]), + ); findFilesStub.resolves([ Uri.file(path.join(root, 'app', 'setup.py')), Uri.file(path.join(nested, 'pyproject.toml')), @@ -385,7 +398,12 @@ suite('Auto Find Project tests', () => { test('Still excludes packages outside a nested workspace under the selected prefix', async () => { const envPrefix = path.join(root, 'custom-env'); const nested = path.join(envPrefix, 'docs'); - setupEnvironments(new Map([[root, envPrefix], [nested, undefined]])); + setupEnvironments( + new Map([ + [root, envPrefix], + [nested, undefined], + ]), + ); findFilesStub.resolves([ Uri.file(path.join(nested, 'pyproject.toml')), Uri.file(path.join(envPrefix, 'lib', 'site-packages', 'pkg', 'setup.py')), @@ -422,7 +440,12 @@ suite('Auto Find Project tests', () => { test('Excludes a selected environment inside a different workspace folder', async () => { const envPrefix = path.join(root2, 'shared-env'); - setupEnvironments(new Map([[root, envPrefix], [root2, undefined]])); + setupEnvironments( + new Map([ + [root, envPrefix], + [root2, undefined], + ]), + ); findFilesStub.resolves([ Uri.file(path.join(root, 'app', 'pyproject.toml')), Uri.file(path.join(root2, 'other', 'pyproject.toml')), diff --git a/src/test/features/inlineScript/setupEnvironment.unit.test.ts b/src/test/features/inlineScript/setupEnvironment.unit.test.ts index 4f5480a19..80a5d7ae2 100644 --- a/src/test/features/inlineScript/setupEnvironment.unit.test.ts +++ b/src/test/features/inlineScript/setupEnvironment.unit.test.ts @@ -458,7 +458,8 @@ suite('setupInlineScriptEnvironmentHandler', () => { return env; } - test('saves a dirty document before setup, because setup reads the block from disk', async () => { openDirtyDocument(); + test('saves a dirty document before setup, because setup reads the block from disk', async () => { + openDirtyDocument(); expectEnvironmentCreated(); await setupInlineScriptEnvironmentHandler(em.object, routing)(scriptUri); @@ -516,7 +517,8 @@ suite('setupInlineScriptEnvironmentHandler', () => { sinon.assert.calledOnce(errorStub); }); - test('does not report a failing companion-extension prompt as a setup failure', async () => { expectEnvironmentCreated(); + test('does not report a failing companion-extension prompt as a setup failure', async () => { + expectEnvironmentCreated(); promptStub.rejects(new Error('boom')); await setupInlineScriptEnvironmentHandler(em.object, routing)(scriptUri); diff --git a/src/test/managers/builtin/pipUtils.unit.test.ts b/src/test/managers/builtin/pipUtils.unit.test.ts index 492d16a20..89d815c1b 100644 --- a/src/test/managers/builtin/pipUtils.unit.test.ts +++ b/src/test/managers/builtin/pipUtils.unit.test.ts @@ -1,7 +1,18 @@ import assert from 'assert'; import * as path from 'path'; import * as sinon from 'sinon'; -import { CancellationError, CancellationToken, CancellationTokenSource, LogOutputChannel, Progress, ProgressLocation, ProgressOptions, RelativePattern, Uri, WorkspaceFolder } from 'vscode'; +import { + CancellationError, + CancellationToken, + CancellationTokenSource, + LogOutputChannel, + Progress, + ProgressLocation, + ProgressOptions, + RelativePattern, + Uri, + WorkspaceFolder, +} from 'vscode'; import * as fse from 'fs-extra'; import * as os from 'os'; import { PythonEnvironment, PythonEnvironmentApi, PythonProject } from '../../../api'; @@ -324,9 +335,7 @@ suite('Pip Utils - getProjectInstallable', () => { const workspacePath = Uri.file('/test/path/root').fsPath; findFilesStub.callsFake((pattern: string) => Promise.resolve( - pattern === '*requirements*.txt' - ? [Uri.file(path.join(workspacePath, 'requirements.txt'))] - : [], + pattern === '*requirements*.txt' ? [Uri.file(path.join(workspacePath, 'requirements.txt'))] : [], ), ); @@ -365,9 +374,11 @@ suite('Pip Utils - getProjectInstallable', () => { return Promise.resolve([]); }); const picker = sinon.stub(winapi, 'showQuickPickWithButtons'); - picker.onFirstCall().callsFake(async (items) => - items.find((item) => item.label === PackageManagement.workspaceDependencies), - ); + picker + .onFirstCall() + .callsFake(async (items) => + items.find((item) => item.label === PackageManagement.workspaceDependencies), + ); picker.onSecondCall().callsFake(async (items) => { const requirement = items.find((item) => (item as { args?: string[] }).args?.[0] === '-r'); assert.ok(requirement); @@ -395,13 +406,13 @@ suite('Pip Utils - getProjectInstallable', () => { await fse.writeFile(invalidToml, '[project]\nname = "unterminated\n'); mockApi.getPythonProject = () => ({ name: 'workspace', uri: Uri.file(tempRoot) }); findFilesStub.callsFake((pattern: string) => - pattern === '**/pyproject.toml' - ? Promise.resolve([Uri.file(invalidToml)]) - : Promise.resolve([]), - ); - sinon.stub(winapi, 'showQuickPickWithButtons').callsFake(async (items) => - items.find((item) => item.label === PackageManagement.workspaceDependencies), + pattern === '**/pyproject.toml' ? Promise.resolve([Uri.file(invalidToml)]) : Promise.resolve([]), ); + sinon + .stub(winapi, 'showQuickPickWithButtons') + .callsFake(async (items) => + items.find((item) => item.label === PackageManagement.workspaceDependencies), + ); const showError = sinon.stub(winapi, 'showErrorMessage'); showError.callsFake(async (...args: unknown[]) => args[3] as never); @@ -426,9 +437,7 @@ suite('Pip Utils - getProjectInstallable', () => { const workspacePath = Uri.file('/test/path/root').fsPath; findFilesStub.callsFake((pattern: string) => Promise.resolve( - pattern === '*requirements*.txt' - ? [Uri.file(path.join(workspacePath, 'requirements.txt'))] - : [], + pattern === '*requirements*.txt' ? [Uri.file(path.join(workspacePath, 'requirements.txt'))] : [], ), ); sinon.stub(helpers, 'shouldUseUv').resolves(false); @@ -585,17 +594,29 @@ suite('Pip Utils - getProjectInstallable', () => { const siblingRequirement = Uri.file(path.join(temp, 'sibling', 'requirements.txt')); const siblingToml = Uri.file(path.join(temp, 'sibling', 'pyproject.toml')); const invalidSiblingSyntax = Uri.file(path.join(temp, 'sibling-two', 'pyproject.toml')); - await fse.outputFile(siblingToml.fsPath, '[project]\nname = "sibling"\n[build-system]\nbuild-backend = "missing"\n'); + await fse.outputFile( + siblingToml.fsPath, + '[project]\nname = "sibling"\n[build-system]\nbuild-backend = "missing"\n', + ); await fse.outputFile(invalidSiblingSyntax.fsPath, '[invalid syntax'); mockApi.getPythonProject = () => project; - findFilesStub.resolves([rootRequirement, ownRequirement, siblingRequirement, siblingToml, invalidSiblingSyntax]); + findFilesStub.resolves([ + rootRequirement, + ownRequirement, + siblingRequirement, + siblingToml, + invalidSiblingSyntax, + ]); const result = await getProjectInstallable(mockApi as PythonEnvironmentApi, [project], { runHeadless: true, token: source.token, preferredRoot: nested, deduplicateProjectPackages: true, }); - assert.deepStrictEqual(result.installables.map((item) => item.uri?.fsPath), [ownRequirement.fsPath]); + assert.deepStrictEqual( + result.installables.map((item) => item.uri?.fsPath), + [ownRequirement.fsPath], + ); assert.strictEqual(result.validationErrors[0], undefined); assert.ok(withProgressStub.notCalled); } finally { @@ -671,15 +692,8 @@ suite('Pip Utils - getProjectInstallable', () => { test('preserves complete depth-then-path order across multiple merge rounds', async () => { const workspacePath = Uri.file('/test/path/root').fsPath; const dependencyPaths = Array.from({ length: 129 }, (_, index) => { - const directories = Array.from( - { length: index % 5 }, - (_value, level) => `group-${(index + level) % 11}`, - ); - return path.join( - workspacePath, - ...directories, - `requirements-${index.toString().padStart(3, '0')}.txt`, - ); + const directories = Array.from({ length: index % 5 }, (_value, level) => `group-${(index + level) % 11}`); + return path.join(workspacePath, ...directories, `requirements-${index.toString().padStart(3, '0')}.txt`); }); findFilesStub.callsFake((pattern: string) => Promise.resolve(pattern === '**/*requirements*.txt' ? [...dependencyPaths].reverse().map(Uri.file) : []), @@ -709,11 +723,7 @@ suite('Pip Utils - getProjectInstallable', () => { const workspacePath = Uri.file('/test/path/root').fsPath; const dependencyFiles = Array.from({ length: 129 }, (_, index) => Uri.file( - path.join( - workspacePath, - `group-${index % 7}`, - `requirements-${index.toString().padStart(3, '0')}.txt`, - ), + path.join(workspacePath, `group-${index % 7}`, `requirements-${index.toString().padStart(3, '0')}.txt`), ), ); findFilesStub.callsFake((pattern: string) => @@ -851,10 +861,9 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () pattern === '**/pyproject.toml' ? Promise.resolve([Uri.file(missingToml)]) : Promise.resolve([]), ); - const result = await getProjectInstallable( - mockApi as PythonEnvironmentApi, - [{ name: 'myapp', uri: Uri.file(projectRoot) }], - ); + const result = await getProjectInstallable(mockApi as PythonEnvironmentApi, [ + { name: 'myapp', uri: Uri.file(projectRoot) }, + ]); assert.strictEqual(result.installables.length, 0); assert.strictEqual(result.validationErrors[0]?.fileUri.fsPath, Uri.file(missingToml).fsPath); @@ -879,10 +888,9 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () pattern === '**/pyproject.toml' ? Promise.resolve([Uri.file(invalidToml)]) : Promise.resolve([]), ); - const result = await getProjectInstallable( - mockApi as PythonEnvironmentApi, - [{ name: 'myapp', uri: Uri.file(projectRoot) }], - ); + const result = await getProjectInstallable(mockApi as PythonEnvironmentApi, [ + { name: 'myapp', uri: Uri.file(projectRoot) }, + ]); assert.deepStrictEqual(result.installables[0]?.args, ['-e', projectRoot]); assert.match(result.validationErrors[0]?.message ?? '', /dependencies.*array of strings/i); @@ -906,10 +914,9 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () pattern === '**/pyproject.toml' ? Promise.resolve([Uri.file(invalidToml)]) : Promise.resolve([]), ); - const result = await getProjectInstallable( - mockApi as PythonEnvironmentApi, - [{ name: 'myapp', uri: Uri.file(projectRoot) }], - ); + const result = await getProjectInstallable(mockApi as PythonEnvironmentApi, [ + { name: 'myapp', uri: Uri.file(projectRoot) }, + ]); assert.deepStrictEqual(result.installables[0]?.args, ['-e', projectRoot]); assert.match(result.validationErrors[0]?.message ?? '', /optional-dependencies.*table/i); @@ -918,23 +925,17 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () test('reports invalid optional dependency group names before installation', async () => { const invalidToml = await writeTomlContent( '', - [ - '[project]', - 'name = "myapp"', - '', - '[project.optional-dependencies]', - '"bad extra" = ["pytest"]', - '', - ].join('\n'), + ['[project]', 'name = "myapp"', '', '[project.optional-dependencies]', '"bad extra" = ["pytest"]', ''].join( + '\n', + ), ); findFilesStub.callsFake((pattern: string) => pattern === '**/pyproject.toml' ? Promise.resolve([Uri.file(invalidToml)]) : Promise.resolve([]), ); - const result = await getProjectInstallable( - mockApi as PythonEnvironmentApi, - [{ name: 'myapp', uri: Uri.file(projectRoot) }], - ); + const result = await getProjectInstallable(mockApi as PythonEnvironmentApi, [ + { name: 'myapp', uri: Uri.file(projectRoot) }, + ]); assert.deepStrictEqual(result.installables[0]?.args, ['-e', `${projectRoot}[bad extra]`]); assert.match(result.validationErrors[0]?.message ?? '', /invalid optional dependency group name/i); @@ -946,10 +947,9 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () pattern === '**/pyproject.toml' ? Promise.resolve([Uri.file(validToml)]) : Promise.resolve([]), ); - const result = await getProjectInstallable( - mockApi as PythonEnvironmentApi, - [{ name: 'myapp', uri: Uri.file(projectRoot) }], - ); + const result = await getProjectInstallable(mockApi as PythonEnvironmentApi, [ + { name: 'myapp', uri: Uri.file(projectRoot) }, + ]); assert.deepStrictEqual(result.installables[0]?.args, ['-e', projectRoot]); assert.deepStrictEqual(result.validationErrors, []); @@ -970,7 +970,10 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () automaticOptions(path.join(projectRoot, 'main')), ); - assert.deepStrictEqual(result.installables.map((item) => item.args?.[1]), [path.join(projectRoot, 'main')]); + assert.deepStrictEqual( + result.installables.map((item) => item.args?.[1]), + [path.join(projectRoot, 'main')], + ); assert.deepStrictEqual(result.validationErrors, []); }); @@ -1011,7 +1014,10 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () automaticOptions(path.join(projectRoot, 'main')), ); - assert.deepStrictEqual(result.installables.map((item) => item.args?.[1]), [path.join(projectRoot, 'main')]); + assert.deepStrictEqual( + result.installables.map((item) => item.args?.[1]), + [path.join(projectRoot, 'main')], + ); assert.deepStrictEqual(result.validationErrors, []); }); @@ -1062,7 +1068,10 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () automaticOptions(path.join(projectRoot, 'main')), ); - assert.deepStrictEqual(result.installables.map((item) => item.args?.[1]), [path.join(projectRoot, 'main')]); + assert.deepStrictEqual( + result.installables.map((item) => item.args?.[1]), + [path.join(projectRoot, 'main')], + ); assert.deepStrictEqual(result.validationErrors, []); }); @@ -1081,9 +1090,10 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () automaticOptions(path.join(projectRoot, 'main')), ); - assert.deepStrictEqual(result.installables.map((item) => item.args?.[1]), [ - path.join(projectRoot, 'main', 'pkg'), - ]); + assert.deepStrictEqual( + result.installables.map((item) => item.args?.[1]), + [path.join(projectRoot, 'main', 'pkg')], + ); assert.deepStrictEqual(result.validationErrors, []); }); @@ -1127,9 +1137,10 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () automaticOptions(path.join(projectRoot, 'main', 'pkg')), ); - assert.deepStrictEqual(result.installables.map((item) => item.args?.[1]), [ - path.join(projectRoot, 'main', 'pkg'), - ]); + assert.deepStrictEqual( + result.installables.map((item) => item.args?.[1]), + [path.join(projectRoot, 'main', 'pkg')], + ); assert.deepStrictEqual(result.validationErrors, []); }); @@ -1161,9 +1172,10 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () automaticOptions(path.join(projectRoot, 'main', 'pkg')), ); - assert.deepStrictEqual(result.installables.map((item) => item.args?.[1]), [ - path.join(projectRoot, 'main', 'pkg'), - ]); + assert.deepStrictEqual( + result.installables.map((item) => item.args?.[1]), + [path.join(projectRoot, 'main', 'pkg')], + ); assert.deepStrictEqual(result.validationErrors, []); }); @@ -1194,9 +1206,10 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () automaticOptions(path.join(projectRoot, 'main')), ); - assert.deepStrictEqual(result.installables.map((item) => item.args?.[1]), [ - path.join(projectRoot, 'main', 'pkg'), - ]); + assert.deepStrictEqual( + result.installables.map((item) => item.args?.[1]), + [path.join(projectRoot, 'main', 'pkg')], + ); assert.strictEqual(result.validationErrors[0]?.fileUri.fsPath, owningToml); }); @@ -1227,9 +1240,10 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () automaticOptions(path.join(projectRoot, 'main')), ); - assert.deepStrictEqual(result.installables.map((item) => item.args?.[1]), [ - path.join(projectRoot, 'main', 'pkg'), - ]); + assert.deepStrictEqual( + result.installables.map((item) => item.args?.[1]), + [path.join(projectRoot, 'main', 'pkg')], + ); assert.deepStrictEqual(result.validationErrors, []); }); @@ -1247,9 +1261,8 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () }); const projects = [{ name: 'myapp', uri: Uri.file(projectRoot) }]; - const result = ( - await getProjectInstallable(mockApi as PythonEnvironmentApi, projects, automaticOptions()) - ).installables; + const result = (await getProjectInstallable(mockApi as PythonEnvironmentApi, projects, automaticOptions())) + .installables; const editable = result.filter((r) => r.args?.[0] === '-e'); assert.strictEqual(editable.length, 0, 'Quick Create should not choose an arbitrary worktree'); @@ -1267,16 +1280,11 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () }); const projects = [{ name: 'myapp', uri: Uri.file(projectRoot) }]; - const result = ( - await getProjectInstallable(mockApi as PythonEnvironmentApi, projects, automaticOptions()) - ).installables; + const result = (await getProjectInstallable(mockApi as PythonEnvironmentApi, projects, automaticOptions())) + .installables; const editable = result.filter((r) => r.args?.[0] === '-e'); - assert.strictEqual( - editable.length, - 0, - '"My_App" and "my-app" normalize to the same ambiguous package name', - ); + assert.strictEqual(editable.length, 0, '"My_App" and "my-app" normalize to the same ambiguous package name'); }); test('uses the candidate containing the creation root', async () => { @@ -1303,7 +1311,11 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () const editable = result.installables.filter((item) => item.args?.[0] === '-e'); assert.strictEqual(editable.length, 1); assert.strictEqual(editable[0].args?.[1], path.join(projectRoot, 'z-valid')); - assert.strictEqual(result.validationErrors[0], undefined, 'ignored duplicates should not surface validation errors'); + assert.strictEqual( + result.validationErrors[0], + undefined, + 'ignored duplicates should not surface validation errors', + ); }); test('reports validation from the preferred candidate instead of substituting another worktree', async () => { @@ -1395,9 +1407,8 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () }); const projects = [{ name: 'myapp', uri: Uri.file(projectRoot) }]; - const result = ( - await getProjectInstallable(mockApi as PythonEnvironmentApi, projects, automaticOptions()) - ).installables; + const result = (await getProjectInstallable(mockApi as PythonEnvironmentApi, projects, automaticOptions())) + .installables; const editable = result.filter((item) => item.args?.[0] === '-e'); assert.strictEqual(editable.length, 1); @@ -1441,9 +1452,8 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () }); const projects = [{ name: 'myapp', uri: Uri.file(projectRoot) }]; - const result = ( - await getProjectInstallable(mockApi as PythonEnvironmentApi, projects, automaticOptions()) - ).installables; + const result = (await getProjectInstallable(mockApi as PythonEnvironmentApi, projects, automaticOptions())) + .installables; const editableDirs = result.filter((r) => r.args?.[0] === '-e').map((r) => r.args?.[1]); assert.strictEqual(editableDirs.length, 2, 'Distinct package names should both be kept'); @@ -1470,9 +1480,8 @@ suite('Pip Utils - getProjectInstallable duplicate pyproject.toml handling', () }); const projects = [{ name: 'myapp', uri: Uri.file(projectRoot) }]; - const result = ( - await getProjectInstallable(mockApi as PythonEnvironmentApi, projects, automaticOptions()) - ).installables; + const result = (await getProjectInstallable(mockApi as PythonEnvironmentApi, projects, automaticOptions())) + .installables; const reqs = result.filter((r) => r.args?.[0] === '-r'); assert.strictEqual(reqs.length, 2, 'Both requirements.txt files should be preserved');