diff --git a/src/extension/common/multiStepInput.ts b/src/extension/common/multiStepInput.ts index 6f28506c..614f7d61 100644 --- a/src/extension/common/multiStepInput.ts +++ b/src/extension/common/multiStepInput.ts @@ -15,7 +15,9 @@ import { QuickPickItem, Event, window, + QuickPickItemButtonEvent, } from 'vscode'; +import { createDeferred } from './utils/async'; // Borrowed from https://github.com/Microsoft/vscode-extension-samples/blob/master/quickinput-sample/src/multiStepInput.ts // Why re-invent the wheel :) @@ -37,7 +39,7 @@ export type InputStep = (input: MultiStepInput, state: T) => P type buttonCallbackType = (quickPick: QuickPick) => void; -type QuickInputButtonSetup = { +export type QuickInputButtonSetup = { /** * Button for an action in a QuickPick. */ @@ -54,13 +56,12 @@ export interface IQuickPickParameters { totalSteps?: number; canGoBack?: boolean; items: T[]; - activeItem?: T | Promise; + activeItem?: T | ((quickPick: QuickPick) => Promise); placeholder: string | undefined; customButtonSetups?: QuickInputButtonSetup[]; matchOnDescription?: boolean; matchOnDetail?: boolean; keepScrollPosition?: boolean; - sortByLabel?: boolean; acceptFilterBoxTextAsSelection?: boolean; /** * A method called only after quickpick has been created and all handlers are registered. @@ -70,6 +71,7 @@ export interface IQuickPickParameters { callback: (event: E, quickPick: QuickPick) => void; event: Event; }; + onDidTriggerItemButton?: (e: QuickPickItemButtonEvent) => void; } interface InputBoxParameters { @@ -83,7 +85,7 @@ interface InputBoxParameters { validate(value: string): Promise; } -type MultiStepInputQuickPicResponseType = T | (P extends { buttons: (infer I)[] } ? I : never) | undefined; +type MultiStepInputQuickPickResponseType = T | (P extends { buttons: (infer I)[] } ? I : never) | undefined; type MultiStepInputInputBoxResponseType

= string | (P extends { buttons: (infer I)[] } ? I : never) | undefined; export interface IMultiStepInput { run(start: InputStep, state: S): Promise; @@ -95,7 +97,7 @@ export interface IMultiStepInput { activeItem, placeholder, customButtonSetups, - }: P): Promise>; + }: P): Promise>; showInputBox

