diff --git a/src/extension/common/utils/localize.ts b/src/extension/common/utils/localize.ts index 5ed37ca7..c81182c1 100644 --- a/src/extension/common/utils/localize.ts +++ b/src/extension/common/utils/localize.ts @@ -100,9 +100,7 @@ export namespace DebugConfigStrings { }; export const djangoConfigPromp = { title: l10n.t('Debug Django'), - prompt: l10n.t( - "Enter the path to manage.py or select a file from the list ('${workspaceFolderToken}' points to the root of the current workspace folder)", - ), + prompt: l10n.t('Enter the path to manage.py or select a file from the list.'), }; } export namespace fastapi { @@ -132,6 +130,10 @@ export namespace DebugConfigStrings { prompt: l10n.t('Python Debugger: Flask'), invalid: l10n.t('Enter a valid name'), }; + export const flaskConfigPromp = { + title: l10n.t('Debug Flask'), + prompt: l10n.t('Enter the path to app.py or select a file from the list.'), + }; } export namespace pyramid { export const snippet = { diff --git a/src/extension/debugger/configuration/providers/flaskLaunch.ts b/src/extension/debugger/configuration/providers/flaskLaunch.ts index bd64bdc0..a83359fc 100644 --- a/src/extension/debugger/configuration/providers/flaskLaunch.ts +++ b/src/extension/debugger/configuration/providers/flaskLaunch.ts @@ -5,29 +5,31 @@ 'use strict'; import * as path from 'path'; -import * as fs from 'fs-extra'; -import { WorkspaceFolder } from 'vscode'; +import { Uri } from 'vscode'; import { DebugConfigStrings } from '../../../common/utils/localize'; import { MultiStepInput } from '../../../common/multiStepInput'; -import { sendTelemetryEvent } from '../../../telemetry'; -import { EventName } from '../../../telemetry/constants'; import { DebuggerTypeName } from '../../../constants'; import { LaunchRequestArguments } from '../../../types'; -import { DebugConfigurationState, DebugConfigurationType } from '../../types'; +import { DebugConfigurationState } from '../../types'; +import { getFlaskPaths } from '../utils/configuration'; +import { QuickPickType } from './providerQuickPick/types'; +import { goToFileButton } from './providerQuickPick/providerQuickPick'; +import { parseFlaskPath, pickFlaskPrompt } from './providerQuickPick/flaskProviderQuickPick'; export async function buildFlaskLaunchDebugConfiguration( input: MultiStepInput, state: DebugConfigurationState, ): Promise { - const application = await getApplicationPath(state.folder); - let manuallyEnteredAValue: boolean | undefined; + let flaskPaths = await getFlaskPaths(state.folder); + let options: QuickPickType[] = []; + const config: Partial = { name: DebugConfigStrings.flask.snippet.name, type: DebuggerTypeName, request: 'launch', module: 'flask', env: { - FLASK_APP: application || 'app.py', + FLASK_APP: 'app.py', FLASK_DEBUG: '1', }, args: ['run', '--no-debugger', '--no-reload'], @@ -35,40 +37,23 @@ export async function buildFlaskLaunchDebugConfiguration( autoStartBrowser: false, }; - if (!application) { - const selectedApp = await input.showInputBox({ - title: DebugConfigStrings.flask.enterAppPathOrNamePath.title, - value: 'app.py', - prompt: DebugConfigStrings.flask.enterAppPathOrNamePath.prompt, - validate: (value) => - Promise.resolve( - value && value.trim().length > 0 - ? undefined - : DebugConfigStrings.flask.enterAppPathOrNamePath.invalid, - ), + //add found paths to options + if (flaskPaths.length > 0) { + options.push( + ...flaskPaths.map((item) => ({ + label: path.basename(item.fsPath), + filePath: item, + description: parseFlaskPath(state.folder, item.fsPath), + buttons: [goToFileButton], + })), + ); + } else { + const managePath = path.join(state?.folder?.uri.fsPath || '', 'app.py'); + options.push({ + label: 'Default', + description: parseFlaskPath(state.folder, managePath), + filePath: Uri.file(managePath), }); - if (selectedApp) { - manuallyEnteredAValue = true; - config.env!.FLASK_APP = selectedApp; - } else { - return; - } - } - - sendTelemetryEvent(EventName.DEBUGGER_CONFIGURATION_PROMPTS, undefined, { - configurationType: DebugConfigurationType.launchFlask, - autoDetectedFlaskAppPyPath: !!application, - manuallyEnteredAValue, - }); - Object.assign(state.config, config); -} -export async function getApplicationPath(folder: WorkspaceFolder | undefined): Promise { - if (!folder) { - return undefined; - } - const defaultLocationOfManagePy = path.join(folder.uri.fsPath, 'app.py'); - if (await fs.pathExists(defaultLocationOfManagePy)) { - return 'app.py'; } - return undefined; + await input.run((_input, state) => pickFlaskPrompt(input, state, config, options), state); } diff --git a/src/extension/debugger/configuration/providers/providerQuickPick/flaskProviderQuickPick.ts b/src/extension/debugger/configuration/providers/providerQuickPick/flaskProviderQuickPick.ts new file mode 100644 index 00000000..6dfd3b27 --- /dev/null +++ b/src/extension/debugger/configuration/providers/providerQuickPick/flaskProviderQuickPick.ts @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as path from 'path'; +import { window, QuickPickItemButtonEvent, QuickPickItemKind, WorkspaceFolder } from 'vscode'; +import { IQuickPickParameters, InputFlowAction, MultiStepInput } from '../../../../common/multiStepInput'; +import { LaunchRequestArguments } from '../../../../types'; +import { DebugConfigurationState, DebugConfigurationType } from '../../../types'; +import { QuickPickType } from './types'; +import { browseFileOption, openFileExplorer } from './providerQuickPick'; +import { DebugConfigStrings } from '../../../../common/utils/localize'; +import { sendTelemetryEvent } from '../../../../telemetry'; +import { EventName } from '../../../../telemetry/constants'; + +export async function pickFlaskPrompt( + input: MultiStepInput, + state: DebugConfigurationState, + config: Partial, + pathsOptions: QuickPickType[], +) { + let options: QuickPickType[] = [ + ...pathsOptions, + { label: '', kind: QuickPickItemKind.Separator }, + browseFileOption, + ]; + + const selection = await input.showQuickPick>({ + placeholder: DebugConfigStrings.flask.flaskConfigPromp.prompt, + items: options, + acceptFilterBoxTextAsSelection: true, + activeItem: options[0], + matchOnDescription: true, + title: DebugConfigStrings.flask.flaskConfigPromp.title, + onDidTriggerItemButton: async (e: QuickPickItemButtonEvent) => { + if (e.item && 'filePath' in e.item) { + await window.showTextDocument(e.item.filePath, { preview: true }); + } + }, + }); + + if (selection === undefined) { + return; + } else if (selection.label === browseFileOption.label) { + const uris = await openFileExplorer(state.folder?.uri); + if (uris && uris.length > 0) { + config.env!.FLASK_APP = parseFlaskPath(state.folder, uris[0].fsPath); + sendTelemetryEvent(EventName.DEBUGGER_CONFIGURATION_PROMPTS, undefined, { + configurationType: DebugConfigurationType.launchFlask, + browsefilevalue: true, + }); + } else { + return Promise.reject(InputFlowAction.resume); + } + } else if (typeof selection === 'string') { + config.env!.FLASK_APP = selection; + sendTelemetryEvent(EventName.DEBUGGER_CONFIGURATION_PROMPTS, undefined, { + configurationType: DebugConfigurationType.launchFlask, + manuallyEnteredAValue: true, + }); + } else { + config.env!.FLASK_APP = selection.description; + sendTelemetryEvent(EventName.DEBUGGER_CONFIGURATION_PROMPTS, undefined, { + configurationType: DebugConfigurationType.launchFlask, + autoDetectedFlaskAppPyPath: true, + }); + } + Object.assign(state.config, config); +} + +export function parseFlaskPath(folder: WorkspaceFolder | undefined, flaskPath: string): string | undefined { + if (!folder) { + return flaskPath; + } + const baseManagePath = path.relative(folder.uri.fsPath, flaskPath); + if (baseManagePath && !baseManagePath.startsWith('..')) { + return baseManagePath; + } else { + return flaskPath; + } +} diff --git a/src/extension/debugger/configuration/utils/configuration.ts b/src/extension/debugger/configuration/utils/configuration.ts index 67f296ab..a3af3681 100644 --- a/src/extension/debugger/configuration/utils/configuration.ts +++ b/src/extension/debugger/configuration/utils/configuration.ts @@ -70,7 +70,7 @@ export async function getDjangoPaths(folder: WorkspaceFolder | undefined): Promi export async function getFastApiPaths(folder: WorkspaceFolder | undefined) { if (!folder) { - return undefined; + return []; } const regExpression = /app\s*=\s*FastAPI\(/; const fastApiPaths = await getPossiblePaths( @@ -83,7 +83,7 @@ export async function getFastApiPaths(folder: WorkspaceFolder | undefined) { export async function getFlaskPaths(folder: WorkspaceFolder | undefined) { if (!folder) { - return undefined; + return []; } const regExpression = /app(?:lication)?\s*=\s*(?:flask\.)?Flask\(|def\s+(?:create|make)_app\(/; const flaskPaths = await getPossiblePaths( diff --git a/src/test/unittest/configuration/providers/flaskLaunch.unit.test.ts b/src/test/unittest/configuration/providers/flaskLaunch.unit.test.ts index 7fd6f1ea..95f3110a 100644 --- a/src/test/unittest/configuration/providers/flaskLaunch.unit.test.ts +++ b/src/test/unittest/configuration/providers/flaskLaunch.unit.test.ts @@ -5,87 +5,71 @@ import { expect } from 'chai'; import * as path from 'path'; -import * as fs from 'fs-extra'; import * as sinon from 'sinon'; -import { anything, instance, mock, when } from 'ts-mockito'; -import { Uri } from 'vscode'; -import { DebugConfigStrings } from '../../../../extension/common/utils/localize'; -import { DebuggerTypeName } from '../../../../extension/constants'; +import * as typemoq from 'typemoq'; +import { ThemeIcon, Uri } from 'vscode'; import { DebugConfigurationState } from '../../../../extension/debugger/types'; import * as flaskLaunch from '../../../../extension/debugger/configuration/providers/flaskLaunch'; import { MultiStepInput } from '../../../../extension/common/multiStepInput'; +import * as configuration from '../../../../extension/debugger/configuration/utils/configuration'; +import * as flaskProviderQuickPick from '../../../../extension/debugger/configuration/providers/providerQuickPick/flaskProviderQuickPick'; suite('Debugging - Configuration Provider Flask', () => { - let pathExistsStub: sinon.SinonStub; - let input: MultiStepInput; + let multiStepInput: typemoq.IMock>; + let getFlaskPathsStub: sinon.SinonStub; + let pickFlaskPromptStub: sinon.SinonStub; + setup(() => { - input = mock>(MultiStepInput); - pathExistsStub = sinon.stub(fs, 'pathExists'); + multiStepInput = typemoq.Mock.ofType>(); + multiStepInput + .setup((i) => i.run(typemoq.It.isAny(), typemoq.It.isAny())) + .returns((callback, _state) => callback()); + getFlaskPathsStub = sinon.stub(configuration, 'getFlaskPaths'); + pickFlaskPromptStub = sinon.stub(flaskProviderQuickPick, 'pickFlaskPrompt'); }); teardown(() => { sinon.restore(); }); - test("getApplicationPath should return undefined if file doesn't exist", async () => { - const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; - const appPyPath = path.join(folder.uri.fsPath, 'app.py'); - pathExistsStub.withArgs(appPyPath).resolves(false); - const file = await flaskLaunch.getApplicationPath(folder); - - expect(file).to.be.equal(undefined, 'Should return undefined'); - }); - test('getApplicationPath should file path', async () => { - const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; - const appPyPath = path.join(folder.uri.fsPath, 'app.py'); - pathExistsStub.withArgs(appPyPath).resolves(true); - const file = await flaskLaunch.getApplicationPath(folder); - - expect(file).to.be.equal('app.py'); - }); - test('Launch JSON with selected app path', async () => { + test('Show picker and send parsed found flask paths', async () => { const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; const state = { config: {}, folder }; - - when(input.showInputBox(anything())).thenResolve('hello'); - - await flaskLaunch.buildFlaskLaunchDebugConfiguration(instance(input), state); - - const config = { - name: DebugConfigStrings.flask.snippet.name, - type: DebuggerTypeName, - request: 'launch', - module: 'flask', - env: { - FLASK_APP: 'hello', - FLASK_DEBUG: '1', + const appPath = Uri.file(path.join(folder.uri.fsPath, 'app.py')); + getFlaskPathsStub.resolves([appPath]); + pickFlaskPromptStub.resolves(); + await flaskLaunch.buildFlaskLaunchDebugConfiguration(multiStepInput.object, state); + const options = pickFlaskPromptStub.getCall(0).args[3]; + const expectedOptions = [ + { + label: path.basename(appPath.fsPath), + filePath: appPath, + description: 'app.py', + buttons: [ + { + iconPath: new ThemeIcon('go-to-file'), + tooltip: `Open in Preview`, + }, + ], }, - args: ['run', '--no-debugger', '--no-reload'], - jinja: true, - autoStartBrowser: false, - }; + ]; - expect(state.config).to.be.deep.equal(config); + expect(options).to.be.deep.equal(expectedOptions); }); - test('Launch JSON with default managepy path', async () => { + test('Show picker and send default app.py path', async () => { const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; const state = { config: {}, folder }; - when(input.showInputBox(anything())).thenResolve('app.py'); - - await flaskLaunch.buildFlaskLaunchDebugConfiguration(instance(input), state); - - const config = { - name: DebugConfigStrings.flask.snippet.name, - type: DebuggerTypeName, - request: 'launch', - module: 'flask', - env: { - FLASK_APP: 'app.py', - FLASK_DEBUG: '1', + const appPath = path.join(state?.folder?.uri.fsPath, 'app.py'); + getFlaskPathsStub.resolves([]); + pickFlaskPromptStub.resolves(); + await flaskLaunch.buildFlaskLaunchDebugConfiguration(multiStepInput.object, state); + const options = pickFlaskPromptStub.getCall(0).args[3]; + const expectedOptions = [ + { + label: 'Default', + filePath: Uri.file(appPath), + description: 'app.py', }, - args: ['run', '--no-debugger', '--no-reload'], - jinja: true, - autoStartBrowser: false, - }; + ]; - expect(state.config).to.be.deep.equal(config); + expect(options).to.be.deep.equal(expectedOptions); }); }); diff --git a/src/test/unittest/configuration/providers/providerQuickPick/flaskProviderQuickPick.unit.test.ts b/src/test/unittest/configuration/providers/providerQuickPick/flaskProviderQuickPick.unit.test.ts new file mode 100644 index 00000000..0cddf9d4 --- /dev/null +++ b/src/test/unittest/configuration/providers/providerQuickPick/flaskProviderQuickPick.unit.test.ts @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { Uri } from 'vscode'; +import { expect } from 'chai'; +import * as path from 'path'; +import * as typemoq from 'typemoq'; +import * as sinon from 'sinon'; +import { MultiStepInput } from '../../../../../extension/common/multiStepInput'; +import { DebugConfigurationState } from '../../../../../extension/debugger/types'; +import { parseFlaskPath } from '../../../../../extension/debugger/configuration/providers/providerQuickPick/flaskProviderQuickPick'; + +suite('Debugging - Configuration Provider Flask QuickPick', () => { + let pathSeparatorStub: sinon.SinonStub; + let multiStepInput: typemoq.IMock>; + + setup(() => { + multiStepInput = typemoq.Mock.ofType>(); + multiStepInput + .setup((i) => i.run(typemoq.It.isAny(), typemoq.It.isAny())) + .returns((callback, _state) => callback()); + pathSeparatorStub = sinon.stub(path, 'sep'); + pathSeparatorStub.value('-'); + }); + teardown(() => { + sinon.restore(); + }); + test('parseManagePyPath should parse the path and return it with workspaceFolderToken', () => { + const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; + const flaskPath = path.join(folder.uri.fsPath, 'app.py'); + const file = parseFlaskPath(folder, flaskPath); + pathSeparatorStub.value('-'); + const expectedValue = `app.py`; + expect(file).to.be.equal(expectedValue); + }); + test('parseManagePyPath should return the same path if the workspace do not match', () => { + const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; + const flaskPath = 'random/path/app.py'; + const file = parseFlaskPath(folder, flaskPath); + + expect(file).to.be.equal(flaskPath); + }); +});