diff --git a/utils/hostapi.d.ts b/utils/hostapi.d.ts index 22a76daf6c..a7a9167040 100644 --- a/utils/hostapi.d.ts +++ b/utils/hostapi.d.ts @@ -18,7 +18,7 @@ export interface AzureHostExtensionApi { /** * The VSCode TreeView for the shared app resource view */ - readonly appResourceTreeView: vscode.TreeView; + readonly appResourceTreeView: vscode.TreeView; /** * The `AzExtTreeDataProvider` for the shared workspace resource view @@ -28,12 +28,12 @@ export interface AzureHostExtensionApi { /** * The VSCode TreeView for the shared workspace resource view */ - readonly workspaceResourceTreeView: vscode.TreeView; + readonly workspaceResourceTreeView: vscode.TreeView; /** * Version of the API */ - readonly apiVersion: string; + readonly apiVersion: '0.0.1'; /** * Reveals an item in the shared app resource tree @@ -76,7 +76,7 @@ export interface AzureHostExtensionApi { /** * @deprecated Use `appResourceTreeView` instead */ - readonly treeView: vscode.TreeView; + readonly treeView: vscode.TreeView; /** * @deprecated Use `registerWorkspaceResourceProvider` instead diff --git a/utils/hostapi.v2.d.ts b/utils/hostapi.v2.d.ts index 1b4541d67f..5c63e416fc 100644 --- a/utils/hostapi.v2.d.ts +++ b/utils/hostapi.v2.d.ts @@ -6,7 +6,7 @@ import * as vscode from 'vscode'; import type { Environment } from '@azure/ms-rest-azure-env'; import type { Activity } from './hostapi'; -import type { AzExtResourceType } from './index'; +import type { AzExtResourceType, QuickPickWizardContext } from './index'; import type { AzureExtensionApi } from './api'; /** @@ -325,3 +325,12 @@ export interface v2AzureResourcesApi extends AzureExtensionApi { */ registerWorkspaceResourceBranchDataProvider(type: WorkspaceResourceType, provider: WorkspaceResourceBranchDataProvider): vscode.Disposable; } + +export declare interface PickSubscriptionWizardContext extends QuickPickWizardContext { + subscription?: AzureSubscription; +} + +export declare interface AzureResourceQuickPickWizardContext extends QuickPickWizardContext, PickSubscriptionWizardContext { + resource?: AzureResource; + resourceGroup?: string; +} diff --git a/utils/index.d.ts b/utils/index.d.ts index c6331a23e1..62961e538e 100644 --- a/utils/index.d.ts +++ b/utils/index.d.ts @@ -6,10 +6,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { Environment } from '@azure/ms-rest-azure-env'; -import { CancellationToken, CancellationTokenSource, Disposable, Event, ExtensionContext, FileChangeEvent, FileChangeType, FileStat, FileSystemProvider, FileType, InputBoxOptions, MarkdownString, MessageItem, MessageOptions, OpenDialogOptions, OutputChannel, Progress, QuickPickItem, QuickPickOptions, TextDocumentShowOptions, ThemeIcon, TreeDataProvider, TreeItem, TreeItemCollapsibleState, TreeView, Uri } from 'vscode'; +import { CancellationToken, CancellationTokenSource, Disposable, Event, ExtensionContext, FileChangeEvent, FileChangeType, FileStat, FileSystemProvider, FileType, InputBoxOptions, MarkdownString, MessageItem, MessageOptions, OpenDialogOptions, OutputChannel, Progress, QuickPickItem, QuickPickOptions as VSCodeQuickPickOptions, TextDocumentShowOptions, ThemeIcon, TreeDataProvider, TreeItem, TreeItemCollapsibleState, TreeView, Uri } from 'vscode'; import { TargetPopulation } from 'vscode-tas-client'; import { AzureExtensionApi, AzureExtensionApiProvider } from './api'; -import type { Activity, ActivityTreeItemOptions, AppResource, OnErrorActivityData, OnProgressActivityData, OnStartActivityData, OnSuccessActivityData } from './hostapi'; // This must remain `import type` or else a circular reference will result +import type { Activity, ActivityTreeItemOptions, AppResource, AzureHostExtensionApi, OnErrorActivityData, OnProgressActivityData, OnStartActivityData, OnSuccessActivityData } from './hostapi'; // This must remain `import type` or else a circular reference will result export declare interface RunWithTemporaryDescriptionOptions { description: string; @@ -640,7 +640,9 @@ export declare class UserCancelledError extends Error { constructor(stepName?: string); } -export declare class NoResourceFoundError extends Error { } +export declare class NoResourceFoundError extends Error { + constructor(context?: ITreeItemPickerContext); +} export type CommandCallback = (context: IActionContext, ...args: any[]) => any; @@ -995,7 +997,7 @@ export interface IAzureQuickPickItem extends QuickPickItem { /** * Provides additional options for QuickPicks used in Azure Extensions */ -export interface IAzureQuickPickOptions extends QuickPickOptions, AzExtUserInputOptions { +export interface IAzureQuickPickOptions extends VSCodeQuickPickOptions, AzExtUserInputOptions { /** * An optional id to identify this QuickPick across sessions, used in persisting previous selections * If not specified, a hash of the placeHolder will be used @@ -1714,6 +1716,9 @@ export declare interface Wrapper { unwrap(): T; } +// temporary +type ResourceGroupsItem = unknown; + /** * Tests to see if something is a wrapper, by ensuring it is an object * and has an "unwrap" function @@ -1722,6 +1727,47 @@ export declare interface Wrapper { */ export declare function isWrapper(maybeWrapper: unknown): maybeWrapper is Wrapper; +export declare function appResourceExperience(context: IActionContext, tdp: TreeDataProvider, resourceTypes?: AzExtResourceType | AzExtResourceType[], childItemFilter?: ContextValueFilter): Promise; +export declare function contextValueExperience(context: IActionContext, tdp: TreeDataProvider, contextValueFilter: ContextValueFilter): Promise; + +interface CompatibilityPickResourceExperienceOptions { + resourceTypes?: AzExtResourceType | AzExtResourceType[]; + childItemFilter?: ContextValueFilter +} + +export declare namespace PickTreeItemWithCompatibility { + /** + * Provides compatibility for the legacy `pickAppResource` Resource Groups API + */ + export function resource(context: IActionContext, tdp: TreeDataProvider, options: CompatibilityPickResourceExperienceOptions): Promise; + /** + * Returns `ISubscriptionContext` instead of `ApplicationSubscription` for compatibility. + */ + export function subscription(context: IActionContext, tdp: TreeDataProvider): Promise; +} + +export declare interface QuickPickWizardContext extends IActionContext { + pickedNodes: unknown[]; +} + +/** + * Describes filtering based on context value. Items that pass the filter will + * match at least one of the `include` filters, but none of the `exclude` filters. + */ +export declare interface ContextValueFilter { + /** + * This filter will include items that match *any* of the values in the array. + * When a string is used, exact value comparison is done. + */ + include: string | RegExp | (string | RegExp)[]; + + /** + * This filter will exclude items that match *any* of the values in the array. + * When a string is used, exact value comparison is done. + */ + exclude?: string | RegExp | (string | RegExp)[]; +} + /** * Get extension exports for the extension with the given id. Activates extension first if needed. * diff --git a/utils/package-lock.json b/utils/package-lock.json index bd72910bd5..e2ac407876 100644 --- a/utils/package-lock.json +++ b/utils/package-lock.json @@ -24,7 +24,7 @@ "@microsoft/vscode-azext-dev": "^0.1.4", "@types/html-to-text": "^8.1.0", "@types/mocha": "^7.0.2", - "@types/node": "^14.0.0", + "@types/node": "^16.0.0", "@types/semver": "^7.3.9", "@types/vscode": "1.64.0", "@typescript-eslint/eslint-plugin": "^4.28.3", @@ -582,9 +582,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "14.17.34", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.17.34.tgz", - "integrity": "sha512-USUftMYpmuMzeWobskoPfzDi+vkpe0dvcOBRNOscFrGxVp4jomnRxWuVohgqBow2xyIPC0S3gjxV/5079jhmDg==", + "version": "16.18.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.3.tgz", + "integrity": "sha512-jh6m0QUhIRcZpNv7Z/rpN+ZWXOicUUQbSoWks7Htkbb9IjFQj4kzcX/xFCkjstCj5flMsN8FiSvt+q+Tcs4Llg==", "dev": true }, "node_modules/@types/semver": { @@ -7830,9 +7830,9 @@ "dev": true }, "@types/node": { - "version": "14.17.34", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.17.34.tgz", - "integrity": "sha512-USUftMYpmuMzeWobskoPfzDi+vkpe0dvcOBRNOscFrGxVp4jomnRxWuVohgqBow2xyIPC0S3gjxV/5079jhmDg==", + "version": "16.18.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.3.tgz", + "integrity": "sha512-jh6m0QUhIRcZpNv7Z/rpN+ZWXOicUUQbSoWks7Htkbb9IjFQj4kzcX/xFCkjstCj5flMsN8FiSvt+q+Tcs4Llg==", "dev": true }, "@types/semver": { diff --git a/utils/package.json b/utils/package.json index e2171ca583..1357e551fd 100644 --- a/utils/package.json +++ b/utils/package.json @@ -47,7 +47,7 @@ "@microsoft/vscode-azext-dev": "^0.1.4", "@types/html-to-text": "^8.1.0", "@types/mocha": "^7.0.2", - "@types/node": "^14.0.0", + "@types/node": "^16.0.0", "@types/semver": "^7.3.9", "@types/vscode": "1.64.0", "@typescript-eslint/eslint-plugin": "^4.28.3", diff --git a/utils/src/index.ts b/utils/src/index.ts index 6ef20c4fac..648ae65706 100644 --- a/utils/src/index.ts +++ b/utils/src/index.ts @@ -37,6 +37,9 @@ export * from './utils/contextUtils'; export * from './activityLog/activities/ExecuteActivity'; export * from './getAzExtResourceType'; export * from './AzExtResourceType'; +export * from './treev2/quickPickWizard/experiences/appResourceExperience'; +export * from './treev2/quickPickWizard/experiences/compatibility/PickTreeItemWithCompatibility'; +export * from './treev2/quickPickWizard/experiences/contextValueExperience'; export * from './utils/apiUtils'; export * from './tree/isAzExtTreeItem'; // NOTE: The auto-fix action "source.organizeImports" does weird things with this file, but there doesn't seem to be a way to disable it on a per-file basis so we'll just let it happen diff --git a/utils/src/treev2/quickPickWizard/ContextValueQuickPickStep.ts b/utils/src/treev2/quickPickWizard/ContextValueQuickPickStep.ts new file mode 100644 index 0000000000..4fef52c5f5 --- /dev/null +++ b/utils/src/treev2/quickPickWizard/ContextValueQuickPickStep.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { TreeItem } from 'vscode'; +import * as types from '../../../index'; +import { parseContextValue } from '../../utils/contextUtils'; +import { PickFilter } from './common/PickFilter'; +import { GenericQuickPickOptions, GenericQuickPickStep } from './GenericQuickPickStep'; + +export interface ContextValueFilterQuickPickOptions extends GenericQuickPickOptions { + contextValueFilter: types.ContextValueFilter; +} + +export class ContextValueQuickPickStep extends GenericQuickPickStep { + protected readonly pickFilter: PickFilter = new ContextValuePickFilter(this.pickOptions); +} + +class ContextValuePickFilter implements PickFilter { + constructor(private readonly pickOptions: ContextValueFilterQuickPickOptions) { } + + isFinalPick(node: TreeItem): boolean { + const includeOption = this.pickOptions.contextValueFilter.include; + const excludeOption = this.pickOptions.contextValueFilter.exclude; + + const includeArray: (string | RegExp)[] = Array.isArray(includeOption) ? includeOption : [includeOption]; + const excludeArray: (string | RegExp)[] = excludeOption ? + (Array.isArray(excludeOption) ? excludeOption : [excludeOption]) : + []; + + const nodeContextValues: string[] = parseContextValue(node.contextValue); + + return includeArray.some(i => this.matchesSingleFilter(i, nodeContextValues)) && + !excludeArray.some(e => this.matchesSingleFilter(e, nodeContextValues)); + } + + isAncestorPick(node: TreeItem): boolean { + // `TreeItemCollapsibleState.None` and `undefined` are both falsy, and indicate that a `TreeItem` cannot have children--and therefore, cannot be an indirect pick + return !node.collapsibleState; + } + + private matchesSingleFilter(matcher: string | RegExp, nodeContextValues: string[]): boolean { + return nodeContextValues.some(c => { + if (matcher instanceof RegExp) { + return matcher.test(c); + } + + // Context value matcher is a string, do full equality (same as old behavior) + return c === matcher; + }) + } +} diff --git a/utils/src/treev2/quickPickWizard/GenericQuickPickStep.ts b/utils/src/treev2/quickPickWizard/GenericQuickPickStep.ts new file mode 100644 index 0000000000..ff7fbc78bc --- /dev/null +++ b/utils/src/treev2/quickPickWizard/GenericQuickPickStep.ts @@ -0,0 +1,99 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as types from '../../../index'; +import * as vscode from 'vscode'; +import { getLastNode } from './common/getLastNode'; +import { AzureWizardPromptStep } from '../../wizard/AzureWizardPromptStep'; +import { PickFilter } from './common/PickFilter'; +import { localize } from '../../localize'; + +export interface GenericQuickPickOptions { + skipIfOne?: boolean; +} + +export interface SkipIfOneQuickPickOptions extends GenericQuickPickOptions { + skipIfOne?: true; +} + +export abstract class GenericQuickPickStep extends AzureWizardPromptStep { + public readonly supportsDuplicateSteps = true; + + protected readonly abstract pickFilter: PickFilter; + + public constructor( + protected readonly treeDataProvider: vscode.TreeDataProvider, + protected readonly pickOptions: TOptions + ) { + super(); + } + + public async prompt(wizardContext: TContext): Promise { + const pick = await this.promptInternal(wizardContext); + wizardContext.pickedNodes.push(pick); + } + + public undo(wizardContext: TContext): void { + wizardContext.pickedNodes.pop(); + } + + public shouldPrompt(_wizardContext: TContext): boolean { + return true; + } + + protected async promptInternal(wizardContext: TContext): Promise { + const picks = await this.getPicks(wizardContext); + + if (picks.length === 1 && this.pickOptions.skipIfOne) { + return picks[0].data; + } else { + const selected = await wizardContext.ui.showQuickPick(picks, { + noPicksMessage: localize('noMatchingResources', 'No matching resources found.'), + /* TODO: options */ + /* TODO: set id here so recently picked items appear at the top */ + }); + + return selected.data; + } + } + + protected async getPicks(wizardContext: TContext): Promise[]> { + const lastPickedItem: unknown | undefined = getLastNode(wizardContext); + + // TODO: if `lastPickedItem` is an `AzExtParentTreeItem`, should we clear its cache? + const childElements = (await this.treeDataProvider.getChildren(lastPickedItem)) || []; + const childItems = await Promise.all(childElements.map(async (childElement: unknown) => await this.treeDataProvider.getTreeItem(childElement))); + const childPairs: [unknown, vscode.TreeItem][] = childElements.map((childElement: unknown, i: number) => [childElement, childItems[i]]); + + const finalChoices = childPairs.filter(([, ti]) => this.pickFilter.isFinalPick(ti)); + const ancestorChoices = childPairs.filter(([, ti]) => this.pickFilter.isAncestorPick(ti)); + + let promptChoices: [unknown, vscode.TreeItem][] = []; + if (finalChoices.length === 0) { + if (ancestorChoices.length === 0) { + // Don't throw and end the wizard, let user use back button instead + } else { + promptChoices = ancestorChoices; + } + } else { + promptChoices = finalChoices; + } + + const picks: types.IAzureQuickPickItem[] = []; + for (const choice of promptChoices) { + picks.push(await this.getQuickPickItem(...choice)); + } + + return picks; + } + + private async getQuickPickItem(element: unknown, item: vscode.TreeItem): Promise> { + return { + label: ((item.label as vscode.TreeItemLabel)?.label || item.label) as string, + description: item.description as string, + data: element, + }; + } +} diff --git a/utils/src/treev2/quickPickWizard/RecursiveQuickPickStep.ts b/utils/src/treev2/quickPickWizard/RecursiveQuickPickStep.ts new file mode 100644 index 0000000000..67e4da32c1 --- /dev/null +++ b/utils/src/treev2/quickPickWizard/RecursiveQuickPickStep.ts @@ -0,0 +1,34 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as types from '../../../index'; +import { ContextValueFilterQuickPickOptions, ContextValueQuickPickStep } from './ContextValueQuickPickStep'; +import { getLastNode } from './common/getLastNode'; + +export class RecursiveQuickPickStep extends ContextValueQuickPickStep { + hideStepCount: boolean = true; + + public async getSubWizard(wizardContext: TContext): Promise | undefined> { + const lastPickedItem = getLastNode(wizardContext); + + if (!lastPickedItem) { + // Something went wrong, no node was chosen + throw new Error('No node was set after prompt step.'); + } + + if (this.pickFilter.isFinalPick(await this.treeDataProvider.getTreeItem(lastPickedItem))) { + // The last picked node matches the expected filter + // No need to continue prompting + return undefined; + } else { + // Need to keep going because the last picked node is not a match + return { + promptSteps: [ + new RecursiveQuickPickStep(this.treeDataProvider, this.pickOptions) + ], + }; + } + } +} diff --git a/utils/src/treev2/quickPickWizard/common/PickFilter.ts b/utils/src/treev2/quickPickWizard/common/PickFilter.ts new file mode 100644 index 0000000000..1e6a4b5e5a --- /dev/null +++ b/utils/src/treev2/quickPickWizard/common/PickFilter.ts @@ -0,0 +1,20 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Microsoft Corporation. All rights reserved. +* Licensed under the MIT License. See License.txt in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +import * as vscode from "vscode"; + +export interface PickFilter { + /** + * Filters for nodes that match the final target. + * @param node The node to apply the filter to + */ + isFinalPick(node: TPick): boolean; + + /** + * Filters for nodes that could be an ancestor of a node matching the final target. + * @param node The node to apply the filter to + */ + isAncestorPick(node: TPick): boolean; +} diff --git a/utils/src/treev2/quickPickWizard/common/getLastNode.ts b/utils/src/treev2/quickPickWizard/common/getLastNode.ts new file mode 100644 index 0000000000..e1ef19bcfa --- /dev/null +++ b/utils/src/treev2/quickPickWizard/common/getLastNode.ts @@ -0,0 +1,10 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as types from '../../../../index'; + +export function getLastNode(context: types.QuickPickWizardContext): TNode | undefined { + return context.pickedNodes.at(-1) as TNode | undefined; +} diff --git a/utils/src/treev2/quickPickWizard/compatibility/CompatibilityContextValueQuickPickStep.ts b/utils/src/treev2/quickPickWizard/compatibility/CompatibilityContextValueQuickPickStep.ts new file mode 100644 index 0000000000..3df53643d8 --- /dev/null +++ b/utils/src/treev2/quickPickWizard/compatibility/CompatibilityContextValueQuickPickStep.ts @@ -0,0 +1,54 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as types from "../../../../index"; +import { ContextValueFilterQuickPickOptions, ContextValueQuickPickStep } from "../ContextValueQuickPickStep"; +import { getLastNode } from "../common/getLastNode"; +import { AzExtTreeItem } from "../../../tree/AzExtTreeItem"; +import { AzExtParentTreeItem } from "../../../tree/AzExtParentTreeItem"; +import { isWrapper } from "../../../registerCommandWithTreeNodeUnwrapping"; +import { isAzExtParentTreeItem } from "../../../tree/isAzExtTreeItem"; + +/** + * Provides compatability with {@link AzExtParentTreeItem.pickTreeItemImpl} + */ +export class CompatibilityContextValueQuickPickStep extends ContextValueQuickPickStep { + + public override async prompt(wizardContext: TContext): Promise { + await this.provideCompatabilityWithPickTreeItemImpl(wizardContext) || await super.prompt(wizardContext); + } + + /** + * Mimics how the legacy {@link AzExtParentTreeItem.pickChildTreeItem} + * uses {@link AzExtParentTreeItem.pickTreeItemImpl} to customize the tree item picker. + * + * An example customization is skipping having to pick a UI-only node (ex: App Settings parent node) + */ + private async provideCompatabilityWithPickTreeItemImpl(wizardContext: TContext): Promise { + const lastPickedItem = getLastNode(wizardContext); + const lastPickedItemUnwrapped = isWrapper(lastPickedItem) ? lastPickedItem.unwrap() : lastPickedItem; + if (isAzExtParentTreeItem(lastPickedItemUnwrapped)) { + const children = await this.treeDataProvider.getChildren(lastPickedItem); + if (children && children.length) { + const customChild = await this.getCustomChildren(wizardContext, lastPickedItemUnwrapped); + + const customPick = children.find((child) => { + const ti: AzExtTreeItem = isWrapper(child) ? child.unwrap() : child as unknown as AzExtTreeItem; + return ti.fullId === customChild?.fullId; + }); + + if (customPick) { + wizardContext.pickedNodes.push(customPick); + return true; + } + } + } + return false; + } + + private async getCustomChildren(context: TContext, node: AzExtParentTreeItem): Promise { + return await node.pickTreeItemImpl?.(Array.isArray(this.pickOptions.contextValueFilter.include) ? this.pickOptions.contextValueFilter.include : [this.pickOptions.contextValueFilter.include], context); + } +} diff --git a/utils/src/treev2/quickPickWizard/compatibility/CompatibilityRecursiveQuickPickStep.ts b/utils/src/treev2/quickPickWizard/compatibility/CompatibilityRecursiveQuickPickStep.ts new file mode 100644 index 0000000000..7142f7abbc --- /dev/null +++ b/utils/src/treev2/quickPickWizard/compatibility/CompatibilityRecursiveQuickPickStep.ts @@ -0,0 +1,122 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as types from "../../../../index"; +import { getLastNode } from "../common/getLastNode"; +import { CompatibilityContextValueQuickPickStep } from './CompatibilityContextValueQuickPickStep'; +import { localize } from "../../../localize"; +import { NoResourceFoundError, UserCancelledError } from "../../../errors"; +import type { ContextValueFilterQuickPickOptions } from "../ContextValueQuickPickStep"; +import { AzExtTreeItem } from "../../../tree/AzExtTreeItem"; +import { isAzExtParentTreeItem, isAzExtTreeItem } from "../../../tree/isAzExtTreeItem"; +import { isWrapper } from "../../../registerCommandWithTreeNodeUnwrapping"; + +type CreateCallback = (context: types.IActionContext) => TNode | Promise; + +type CreateOptions = { + label?: string; + callback: CreateCallback; +} + +interface CompatibilityRecursiveQuickPickOptions extends ContextValueFilterQuickPickOptions { + create?: CreateOptions; +} + +/** + * Recursive step which is compatible which adds create picks based if the node has {@link types.CompatibleQuickPickOptions.createChild quickPickOptions.createChild} defined. + */ +export class CompatibilityRecursiveQuickPickStep extends CompatibilityContextValueQuickPickStep { + + protected override async promptInternal(wizardContext: TContext): Promise { + const picks = await this.getPicks(wizardContext) as types.IAzureQuickPickItem[]; + + if (picks.length === 1 && this.pickOptions.skipIfOne) { + return picks[0].data; + } else { + const selected = await wizardContext.ui.showQuickPick(picks, { + /* TODO: options */ + /* TODO: set id here so recently picked items appear at the top */ + }); + + // check if the last picked item is a create callback + if (typeof selected.data === 'function') { + // If the last node is a function, pop it off the list and execute it + const callback = selected.data as unknown as CreateCallback; + + // context passed to callback must have the same `ui` as the wizardContext + // to prevent the wizard from being cancelled unexpectedly + const createdPick = await callback?.(wizardContext); + + if (createdPick) { + return createdPick; + } + + throw new UserCancelledError(); + } + + return selected.data; + } + } + + public async getSubWizard(wizardContext: TContext): Promise | undefined> { + const lastPickedItem = getLastNode(wizardContext); + + if (!lastPickedItem) { + // Something went wrong, no node was chosen + throw new Error('No node was set after prompt step.'); + } + + // lastPickedItem might already be a tree item if the user picked a create callback + const ti = isAzExtTreeItem(lastPickedItem) ? lastPickedItem : await this.treeDataProvider.getTreeItem(lastPickedItem) as AzExtTreeItem; + + if (this.pickFilter.isFinalPick(ti)) { + // The last picked node matches the expected filter + // No need to continue prompting + return undefined; + } else { + const lastPickedItemTi = isWrapper(lastPickedItem) ? lastPickedItem.unwrap() : lastPickedItem; + // Need to keep going because the last picked node is not a match + return { + hideStepCount: true, + promptSteps: [ + new CompatibilityRecursiveQuickPickStep(this.treeDataProvider, { + ...this.pickOptions, + skipIfOne: isAzExtParentTreeItem(lastPickedItemTi) && !!lastPickedItemTi.createChildImpl, + create: (isAzExtParentTreeItem(lastPickedItemTi) && !!lastPickedItemTi.createChildImpl) ? { + callback: lastPickedItemTi.createChild.bind(lastPickedItemTi) as typeof lastPickedItemTi.createChild, + label: lastPickedItemTi.createNewLabel ?? localize('createNewItem', '$(plus) Create new {0}', lastPickedItemTi.childTypeLabel) + } : undefined + }) + ], + }; + } + } + + protected override async getPicks(wizardContext: TContext): Promise[]> { + const picks: types.IAzureQuickPickItem[] = []; + try { + picks.push(...await super.getPicks(wizardContext)); + } catch (error) { + if (error instanceof NoResourceFoundError && !!this.pickOptions.create) { + // swallow NoResourceFoundError if create is defined, since we'll add a create pick + } else { + throw error; + } + } + + if (this.pickOptions.create) { + picks.push(this.getCreatePick(this.pickOptions.create)); + } + + return picks as types.IAzureQuickPickItem[]; + } + + private getCreatePick(options: CreateOptions): types.IAzureQuickPickItem { + return { + label: options.label || localize('createQuickPickLabel', '$(add) Create...'), + data: options.callback, + }; + } +} diff --git a/utils/src/treev2/quickPickWizard/experiences/appResourceExperience.ts b/utils/src/treev2/quickPickWizard/experiences/appResourceExperience.ts new file mode 100644 index 0000000000..5582ba5ae7 --- /dev/null +++ b/utils/src/treev2/quickPickWizard/experiences/appResourceExperience.ts @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as types from '../../../../index'; +import { QuickPickAzureSubscriptionStep } from '../quickPickAzureResource/QuickPickAzureSubscriptionStep'; +import { QuickPickGroupStep } from '../quickPickAzureResource/QuickPickGroupStep'; +import { QuickPickAppResourceStep } from '../quickPickAzureResource/QuickPickAppResourceStep'; +import { RecursiveQuickPickStep } from '../RecursiveQuickPickStep'; +import { getLastNode } from '../common/getLastNode'; +import { NoResourceFoundError } from '../../../errors'; +import { AzureWizardPromptStep } from '../../../wizard/AzureWizardPromptStep'; +import { AzExtResourceType } from '../../../AzExtResourceType'; +import { AzureWizard } from '../../../wizard/AzureWizard'; +import { AzureResourceQuickPickWizardContext } from '../../../../hostapi.v2'; +import { isWrapper } from '../../../registerCommandWithTreeNodeUnwrapping'; +import { ResourceGroupsItem } from '../quickPickAzureResource/tempTypes'; + +export async function appResourceExperience(context: types.IActionContext, tdp: vscode.TreeDataProvider, resourceTypes?: AzExtResourceType | AzExtResourceType[], childItemFilter?: types.ContextValueFilter): Promise { + const promptSteps: AzureWizardPromptStep[] = [ + new QuickPickAzureSubscriptionStep(tdp), + new QuickPickGroupStep(tdp, { + groupType: resourceTypes ? + (Array.isArray(resourceTypes) ? resourceTypes : [resourceTypes]) : + undefined, + }), + new QuickPickAppResourceStep(tdp, { + resourceTypes: resourceTypes ? + (Array.isArray(resourceTypes) ? resourceTypes : [resourceTypes]) : + undefined, + skipIfOne: false, + }), + ]; + + if (childItemFilter) { + promptSteps.push(new RecursiveQuickPickStep(tdp, { + contextValueFilter: childItemFilter, + skipIfOne: false, + })); + } + + // Fill in the `pickedNodes` property + const wizardContext = { ...context } as AzureResourceQuickPickWizardContext; + wizardContext.pickedNodes = []; + + const wizard = new AzureWizard(context, { + hideStepCount: true, + promptSteps: promptSteps, + showLoadingPrompt: true, + }); + + await wizard.prompt(); + + const lastPickedItem = getLastNode(wizardContext); + + if (!lastPickedItem) { + throw new NoResourceFoundError(wizardContext); + } else { + return isWrapper(lastPickedItem) ? lastPickedItem.unwrap() : lastPickedItem as unknown as TPick; + } +} diff --git a/utils/src/treev2/quickPickWizard/experiences/compatibility/PickTreeItemWithCompatibility.ts b/utils/src/treev2/quickPickWizard/experiences/compatibility/PickTreeItemWithCompatibility.ts new file mode 100644 index 0000000000..4e4f3365f7 --- /dev/null +++ b/utils/src/treev2/quickPickWizard/experiences/compatibility/PickTreeItemWithCompatibility.ts @@ -0,0 +1,31 @@ +import * as types from '../../../../../index'; +import * as vscode from 'vscode'; +import { ResourceGroupsItem } from '../../quickPickAzureResource/tempTypes'; +import { appResourceExperience } from '../appResourceExperience'; +import { subscriptionExperience } from '../subscriptionExperience'; +import { isAzExtTreeItem } from '../../../../tree/isAzExtTreeItem'; +import { createSubscriptionContext } from '../../../../utils/credentialUtils'; +import { ISubscriptionContext } from '@microsoft/vscode-azext-dev'; + +export namespace PickTreeItemWithCompatibility { + /** + * Provides compatibility for the legacy `pickAppResource` Resource Groups API + */ + export async function resource(context: types.IActionContext, tdp: vscode.TreeDataProvider, options: types.CompatibilityPickResourceExperienceOptions): Promise { + const { resourceTypes, childItemFilter } = options; + return appResourceExperience(context, tdp, resourceTypes ? Array.isArray(resourceTypes) ? resourceTypes : [resourceTypes] : undefined, childItemFilter); + } + + /** + * Returns `ISubscriptionContext` instead of `ApplicationSubscription` for compatibility. + */ + export async function subscription(context: types.IActionContext, tdp: vscode.TreeDataProvider): Promise { + const applicationSubscription = await subscriptionExperience(context, tdp); + + if (isAzExtTreeItem(applicationSubscription)) { + return applicationSubscription.subscription; + } + + return createSubscriptionContext(applicationSubscription); + } +} diff --git a/utils/src/treev2/quickPickWizard/experiences/contextValueExperience.ts b/utils/src/treev2/quickPickWizard/experiences/contextValueExperience.ts new file mode 100644 index 0000000000..b0db875ce0 --- /dev/null +++ b/utils/src/treev2/quickPickWizard/experiences/contextValueExperience.ts @@ -0,0 +1,42 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as types from '../../../../index'; +import * as vscode from 'vscode'; +import { RecursiveQuickPickStep } from '../RecursiveQuickPickStep'; +import { getLastNode } from '../common/getLastNode'; +import { NoResourceFoundError } from '../../../errors'; +import { AzureWizardPromptStep } from '../../../wizard/AzureWizardPromptStep'; +import { AzureWizard } from '../../../wizard/AzureWizard'; +import { isWrapper } from '../../../registerCommandWithTreeNodeUnwrapping'; +import { ResourceGroupsItem } from '../quickPickAzureResource/tempTypes'; + +export async function contextValueExperience(context: types.IActionContext, tdp: vscode.TreeDataProvider, contextValueFilter: types.ContextValueFilter): Promise { + const promptSteps: AzureWizardPromptStep[] = [ + new RecursiveQuickPickStep(tdp, { + contextValueFilter: contextValueFilter, + skipIfOne: false, + }), + ]; + + // Fill in the `pickedNodes` property + const wizardContext = { ...context } as types.QuickPickWizardContext; + wizardContext.pickedNodes = []; + + const wizard = new AzureWizard(context, { + hideStepCount: true, + promptSteps: promptSteps, + }); + + await wizard.prompt(); + + const lastPickedItem = getLastNode(wizardContext); + + if (!lastPickedItem) { + throw new NoResourceFoundError(wizardContext); + } else { + return isWrapper(lastPickedItem) ? lastPickedItem.unwrap() : lastPickedItem as unknown as TPick; + } +} diff --git a/utils/src/treev2/quickPickWizard/experiences/subscriptionExperience.ts b/utils/src/treev2/quickPickWizard/experiences/subscriptionExperience.ts new file mode 100644 index 0000000000..ab96a1fb30 --- /dev/null +++ b/utils/src/treev2/quickPickWizard/experiences/subscriptionExperience.ts @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Microsoft Corporation. All rights reserved. +* Licensed under the MIT License. See License.txt in the project root for license information. +*--------------------------------------------------------------------------------------------*/ + +import * as types from '../../../../index'; +import * as vscode from 'vscode'; +import { AzureWizard } from '../../../wizard/AzureWizard'; +import { QuickPickAzureSubscriptionStep } from '../quickPickAzureResource/QuickPickAzureSubscriptionStep'; +import { AzureSubscription, PickSubscriptionWizardContext } from '../../../../hostapi.v2'; +import { ResourceGroupsItem } from '../quickPickAzureResource/tempTypes'; +import { NoResourceFoundError } from '../../../errors'; + +export async function subscriptionExperience(context: types.IActionContext, tdp: vscode.TreeDataProvider): Promise { + + const wizardContext = { ...context } as PickSubscriptionWizardContext; + wizardContext.pickedNodes = []; + + const wizard = new AzureWizard(wizardContext, { + hideStepCount: true, + promptSteps: [new QuickPickAzureSubscriptionStep(tdp)], + showLoadingPrompt: true, + }); + + await wizard.prompt(); + + if (!wizardContext.subscription) { + throw new NoResourceFoundError(wizardContext); + } else { + return wizardContext.subscription; + } +} diff --git a/utils/src/treev2/quickPickWizard/quickPickAzureResource/QuickPickAppResourceStep.ts b/utils/src/treev2/quickPickWizard/quickPickAzureResource/QuickPickAppResourceStep.ts new file mode 100644 index 0000000000..0aa5b0c0ea --- /dev/null +++ b/utils/src/treev2/quickPickWizard/quickPickAzureResource/QuickPickAppResourceStep.ts @@ -0,0 +1,62 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { TreeItem } from 'vscode'; +import { AzureResourceQuickPickWizardContext } from '../../../../hostapi.v2'; +import * as types from '../../../../index'; +import { parseContextValue } from '../../../utils/contextUtils'; +import { PickFilter } from '../common/PickFilter'; +import { GenericQuickPickOptions, GenericQuickPickStep } from '../GenericQuickPickStep'; +import { AzureResourceItem } from './tempTypes'; + +interface AppResourceQuickPickOptions extends GenericQuickPickOptions { + resourceTypes?: types.AzExtResourceType[]; + childItemFilter?: types.ContextValueFilter; +} + +export class QuickPickAppResourceStep extends GenericQuickPickStep { + protected override async promptInternal(wizardContext: AzureResourceQuickPickWizardContext): Promise { + const pickedAppResource = (await super.promptInternal(wizardContext)) as unknown as AzureResourceItem; + + // TODO + wizardContext.resource = pickedAppResource.resource; + wizardContext.resourceGroup = pickedAppResource.resource.resourceGroup; + + return pickedAppResource; + } + + protected readonly pickFilter: PickFilter = new AppResourcePickFilter(this.pickOptions); +} + +class AppResourcePickFilter implements PickFilter { + + constructor(private readonly pickOptions: AppResourceQuickPickOptions) { } + + isFinalPick(node: TreeItem): boolean { + // If childItemFilter is defined, this cannot be a direct pick + if (this.pickOptions.childItemFilter) { + return false; + } + + return this.matchesResourceType(parseContextValue(node.contextValue)); + } + + isAncestorPick(node: TreeItem): boolean { + // If childItemFilter is undefined, this cannot be an indirect pick + if (!this.pickOptions.childItemFilter) { + return false; + } + + return this.matchesResourceType(parseContextValue(node.contextValue)); + } + + private matchesResourceType(contextValues: string[]): boolean { + if (!contextValues.includes('azureResource')) { + return false; + } + + return !this.pickOptions.resourceTypes || this.pickOptions.resourceTypes.some((type) => contextValues.includes(type)); + } +} diff --git a/utils/src/treev2/quickPickWizard/quickPickAzureResource/QuickPickAzureSubscriptionStep.ts b/utils/src/treev2/quickPickWizard/quickPickAzureResource/QuickPickAzureSubscriptionStep.ts new file mode 100644 index 0000000000..6e7b18a64d --- /dev/null +++ b/utils/src/treev2/quickPickWizard/quickPickAzureResource/QuickPickAzureSubscriptionStep.ts @@ -0,0 +1,42 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { AzureResourceQuickPickWizardContext } from '../../../../hostapi.v2'; +import { PickFilter } from '../common/PickFilter'; +import { GenericQuickPickOptions, GenericQuickPickStep, SkipIfOneQuickPickOptions } from '../GenericQuickPickStep'; +import { ResourceGroupsItem, SubscriptionItem } from './tempTypes'; + +export class QuickPickAzureSubscriptionStep extends GenericQuickPickStep { + public constructor(tdp: vscode.TreeDataProvider, options?: GenericQuickPickOptions) { + super(tdp, { + ...options, + skipIfOne: true, // Subscription is always skip-if-one + }); + } + + protected readonly pickFilter = new AzureSubscriptionPickFilter(); + + protected override async promptInternal(wizardContext: AzureResourceQuickPickWizardContext): Promise { + const pickedSubscription = await super.promptInternal(wizardContext) as SubscriptionItem; + + // TODO + wizardContext.subscription = pickedSubscription.subscription; + + return pickedSubscription; + } +} + +class AzureSubscriptionPickFilter implements PickFilter { + isFinalPick(_node: vscode.TreeItem): boolean { + // Subscription is never a direct pick + return false; + } + + isAncestorPick(_node: vscode.TreeItem): boolean { + // All nodes at this level are always subscription nodes + return true; + } +} diff --git a/utils/src/treev2/quickPickWizard/quickPickAzureResource/QuickPickGroupStep.ts b/utils/src/treev2/quickPickWizard/quickPickAzureResource/QuickPickGroupStep.ts new file mode 100644 index 0000000000..f89a6cad92 --- /dev/null +++ b/utils/src/treev2/quickPickWizard/quickPickAzureResource/QuickPickGroupStep.ts @@ -0,0 +1,44 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as types from '../../../../index'; +import * as vscode from 'vscode'; +import { GenericQuickPickStep, SkipIfOneQuickPickOptions } from '../GenericQuickPickStep'; +import { AzureResourceQuickPickWizardContext } from '../../../../hostapi.v2'; +import { parseContextValue } from '../../../utils/contextUtils'; +import { PickFilter } from '../common/PickFilter'; + +interface GroupQuickPickOptions extends SkipIfOneQuickPickOptions { + groupType?: types.AzExtResourceType[]; + skipIfOne?: true; +} + +export class QuickPickGroupStep extends GenericQuickPickStep { + public constructor(tdp: vscode.TreeDataProvider, options: GroupQuickPickOptions) { + super(tdp, { + ...options, + skipIfOne: true, // Group is always skip-if-one + }); + } + + protected readonly pickFilter: PickFilter = new GroupPickFilter(this.pickOptions); +} + +class GroupPickFilter implements PickFilter { + constructor(private readonly pickOptions: GroupQuickPickOptions) { } + + isFinalPick(_node: vscode.TreeItem): boolean { + // Group is never a direct pick + return false; + } + + isAncestorPick(node: vscode.TreeItem): boolean { + const contextValues = parseContextValue(node.contextValue); + + return !this.pickOptions.groupType || + !contextValues.includes('azureResourceTypeGroup') || + this.pickOptions.groupType.some(groupType => contextValues.includes(groupType)); + } +} diff --git a/utils/src/treev2/quickPickWizard/quickPickAzureResource/tempTypes.ts b/utils/src/treev2/quickPickWizard/quickPickAzureResource/tempTypes.ts new file mode 100644 index 0000000000..faed8a3cf6 --- /dev/null +++ b/utils/src/treev2/quickPickWizard/quickPickAzureResource/tempTypes.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureResource, AzureSubscription } from '../../../../hostapi.v2'; +import * as types from '../../../../index'; + +// TODO: THIS FILE IS TEMPORARY // +// It needs to be replaced by real Resources extension interfaces // + +// These are assumptions made about the nodes in the tree + +export type SubscriptionItem = ResourceGroupsItem & { + subscription: AzureSubscription; +} + +export type GroupingItem = ResourceGroupsItem & { + resourceType?: types.AzExtResourceType +} + +export type AzureResourceItem = ResourceGroupsItem & { + resource: AzureResource; +}; + +export type ResourceGroupsItem = unknown; diff --git a/utils/src/utils/contextUtils.ts b/utils/src/utils/contextUtils.ts index 2082841e14..109255d98c 100644 --- a/utils/src/utils/contextUtils.ts +++ b/utils/src/utils/contextUtils.ts @@ -6,3 +6,7 @@ export function createContextValue(values: string[]): string { return Array.from(new Set(values)).sort().join(';'); } + +export function parseContextValue(contextValue?: string): string[] { + return contextValue?.split(';') ?? []; +} diff --git a/utils/src/utils/credentialUtils.ts b/utils/src/utils/credentialUtils.ts new file mode 100644 index 0000000000..4ad847a41b --- /dev/null +++ b/utils/src/utils/credentialUtils.ts @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { AzureSubscription } from '../../hostapi.v2'; +import { AzExtServiceClientCredentials, ISubscriptionContext } from '../../index'; +import { localize } from '../localize'; + +/** + * Converts a VS Code authentication session to an Azure Track 1 & 2 compatible compatible credential. + */ +export function createCredential(getSession: (scopes?: string[]) => vscode.ProviderResult): AzExtServiceClientCredentials { + return { + getToken: async (scopes?: string | string[]) => { + if (typeof scopes === 'string') { + scopes = [scopes]; + } + + const session = await getSession(scopes); + + if (session) { + return { + token: session.accessToken + }; + } else { + return null; + } + }, + signRequest: async () => { + throw new Error((localize('signRequestError', 'Track 1 credentials are not (currently) supported.'))); + } + }; +} + +/** + * Creates a subscription context from an application subscription. + * + * TODO: expose these utils and remove duplicate code in resource groups + client extensions + */ +export function createSubscriptionContext(subscription: AzureSubscription): ISubscriptionContext { + return { + subscriptionDisplayName: subscription.name, + userId: '', // TODO + subscriptionPath: subscription.subscriptionId, + ...subscription, + credentials: createCredential(subscription.authentication.getSession) + }; +} diff --git a/utils/src/wizard/AzureWizard.ts b/utils/src/wizard/AzureWizard.ts index f862c18069..c563880627 100644 --- a/utils/src/wizard/AzureWizard.ts +++ b/utils/src/wizard/AzureWizard.ts @@ -221,6 +221,7 @@ export class AzureWizard impl public abstract prompt(wizardContext: T): Promise; public getSubWizard?(wizardContext: T): Promise | undefined>; + public undo?(wizardContext: T): void; public abstract shouldPrompt(wizardContext: T): boolean;