({ title, step, @@ -131,8 +133,9 @@ export class MultiStepInput implements IMultiStepInput { acceptFilterBoxTextAsSelection, onChangeItem, keepScrollPosition, + onDidTriggerItemButton, initialize, - }: P): Promise> { + }: P): Promise> { const disposables: Disposable[] = []; const input = window.createQuickPick(); input.title = title; @@ -161,7 +164,13 @@ export class MultiStepInput implements IMultiStepInput { initialize(input); } if (activeItem) { - input.activeItems = [await activeItem]; + if (typeof activeItem === 'function') { + activeItem(input).then((item) => { + if (input.activeItems.length === 0) { + input.activeItems = [item]; + } + }); + } } else { input.activeItems = []; } @@ -170,35 +179,46 @@ export class MultiStepInput implements IMultiStepInput { // so do it after initialization. This ensures quickpick starts with the active // item in focus when this is true, instead of having scroll position at top. input.keepScrollPosition = keepScrollPosition; - try { - return await new Promise>((resolve, reject) => { - disposables.push( - input.onDidTriggerButton(async (item) => { - if (item === QuickInputButtons.Back) { - reject(InputFlowAction.back); - } - if (customButtonSetups) { - for (const customButtonSetup of customButtonSetups) { - if (JSON.stringify(item) === JSON.stringify(customButtonSetup?.button)) { - await customButtonSetup?.callback(input); - } - } + + const deferred = createDeferred(); + + disposables.push( + input.onDidTriggerButton(async (item) => { + if (item === QuickInputButtons.Back) { + deferred.reject(InputFlowAction.back); + input.hide(); + } + if (customButtonSetups) { + for (const customButtonSetup of customButtonSetups) { + if (JSON.stringify(item) === JSON.stringify(customButtonSetup?.button)) { + await customButtonSetup?.callback(input); } - }), - input.onDidChangeSelection((selectedItems) => resolve(selectedItems[0])), - input.onDidHide(() => { - resolve(undefined); - }), - ); - if (acceptFilterBoxTextAsSelection) { - disposables.push( - input.onDidAccept(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - resolve(input.value as any); - }), - ); + } } - }); + }), + input.onDidChangeSelection((selectedItems) => deferred.resolve(selectedItems[0])), + input.onDidHide(() => { + if (!deferred.completed) { + deferred.resolve(undefined); + } + }), + input.onDidTriggerItemButton(async (item) => { + if (onDidTriggerItemButton) { + await onDidTriggerItemButton(item); + } + }), + ); + if (acceptFilterBoxTextAsSelection) { + disposables.push( + input.onDidAccept(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + deferred.resolve(input.value as any); + }), + ); + } + + try { + return await deferred.promise; } finally { disposables.forEach((d) => d.dispose()); } @@ -283,6 +303,9 @@ export class MultiStepInput implements IMultiStepInput { if (err === InputFlowAction.back) { this.steps.pop(); step = this.steps.pop(); + if (step === undefined) { + throw err; + } } else if (err === InputFlowAction.resume) { step = this.steps.pop(); } else if (err === InputFlowAction.cancel) { @@ -297,6 +320,7 @@ export class MultiStepInput implements IMultiStepInput { } } } + export const IMultiStepInputFactory = Symbol('IMultiStepInputFactory'); export interface IMultiStepInputFactory { create(): IMultiStepInput; diff --git a/src/extension/common/utils/async.ts b/src/extension/common/utils/async.ts index 516fbf1d..ca8c8d52 100644 --- a/src/extension/common/utils/async.ts +++ b/src/extension/common/utils/async.ts @@ -11,7 +11,7 @@ export interface Deferred { readonly rejected: boolean; readonly completed: boolean; resolve(value?: T | PromiseLike): void; - reject(reason?: string | Error | Record): void; + reject(reason?: string | Error | Record | unknown): void; } class DeferredImpl implements Deferred { diff --git a/src/extension/common/utils/localize.ts b/src/extension/common/utils/localize.ts index 06b6fbdb..5ed37ca7 100644 --- a/src/extension/common/utils/localize.ts +++ b/src/extension/common/utils/localize.ts @@ -22,6 +22,12 @@ export namespace DebugConfigStrings { label: l10n.t('Python Debugger'), description: l10n.t('Select a Python Debugger debug configuration'), }; + export const browsePath = { + label: l10n.t('Browse Files...'), + detail: l10n.t('Browse your file system to find a Python file.'), + openButtonLabel: l10n.t('Select File'), + title: l10n.t('Select Python File'), + }; export namespace file { export const snippet = { name: l10n.t('Python Debugger: Current File'), @@ -92,12 +98,11 @@ export namespace DebugConfigStrings { label: l10n.t('Django'), description: l10n.t('Launch and debug a Django web application'), }; - export const enterManagePyPath = { + export const djangoConfigPromp = { title: l10n.t('Debug Django'), prompt: l10n.t( - "Enter the path to manage.py ('${workspaceFolderToken}' points to the root of the current workspace folder)", + "Enter the path to manage.py or select a file from the list ('${workspaceFolderToken}' points to the root of the current workspace folder)", ), - invalid: l10n.t('Enter a valid Python file path'), }; } export namespace fastapi { diff --git a/src/extension/debugger/configuration/debugConfigurationService.ts b/src/extension/debugger/configuration/debugConfigurationService.ts index 5528d508..50da3c18 100644 --- a/src/extension/debugger/configuration/debugConfigurationService.ts +++ b/src/extension/debugger/configuration/debugConfigurationService.ts @@ -42,7 +42,6 @@ export class PythonDebugConfigurationService implements IDebugConfigurationServi const config: Partial = {}; const state = { config, folder, token }; - // Disabled until configuration issues are addressed by VS Code. See #4007 const multiStep = this.multiStepFactory.create(); await multiStep.run((input, s) => PythonDebugConfigurationService.pickDebugConfiguration(input, s), state); diff --git a/src/extension/debugger/configuration/dynamicdebugConfigurationService.ts b/src/extension/debugger/configuration/dynamicdebugConfigurationService.ts index 3296f4c7..44f8f2d9 100644 --- a/src/extension/debugger/configuration/dynamicdebugConfigurationService.ts +++ b/src/extension/debugger/configuration/dynamicdebugConfigurationService.ts @@ -5,12 +5,11 @@ 'use strict'; import * as path from 'path'; -import * as fs from 'fs-extra'; import { CancellationToken, DebugConfiguration, WorkspaceFolder } from 'vscode'; import { IDynamicDebugConfigurationService } from '../types'; -import { asyncFilter } from '../../common/utilities'; import { DebuggerTypeName } from '../../constants'; import { replaceAll } from '../../common/stringUtils'; +import { getDjangoPaths, getFastApiPaths, getFlaskPaths } from './utils/configuration'; const workspaceFolderToken = '${workspaceFolder}'; @@ -29,7 +28,10 @@ export class DynamicPythonDebugConfigurationService implements IDynamicDebugConf program: '${file}', }); - const djangoManagePath = await DynamicPythonDebugConfigurationService.getDjangoPath(folder); + const djangoManagePaths = await getDjangoPaths(folder); + const djangoManagePath = djangoManagePaths?.length + ? path.relative(folder.uri.fsPath, djangoManagePaths[0].fsPath) + : null; if (djangoManagePath) { providers.push({ name: 'Python Debugger: Django', @@ -41,7 +43,8 @@ export class DynamicPythonDebugConfigurationService implements IDynamicDebugConf }); } - const flaskPath = await DynamicPythonDebugConfigurationService.getFlaskPath(folder); + const flaskPaths = await getFlaskPaths(folder); + const flaskPath = flaskPaths?.length ? flaskPaths[0].fsPath : null; if (flaskPath) { providers.push({ name: 'Python Debugger: Flask', @@ -57,7 +60,8 @@ export class DynamicPythonDebugConfigurationService implements IDynamicDebugConf }); } - let fastApiPath = await DynamicPythonDebugConfigurationService.getFastApiPath(folder); + const fastApiPaths = await getFastApiPaths(folder); + let fastApiPath = fastApiPaths?.length ? fastApiPaths[0].fsPath : null; if (fastApiPath) { fastApiPath = replaceAll(path.relative(folder.uri.fsPath, fastApiPath), path.sep, '.').replace('.py', ''); providers.push({ @@ -72,58 +76,4 @@ export class DynamicPythonDebugConfigurationService implements IDynamicDebugConf return providers; } - - private static async getDjangoPath(folder: WorkspaceFolder) { - const regExpression = /execute_from_command_line\(/; - const possiblePaths = await DynamicPythonDebugConfigurationService.getPossiblePaths( - folder, - ['manage.py', '*/manage.py', 'app.py', '*/app.py'], - regExpression, - ); - return possiblePaths.length ? path.relative(folder.uri.fsPath, possiblePaths[0]) : null; - } - - private static async getFastApiPath(folder: WorkspaceFolder) { - const regExpression = /app\s*=\s*FastAPI\(/; - const fastApiPaths = await DynamicPythonDebugConfigurationService.getPossiblePaths( - folder, - ['main.py', 'app.py', '*/main.py', '*/app.py', '*/*/main.py', '*/*/app.py'], - regExpression, - ); - - return fastApiPaths.length ? fastApiPaths[0] : null; - } - - private static async getFlaskPath(folder: WorkspaceFolder) { - const regExpression = /app(?:lication)?\s*=\s*(?:flask\.)?Flask\(|def\s+(?:create|make)_app\(/; - const flaskPaths = await DynamicPythonDebugConfigurationService.getPossiblePaths( - folder, - ['__init__.py', 'app.py', 'wsgi.py', '*/__init__.py', '*/app.py', '*/wsgi.py'], - regExpression, - ); - - return flaskPaths.length ? flaskPaths[0] : null; - } - - private static async getPossiblePaths( - folder: WorkspaceFolder, - globPatterns: string[], - regex: RegExp, - ): Promise { - const foundPathsPromises = (await Promise.allSettled( - globPatterns.map( - async (pattern): Promise => - (await fs.pathExists(path.join(folder.uri.fsPath, pattern))) - ? [path.join(folder.uri.fsPath, pattern)] - : [], - ), - )) as { status: string; value: [] }[]; - const possiblePaths: string[] = []; - foundPathsPromises.forEach((result) => possiblePaths.push(...result.value)); - const finalPaths = await asyncFilter(possiblePaths, async (possiblePath) => - regex.exec((await fs.readFile(possiblePath)).toString()), - ); - - return finalPaths; - } } diff --git a/src/extension/debugger/configuration/providers/djangoLaunch.ts b/src/extension/debugger/configuration/providers/djangoLaunch.ts index 3aa75e33..bff23756 100644 --- a/src/extension/debugger/configuration/providers/djangoLaunch.ts +++ b/src/extension/debugger/configuration/providers/djangoLaunch.ts @@ -3,88 +3,50 @@ 'use strict'; -import * as vscode from 'vscode'; +import { Uri } from 'vscode'; import * as path from 'path'; -import * as fs from 'fs-extra'; 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 { resolveVariables } from '../utils/common'; +import { DebugConfigurationState } from '../../types'; import { DebugConfigStrings } from '../../../common/utils/localize'; - -const workspaceFolderToken = '${workspaceFolder}'; +import { getDjangoPaths } from '../utils/configuration'; +import { goToFileButton } from './providerQuickPick/providerQuickPick'; +import { QuickPickType } from './providerQuickPick/types'; +import { parseManagePyPath, pickDjangoPrompt } from './providerQuickPick/djangoProviderQuickPick'; export async function buildDjangoLaunchDebugConfiguration( input: MultiStepInput, state: DebugConfigurationState, ): Promise { - const program = await getManagePyPath(state.folder); - let manuallyEnteredAValue: boolean | undefined; - const defaultProgram = `${workspaceFolderToken}${path.sep}manage.py`; const config: Partial = { name: DebugConfigStrings.django.snippet.name, type: DebuggerTypeName, request: 'launch', - program: program || defaultProgram, args: ['runserver'], django: true, autoStartBrowser: false, }; - if (!program) { - const selectedProgram = await input.showInputBox({ - title: DebugConfigStrings.django.enterManagePyPath.title, - value: defaultProgram, - prompt: DebugConfigStrings.django.enterManagePyPath.prompt, - validate: (value) => validateManagePy(state.folder, defaultProgram, value), - }); - if (selectedProgram) { - manuallyEnteredAValue = true; - config.program = selectedProgram; - } else { - return; - } - } - - sendTelemetryEvent(EventName.DEBUGGER_CONFIGURATION_PROMPTS, undefined, { - configurationType: DebugConfigurationType.launchDjango, - autoDetectedDjangoManagePyPath: !!program, - manuallyEnteredAValue, - }); - - Object.assign(state.config, config); -} -export async function validateManagePy( - folder: vscode.WorkspaceFolder | undefined, - defaultValue: string, - selected?: string, -): Promise { - const error = DebugConfigStrings.django.enterManagePyPath.invalid; - if (!selected || selected.trim().length === 0) { - return error; - } - const resolvedPath = resolveVariables(selected, undefined, folder); - if (resolvedPath) { - if (selected !== defaultValue && !(await fs.pathExists(resolvedPath))) { - return error; - } - if (!resolvedPath.trim().toLowerCase().endsWith('.py')) { - return error; - } - } - return undefined; -} - -export async function getManagePyPath(folder: vscode.WorkspaceFolder | undefined): Promise { - if (!folder) { - return undefined; - } - const defaultLocationOfManagePy = path.join(folder.uri.fsPath, 'manage.py'); - if (await fs.pathExists(defaultLocationOfManagePy)) { - return `${workspaceFolderToken}${path.sep}manage.py`; + let djangoPaths = await getDjangoPaths(state.folder); + let options: QuickPickType[] = []; + //add found paths to options + if (djangoPaths.length > 0) { + options.push( + ...djangoPaths.map((item) => ({ + label: path.basename(item.fsPath), + filePath: item, + description: parseManagePyPath(state.folder, item.fsPath), + buttons: [goToFileButton], + })), + ); + } else { + const managePath = path.join(state?.folder?.uri.fsPath || '', 'manage.py'); + options.push({ + label: 'Default', + description: parseManagePyPath(state.folder, managePath), + filePath: Uri.file(managePath), + }); } - return undefined; + await input.run((_input, state) => pickDjangoPrompt(input, state, config, options), state); } diff --git a/src/extension/debugger/configuration/providers/providerQuickPick/djangoProviderQuickPick.ts b/src/extension/debugger/configuration/providers/providerQuickPick/djangoProviderQuickPick.ts new file mode 100644 index 00000000..07feb0f9 --- /dev/null +++ b/src/extension/debugger/configuration/providers/providerQuickPick/djangoProviderQuickPick.ts @@ -0,0 +1,82 @@ +// 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 const workspaceFolderToken = '${workspaceFolder}'; + +export async function pickDjangoPrompt( + input: MultiStepInput, + state: DebugConfigurationState, + config: Partial, + pathsOptions: QuickPickType[], +) { + let options: QuickPickType[] = [ + ...pathsOptions, + { label: '', kind: QuickPickItemKind.Separator }, + browseFileOption, + ]; + + const selection = await input.showQuickPick>({ + placeholder: DebugConfigStrings.django.djangoConfigPromp.prompt, + items: options, + acceptFilterBoxTextAsSelection: true, + activeItem: options[0], + matchOnDescription: true, + title: DebugConfigStrings.django.djangoConfigPromp.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.program = parseManagePyPath(state.folder, uris[0].fsPath); + sendTelemetryEvent(EventName.DEBUGGER_CONFIGURATION_PROMPTS, undefined, { + configurationType: DebugConfigurationType.launchDjango, + browsefilevalue: true, + }); + } else { + return Promise.reject(InputFlowAction.resume); + } + } else if (typeof selection === 'string') { + config.program = selection; + sendTelemetryEvent(EventName.DEBUGGER_CONFIGURATION_PROMPTS, undefined, { + configurationType: DebugConfigurationType.launchDjango, + manuallyEnteredAValue: true, + }); + } else { + config.program = selection.description; + sendTelemetryEvent(EventName.DEBUGGER_CONFIGURATION_PROMPTS, undefined, { + configurationType: DebugConfigurationType.launchDjango, + autoDetectedDjangoManagePyPath: true, + }); + } + Object.assign(state.config, config); +} + +export function parseManagePyPath(folder: WorkspaceFolder | undefined, djangoPath: string): string | undefined { + if (!folder) { + return djangoPath; + } + const baseManagePath = path.relative(folder.uri.fsPath, djangoPath); + if (baseManagePath && !baseManagePath.startsWith('..')) { + return `${workspaceFolderToken}${path.sep}${baseManagePath}`; + } else { + return djangoPath; + } +} diff --git a/src/extension/debugger/configuration/providers/providerQuickPick/providerQuickPick.ts b/src/extension/debugger/configuration/providers/providerQuickPick/providerQuickPick.ts new file mode 100644 index 00000000..bf776f65 --- /dev/null +++ b/src/extension/debugger/configuration/providers/providerQuickPick/providerQuickPick.ts @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { QuickInputButton, ThemeIcon, Uri, window } from 'vscode'; +import { OSType, getOSType } from '../../../../common/platform'; +import { DebugConfigStrings } from '../../../../common/utils/localize'; + +export const goToFileButton: QuickInputButton = { + iconPath: new ThemeIcon('go-to-file'), + tooltip: `Open in Preview`, +}; + +export const browseFileOption = { + label: `$(folder) ${DebugConfigStrings.browsePath.label}`, + description: DebugConfigStrings.browsePath.detail, +}; + +export async function openFileExplorer(folder: Uri | undefined) { + const filtersKey = 'Executables'; + const filtersObject: { [name: string]: string[] } = {}; + filtersObject[filtersKey] = ['exe']; + return await window.showOpenDialog({ + filters: getOSType() == OSType.Windows ? filtersObject : undefined, + openLabel: DebugConfigStrings.browsePath.openButtonLabel, + canSelectMany: false, + title: DebugConfigStrings.browsePath.title, + defaultUri: folder ? folder : undefined, + }); +} diff --git a/src/extension/debugger/configuration/providers/providerQuickPick/types.ts b/src/extension/debugger/configuration/providers/providerQuickPick/types.ts new file mode 100644 index 00000000..ecac4f3b --- /dev/null +++ b/src/extension/debugger/configuration/providers/providerQuickPick/types.ts @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { QuickPickItem, QuickPickItemKind, Uri } from 'vscode'; + +export interface PathQuickPickItem extends QuickPickItem { + filePath: Uri; + kind?: QuickPickItemKind; + description: string; +} +export interface SeparatorQuickPickItem extends QuickPickItem { + label: string; + kind?: QuickPickItemKind; +} + +export type QuickPickType = PathQuickPickItem | SeparatorQuickPickItem; diff --git a/src/extension/debugger/configuration/utils/configuration.ts b/src/extension/debugger/configuration/utils/configuration.ts index f880d1a9..67f296ab 100644 --- a/src/extension/debugger/configuration/utils/configuration.ts +++ b/src/extension/debugger/configuration/utils/configuration.ts @@ -6,12 +6,15 @@ 'use strict'; +import * as fs from 'fs-extra'; import { MultiStepInput } from '../../../common/multiStepInput'; import { sendTelemetryEvent } from '../../../telemetry'; import { EventName } from '../../../telemetry/constants'; import { DebugConfigStrings } from '../../../common/utils/localize'; import { AttachRequestArguments } from '../../../types'; import { DebugConfigurationState, DebugConfigurationType } from '../../types'; +import { Uri, WorkspaceFolder, workspace } from 'vscode'; +import { asyncFilter } from '../../../common/utilities'; const defaultPort = 5678; @@ -42,3 +45,51 @@ export async function configurePort( manuallyEnteredAValue: connect.port !== defaultPort, }); } + +async function getPossiblePaths(globPatterns: string[], regex: RegExp): Promise { + const foundPathsPromises = (await Promise.allSettled( + globPatterns.map(async (pattern): Promise => await workspace.findFiles(pattern)), + )) as { status: string; value: [] }[]; + const possiblePaths: Uri[] = []; + foundPathsPromises.forEach((result) => possiblePaths.push(...result.value)); + const finalPaths = await asyncFilter(possiblePaths, async (possiblePath) => + regex.exec((await fs.readFile(possiblePath.fsPath)).toString()), + ); + + return finalPaths; +} + +export async function getDjangoPaths(folder: WorkspaceFolder | undefined): Promise { + if (!folder) { + return []; + } + const regExpression = /execute_from_command_line\(/; + const djangoPaths = await getPossiblePaths(['manage.py', '*/manage.py', 'app.py', '*/app.py'], regExpression); + return djangoPaths; +} + +export async function getFastApiPaths(folder: WorkspaceFolder | undefined) { + if (!folder) { + return undefined; + } + const regExpression = /app\s*=\s*FastAPI\(/; + const fastApiPaths = await getPossiblePaths( + ['main.py', 'app.py', '*/main.py', '*/app.py', '*/*/main.py', '*/*/app.py'], + regExpression, + ); + + return fastApiPaths; +} + +export async function getFlaskPaths(folder: WorkspaceFolder | undefined) { + if (!folder) { + return undefined; + } + const regExpression = /app(?:lication)?\s*=\s*(?:flask\.)?Flask\(|def\s+(?:create|make)_app\(/; + const flaskPaths = await getPossiblePaths( + ['__init__.py', 'app.py', 'wsgi.py', '*/__init__.py', '*/app.py', '*/wsgi.py'], + regExpression, + ); + + return flaskPaths; +} diff --git a/src/extension/telemetry/index.ts b/src/extension/telemetry/index.ts index d7b2c457..5853002a 100644 --- a/src/extension/telemetry/index.ts +++ b/src/extension/telemetry/index.ts @@ -603,7 +603,9 @@ export interface IEventNamePropertyMapping { "autodetectedpyramidinipath" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "paulacamargo25" }, "autodetectedfastapimainpypath" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "paulacamargo25" }, "autodetectedflaskapppypath" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "paulacamargo25" }, - "manuallyenteredavalue" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "paulacamargo25" } + "manuallyenteredavalue" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "paulacamargo25" }, + "browsefilevalue" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "paulacamargo25" }, + } */ @@ -645,6 +647,12 @@ export interface IEventNamePropertyMapping { * @type {boolean} */ manuallyEnteredAValue?: boolean; + /** + * Carries `true` if the user choose a file from the folder picker, `false` otherwise + * + * @type {boolean} + */ + browsefilevalue?: boolean; }; /** * Telemetry event sent when providing completion provider in launch.json. It is sent just *after* inserting the completion. diff --git a/src/test/unittest/configuration/providers/djangoLaunch.unit.test.ts b/src/test/unittest/configuration/providers/djangoLaunch.unit.test.ts index 60873509..fd287328 100644 --- a/src/test/unittest/configuration/providers/djangoLaunch.unit.test.ts +++ b/src/test/unittest/configuration/providers/djangoLaunch.unit.test.ts @@ -3,136 +3,76 @@ 'use strict'; -import { Uri } from 'vscode'; +import { ThemeIcon, Uri } from 'vscode'; import { expect } from 'chai'; import * as path from 'path'; -import * as fs from 'fs-extra'; +import * as typemoq from 'typemoq'; import * as sinon from 'sinon'; -import { anything, instance, mock, when } from 'ts-mockito'; -import { DebugConfigStrings } from '../../../../extension/common/utils/localize'; -import { DebuggerTypeName } from '../../../../extension/constants'; import { MultiStepInput } from '../../../../extension/common/multiStepInput'; import { DebugConfigurationState } from '../../../../extension/debugger/types'; -import { resolveVariables } from '../../../../extension/debugger/configuration/utils/common'; -import * as vscodeapi from '../../../../extension/common/vscodeapi'; import * as djangoLaunch from '../../../../extension/debugger/configuration/providers/djangoLaunch'; +import * as configuration from '../../../../extension/debugger/configuration/utils/configuration'; +import * as djangoProviderQuickPick from '../../../../extension/debugger/configuration/providers/providerQuickPick/djangoProviderQuickPick'; suite('Debugging - Configuration Provider Django', () => { - let pathExistsStub: sinon.SinonStub; let pathSeparatorStub: sinon.SinonStub; - let workspaceStub: sinon.SinonStub; - let input: MultiStepInput; + let getDjangoPathsStub: sinon.SinonStub; + let pickDjangoPromptStub: sinon.SinonStub; + let multiStepInput: typemoq.IMock>; 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()); pathSeparatorStub = sinon.stub(path, 'sep'); - workspaceStub = sinon.stub(vscodeapi, 'getWorkspaceFolder'); + getDjangoPathsStub = sinon.stub(configuration, 'getDjangoPaths'); + pickDjangoPromptStub = sinon.stub(djangoProviderQuickPick, 'pickDjangoPrompt'); + pathSeparatorStub.value('-'); }); teardown(() => { sinon.restore(); }); - test("getManagePyPath should return undefined if file doesn't exist", async () => { - const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; - const managePyPath = path.join(folder.uri.fsPath, 'manage.py'); - pathExistsStub.withArgs(managePyPath).resolves(false); - const file = await djangoLaunch.getManagePyPath(folder); - - expect(file).to.be.equal(undefined, 'Should return undefined'); - }); - test('getManagePyPath should file path', async () => { - const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; - const managePyPath = path.join(folder.uri.fsPath, 'manage.py'); - pathExistsStub.withArgs(managePyPath).resolves(true); - pathSeparatorStub.value('-'); - const file = await djangoLaunch.getManagePyPath(folder); - - expect(file).to.be.equal('${workspaceFolder}-manage.py'); - }); - test('Resolve variables (with resource)', async () => { - const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; - workspaceStub.returns(folder); - const resolvedPath = resolveVariables('${workspaceFolder}/one.py', undefined, folder); - - expect(resolvedPath).to.be.equal(`${folder.uri.fsPath}/one.py`); - }); - test('Validation of path should return errors if path is undefined', async () => { - const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; - const error = await djangoLaunch.validateManagePy(folder, ''); - - expect(error).to.be.length.greaterThan(1); - }); - test('Validation of path should return errors if path is empty', async () => { - const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; - const error = await djangoLaunch.validateManagePy(folder, '', ''); - - expect(error).to.be.length.greaterThan(1); - }); - test('Validation of path should return errors if resolved path is empty', async () => { - const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; - const error = await djangoLaunch.validateManagePy(folder, '', 'x'); - - expect(error).to.be.length.greaterThan(1); - }); - test("Validation of path should return errors if resolved path doesn't exist", async () => { - const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; - pathExistsStub.withArgs('xyz').resolves(false); - const error = await djangoLaunch.validateManagePy(folder, '', 'x'); - - expect(error).to.be.length.greaterThan(1); - }); - test('Validation of path should return errors if resolved path is non-python', async () => { - const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; - pathExistsStub.withArgs('xyz.txt').resolves(true); - const error = await djangoLaunch.validateManagePy(folder, '', 'x'); - - expect(error).to.be.length.greaterThan(1); - }); - test('Validation of path should return errors if resolved path is python', async () => { - const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; - pathExistsStub.withArgs('xyz.py').resolves(true); - const error = await djangoLaunch.validateManagePy(folder, '', 'xyz.py'); - - expect(error).to.be.equal(undefined, 'should not have errors'); - }); - test('Launch JSON with selected managepy path', async () => { + test('Show picker and send parsed found managepy paths', async () => { const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; const state = { config: {}, folder }; - pathSeparatorStub.value('-'); - when(input.showInputBox(anything())).thenResolve('hello'); - await djangoLaunch.buildDjangoLaunchDebugConfiguration(instance(input), state); - - const config = { - name: DebugConfigStrings.django.snippet.name, - type: DebuggerTypeName, - request: 'launch', - program: 'hello', - args: ['runserver'], - django: true, - autoStartBrowser: false, - }; - - expect(state.config).to.be.deep.equal(config); + const managePath = Uri.file(path.join(folder.uri.fsPath, 'manage.py')); + getDjangoPathsStub.resolves([managePath]); + pickDjangoPromptStub.resolves(); + await djangoLaunch.buildDjangoLaunchDebugConfiguration(multiStepInput.object, state); + const options = pickDjangoPromptStub.getCall(0).args[3]; + const expectedOptions = [ + { + label: path.basename(managePath.fsPath), + filePath: managePath, + description: `${djangoProviderQuickPick.workspaceFolderToken}-manage.py`, + buttons: [ + { + iconPath: new ThemeIcon('go-to-file'), + tooltip: `Open in Preview`, + }, + ], + }, + ]; + + expect(options).to.be.deep.equal(expectedOptions); }); - test('Launch JSON with default managepy path', async () => { + test('Show picker and send defauge managepy path', async () => { const folder = { uri: Uri.parse(path.join('one', 'two')), name: '1', index: 0 }; const state = { config: {}, folder }; - const workspaceFolderToken = '${workspaceFolder}'; - const defaultProgram = `${workspaceFolderToken}-manage.py`; - pathSeparatorStub.value('-'); - when(input.showInputBox(anything())).thenResolve(defaultProgram); - await djangoLaunch.buildDjangoLaunchDebugConfiguration(instance(input), state); - - const config = { - name: DebugConfigStrings.django.snippet.name, - type: DebuggerTypeName, - request: 'launch', - program: defaultProgram, - args: ['runserver'], - django: true, - autoStartBrowser: false, - }; - - expect(state.config).to.be.deep.equal(config); + const managePath = path.join(state?.folder?.uri.fsPath, 'manage.py'); + getDjangoPathsStub.resolves([]); + pickDjangoPromptStub.resolves(); + await djangoLaunch.buildDjangoLaunchDebugConfiguration(multiStepInput.object, state); + const options = pickDjangoPromptStub.getCall(0).args[3]; + const expectedOptions = [ + { + label: 'Default', + filePath: Uri.file(managePath), + description: `${djangoProviderQuickPick.workspaceFolderToken}-manage.py`, + }, + ]; + + expect(options).to.be.deep.equal(expectedOptions); }); }); diff --git a/src/test/unittest/configuration/providers/providerQuickPick/djangoProviderQuickPick.unit.test.ts b/src/test/unittest/configuration/providers/providerQuickPick/djangoProviderQuickPick.unit.test.ts new file mode 100644 index 00000000..c268c340 --- /dev/null +++ b/src/test/unittest/configuration/providers/providerQuickPick/djangoProviderQuickPick.unit.test.ts @@ -0,0 +1,48 @@ +// 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 { + parseManagePyPath, + workspaceFolderToken, +} from '../../../../../extension/debugger/configuration/providers/providerQuickPick/djangoProviderQuickPick'; + +suite('Debugging - Configuration Provider Django 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 managePyPath = path.join(folder.uri.fsPath, 'manage.py'); + const file = parseManagePyPath(folder, managePyPath); + pathSeparatorStub.value('-'); + const expectedValue = `${workspaceFolderToken}-manage.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 managePyPath = 'random/path/manage.py'; + const file = parseManagePyPath(folder, managePyPath); + + expect(file).to.be.equal(managePyPath); + }); +});