From a8e3de97e852595b196d291ffef3a39a0568aadf Mon Sep 17 00:00:00 2001 From: andrew-eldridge Date: Tue, 11 Aug 2026 15:57:21 -0400 Subject: [PATCH 1/6] add extension command 'Add custom code' --- .../__test__/commandWebviewWrappers.test.ts | 23 +- .../__test__/registerCommands.test.ts | 7 + .../__test__/addCustomCode.test.ts | 197 ++++++++++++++++++ .../commands/addCustomCode/addCustomCode.ts | 133 ++++++++++++ .../commands/createProject/createProject.ts | 11 +- .../src/app/commands/registerCommands.ts | 4 +- .../utils/__test__/customCodeUtils.test.ts | 1 + .../src/app/utils/customCodeUtils.ts | 26 ++- .../src/app/utils/workspace.ts | 135 +----------- apps/vs-code-designer/src/constants.ts | 2 + apps/vs-code-designer/src/main.ts | 7 +- apps/vs-code-designer/src/package.json | 14 ++ .../__test__/createWorkspace.test.tsx | 1 + .../__test__/dotNetFrameworkStep.test.tsx | 1 + .../steps/__test__/logicAppTypeStep.test.tsx | 34 ++- .../steps/__test__/reviewCreateStep.test.tsx | 1 + .../steps/__test__/workflowTypeStep.test.tsx | 1 + .../steps/__test__/workspaceNameStep.test.tsx | 1 + .../steps/logicAppTypeStep.tsx | 5 +- .../__test__/createWorkspaceSlice.test.ts | 30 +++ .../src/state/createWorkspaceSlice.ts | 19 +- 21 files changed, 497 insertions(+), 156 deletions(-) create mode 100644 apps/vs-code-designer/src/app/commands/addCustomCode/__test__/addCustomCode.test.ts create mode 100644 apps/vs-code-designer/src/app/commands/addCustomCode/addCustomCode.ts diff --git a/apps/vs-code-designer/src/app/commands/__test__/commandWebviewWrappers.test.ts b/apps/vs-code-designer/src/app/commands/__test__/commandWebviewWrappers.test.ts index 110438f2fc6..6816b72b4f4 100644 --- a/apps/vs-code-designer/src/app/commands/__test__/commandWebviewWrappers.test.ts +++ b/apps/vs-code-designer/src/app/commands/__test__/commandWebviewWrappers.test.ts @@ -4,7 +4,8 @@ import * as vscode from 'vscode'; import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; import { ext } from '../../../extensionVariables'; import { hasCodefulWorkflowSetting } from '../../utils/codeful'; -import { getLogicAppWithoutCustomCode, getWorkspaceRoot } from '../../utils/workspace'; +import { getWorkspaceRoot } from '../../utils/workspace'; +import { getEligibleLogicAppFoldersForCustomCode } from '../../utils/customCodeUtils'; import { tryGetLogicAppProjectRoot } from '../../utils/verifyIsProject'; import { cloudToLocal } from '../cloudToLocal/cloudToLocal'; import { ensureWorkspace } from '../ensureWorkspace'; @@ -54,10 +55,13 @@ vi.mock('../createWorkflow/createLogicAppWorkflow', () => ({ })); vi.mock('../../utils/workspace', () => ({ - getLogicAppWithoutCustomCode: vi.fn(), getWorkspaceRoot: vi.fn(), })); +vi.mock('../../utils/customCodeUtils', () => ({ + getEligibleLogicAppFoldersForCustomCode: vi.fn(), +})); + vi.mock('../../utils/codeful', () => ({ hasCodefulWorkflowSetting: vi.fn(), })); @@ -73,7 +77,7 @@ function getLastWebviewConfig(): WorkspaceWebviewCommandConfig { describe('workspace webview command wrappers', () => { const context = { telemetry: { properties: {}, measurements: {} } } as any; - const workspaceRoot = 'D:\\workspace'; + const workspaceRoot = path.resolve(path.sep, 'workspace'); const logicAppRoot = path.join(workspaceRoot, 'LogicApp'); beforeEach(() => { @@ -83,7 +87,7 @@ describe('workspace webview command wrappers', () => { (getWorkspaceRoot as Mock).mockResolvedValue(workspaceRoot); (tryGetLogicAppProjectRoot as Mock).mockResolvedValue(logicAppRoot); (hasCodefulWorkflowSetting as Mock).mockResolvedValue(false); - (getLogicAppWithoutCustomCode as Mock).mockResolvedValue([]); + (getEligibleLogicAppFoldersForCustomCode as Mock).mockResolvedValue([]); }); it('createWorkspace passes workspace config and invokes createLogicAppWorkspace', async () => { @@ -126,9 +130,9 @@ describe('workspace webview command wrappers', () => { }); it('createProject opens the project webview when a workspace is present', async () => { - const workspaceFile = { fsPath: 'D:\\workspace\\MyWorkspace.code-workspace' }; + const workspaceFile = { fsPath: path.join(workspaceRoot, 'MyWorkspace.code-workspace') }; const workspaceFileJson = { folders: [{ path: './LogicApp' }] }; - const logicAppsWithoutCustomCode = ['LogicApp']; + const eligiblePaths = [path.join(workspaceRoot, 'LogicApp')]; (vscode.workspace as any).workspaceFile = workspaceFile; (vscode.workspace.fs.readFile as Mock).mockResolvedValue(Buffer.from(JSON.stringify(workspaceFileJson))); (vscode.workspace.fs.readDirectory as Mock).mockResolvedValue([ @@ -136,7 +140,7 @@ describe('workspace webview command wrappers', () => { ['CSharpProject', 'directory'], ['MyWorkspace.code-workspace', 'file'], ]); - (getLogicAppWithoutCustomCode as Mock).mockResolvedValue(logicAppsWithoutCustomCode); + (getEligibleLogicAppFoldersForCustomCode as Mock).mockResolvedValue(eligiblePaths); await createProject(context); @@ -147,9 +151,10 @@ describe('workspace webview command wrappers', () => { projectName: ProjectName.createLogicApp, createCommand: ExtensionCommand.createLogicApp, }); + const expectedLogicAppPath = path.join(workspaceRoot, 'LogicApp'); expect(config.extraInitializeData).toEqual({ workspaceFileJson, - logicAppsWithoutCustomCode, + logicAppsWithoutCustomCode: [{ label: 'LogicApp', description: expectedLogicAppPath, data: expectedLogicAppPath }], existingFolders: ['LogicApp', 'CSharpProject'], }); expect(config.dialogOptions?.workspace).toMatchObject({ @@ -166,7 +171,7 @@ describe('workspace webview command wrappers', () => { }); it('getExistingFoldersOnDisk filters out non-directory entries using FileType mock', async () => { - const workspaceFile = { fsPath: 'D:\\workspace\\MyWorkspace.code-workspace' }; + const workspaceFile = { fsPath: path.join(workspaceRoot, 'MyWorkspace.code-workspace') }; const workspaceFileJson = { folders: [{ path: './LogicApp' }] }; (vscode.workspace as any).workspaceFile = workspaceFile; (vscode.workspace.fs.readFile as Mock).mockResolvedValue(Buffer.from(JSON.stringify(workspaceFileJson))); diff --git a/apps/vs-code-designer/src/app/commands/__test__/registerCommands.test.ts b/apps/vs-code-designer/src/app/commands/__test__/registerCommands.test.ts index 1878744f48f..83184a954b3 100644 --- a/apps/vs-code-designer/src/app/commands/__test__/registerCommands.test.ts +++ b/apps/vs-code-designer/src/app/commands/__test__/registerCommands.test.ts @@ -96,6 +96,7 @@ vi.mock('../cloudToLocal/cloudToLocal', () => ({ cloudToLocal: vi.fn() })); vi.mock('../createProject/createProject', () => ({ createProject: vi.fn() })); vi.mock('../createWorkspace/createWorkspace', () => ({ createWorkspace: vi.fn() })); vi.mock('../createCustomCodeFunction/createCustomCodeFunction', () => ({ createCustomCodeFunction: vi.fn() })); +vi.mock('../addCustomCode/addCustomCode', () => ({ addCustomCode: vi.fn() })); vi.mock('../createSlot', () => ({ createSlot: vi.fn() })); vi.mock('../createWorkflow/createWorkflow', () => ({ createWorkflow: vi.fn() })); vi.mock('../dataMapper/dataMapper', () => ({ createDataMap: vi.fn(), loadDataMapFile: vi.fn() })); @@ -195,6 +196,12 @@ describe('registerCommands', () => { expect(totalRegistrations).toBeGreaterThan(40); }); + it('should register the addCustomCode command', () => { + registerCommands(); + const registeredCommands = mockRegisterCommand.mock.calls.map((call: any[]) => call[0]); + expect(registeredCommands).toContain('azureLogicAppsStandard.addCustomCode'); + }); + describe('error handler', () => { it('should suppress report issue button for all errors', () => { registerCommands(); diff --git a/apps/vs-code-designer/src/app/commands/addCustomCode/__test__/addCustomCode.test.ts b/apps/vs-code-designer/src/app/commands/addCustomCode/__test__/addCustomCode.test.ts new file mode 100644 index 00000000000..1406e28ded0 --- /dev/null +++ b/apps/vs-code-designer/src/app/commands/addCustomCode/__test__/addCustomCode.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { IActionContext } from '@microsoft/vscode-azext-utils'; +import * as vscode from 'vscode'; + +// Hoisted mocks +const { + mockIsLogicAppProject, + mockHasCodefulSdkReference, + mockTryGetLogicAppCustomCodeFunctionsProjects, + mockCreateWorkspaceWebviewCommandHandler, + mockShowErrorMessage, + mockCallWithTelemetryAndErrorHandling, +} = vi.hoisted(() => ({ + mockIsLogicAppProject: vi.fn(), + mockHasCodefulSdkReference: vi.fn(), + mockTryGetLogicAppCustomCodeFunctionsProjects: vi.fn(), + mockCreateWorkspaceWebviewCommandHandler: vi.fn(), + mockShowErrorMessage: vi.fn(), + mockCallWithTelemetryAndErrorHandling: vi.fn(async (_id: string, cb: any) => + cb({ + telemetry: { properties: {} }, + errorHandling: {}, + }) + ), +})); + +vi.mock('vscode', () => ({ + window: { + showErrorMessage: mockShowErrorMessage, + }, + workspace: { + workspaceFile: undefined as vscode.Uri | undefined, + fs: { + readFile: vi.fn(), + readDirectory: vi.fn().mockResolvedValue([]), + }, + }, + Uri: { + file: (p: string) => ({ fsPath: p, path: p, scheme: 'file' }), + }, + FileType: { Directory: 2 }, +})); + +vi.mock('../../../utils/verifyIsProject', () => ({ + isLogicAppProject: mockIsLogicAppProject, +})); + +vi.mock('../../../utils/codeful', () => ({ + hasCodefulSdkReference: mockHasCodefulSdkReference, +})); + +vi.mock('../../../utils/customCodeUtils', () => ({ + tryGetLogicAppCustomCodeFunctionsProjects: mockTryGetLogicAppCustomCodeFunctionsProjects, + getAllCustomCodeFunctionsProjects: vi.fn().mockResolvedValue([]), + getEligibleLogicAppFoldersForCustomCode: vi.fn().mockResolvedValue([]), +})); + +vi.mock('../../shared/workspaceWebviewCommandHandler', () => ({ + createWorkspaceWebviewCommandHandler: mockCreateWorkspaceWebviewCommandHandler, +})); + +vi.mock('../../createNewCodeProject/CodeProjectBase/CreateLogicAppProjects', () => ({ + createLogicAppProject: vi.fn(), +})); + +vi.mock('../../../../localize', () => ({ + localize: (_key: string, msg: string, ...args: any[]) => { + let result = msg; + args.forEach((a, i) => { + result = result.replace(`{${i}}`, String(a)); + }); + return result; + }, +})); + +vi.mock('../../../../extensionVariables', () => ({ + ext: { + outputChannel: { appendLog: vi.fn() }, + webViewKey: { createLogicApp: 'createLogicApp' }, + extensionVersion: '1.0.0', + context: { extensionPath: '/mock' }, + }, +})); + +vi.mock('../../../../constants', () => ({ + extensionContext: { + customCodeFunctionsFolders: 'azureLogicAppsStandard.customCode.functionsFolders', + eligibleLogicAppFolders: 'azureLogicAppsStandard.customCode.eligibleLogicAppFolders', + }, +})); + +vi.mock('@microsoft/vscode-azext-utils', () => ({ + callWithTelemetryAndErrorHandling: mockCallWithTelemetryAndErrorHandling, + AzureWizardPromptStep: vi.fn(), + AzureWizardExecuteStep: vi.fn(), + AzureWizard: class { + async prompt() {} + async execute() {} + }, + registerCommand: vi.fn(), + registerCommandWithTreeNodeUnwrapping: vi.fn(), + registerErrorHandler: vi.fn(), + registerReportIssueCommand: vi.fn(), + unwrapTreeNodeCommandCallback: vi.fn(), + parseError: vi.fn(() => ({ message: 'mock error' })), + UserCancelledError: class extends Error {}, + nonNullProp: vi.fn(), + nonNullValue: vi.fn(), + nonNullOrEmptyValue: vi.fn((v: any) => v), + DialogResponses: vi.fn(), + AzExtTreeItem: class {}, + AzExtParentTreeItem: class {}, + openUrl: vi.fn(), +})); + +import { addCustomCode } from '../addCustomCode'; + +describe('addCustomCode', () => { + const createContext = (): IActionContext => + ({ + telemetry: { properties: {}, measurements: {} }, + errorHandling: {}, + ui: {}, + valuesToMask: [], + }) as unknown as IActionContext; + + beforeEach(() => { + vi.clearAllMocks(); + mockIsLogicAppProject.mockResolvedValue(false); + mockHasCodefulSdkReference.mockResolvedValue(false); + mockTryGetLogicAppCustomCodeFunctionsProjects.mockResolvedValue(undefined); + }); + + it('should show error when no URI is provided', async () => { + await addCustomCode(createContext(), undefined); + expect(mockShowErrorMessage).toHaveBeenCalledWith(expect.stringContaining('Explorer context menu')); + expect(mockCreateWorkspaceWebviewCommandHandler).not.toHaveBeenCalled(); + }); + + it('should show error when folder is not a Logic App project', async () => { + mockIsLogicAppProject.mockResolvedValue(false); + const uri = vscode.Uri.file('/workspace/notALogicApp'); + await addCustomCode(createContext(), uri); + expect(mockShowErrorMessage).toHaveBeenCalledWith(expect.stringContaining('not a Logic App project')); + expect(mockCreateWorkspaceWebviewCommandHandler).not.toHaveBeenCalled(); + }); + + it('should show error when folder is a codeful project', async () => { + mockIsLogicAppProject.mockResolvedValue(true); + mockHasCodefulSdkReference.mockResolvedValue(true); + const uri = vscode.Uri.file('/workspace/codefulApp'); + await addCustomCode(createContext(), uri); + expect(mockShowErrorMessage).toHaveBeenCalledWith(expect.stringContaining('.NET SDK project')); + expect(mockCreateWorkspaceWebviewCommandHandler).not.toHaveBeenCalled(); + }); + + it('should show error when custom code already exists', async () => { + mockIsLogicAppProject.mockResolvedValue(true); + mockHasCodefulSdkReference.mockResolvedValue(false); + mockTryGetLogicAppCustomCodeFunctionsProjects.mockResolvedValue(['/workspace/MyFunctions']); + const uri = vscode.Uri.file('/workspace/myLogicApp'); + await addCustomCode(createContext(), uri); + expect(mockShowErrorMessage).toHaveBeenCalledWith(expect.stringContaining('already has an associated custom code project')); + expect(mockCreateWorkspaceWebviewCommandHandler).not.toHaveBeenCalled(); + }); + + it('should show error when no workspace file is open', async () => { + mockIsLogicAppProject.mockResolvedValue(true); + mockHasCodefulSdkReference.mockResolvedValue(false); + mockTryGetLogicAppCustomCodeFunctionsProjects.mockResolvedValue([]); + (vscode.workspace as any).workspaceFile = undefined; + const uri = vscode.Uri.file('/workspace/myLogicApp'); + await addCustomCode(createContext(), uri); + expect(mockShowErrorMessage).toHaveBeenCalledWith(expect.stringContaining('.code-workspace')); + expect(mockCreateWorkspaceWebviewCommandHandler).not.toHaveBeenCalled(); + }); + + it('should open wizard with pre-configured custom code data when all validations pass', async () => { + mockIsLogicAppProject.mockResolvedValue(true); + mockHasCodefulSdkReference.mockResolvedValue(false); + mockTryGetLogicAppCustomCodeFunctionsProjects.mockResolvedValue([]); + const workspaceUri = vscode.Uri.file('/workspace/myWorkspace.code-workspace'); + (vscode.workspace as any).workspaceFile = workspaceUri; + (vscode.workspace.fs.readFile as any).mockResolvedValue(Buffer.from(JSON.stringify({ folders: [{ path: 'myLogicApp' }] }))); + (vscode.workspace.fs.readDirectory as any).mockResolvedValue([['myLogicApp', vscode.FileType.Directory]]); + + const uri = vscode.Uri.file('/workspace/myLogicApp'); + await addCustomCode(createContext(), uri); + + expect(mockCreateWorkspaceWebviewCommandHandler).toHaveBeenCalledTimes(1); + const config = mockCreateWorkspaceWebviewCommandHandler.mock.calls[0][0]; + expect(config.extraInitializeData.isAddCustomCodeFlow).toBe(true); + expect(config.extraInitializeData.preselectedLogicAppName).toBe('myLogicApp'); + expect(config.extraInitializeData.preselectedLogicAppType).toBe('customCode'); + expect(config.extraInitializeData.logicAppsWithoutCustomCode).toEqual([expect.objectContaining({ label: 'myLogicApp' })]); + }); +}); diff --git a/apps/vs-code-designer/src/app/commands/addCustomCode/addCustomCode.ts b/apps/vs-code-designer/src/app/commands/addCustomCode/addCustomCode.ts new file mode 100644 index 00000000000..bf4941d4316 --- /dev/null +++ b/apps/vs-code-designer/src/app/commands/addCustomCode/addCustomCode.ts @@ -0,0 +1,133 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils'; +import { ExtensionCommand, ProjectName, ProjectType } from '@microsoft/vscode-extension-logic-apps'; +import { localize } from '../../../localize'; +import { ext } from '../../../extensionVariables'; +import { extensionContext } from '../../../constants'; +import { createWorkspaceWebviewCommandHandler } from '../shared/workspaceWebviewCommandHandler'; +import { createLogicAppProject } from '../createNewCodeProject/CodeProjectBase/CreateLogicAppProjects'; +import { isLogicAppProject } from '../../utils/verifyIsProject'; +import { hasCodefulSdkReference } from '../../utils/codeful'; +import { + tryGetLogicAppCustomCodeFunctionsProjects, + getAllCustomCodeFunctionsProjects, + getEligibleLogicAppFoldersForCustomCode, +} from '../../utils/customCodeUtils'; +import * as vscode from 'vscode'; +import * as path from 'path'; + +/** + * Command handler for the "Add .NET custom code" Explorer context-menu action. + * Opens the Create Project wizard pre-configured for custom-code with the target Logic App locked. + */ +export async function addCustomCode(context: IActionContext, node?: vscode.Uri): Promise { + if (!node) { + context.telemetry.properties.result = 'Failed'; + vscode.window.showErrorMessage(localize('addCustomCodeNoUri', 'This command must be invoked from the Explorer context menu on a Logic App project folder.')); + return; + } + + const projectPath = node.fsPath; + context.telemetry.properties.lastStep = 'validateFolder'; + if (!(await isLogicAppProject(projectPath))) { + context.telemetry.properties.result = 'Failed'; + vscode.window.showErrorMessage(localize('addCustomCodeNotLogicApp', 'The selected folder is not a Logic App project.')); + return; + } + + if (await hasCodefulSdkReference(projectPath)) { + context.telemetry.properties.result = 'Failed'; + vscode.window.showErrorMessage(localize('addCustomCodeIsCodeful', 'This Logic App already uses a .NET SDK project. Custom code is not applicable.')); + return; + } + + context.telemetry.properties.lastStep = 'checkExistingCustomCode'; + const existingCustomCode = await tryGetLogicAppCustomCodeFunctionsProjects(projectPath); + if (existingCustomCode && existingCustomCode.length > 0) { + context.telemetry.properties.result = 'Failed'; + vscode.window.showErrorMessage( + localize( + 'addCustomCodeAlreadyExists', + 'This Logic App already has an associated custom code project: "{0}".', + path.basename(existingCustomCode[0]) + ) + ); + return; + } + + if (!vscode.workspace.workspaceFile) { + context.telemetry.properties.result = 'Failed'; + vscode.window.showErrorMessage(localize('addCustomCodeNoWorkspace', 'Please open a Logic App workspace (.code-workspace) before adding custom code.')); + return; + } + + const workspaceRootFolder = path.dirname(vscode.workspace.workspaceFile.fsPath); + const logicAppName = path.basename(projectPath); + + context.telemetry.properties.lastStep = 'readWorkspace'; + const workspaceFileContent = await vscode.workspace.fs.readFile(vscode.workspace.workspaceFile); + const workspaceFileJson = JSON.parse(workspaceFileContent.toString()); + + const existingFolders = await getExistingFoldersOnDisk(workspaceRootFolder); + + ext.outputChannel.appendLog(`[addCustomCode] target=${logicAppName}, workspaceRoot=${workspaceRootFolder}`); + + context.telemetry.properties.lastStep = 'openWizard'; + await createWorkspaceWebviewCommandHandler({ + panelName: localize('addCustomCodeProject', 'Add .NET custom code'), + panelGroupKey: ext.webViewKey.createLogicApp, + projectName: ProjectName.createLogicApp, + createCommand: ExtensionCommand.createLogicApp, + createHandler: async (data: any) => { + await callWithTelemetryAndErrorHandling('addCustomCode.createHandler', async (actionContext: IActionContext) => { + await createLogicAppProject(actionContext, data, workspaceRootFolder); + }); + // Refresh context keys after successful creation + await callWithTelemetryAndErrorHandling('addCustomCode.refreshContext', async (actionContext: IActionContext) => { + vscode.commands.executeCommand( + 'setContext', + extensionContext.customCodeFunctionsFolders, + await getAllCustomCodeFunctionsProjects(actionContext) + ); + vscode.commands.executeCommand( + 'setContext', + extensionContext.customCodeEligibleLogicAppFolders, + await getEligibleLogicAppFoldersForCustomCode() + ); + }); + }, + dialogOptions: { + workspace: { + canSelectMany: false, + openLabel: localize('selectWorkspaceParentFolder', 'Select workspace parent folder'), + canSelectFiles: false, + canSelectFolders: true, + }, + }, + extraInitializeData: { + workspaceFileJson, + logicAppsWithoutCustomCode: [{ label: logicAppName, description: projectPath, data: projectPath }], + existingFolders, + isAddCustomCodeFlow: true, + preselectedLogicAppName: logicAppName, + preselectedLogicAppType: ProjectType.customCode, + }, + }); +} + +/** + * Enumerates all directory names in the workspace root folder. + */ +async function getExistingFoldersOnDisk(workspaceRootFolder: string): Promise { + try { + const rootUri = vscode.Uri.file(workspaceRootFolder); + const entries = await vscode.workspace.fs.readDirectory(rootUri); + return entries.filter(([, type]) => type === vscode.FileType.Directory).map(([name]) => name); + } catch { + return []; + } +} diff --git a/apps/vs-code-designer/src/app/commands/createProject/createProject.ts b/apps/vs-code-designer/src/app/commands/createProject/createProject.ts index b49f15c3fb7..fde253cfa12 100644 --- a/apps/vs-code-designer/src/app/commands/createProject/createProject.ts +++ b/apps/vs-code-designer/src/app/commands/createProject/createProject.ts @@ -12,9 +12,9 @@ import { createWorkspaceWebviewCommandHandler } from '../shared/workspaceWebview import * as vscode from 'vscode'; import path from 'path'; import { createLogicAppProject } from '../createNewCodeProject/CodeProjectBase/CreateLogicAppProjects'; -import { getLogicAppWithoutCustomCode } from '../../utils/workspace'; +import { getEligibleLogicAppFoldersForCustomCode } from '../../utils/customCodeUtils'; -export async function createProject(context: IActionContext): Promise { +export async function createProject(_context: IActionContext): Promise { // Determine if in workspace, if not in workspace but there is a logic app project found, // prompt to see if they want to move the project over to a logic app workspace let workspaceRootFolder = ''; @@ -35,7 +35,12 @@ export async function createProject(context: IActionContext): Promise { // Get workspace data for the webview const workspaceFileContent = await vscode.workspace.fs.readFile(vscode.workspace.workspaceFile); const workspaceFileJson = JSON.parse(workspaceFileContent.toString()); - const logicAppsWithoutCustomCode = await getLogicAppWithoutCustomCode(context); + const customCodeEligibleProjectPaths = await getEligibleLogicAppFoldersForCustomCode(); + const logicAppsWithoutCustomCode = customCodeEligibleProjectPaths.map((projectPath) => ({ + label: path.basename(projectPath), + description: projectPath, + data: projectPath, + })); // Enumerate all existing directories on disk (includes C# project folders, etc.) const existingFolders = await getExistingFoldersOnDisk(workspaceRootFolder); diff --git a/apps/vs-code-designer/src/app/commands/registerCommands.ts b/apps/vs-code-designer/src/app/commands/registerCommands.ts index 04c450385be..8c4ec237761 100644 --- a/apps/vs-code-designer/src/app/commands/registerCommands.ts +++ b/apps/vs-code-designer/src/app/commands/registerCommands.ts @@ -39,7 +39,7 @@ import { startStreamingLogs } from './logstream/startStreamingLogs'; import { stopStreamingLogs } from './logstream/stopStreamingLogs'; import { openFile } from './openFile'; import { openInPortal } from './openInPortal'; -import { parameterizeAllConnections, parameterizeProjectConnections } from './parameterizeConnections'; +import { parameterizeProjectConnections } from './parameterizeConnections'; import { pickFuncProcess } from './pickFuncProcess'; import { startRemoteDebug } from './remoteDebug/startRemoteDebug'; import { restartLogicApp } from './restartLogicApp'; @@ -82,6 +82,7 @@ import { enableDevContainer } from './enableDevContainer/enableDevContainer'; import { toggleDesignTimeNodeWorker } from './toggleDesignTimeNodeWorker'; import { enableLocalManagedIdentityAuth } from '../utils/managedIdentity'; import { runProjectConsistencyCheck } from './runProjectConsistencyCheck'; +import { addCustomCode } from './addCustomCode/addCustomCode'; export function registerCommands(): void { registerCommandWithTreeNodeUnwrapping(extensionCommand.openDesigner, openDesigner); @@ -172,6 +173,7 @@ export function registerCommands(): void { // Custom code registerCommandWithTreeNodeUnwrapping(extensionCommand.buildCustomCodeFunctionsProject, tryBuildCustomCodeFunctionsProject); registerCommand(extensionCommand.createCustomCodeFunction, createCustomCodeFunction); + registerCommand(extensionCommand.addCustomCode, addCustomCode); registerCommand(extensionCommand.debugLogicApp, debugLogicApp); registerCommand(extensionCommand.switchToDataMapperV2, switchToDataMapperV2); registerCommand(extensionCommand.enableDevContainer, enableDevContainer); diff --git a/apps/vs-code-designer/src/app/utils/__test__/customCodeUtils.test.ts b/apps/vs-code-designer/src/app/utils/__test__/customCodeUtils.test.ts index bbc94b8b8ff..f60b5be930f 100644 --- a/apps/vs-code-designer/src/app/utils/__test__/customCodeUtils.test.ts +++ b/apps/vs-code-designer/src/app/utils/__test__/customCodeUtils.test.ts @@ -7,6 +7,7 @@ import { CustomCodeFunctionsProjectMetadata, getCustomCodeFunctionsProjectMetadata, getAllCustomCodeFunctionsProjects, + getEligibleLogicAppFoldersForCustomCode, isCustomCodeFunctionsProject, isCustomCodeFunctionsProjectInRoot, tryGetCustomCodeFunctionsProjects, diff --git a/apps/vs-code-designer/src/app/utils/customCodeUtils.ts b/apps/vs-code-designer/src/app/utils/customCodeUtils.ts index 190028c0d26..c9f0dba0c93 100644 --- a/apps/vs-code-designer/src/app/utils/customCodeUtils.ts +++ b/apps/vs-code-designer/src/app/utils/customCodeUtils.ts @@ -4,8 +4,9 @@ import { parseString } from 'xml2js'; import { isNullOrUndefined, isString } from '@microsoft/logic-apps-shared'; import type { WorkspaceFolder } from 'vscode'; import { isLogicAppProject } from './verifyIsProject'; +import { hasCodefulSdkReference } from './codeful'; import { ext } from '../../extensionVariables'; -import { getWorkspaceRoot } from './workspace'; +import { getWorkspaceLogicAppRoots, getWorkspaceRoot } from './workspace'; import type { IActionContext } from '@microsoft/vscode-azext-utils'; import { TargetFramework } from '@microsoft/vscode-extension-logic-apps'; import { customDirectory, libDirectory } from '../../constants'; @@ -63,6 +64,27 @@ export async function getAllCustomCodeFunctionsProjects(context: IActionContext) return customCodeProjectPaths; } +/** + * Gets the paths of codeless Logic App workspace roots that do NOT already have an associated custom-code functions project. + */ +export async function getEligibleLogicAppFoldersForCustomCode(): Promise { + const projectPaths = await getWorkspaceLogicAppRoots(); + + const eligiblePathTasks = projectPaths.map(async (projectPath) => { + if ((await hasCodefulSdkReference(projectPath))) { + return undefined; + } + const existingCustomCode = await tryGetLogicAppCustomCodeFunctionsProjects(projectPath); + if (existingCustomCode && existingCustomCode.length > 0) { + return undefined; + } + return projectPath; + }); + const eligibleProjectPaths = (await Promise.all(eligiblePathTasks)).filter((p?: string) => p !== undefined); + + return eligibleProjectPaths; +} + /** * Checks if the folder is a custom code functions project. * @param {string} folderPath - The folder path. @@ -93,7 +115,7 @@ export async function detectCustomCodeTargetFramework(projectPath: string): Prom const metadata = await getCustomCodeFunctionsProjectMetadata(customCodeProjects[0]); return metadata?.targetFramework; } - + return undefined; } diff --git a/apps/vs-code-designer/src/app/utils/workspace.ts b/apps/vs-code-designer/src/app/utils/workspace.ts index 16ff00e21f1..48d4da4cafa 100644 --- a/apps/vs-code-designer/src/app/utils/workspace.ts +++ b/apps/vs-code-designer/src/app/utils/workspace.ts @@ -6,12 +6,7 @@ import { workflowFileName } from '../../constants'; import { localize } from '../../localize'; import type { RemoteWorkflowTreeItem } from '../tree/remoteWorkflowsTree/RemoteWorkflowTreeItem'; import { isPathEqual, isSubpath } from './fs'; -import { - isLogicAppProject, - promptOpenProjectOrWorkspace, - tryGetLogicAppProjectRoot, - getFirstLogicAppProjectRoot, -} from './verifyIsProject'; +import { isLogicAppProject, promptOpenProjectOrWorkspace, tryGetLogicAppProjectRoot, getFirstLogicAppProjectRoot } from './verifyIsProject'; import { isNullOrUndefined, isString } from '@microsoft/logic-apps-shared'; import { UserCancelledError, nonNullValue } from '@microsoft/vscode-azext-utils'; import type { IActionContext, IAzureQuickPickItem } from '@microsoft/vscode-azext-utils'; @@ -21,7 +16,6 @@ import * as vscode from 'vscode'; import { FileManagement } from '../commands/generateDeploymentScripts/iacGestureHelperFunctions'; import { ext } from '../../extensionVariables'; import * as fse from 'fs-extra'; -import { tryGetLogicAppCustomCodeFunctionsProjects } from './customCodeUtils'; /** * Checks if the current workspace has a Logic App project. @@ -302,133 +296,6 @@ async function getLogicAppWorkspaceFolder( return selectedFolder; } -/** - * Gets user selection of either an existing logic app that isn't associated with a custom code project or new (undefined) logic app project. - * @param {IActionContext} context - Command context. - * @param {string} message - The message to display to the user if workspace is not open. - * @returns {Promise} Returns either the selected logic app or undefined for a new logic app. - */ -export async function promptForLogicAppWithoutCustomCode( - context: IActionContext, - message?: string -): Promise { - const promptMessage: string = message ?? localize('noWorkspaceWarning', 'You must have a workspace open to perform this action.'); - - if (!vscode.workspace.workspaceFolders || vscode.workspace.workspaceFolders.length === 0) { - await promptOpenProjectOrWorkspace(context, promptMessage); - } - - if (vscode.workspace.workspaceFolders.length === 1) { - const workspaceFolder = vscode.workspace.workspaceFolders[0]; - const workspaceFolderPath = workspaceFolder.uri.fsPath; - if (!(await isLogicAppProject(workspaceFolderPath))) { - const folderContents = await fse.readdir(workspaceFolderPath, { withFileTypes: true }); - const subFolders = folderContents - .filter((dirent) => dirent.isDirectory()) - .map((dirent) => path.join(workspaceFolderPath, dirent.name)); - return await selectLogicAppWorkspaceFolderWithoutCustomCode(context, false, subFolders); - } - } - - return await selectLogicAppWorkspaceFolderWithoutCustomCode(context, true, null); -} - -async function selectLogicAppWorkspaceFolderWithoutCustomCode( - context: IActionContext, - returnsWorkspaceFolder: boolean, - subFolders: string[] -): Promise { - const logicAppsWorkspaces = []; - for (const folder of returnsWorkspaceFolder ? vscode.workspace.workspaceFolders : subFolders) { - const projectRoot = await tryGetLogicAppProjectRoot(context, folder, true); - if (projectRoot) { - logicAppsWorkspaces.push(projectRoot); - } - } - - const placeHolder: string = localize('selectProjectFolder', 'Select the folder containing your logic app project'); - const folderPicksPromises = logicAppsWorkspaces.map(async (projectRoot) => { - const workspaceFolder = vscode.workspace.workspaceFolders?.find((folder) => folder.uri.fsPath === projectRoot); - const logicAppCustomCodeFunctionsProjects = await tryGetLogicAppCustomCodeFunctionsProjects(projectRoot); - if (!logicAppCustomCodeFunctionsProjects || logicAppCustomCodeFunctionsProjects.length === 0) { - return { - label: path.basename(projectRoot), - description: projectRoot, - data: returnsWorkspaceFolder ? workspaceFolder : projectRoot, - }; - } - return undefined; - }); - - const folderPicks = (await Promise.all(folderPicksPromises)).filter((item) => item !== undefined); - - folderPicks.push({ - label: localize('newLogicAppProject', 'Create a new Logic App project...'), - description: '', - data: undefined, - }); - - const selectedItem = await context.ui.showQuickPick(folderPicks, { placeHolder }); - return selectedItem?.data; -} - -interface FolderPicks { - label: any; - description: any; - data: any; -} - -/** - * Gets user selection of either an existing logic app that isn't associated with a custom code project or new (undefined) logic app project. - * @param {IActionContext} context - Command context. - * @returns {Promise} Returns either the selected logic app or undefined for a new logic app. - */ -export async function getLogicAppWithoutCustomCode(context: IActionContext): Promise { - if (vscode.workspace.workspaceFolders.length === 1) { - const workspaceFolder = vscode.workspace.workspaceFolders[0]; - const workspaceFolderPath = workspaceFolder.uri.fsPath; - if (!(await isLogicAppProject(workspaceFolderPath))) { - const folderContents = await fse.readdir(workspaceFolderPath, { withFileTypes: true }); - const subFolders = folderContents - .filter((dirent) => dirent.isDirectory()) - .map((dirent) => path.join(workspaceFolderPath, dirent.name)); - return await getLogicAppWorkspaceFolderWithoutCustomCode(context, false, subFolders); - } - } - - return await getLogicAppWorkspaceFolderWithoutCustomCode(context, true, null); -} - -export async function getLogicAppWorkspaceFolderWithoutCustomCode( - context: IActionContext, - returnsWorkspaceFolder: boolean, - subFolders: string[] -): Promise { - const logicAppsWorkspaces = []; - for (const folder of returnsWorkspaceFolder ? vscode.workspace.workspaceFolders : subFolders) { - const projectRoot = await tryGetLogicAppProjectRoot(context, folder, true); - if (projectRoot) { - logicAppsWorkspaces.push(projectRoot); - } - } - - const folderPicksPromises = logicAppsWorkspaces.map(async (projectRoot) => { - const workspaceFolder = vscode.workspace.workspaceFolders?.find((folder) => folder.uri.fsPath === projectRoot); - const logicAppCustomCodeFunctionsProjects = await tryGetLogicAppCustomCodeFunctionsProjects(projectRoot); - if (!logicAppCustomCodeFunctionsProjects || logicAppCustomCodeFunctionsProjects.length === 0) { - return { - label: path.basename(projectRoot), - description: projectRoot, - data: returnsWorkspaceFolder ? workspaceFolder : projectRoot, - }; - } - return undefined; - }); - - const folderPicks = (await Promise.all(folderPicksPromises)).filter((item) => item !== undefined); - return folderPicks; -} - /** * Gets workflow node structure of JSON file if needed. * @param {vscode.Uri | undefined} node - Workflow node. diff --git a/apps/vs-code-designer/src/constants.ts b/apps/vs-code-designer/src/constants.ts index 475553a46cd..5590e64c657 100644 --- a/apps/vs-code-designer/src/constants.ts +++ b/apps/vs-code-designer/src/constants.ts @@ -213,6 +213,7 @@ export const extensionCommand = { sdkLspApplyEdits: 'azureLogicAppsStandard.sdklsp.applyEdits', enableDevContainer: 'azureLogicAppsStandard.enableDevContainer', runProjectConsistencyCheck: 'azureLogicAppsStandard.runProjectConsistencyCheck', + addCustomCode: 'azureLogicAppsStandard.addCustomCode', } as const; export type extensionCommand = (typeof extensionCommand)[keyof typeof extensionCommand]; @@ -228,6 +229,7 @@ export const extensionContext = { hasProject: 'azureLogicAppsStandard.hasProject', isCodeful: 'azureLogicAppsStandard.isCodeful', customCodeFunctionsFolders: 'azureLogicAppsStandard.customCode.functionsFolders', + customCodeEligibleLogicAppFolders: 'azureLogicAppsStandard.customCode.eligibleLogicAppFolders', dataMapSupportedDataMapDefinitionFileExts: 'azureLogicAppsStandard.dataMap.supportedDataMapDefinitionFileExts', dataMapSupportedSchemaFileExts: 'azureLogicAppsStandard.dataMap.supportedSchemaFileExts', dataMapSupportedFileExts: 'azureLogicAppsStandard.dataMap.supportedFileExts', diff --git a/apps/vs-code-designer/src/main.ts b/apps/vs-code-designer/src/main.ts index c91793c4419..c06d079b349 100644 --- a/apps/vs-code-designer/src/main.ts +++ b/apps/vs-code-designer/src/main.ts @@ -39,7 +39,7 @@ import type { IActionContext } from '@microsoft/vscode-azext-utils'; import * as vscode from 'vscode'; import { ensureWorkspace } from './app/commands/ensureWorkspace'; import TelemetryReporter from '@vscode/extension-telemetry'; -import { getAllCustomCodeFunctionsProjects } from './app/utils/customCodeUtils'; +import { getAllCustomCodeFunctionsProjects, getEligibleLogicAppFoldersForCustomCode } from './app/utils/customCodeUtils'; import { createVSCodeAzureSubscriptionProvider } from './app/utils/services/VSCodeAzureSubscriptionProvider'; import { logExtensionSettings, logSubscriptions } from './app/utils/telemetry'; import { registerAzureUtilsExtensionVariables } from '@microsoft/vscode-azext-azureutils'; @@ -97,6 +97,11 @@ export async function activate(context: vscode.ExtensionContext) { extensionContext.customCodeFunctionsFolders, await getAllCustomCodeFunctionsProjects(activateContext) ); + vscode.commands.executeCommand( + 'setContext', + extensionContext.customCodeEligibleLogicAppFolders, + await getEligibleLogicAppFoldersForCustomCode() + ); // Workspace setup and consistency checks runPostExtractStepsFromCache(); diff --git a/apps/vs-code-designer/src/package.json b/apps/vs-code-designer/src/package.json index bc9f4f895bd..787eb1b79ca 100644 --- a/apps/vs-code-designer/src/package.json +++ b/apps/vs-code-designer/src/package.json @@ -67,6 +67,11 @@ "dark": "assets/dark/CreateNewProject.svg" } }, + { + "command": "azureLogicAppsStandard.addCustomCode", + "title": "Add .NET custom code", + "category": "Azure Logic Apps" + }, { "command": "azureLogicAppsStandard.createWorkspace", "title": "Create new logic app workspace...", @@ -702,6 +707,11 @@ "when": "azureLogicAppsStandard.hasProject && explorerResourceIsRoot == true", "group": "zzz_LogicApptools@1" }, + { + "command": "azureLogicAppsStandard.addCustomCode", + "when": "azureLogicAppsStandard.hasProject && explorerResourceIsRoot == true && resourcePath in azureLogicAppsStandard.customCode.eligibleLogicAppFolders", + "group": "zzz_LogicApptools@1" + }, { "command": "azureLogicAppsStandard.createCustomCodeFunction", "when": "azureLogicAppsStandard.hasProject && explorerResourceIsRoot == true && resourcePath in azureLogicAppsStandard.customCode.functionsFolders", @@ -817,6 +827,10 @@ "command": "azureLogicAppsStandard.runProjectConsistencyCheck", "when": "never" }, + { + "command": "azureLogicAppsStandard.addCustomCode", + "when": "never" + }, { "command": "azureLogicAppsStandard.openLanguageServerConnectionView", "when": "never" diff --git a/apps/vs-code-react/src/app/createWorkspace/__test__/createWorkspace.test.tsx b/apps/vs-code-react/src/app/createWorkspace/__test__/createWorkspace.test.tsx index d0d55e58246..3073b5517f7 100644 --- a/apps/vs-code-react/src/app/createWorkspace/__test__/createWorkspace.test.tsx +++ b/apps/vs-code-react/src/app/createWorkspace/__test__/createWorkspace.test.tsx @@ -78,6 +78,7 @@ const createDefaultState = (overrides: Partial = {}): Crea platform: null, isDevContainerProject: false, availableProjects: [], + isAddCustomCodeFlow: false, ...overrides, }; }; diff --git a/apps/vs-code-react/src/app/createWorkspace/steps/__test__/dotNetFrameworkStep.test.tsx b/apps/vs-code-react/src/app/createWorkspace/steps/__test__/dotNetFrameworkStep.test.tsx index 9eeff3e6b01..8365d6307f1 100644 --- a/apps/vs-code-react/src/app/createWorkspace/steps/__test__/dotNetFrameworkStep.test.tsx +++ b/apps/vs-code-react/src/app/createWorkspace/steps/__test__/dotNetFrameworkStep.test.tsx @@ -49,6 +49,7 @@ const createTestStore = (overrides: Partial = {}) => { platform: null, isDevContainerProject: false, availableProjects: [], + isAddCustomCodeFlow: false, ...overrides, }; diff --git a/apps/vs-code-react/src/app/createWorkspace/steps/__test__/logicAppTypeStep.test.tsx b/apps/vs-code-react/src/app/createWorkspace/steps/__test__/logicAppTypeStep.test.tsx index 2c9679cef8f..bc7250f0836 100644 --- a/apps/vs-code-react/src/app/createWorkspace/steps/__test__/logicAppTypeStep.test.tsx +++ b/apps/vs-code-react/src/app/createWorkspace/steps/__test__/logicAppTypeStep.test.tsx @@ -44,9 +44,9 @@ vi.mock('@fluentui/react-components', async () => { const RadioGroupContext = React.createContext<((value: string) => void) | undefined>(undefined); return { - Combobox: ({ children, onChange, onOptionSelect, placeholder, value }: any) => ( + Combobox: ({ children, onChange, onOptionSelect, placeholder, value, disabled }: any) => (
- +
{children}