diff --git a/src/client/common/utils/localize.ts b/src/client/common/utils/localize.ts index 7ec517f82277..05b525bdf5bf 100644 --- a/src/client/common/utils/localize.ts +++ b/src/client/common/utils/localize.ts @@ -475,6 +475,7 @@ export namespace CreateEnv { ); export const deletingEnvironmentProgress = l10n.t('Deleting existing ".venv" environment...'); export const errorDeletingEnvironment = l10n.t('Error while deleting existing ".venv" environment.'); + export const openRequirementsFile = l10n.t('Open requirements file'); } export namespace Conda { diff --git a/src/client/common/vscodeApis/windowApis.ts b/src/client/common/vscodeApis/windowApis.ts index 1c242314cb87..c761ff60fa65 100644 --- a/src/client/common/vscodeApis/windowApis.ts +++ b/src/client/common/vscodeApis/windowApis.ts @@ -16,9 +16,15 @@ import { TextEditor, window, Disposable, + QuickPickItemButtonEvent, + Uri, } from 'vscode'; import { createDeferred, Deferred } from '../utils/async'; +export function showTextDocument(uri: Uri): Thenable { + return window.showTextDocument(uri); +} + export function showQuickPick( items: readonly T[] | Thenable, options?: QuickPickOptions, @@ -91,6 +97,7 @@ export async function showQuickPickWithBack( items: readonly T[], options?: QuickPickOptions, token?: CancellationToken, + itemButtonHandler?: (e: QuickPickItemButtonEvent) => void, ): Promise { const quickPick: QuickPick = window.createQuickPick(); const disposables: Disposable[] = [quickPick]; @@ -130,6 +137,11 @@ export async function showQuickPickWithBack( deferred.resolve(undefined); } }), + quickPick.onDidTriggerItemButton((e) => { + if (itemButtonHandler) { + itemButtonHandler(e); + } + }), ); if (token) { disposables.push( diff --git a/src/client/pythonEnvironments/creation/provider/venvUtils.ts b/src/client/pythonEnvironments/creation/provider/venvUtils.ts index d7a0be170f99..723337b2a7fa 100644 --- a/src/client/pythonEnvironments/creation/provider/venvUtils.ts +++ b/src/client/pythonEnvironments/creation/provider/venvUtils.ts @@ -5,12 +5,22 @@ import * as tomljs from '@iarna/toml'; import * as fs from 'fs-extra'; import { flatten, isArray } from 'lodash'; import * as path from 'path'; -import { CancellationToken, ProgressLocation, QuickPickItem, RelativePattern, WorkspaceFolder } from 'vscode'; +import { + CancellationToken, + ProgressLocation, + QuickPickItem, + QuickPickItemButtonEvent, + RelativePattern, + ThemeIcon, + Uri, + WorkspaceFolder, +} from 'vscode'; import { Common, CreateEnv } from '../../../common/utils/localize'; import { MultiStepAction, MultiStepNode, showQuickPickWithBack, + showTextDocument, withProgress, } from '../../../common/vscodeApis/windowApis'; import { findFiles } from '../../../common/vscodeApis/workspaceApis'; @@ -20,6 +30,10 @@ import { isWindows } from '../../../common/platform/platformService'; import { getVenvPath, hasVenv } from '../common/commonUtils'; import { deleteEnvironmentNonWindows, deleteEnvironmentWindows } from './venvDeleteUtils'; +export const OPEN_REQUIREMENTS_BUTTON = { + iconPath: new ThemeIcon('go-to-file'), + tooltip: CreateEnv.Venv.openRequirementsFile, +}; const exclude = '**/{.venv*,.git,.nox,.tox,.conda,site-packages,__pypackages__}/**'; async function getPipRequirementsFiles( workspaceFolder: WorkspaceFolder, @@ -78,8 +92,13 @@ async function pickTomlExtras(extras: string[], token?: CancellationToken): Prom return undefined; } -async function pickRequirementsFiles(files: string[], token?: CancellationToken): Promise { +async function pickRequirementsFiles( + files: string[], + root: string, + token?: CancellationToken, +): Promise { const items: QuickPickItem[] = files + .map((p) => path.relative(root, p)) .sort((a, b) => { const al: number = a.split(/[\\\/]/).length; const bl: number = b.split(/[\\\/]/).length; @@ -91,7 +110,10 @@ async function pickRequirementsFiles(files: string[], token?: CancellationToken) } return al - bl; }) - .map((e) => ({ label: e })); + .map((e) => ({ + label: e, + buttons: [OPEN_REQUIREMENTS_BUTTON], + })); const selection = await showQuickPickWithBack( items, @@ -101,6 +123,11 @@ async function pickRequirementsFiles(files: string[], token?: CancellationToken) canPickMany: true, }, token, + async (e: QuickPickItemButtonEvent) => { + if (e.item.label) { + await showTextDocument(Uri.file(path.join(root, e.item.label))); + } + }, ); if (selection && isArray(selection)) { @@ -195,14 +222,11 @@ export async function pickPackagesToInstall( tomlStep, async (context?: MultiStepAction) => { traceVerbose('Looking for pip requirements.'); - const requirementFiles = (await getPipRequirementsFiles(workspaceFolder, token))?.map((p) => - path.relative(workspaceFolder.uri.fsPath, p), - ); - + const requirementFiles = await getPipRequirementsFiles(workspaceFolder, token); if (requirementFiles && requirementFiles.length > 0) { traceVerbose('Found pip requirements.'); try { - const result = await pickRequirementsFiles(requirementFiles, token); + const result = await pickRequirementsFiles(requirementFiles, workspaceFolder.uri.fsPath, token); const installList = result?.map((p) => path.join(workspaceFolder.uri.fsPath, p)); if (installList) { installList.forEach((i) => { diff --git a/src/test/pythonEnvironments/creation/provider/venvUtils.unit.test.ts b/src/test/pythonEnvironments/creation/provider/venvUtils.unit.test.ts index ae4f43a0296c..1671026d5dd4 100644 --- a/src/test/pythonEnvironments/creation/provider/venvUtils.unit.test.ts +++ b/src/test/pythonEnvironments/creation/provider/venvUtils.unit.test.ts @@ -10,11 +10,13 @@ import * as windowApis from '../../../../client/common/vscodeApis/windowApis'; import * as workspaceApis from '../../../../client/common/vscodeApis/workspaceApis'; import { ExistingVenvAction, + OPEN_REQUIREMENTS_BUTTON, pickExistingVenvAction, pickPackagesToInstall, } from '../../../../client/pythonEnvironments/creation/provider/venvUtils'; import { EXTENSION_ROOT_DIR_FOR_TESTS } from '../../../constants'; import { CreateEnv } from '../../../../client/common/utils/localize'; +import { createDeferred } from '../../../../client/common/utils/async'; chaiUse(chaiAsPromised); @@ -23,6 +25,7 @@ suite('Venv Utils test', () => { let showQuickPickWithBackStub: sinon.SinonStub; let pathExistsStub: sinon.SinonStub; let readFileStub: sinon.SinonStub; + let showTextDocumentStub: sinon.SinonStub; const workspace1 = { uri: Uri.file(path.join(EXTENSION_ROOT_DIR_FOR_TESTS, 'src', 'testMultiRootWkspc', 'workspace1')), @@ -35,6 +38,7 @@ suite('Venv Utils test', () => { showQuickPickWithBackStub = sinon.stub(windowApis, 'showQuickPickWithBack'); pathExistsStub = sinon.stub(fs, 'pathExists'); readFileStub = sinon.stub(fs, 'readFile'); + showTextDocumentStub = sinon.stub(windowApis, 'showTextDocument'); }); teardown(() => { @@ -224,13 +228,18 @@ suite('Venv Utils test', () => { await assert.isRejected(pickPackagesToInstall(workspace1)); assert.isTrue( showQuickPickWithBackStub.calledWithExactly( - [{ label: 'requirements.txt' }, { label: 'dev-requirements.txt' }, { label: 'test-requirements.txt' }], + [ + { label: 'requirements.txt', buttons: [OPEN_REQUIREMENTS_BUTTON] }, + { label: 'dev-requirements.txt', buttons: [OPEN_REQUIREMENTS_BUTTON] }, + { label: 'test-requirements.txt', buttons: [OPEN_REQUIREMENTS_BUTTON] }, + ], { placeHolder: CreateEnv.Venv.requirementsQuickPickTitle, ignoreFocusOut: true, canPickMany: true, }, undefined, + sinon.match.func, ), ); assert.isTrue(readFileStub.calledOnce); @@ -257,13 +266,18 @@ suite('Venv Utils test', () => { const actual = await pickPackagesToInstall(workspace1); assert.isTrue( showQuickPickWithBackStub.calledWithExactly( - [{ label: 'requirements.txt' }, { label: 'dev-requirements.txt' }, { label: 'test-requirements.txt' }], + [ + { label: 'requirements.txt', buttons: [OPEN_REQUIREMENTS_BUTTON] }, + { label: 'dev-requirements.txt', buttons: [OPEN_REQUIREMENTS_BUTTON] }, + { label: 'test-requirements.txt', buttons: [OPEN_REQUIREMENTS_BUTTON] }, + ], { placeHolder: CreateEnv.Venv.requirementsQuickPickTitle, ignoreFocusOut: true, canPickMany: true, }, undefined, + sinon.match.func, ), ); assert.deepStrictEqual(actual, []); @@ -290,13 +304,18 @@ suite('Venv Utils test', () => { const actual = await pickPackagesToInstall(workspace1); assert.isTrue( showQuickPickWithBackStub.calledWithExactly( - [{ label: 'requirements.txt' }, { label: 'dev-requirements.txt' }, { label: 'test-requirements.txt' }], + [ + { label: 'requirements.txt', buttons: [OPEN_REQUIREMENTS_BUTTON] }, + { label: 'dev-requirements.txt', buttons: [OPEN_REQUIREMENTS_BUTTON] }, + { label: 'test-requirements.txt', buttons: [OPEN_REQUIREMENTS_BUTTON] }, + ], { placeHolder: CreateEnv.Venv.requirementsQuickPickTitle, ignoreFocusOut: true, canPickMany: true, }, undefined, + sinon.match.func, ), ); assert.deepStrictEqual(actual, [ @@ -328,13 +347,18 @@ suite('Venv Utils test', () => { const actual = await pickPackagesToInstall(workspace1); assert.isTrue( showQuickPickWithBackStub.calledWithExactly( - [{ label: 'requirements.txt' }, { label: 'dev-requirements.txt' }, { label: 'test-requirements.txt' }], + [ + { label: 'requirements.txt', buttons: [OPEN_REQUIREMENTS_BUTTON] }, + { label: 'dev-requirements.txt', buttons: [OPEN_REQUIREMENTS_BUTTON] }, + { label: 'test-requirements.txt', buttons: [OPEN_REQUIREMENTS_BUTTON] }, + ], { placeHolder: CreateEnv.Venv.requirementsQuickPickTitle, ignoreFocusOut: true, canPickMany: true, }, undefined, + sinon.match.func, ), ); assert.deepStrictEqual(actual, [ @@ -349,6 +373,45 @@ suite('Venv Utils test', () => { ]); assert.isTrue(readFileStub.notCalled); }); + + test('User clicks button to open requirements.txt', async () => { + let allow = true; + findFilesStub.callsFake(() => { + if (allow) { + allow = false; + return Promise.resolve([ + Uri.file(path.join(workspace1.uri.fsPath, 'requirements.txt')), + Uri.file(path.join(workspace1.uri.fsPath, 'dev-requirements.txt')), + Uri.file(path.join(workspace1.uri.fsPath, 'test-requirements.txt')), + ]); + } + return Promise.resolve([]); + }); + pathExistsStub.resolves(false); + + const deferred = createDeferred(); + showQuickPickWithBackStub.callsFake(async (_items, _options, _token, callback) => { + callback({ + button: OPEN_REQUIREMENTS_BUTTON, + item: { label: 'requirements.txt' }, + }); + await deferred.promise; + return [{ label: 'requirements.txt' }]; + }); + + let uri: Uri | undefined; + showTextDocumentStub.callsFake((arg: Uri) => { + uri = arg; + deferred.resolve(); + return Promise.resolve(); + }); + + await pickPackagesToInstall(workspace1); + assert.deepStrictEqual( + uri?.toString(), + Uri.file(path.join(workspace1.uri.fsPath, 'requirements.txt')).toString(), + ); + }); }); suite('Test pick existing venv action', () => {