diff --git a/apps/Standalone/src/vscode/VSCodeWrapper.tsx b/apps/Standalone/src/vscode/VSCodeWrapper.tsx index e9a35282296..903ed31fffd 100644 --- a/apps/Standalone/src/vscode/VSCodeWrapper.tsx +++ b/apps/Standalone/src/vscode/VSCodeWrapper.tsx @@ -2,6 +2,9 @@ import { Routes, Route, Navigate } from 'react-router-dom'; import { VSCodeExportWrapper } from './components/VSCodeExportWrapper'; import { VSCodeOverviewWrapper } from './components/VSCodeOverviewWrapper'; import { VSCodeNavigationWrapper } from './components/VSCodeNavigationWrapper'; +import { VSCodeCreateLogicAppWrapper } from './components/VSCodeCreateLogicAppWrapper'; +import { VSCodeCreateWorkspaceWrapper } from './components/VSCodeCreateWorkspaceWrapper'; +import { VSCodeCreateWorkspaceStructureWrapper } from './components/VSCodeCreateWorkspaceStructureWrapper'; import { ThemeProvider } from '../../../vs-code-react/src/themeProvider'; export const VSCodeWrapper = () => { @@ -14,6 +17,10 @@ export const VSCodeWrapper = () => { } /> } /> } /> + } /> + } /> + } /> + } /> } /> diff --git a/apps/Standalone/src/vscode/components/VSCodeCreateLogicAppWrapper.tsx b/apps/Standalone/src/vscode/components/VSCodeCreateLogicAppWrapper.tsx new file mode 100644 index 00000000000..94349e2a0f8 --- /dev/null +++ b/apps/Standalone/src/vscode/components/VSCodeCreateLogicAppWrapper.tsx @@ -0,0 +1,49 @@ +import type React from 'react'; +import { useEffect } from 'react'; +import { CreateLogicApp } from '../../../../vs-code-react/src/app/createLogicApp/createLogicApp'; +import { useDispatch } from 'react-redux'; +import { initializeWorkflow } from '../../../../vs-code-react/src/state/WorkflowSlice'; +import type { AppDispatch } from '../../../../vs-code-react/src/state/store'; +import { VSCodeContextProvider } from '../providers/VSCodeContextProvider'; +import { ReactQueryProvider } from '@microsoft/logic-apps-designer'; +import { IntlProvider } from 'react-intl'; +import '../utils/mockVSCodeApi'; + +const mockInitialData = { + apiVersion: '2018-07-01-preview', + baseUrl: 'https://management.azure.com', + accessToken: 'mock-access-token', + workflowProperties: { + name: 'test-workflow', + stateType: 'Stateful', + }, + hostVersion: '4.0.0', + isLocal: true, +}; + +export const VSCodeCreateLogicAppWrapper: React.FC = () => { + const dispatch = useDispatch(); + + useEffect(() => { + dispatch(initializeWorkflow(mockInitialData)); + }, [dispatch]); + + return ( + { + if (err.code === 'MISSING_TRANSLATION') { + return; + } + console.warn('Intl error:', err); + }} + > + + + + + + + ); +}; diff --git a/apps/Standalone/src/vscode/components/VSCodeCreateWorkspaceFromPackageWrapper.tsx b/apps/Standalone/src/vscode/components/VSCodeCreateWorkspaceFromPackageWrapper.tsx new file mode 100644 index 00000000000..a69648ce129 --- /dev/null +++ b/apps/Standalone/src/vscode/components/VSCodeCreateWorkspaceFromPackageWrapper.tsx @@ -0,0 +1,49 @@ +import type React from 'react'; +import { useEffect } from 'react'; +import { CreateWorkspaceFromPackage } from '../../../../vs-code-react/src/app/createWorkspace/createWorkspace'; +import { useDispatch } from 'react-redux'; +import { initializeWorkflow } from '../../../../vs-code-react/src/state/WorkflowSlice'; +import type { AppDispatch } from '../../../../vs-code-react/src/state/store'; +import { VSCodeContextProvider } from '../providers/VSCodeContextProvider'; +import { ReactQueryProvider } from '@microsoft/logic-apps-designer'; +import { IntlProvider } from 'react-intl'; +import '../utils/mockVSCodeApi'; + +const mockInitialData = { + apiVersion: '2018-07-01-preview', + baseUrl: 'https://management.azure.com', + accessToken: 'mock-access-token', + workflowProperties: { + name: 'test-workflow', + stateType: 'Stateful', + }, + hostVersion: '4.0.0', + isLocal: true, +}; + +export const VSCodeCreateWorkspaceFromPackageWrapper: React.FC = () => { + const dispatch = useDispatch(); + + useEffect(() => { + dispatch(initializeWorkflow(mockInitialData)); + }, [dispatch]); + + return ( + { + if (err.code === 'MISSING_TRANSLATION') { + return; + } + console.warn('Intl error:', err); + }} + > + + + + + + + ); +}; diff --git a/apps/Standalone/src/vscode/components/VSCodeCreateWorkspaceStructureWrapper.tsx b/apps/Standalone/src/vscode/components/VSCodeCreateWorkspaceStructureWrapper.tsx new file mode 100644 index 00000000000..81ffe6ff8b9 --- /dev/null +++ b/apps/Standalone/src/vscode/components/VSCodeCreateWorkspaceStructureWrapper.tsx @@ -0,0 +1,49 @@ +import type React from 'react'; +import { useEffect } from 'react'; +import { CreateWorkspaceStructure } from '../../../../vs-code-react/src/app/createLogicApp/createWorkspaceStructure'; +import { useDispatch } from 'react-redux'; +import { initializeWorkflow } from '../../../../vs-code-react/src/state/WorkflowSlice'; +import type { AppDispatch } from '../../../../vs-code-react/src/state/store'; +import { VSCodeContextProvider } from '../providers/VSCodeContextProvider'; +import { ReactQueryProvider } from '@microsoft/logic-apps-designer'; +import { IntlProvider } from 'react-intl'; +import '../utils/mockVSCodeApi'; + +const mockInitialData = { + apiVersion: '2018-07-01-preview', + baseUrl: 'https://management.azure.com', + accessToken: 'mock-access-token', + workflowProperties: { + name: 'test-workflow', + stateType: 'Stateful', + }, + hostVersion: '4.0.0', + isLocal: true, +}; + +export const VSCodeCreateWorkspaceStructureWrapper: React.FC = () => { + const dispatch = useDispatch(); + + useEffect(() => { + dispatch(initializeWorkflow(mockInitialData)); + }, [dispatch]); + + return ( + { + if (err.code === 'MISSING_TRANSLATION') { + return; + } + console.warn('Intl error:', err); + }} + > + + + + + + + ); +}; diff --git a/apps/Standalone/src/vscode/components/VSCodeCreateWorkspaceWrapper.tsx b/apps/Standalone/src/vscode/components/VSCodeCreateWorkspaceWrapper.tsx new file mode 100644 index 00000000000..ab4bd29f3a3 --- /dev/null +++ b/apps/Standalone/src/vscode/components/VSCodeCreateWorkspaceWrapper.tsx @@ -0,0 +1,49 @@ +import type React from 'react'; +import { useEffect } from 'react'; +import { CreateWorkspace } from '../../../../vs-code-react/src/app/createWorkspace/createWorkspace'; +import { useDispatch } from 'react-redux'; +import { initializeWorkflow } from '../../../../vs-code-react/src/state/WorkflowSlice'; +import type { AppDispatch } from '../../../../vs-code-react/src/state/store'; +import { VSCodeContextProvider } from '../providers/VSCodeContextProvider'; +import { ReactQueryProvider } from '@microsoft/logic-apps-designer'; +import { IntlProvider } from 'react-intl'; +import '../utils/mockVSCodeApi'; + +const mockInitialData = { + apiVersion: '2018-07-01-preview', + baseUrl: 'https://management.azure.com', + accessToken: 'mock-access-token', + workflowProperties: { + name: 'test-workflow', + stateType: 'Stateful', + }, + hostVersion: '4.0.0', + isLocal: true, +}; + +export const VSCodeCreateWorkspaceWrapper: React.FC = () => { + const dispatch = useDispatch(); + + useEffect(() => { + dispatch(initializeWorkflow(mockInitialData)); + }, [dispatch]); + + return ( + { + if (err.code === 'MISSING_TRANSLATION') { + return; + } + console.warn('Intl error:', err); + }} + > + + + + + + + ); +}; diff --git a/apps/Standalone/src/vscode/components/VSCodeNavigationWrapper.tsx b/apps/Standalone/src/vscode/components/VSCodeNavigationWrapper.tsx index cfa51513da4..42f01b1a99f 100644 --- a/apps/Standalone/src/vscode/components/VSCodeNavigationWrapper.tsx +++ b/apps/Standalone/src/vscode/components/VSCodeNavigationWrapper.tsx @@ -43,6 +43,24 @@ export const VSCodeNavigationWrapper = () => { Export + + Create Workspace + + + Create Workspace From Package + + + Create Logic App + + + Create Workspace Structure + Overview diff --git a/apps/vs-code-designer/src/app/commands/__test__/parameterizeConnections.test.ts b/apps/vs-code-designer/src/app/commands/__test__/parameterizeConnections.test.ts index 74cf1dd5dd8..a50e5fa049e 100644 --- a/apps/vs-code-designer/src/app/commands/__test__/parameterizeConnections.test.ts +++ b/apps/vs-code-designer/src/app/commands/__test__/parameterizeConnections.test.ts @@ -9,6 +9,7 @@ import * as parameterUtil from '../../utils/codeless/parameter'; import * as localSettingsUtil from '../../utils/appSettings/localSettings'; import * as parameterizerUtil from '../../utils/codeless/parameterizer'; import * as workspaceUtil from '../../utils/workspace'; +import { localSettingsFileName } from '../../../constants'; describe('parameterizeConnections', () => { const testContext: any = { @@ -85,7 +86,7 @@ describe('parameterizeConnections', () => { expect(parameterUtil.getParametersJson).toHaveBeenCalledWith(testLogicAppProjectPath1); expect(localSettingsUtil.getLocalSettingsJson).toHaveBeenCalledWith( testContext, - path.join(testLogicAppProjectPath1, 'local.settings.json') + path.join(testLogicAppProjectPath1, localSettingsFileName) ); expect(parameterizerUtil.parameterizeConnection).toHaveBeenCalled(); expect(parameterUtil.saveWorkflowParameter).toHaveBeenCalledWith(testContext, testLogicAppProjectPath1, testParametersJson); diff --git a/apps/vs-code-designer/src/app/commands/cloudToLocal/cloudToLocal.ts b/apps/vs-code-designer/src/app/commands/cloudToLocal/cloudToLocal.ts index f2bceffc183..986649d63d7 100644 --- a/apps/vs-code-designer/src/app/commands/cloudToLocal/cloudToLocal.ts +++ b/apps/vs-code-designer/src/app/commands/cloudToLocal/cloudToLocal.ts @@ -1,76 +1,188 @@ -import { funcVersionSetting, projectLanguageSetting, projectOpenBehaviorSetting, projectTemplateKeySetting } from '../../../constants'; import { localize } from '../../../localize'; -import { addLocalFuncTelemetry, tryGetLocalFuncVersion, tryParseFuncVersion } from '../../utils/funcCoreTools/funcVersion'; -import { getGlobalSetting, getWorkspaceSetting } from '../../utils/vsCodeConfig/settings'; -import { OpenBehaviorStep } from '../createWorkspace/createWorkspaceSteps/openBehaviorStep'; -import { ProjectTypeStep } from '../createProject/createProjectSteps/projectTypeStep'; -import { SelectPackageStep } from './cloudToLocalSteps/selectPackageStep'; -import { OpenFolderStep } from '../createWorkspace/createWorkspaceSteps/openFolderStep'; -import { LogicAppNameStep } from '../createProject/createProjectSteps/logicAppNameStep'; -import { WorkspaceNameStep } from '../createWorkspace/createWorkspaceSteps/workspaceNameStep'; -import { AzureWizard } from '@microsoft/vscode-azext-utils'; +import { callWithTelemetryAndErrorHandling } from '@microsoft/vscode-azext-utils'; import type { IActionContext } from '@microsoft/vscode-azext-utils'; -import { latestGAVersion, OpenBehavior } from '@microsoft/vscode-extension-logic-apps'; -import type { ICreateFunctionOptions, IFunctionWizardContext, ProjectLanguage } from '@microsoft/vscode-extension-logic-apps'; -import { ProcessPackageStep } from './cloudToLocalSteps/processPackageStep'; -import { SelectFolderForNewWorkspaceStep } from './cloudToLocalSteps/selectFolderForNewWorkspaceStep'; -import { ExtractPackageStep } from './cloudToLocalSteps/extractPackageStep'; -import { WorkspaceSettingsStep } from '../createWorkspace/createWorkspaceSteps/workspaceSettingsStep'; +import { ExtensionCommand, ProjectName } from '@microsoft/vscode-extension-logic-apps'; +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as os from 'os'; +import { ext } from '../../../extensionVariables'; +import { cacheWebviewPanel, removeWebviewPanelFromCache, tryGetWebviewPanel } from '../../utils/codeless/common'; +import * as fs from 'fs'; +import { getWebViewHTML } from '../../utils/codeless/getWebViewHTML'; +import { createLogicAppWorkspace } from '../createNewCodeProject/CodeProjectBase/CreateLogicAppWorkspace'; +import { assetsFolderName } from '../../../constants'; -const openFolder = true; +const packageDialogOptions: vscode.OpenDialogOptions = { + canSelectMany: false, + defaultUri: vscode.Uri.file(path.join(os.homedir(), 'Downloads')), + openLabel: localize('selectPackageFile', 'Select package file'), + filters: { Packages: ['zip'] }, +}; -export async function cloudToLocal( - context: IActionContext, - options: ICreateFunctionOptions = { - folderPath: undefined, - language: undefined, - version: undefined, - templateId: undefined, - functionName: undefined, - functionSettings: undefined, - suppressOpenFolder: !openFolder, - } -): Promise { - addLocalFuncTelemetry(context); +const workspaceParentDialogOptions: vscode.OpenDialogOptions = { + canSelectMany: false, + openLabel: localize('selectWorkspaceParentFolder', 'Select workspace parent folder'), + canSelectFiles: false, + canSelectFolders: true, +}; - const language: ProjectLanguage | string = (options.language as ProjectLanguage) || getGlobalSetting(projectLanguageSetting); - const version: string = options.version || getGlobalSetting(funcVersionSetting) || (await tryGetLocalFuncVersion()) || latestGAVersion; - const projectTemplateKey: string | undefined = getGlobalSetting(projectTemplateKeySetting); - const wizardContext: Partial & IActionContext = Object.assign(context, options, { - language, - version: tryParseFuncVersion(version), - projectTemplateKey, - projectPath: options.folderPath, - }); +export async function cloudToLocal(): Promise { + const panelName: string = localize('createWorkspaceFromPackage', 'Create Workspace From Package'); + const panelGroupKey = ext.webViewKey.createWorkspaceFromPackage; + const apiVersion = '2021-03-01'; + const existingPanel: vscode.WebviewPanel | undefined = tryGetWebviewPanel(panelGroupKey, panelName); - if (options.suppressOpenFolder) { - wizardContext.openBehavior = OpenBehavior.dontOpen; - } else if (!wizardContext.openBehavior) { - wizardContext.openBehavior = getWorkspaceSetting(projectOpenBehaviorSetting); - context.telemetry.properties.openBehaviorFromSetting = String(!!wizardContext.openBehavior); - } + if (existingPanel) { + if (!existingPanel.active) { + existingPanel.reveal(vscode.ViewColumn.Active); + } - const wizard: AzureWizard = new AzureWizard(wizardContext, { - title: localize('createLogicAppWorkspaceFromPackage', 'Create new logic app workspace from package'), - promptSteps: [ - new SelectPackageStep(), - // TODO(aeldridge): Can we just use WorkspaceFolderStep instead? - new SelectFolderForNewWorkspaceStep(), - new WorkspaceNameStep(), - new LogicAppNameStep(), - await ProjectTypeStep.create(context, options.templateId, options.functionSettings, true), - new WorkspaceSettingsStep(), - new ExtractPackageStep(), - new OpenBehaviorStep(), - ], - executeSteps: [new ProcessPackageStep(), new OpenFolderStep()], - hideStepCount: true, - }); - try { - await wizard.prompt(); - await wizard.execute(); - } catch (error) { - context.telemetry.properties.error = error.message; - console.error('Error during wizard execution:', error); + return; } + + const options: vscode.WebviewOptions & vscode.WebviewPanelOptions = { + enableScripts: true, + retainContextWhenHidden: true, + }; + + const panel: vscode.WebviewPanel = vscode.window.createWebviewPanel('CreateWorkspace', `${panelName}`, vscode.ViewColumn.Active, options); + panel.iconPath = { + light: vscode.Uri.file(path.join(ext.context.extensionPath, assetsFolderName, 'light', 'export.svg')), + dark: vscode.Uri.file(path.join(ext.context.extensionPath, assetsFolderName, 'dark', 'export.svg')), + }; + panel.webview.html = await getWebViewHTML('vs-code-react', panel); + + let interval: NodeJS.Timeout; + + panel.webview.onDidReceiveMessage(async (message) => { + switch (message.command) { + case ExtensionCommand.initialize: { + panel.webview.postMessage({ + command: ExtensionCommand.initialize_frame, + data: { + apiVersion, + project: ProjectName.createWorkspaceFromPackage, + hostVersion: ext.extensionVersion, + }, + }); + break; + } + case ExtensionCommand.createWorkspaceFromPackage: { + await callWithTelemetryAndErrorHandling('CreateWorkspaceFromPackage', async (activateContext: IActionContext) => { + await createLogicAppWorkspace(activateContext, message.data, true); + }); + // Close the webview panel after successful creation + panel.dispose(); + break; + } + case ExtensionCommand.update_package_path: { + vscode.window.showOpenDialog(packageDialogOptions).then((fileUri) => { + if (fileUri && fileUri[0]) { + panel.webview.postMessage({ + command: ExtensionCommand.update_package_path, + data: { + targetDirectory: { + fsPath: fileUri[0].fsPath, + path: fileUri[0].path, + }, + }, + }); + } + }); + break; + } + case ExtensionCommand.select_folder: { + vscode.window.showOpenDialog(workspaceParentDialogOptions).then((fileUri) => { + if (fileUri && fileUri[0]) { + panel.webview.postMessage({ + command: ExtensionCommand.update_workspace_path, + data: { + targetDirectory: { + fsPath: fileUri[0].fsPath, + path: fileUri[0].path, + }, + }, + }); + } + }); + break; + } + case ExtensionCommand.validatePath: { + const { path: pathToValidate, type } = message.data || {}; + let exists = false; + try { + if (pathToValidate && typeof pathToValidate === 'string') { + exists = fs.existsSync(pathToValidate); + if (exists) { + const stats = fs.statSync(pathToValidate); + if (!type) { + // For regular path validation, check if it's a directory + exists = stats.isDirectory(); + } else if (type === ExtensionCommand.workspace_folder) { + // For workspace folder, check if it's a directory + exists = stats.isDirectory(); + } else if (type === ExtensionCommand.workspace_file) { + // For workspace file, check if it's a file (not a directory) + exists = stats.isFile(); + } + } + } + } catch (_error) { + exists = false; + } + + if (type === ExtensionCommand.workspace_folder || type === ExtensionCommand.workspace_file) { + // Send specific workspace existence result + panel.webview.postMessage({ + command: 'workspaceExistenceResult', + data: { + project: ProjectName.createWorkspace, + workspacePath: pathToValidate, + exists: exists, + type: type, + }, + }); + } else if (type === ExtensionCommand.package_file) { + // Send specific workspace existence result + panel.webview.postMessage({ + command: 'packageExistenceResult', + data: { + project: ProjectName.createWorkspaceFromPackage, + path: pathToValidate, + isValid: exists, + }, + }); + } else { + // Send regular path validation result + panel.webview.postMessage({ + command: ExtensionCommand.validatePath, + data: { + project: ProjectName.createWorkspace, + path: pathToValidate, + isValid: exists, + }, + }); + } + break; + } + // case ExtensionCommand.logTelemetry: { + // const eventName = message.key; + // ext.telemetryReporter.sendTelemetryEvent(eventName, { value: message.value }); + // ext.logTelemetry(context, eventName, message.value); + // break; + // } + default: + break; + } + }, ext.context.subscriptions); + + panel.onDidDispose( + () => { + removeWebviewPanelFromCache(panelGroupKey, panelName); + clearInterval(interval); + }, + null, + ext.context.subscriptions + ); + cacheWebviewPanel(panelGroupKey, panelName, panel); } diff --git a/apps/vs-code-designer/src/app/commands/cloudToLocal/cloudToLocalSteps/extractPackageStep.ts b/apps/vs-code-designer/src/app/commands/cloudToLocal/cloudToLocalSteps/extractPackageStep.ts deleted file mode 100644 index 1b1c3ee5174..00000000000 --- a/apps/vs-code-designer/src/app/commands/cloudToLocal/cloudToLocalSteps/extractPackageStep.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; -import type { IFunctionWizardContext, IProjectWizardContext } from '@microsoft/vscode-extension-logic-apps'; -import path from 'path'; -import { unzipLogicAppArtifacts } from '../../../utils/taskUtils'; -import * as fse from 'fs-extra'; - -export class ExtractPackageStep extends AzureWizardPromptStep { - /** - * Checks if this step should prompt the user - * @param context - Project wizard context containing user selections and settings - * @returns True if user should be prompted, otherwise false - */ - public shouldPrompt(context: IProjectWizardContext): boolean { - return context.packagePath !== undefined && context.projectPath !== undefined; - } - - /** - * Unzips package contents to logic app path and removes unnecessary files. Then creates a README.md file. - * @param context - Project wizard context containing user selections and settings - */ - public async prompt(context: IFunctionWizardContext): Promise { - const logicAppPath = path.join(context.workspacePath, context.logicAppName || 'LogicApp'); - - try { - const data: Buffer | Buffer[] = fse.readFileSync(context.packagePath); - await unzipLogicAppArtifacts(data, logicAppPath); - - const projectFiles = fse.readdirSync(logicAppPath); - const filesToExclude = []; - const excludedFiles = ['.vscode', 'obj', 'bin', 'local.settings.json', 'host.json', '.funcignore']; - const excludedExt = ['.csproj']; - - projectFiles.forEach((fileName) => { - if (excludedExt.includes(path.extname(fileName))) { - filesToExclude.push(path.join(logicAppPath, fileName)); - } - }); - - excludedFiles.forEach((excludedFile) => { - if (fse.existsSync(path.join(logicAppPath, excludedFile))) { - filesToExclude.push(path.join(logicAppPath, excludedFile)); - } - }); - - filesToExclude.forEach((path) => { - fse.removeSync(path); - context.telemetry.properties.excludedFile = `Excluded ${path.basename} from package`; - }); - - // Create README.md file - const readMePath = path.join(__dirname, 'assets', 'readmes', 'importReadMe.md'); - const readMeContent = fse.readFileSync(readMePath, 'utf8'); - fse.writeFileSync(path.join(logicAppPath, 'README.md'), readMeContent); - } catch (error) { - context.telemetry.properties.error = error.message; - console.error(`Failed to extract contents of package to ${logicAppPath}`, error); - } - } -} diff --git a/apps/vs-code-designer/src/app/commands/cloudToLocal/cloudToLocalSteps/processPackageStep.ts b/apps/vs-code-designer/src/app/commands/cloudToLocal/cloudToLocalSteps/processPackageStep.ts deleted file mode 100644 index 1a9aeca2912..00000000000 --- a/apps/vs-code-designer/src/app/commands/cloudToLocal/cloudToLocalSteps/processPackageStep.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { AzureWizardExecuteStep, callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils'; -import type { IFunctionWizardContext, ILocalSettingsJson, IProjectWizardContext } from '@microsoft/vscode-extension-logic-apps'; -import { parameterizeConnectionsInProjectLoadSetting } from '../../../../constants'; -import path from 'path'; -import { - changeAuthTypeToRaw, - cleanLocalSettings, - extractConnectionSettings, - mergeAppSettings, - parameterizeConnectionsDuringImport, - updateConnectionKeys, -} from '../../../utils/cloudToLocalUtils'; -import { getGlobalSetting } from '../../../utils/vsCodeConfig/settings'; -import { writeFormattedJson } from '../../../utils/fs'; -import { getConnectionsJson } from '../../../utils/codeless/connection'; -import { getLocalSettingsJson } from '../../../utils/appSettings/localSettings'; -import AdmZip from 'adm-zip'; -import { extend, isEmptyString } from '@microsoft/logic-apps-shared'; -import { Uri, window, workspace } from 'vscode'; -import { localize } from '../../../../localize'; -import * as fse from 'fs-extra'; -import { getContainingWorkspace } from '../../../utils/workspace'; -import { ext } from '../../../../extensionVariables'; - -interface ICachedTextDocument { - projectPath: string; - textDocumentPath: string; -} - -const cacheKey = 'azLAPostExtractReadMe'; - -export function runPostExtractStepsFromCache(): void { - const cachedDocument: ICachedTextDocument | undefined = ext.context.globalState.get(cacheKey); - if (cachedDocument) { - try { - runPostExtractSteps(cachedDocument); - } finally { - ext.context.globalState.update(cacheKey, undefined); - } - } -} - -export class ProcessPackageStep extends AzureWizardExecuteStep { - public priority = 200; - - /** - * Determines whether this step should be executed based on the user's input. - * @param context The context object for the project wizard. - * @returns A boolean value indicating whether this step should be executed. - */ - public shouldExecute(context: IFunctionWizardContext): boolean { - return context.packagePath !== undefined; - } - - /** - * Executes the step to integrate the package into the new Logic App workspace - * @param context The context object for the project wizard. - * @returns A Promise that resolves to void. - */ - public async execute(context: IProjectWizardContext): Promise { - const logicAppPath = path.join(context.workspacePath, context.logicAppName || 'LogicApp'); - const localSettingsPath = path.join(logicAppPath, 'local.settings.json'); - const parameterizeConnectionsSetting = getGlobalSetting(parameterizeConnectionsInProjectLoadSetting); - - let appSettings: ILocalSettingsJson = {}; - let zipSettings: ILocalSettingsJson = {}; - let connectionsData: any = {}; - - try { - const connectionsString = await getConnectionsJson(logicAppPath); - - // merge the app settings from local.settings.json and the settings from the zip file - appSettings = await getLocalSettingsJson(context, localSettingsPath, false); - const zipEntries = await this.getPackageEntries(context.packagePath); - const zipSettingsBuffer = zipEntries.find((entry) => entry.entryName === 'local.settings.json'); - if (zipSettingsBuffer) { - context.telemetry.properties.localSettingsInZip = 'Local settings found in the zip file'; - zipSettings = JSON.parse(zipSettingsBuffer.getData().toString('utf8')); - await writeFormattedJson(localSettingsPath, mergeAppSettings(appSettings, zipSettings)); - } - - if (isEmptyString(connectionsString)) { - context.telemetry.properties.noConnectionsInZip = 'No connections found in the zip file'; - return; - } - - connectionsData = JSON.parse(connectionsString); - if (Object.keys(connectionsData).length && connectionsData.managedApiConnections) { - /** Extract details from connections and add to local.settings.json - * independent of the parameterizeConnectionsInProject setting */ - appSettings = await getLocalSettingsJson(context, localSettingsPath, false); - await writeFormattedJson(localSettingsPath, extend(appSettings, await extractConnectionSettings(context))); - - if (parameterizeConnectionsSetting) { - await parameterizeConnectionsDuringImport(context as IFunctionWizardContext, appSettings.Values); - } - - await changeAuthTypeToRaw(context, parameterizeConnectionsSetting); - await updateConnectionKeys(context); - await cleanLocalSettings(context); - } - - // OpenFolder will restart the extension host so we will cache README to open on next activation - const readMePath = path.join(logicAppPath, 'README.md'); - const postExtractCache: ICachedTextDocument = { projectPath: logicAppPath, textDocumentPath: readMePath }; - ext.context.globalState.update(cacheKey, postExtractCache); - // Delete cached information if the extension host was not restarted after 5 seconds - setTimeout(() => { - ext.context.globalState.update(cacheKey, undefined); - }, 5 * 1000); - runPostExtractSteps(postExtractCache); - } catch (error) { - context.telemetry.properties.error = error.message; - } - } - - private async getPackageEntries(zipFilePath: string) { - const zip = new AdmZip(zipFilePath); - return zip.getEntries(); - } -} - -function runPostExtractSteps(cache: ICachedTextDocument): void { - callWithTelemetryAndErrorHandling('postExtractPackage', async (context: IActionContext) => { - context.telemetry.suppressIfSuccessful = true; - - if (getContainingWorkspace(cache.projectPath)) { - if (await fse.pathExists(cache.textDocumentPath)) { - window.showTextDocument(await workspace.openTextDocument(Uri.file(cache.textDocumentPath))); - } - } - context.telemetry.properties.finishedImportingProject = 'Finished importing project'; - window.showInformationMessage(localize('finishedImporting', 'Finished importing project.')); - }); -} diff --git a/apps/vs-code-designer/src/app/commands/cloudToLocal/cloudToLocalSteps/selectFolderForNewWorkspaceStep.ts b/apps/vs-code-designer/src/app/commands/cloudToLocal/cloudToLocalSteps/selectFolderForNewWorkspaceStep.ts deleted file mode 100644 index fead596275a..00000000000 --- a/apps/vs-code-designer/src/app/commands/cloudToLocal/cloudToLocalSteps/selectFolderForNewWorkspaceStep.ts +++ /dev/null @@ -1,45 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -import { localize } from '../../../../localize'; -import { AzureWizardPromptStep, type IActionContext, type IAzureQuickPickItem } from '@microsoft/vscode-azext-utils'; -import { OpenBehavior, ProjectType, type IProjectWizardContext } from '@microsoft/vscode-extension-logic-apps'; -import type * as vscode from 'vscode'; -import { getContainingWorkspace } from '../../../utils/workspace'; - -export class SelectFolderForNewWorkspaceStep extends AzureWizardPromptStep { - public hideStepCount = true; - - public shouldPrompt(context: IProjectWizardContext): boolean { - return !context.projectPath; - } - - public async prompt(context: IProjectWizardContext): Promise { - const placeHolder: string = localize('selectNewProjectFolder', 'Select the folder to store your logic app workspace'); - const projectPath = await SelectFolderForNewWorkspaceStep.setProjectPath(context, placeHolder); - context.projectPath = projectPath; - context.projectType = ProjectType.logicApp; - context.workspaceFolder = getContainingWorkspace(projectPath); - context.workspacePath = (context.workspaceFolder && context.workspaceFolder.uri.fsPath) || projectPath; - if (context.workspaceFolder) { - context.openBehavior = OpenBehavior.alreadyOpen; - } - } - - private static async setProjectPath(context: IActionContext, placeHolder: string): Promise { - const folderPicks: IAzureQuickPickItem[] = []; - const options: vscode.OpenDialogOptions = { - canSelectFiles: false, - canSelectFolders: true, - canSelectMany: false, - defaultUri: undefined, - openLabel: localize('select', 'Select'), - }; - - folderPicks.push({ label: localize('browse', '$(file-directory) Browse...'), description: '', data: undefined }); - const packageFile: IAzureQuickPickItem | undefined = await context.ui.showQuickPick(folderPicks, { placeHolder }); - - return packageFile && packageFile.data ? packageFile.data : (await context.ui.showOpenDialog(options))[0].fsPath; - } -} diff --git a/apps/vs-code-designer/src/app/commands/cloudToLocal/cloudToLocalSteps/selectPackageStep.ts b/apps/vs-code-designer/src/app/commands/cloudToLocal/cloudToLocalSteps/selectPackageStep.ts deleted file mode 100644 index 56d45a0b803..00000000000 --- a/apps/vs-code-designer/src/app/commands/cloudToLocal/cloudToLocalSteps/selectPackageStep.ts +++ /dev/null @@ -1,45 +0,0 @@ -import * as vscode from 'vscode'; -import * as path from 'path'; -import * as os from 'os'; -import { localize } from '../../../../localize'; -import { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; -import type { IActionContext, IAzureQuickPickItem } from '@microsoft/vscode-azext-utils'; -import type { IFunctionWizardContext, IProjectWizardContext } from '@microsoft/vscode-extension-logic-apps'; - -export class SelectPackageStep extends AzureWizardPromptStep { - public hideStepCount = true; - public supportsDuplicateSteps = false; - - /** - * Checks if this step should prompt the user - * @param context - Project wizard context containing user selections and settings - * @returns True if user should be prompted, otherwise false - */ - public shouldPrompt(context: IProjectWizardContext): boolean { - return context.packagePath === undefined; - } - - /** - * Prompts the user to select the package to be imported to the new logic app workspace - * @param context - Project wizard context containing user selections and settings - */ - public async prompt(context: IFunctionWizardContext): Promise { - const placeHolder: string = localize('selectPackage', 'Select the package to import into your new logic app workspace'); - context.packagePath = await SelectPackageStep.selectPackagePath(context, placeHolder); - } - - private static async selectPackagePath(context: IActionContext, placeHolder: string): Promise { - const packagePicks: IAzureQuickPickItem[] = []; - const options: vscode.OpenDialogOptions = { - canSelectMany: false, - defaultUri: vscode.Uri.file(path.join(os.homedir(), 'Downloads')), - openLabel: localize('selectPackageFile', 'Select package file'), - filters: { Packages: ['zip'] }, - }; - - packagePicks.push({ label: localize('browse', '$(file-directory) Browse...'), description: '', data: undefined }); - const packageFile: IAzureQuickPickItem | undefined = await context.ui.showQuickPick(packagePicks, { placeHolder }); - - return packageFile && packageFile.data ? packageFile.data : (await context.ui.showOpenDialog(options))[0].fsPath; - } -} diff --git a/apps/vs-code-designer/src/app/commands/convertToWorkspace.ts b/apps/vs-code-designer/src/app/commands/convertToWorkspace.ts index 96594b8723e..81b1bf0cb7c 100644 --- a/apps/vs-code-designer/src/app/commands/convertToWorkspace.ts +++ b/apps/vs-code-designer/src/app/commands/convertToWorkspace.ts @@ -1,21 +1,75 @@ -import { extensionCommand } from '../../constants'; +import { assetsFolderName, extensionCommand } from '../../constants'; import { localize } from '../../localize'; import { addLocalFuncTelemetry } from '../utils/funcCoreTools/funcVersion'; -import { WorkspaceFolderStep } from './createWorkspace/createWorkspaceSteps/workspaceFolderStep'; -import { OpenFolderStep } from './createWorkspace/createWorkspaceSteps/openFolderStep'; -import { AzureWizard, DialogResponses } from '@microsoft/vscode-azext-utils'; +import { callWithTelemetryAndErrorHandling, DialogResponses } from '@microsoft/vscode-azext-utils'; import type { IActionContext } from '@microsoft/vscode-azext-utils'; -import type { IFunctionWizardContext } from '@microsoft/vscode-extension-logic-apps'; +import { + ExtensionCommand, + type IWebviewProjectContext, + ProjectName, + type IFunctionWizardContext, +} from '@microsoft/vscode-extension-logic-apps'; import * as vscode from 'vscode'; -import { window } from 'vscode'; -import { getWorkspaceFile, getWorkspaceFileInParentDirectory, getWorkspaceFolder, getWorkspaceRoot } from '../utils/workspace'; -import { WorkspaceNameStep } from './createWorkspace/createWorkspaceSteps/workspaceNameStep'; -import { WorkspaceFileStep } from './createWorkspace/createWorkspaceSteps/workspaceFileStep'; -import { isLogicAppProjectInRoot } from '../utils/verifyIsProject'; +import * as fs from 'fs'; +import { getWorkspaceFile, getWorkspaceFileInParentDirectory, getWorkspaceFolder2, getWorkspaceRoot } from '../utils/workspace'; +import { isLogicAppProject, isLogicAppProjectInRoot } from '../utils/verifyIsProject'; +import { ext } from '../../extensionVariables'; +import { cacheWebviewPanel, removeWebviewPanelFromCache, tryGetWebviewPanel } from '../utils/codeless/common'; +import path from 'path'; +import { getWebViewHTML } from '../utils/codeless/getWebViewHTML'; +import * as fse from 'fs-extra'; + +const workspaceParentDialogOptions: vscode.OpenDialogOptions = { + canSelectMany: false, + openLabel: localize('selectWorkspaceParentFolder', 'Select workspace parent folder'), + canSelectFiles: false, + canSelectFolders: true, +}; +export async function createWorkspaceFile(context: IActionContext, options: any): Promise { + addLocalFuncTelemetry(context); + + const myWebviewProjectContext: IWebviewProjectContext = options; + + const workspaceFolderPath = path.join(myWebviewProjectContext.workspaceProjectPath.fsPath, myWebviewProjectContext.workspaceName); + + await fse.ensureDir(workspaceFolderPath); + const workspaceFilePath = path.join(workspaceFolderPath, `${myWebviewProjectContext.workspaceName}.code-workspace`); + + // Start with an empty folders array + const workspaceFolders = []; + const foldersToAdd = vscode.workspace.workspaceFolders; + + if (foldersToAdd && foldersToAdd.length === 1) { + const folder = foldersToAdd[0]; + const folderPath = folder.uri.fsPath; + if (await isLogicAppProject(folderPath)) { + const destinationPath = path.join(workspaceFolderPath, folder.name); + await fse.copy(folderPath, destinationPath); + workspaceFolders.push({ name: folder.name, path: `./${folder.name}` }); + } else { + const subpaths: string[] = await fse.readdir(folderPath); + for (const subpath of subpaths) { + const fullPath = path.join(folderPath, subpath); + const destinationPath = path.join(workspaceFolderPath, subpath); + await fse.copy(fullPath, destinationPath); + workspaceFolders.push({ name: subpath, path: `./${subpath}` }); + } + } + } + + const workspaceData = { + folders: workspaceFolders, + }; + + await fse.writeJSON(workspaceFilePath, workspaceData, { spaces: 2 }); + + const uri = vscode.Uri.file(workspaceFilePath); + + await vscode.commands.executeCommand(extensionCommand.vscodeOpenFolder, uri, true /* forceNewWindow */); +} export async function convertToWorkspace(context: IActionContext): Promise { - const convertToWorkspaceStartTime = Date.now(); - const workspaceFolder = await getWorkspaceFolder(context, undefined, true); + const workspaceFolder = await getWorkspaceFolder2(); if (await isLogicAppProjectInRoot(workspaceFolder)) { addLocalFuncTelemetry(context); @@ -36,13 +90,11 @@ export async function convertToWorkspace(context: IActionContext): Promise = new AzureWizard(wizardContext, { - title: localize('convertToWorkspace', 'Convert to workspace'), - promptSteps: [new WorkspaceFolderStep(), new WorkspaceNameStep(), new WorkspaceFileStep()], - executeSteps: [new OpenFolderStep()], - }); + // need to create a webview to get the workspace name and etc - await workspaceWizard.prompt(); - await workspaceWizard.execute(); - context.telemetry.properties.createContainingWorkspace = 'true'; - context.telemetry.measurements.convertToWorkspaceDuration = (Date.now() - convertToWorkspaceStartTime) / 1000; - window.showInformationMessage(localize('finishedConvertingWorkspace', 'Finished converting to workspace.')); - return true; - } + const panelName: string = localize('createWorkspaceStructure', 'Create Workspace Structure'); + const panelGroupKey = ext.webViewKey.createWorkspaceStructure; + const apiVersion = '2021-03-01'; + const existingPanel: vscode.WebviewPanel | undefined = tryGetWebviewPanel(panelGroupKey, panelName); + + if (existingPanel) { + if (!existingPanel.active) { + existingPanel.reveal(vscode.ViewColumn.Active); + } + + return; + } + const options: vscode.WebviewOptions & vscode.WebviewPanelOptions = { + enableScripts: true, + retainContextWhenHidden: true, + }; + + const panel: vscode.WebviewPanel = vscode.window.createWebviewPanel( + 'CreateWorkspaceStructure', + `${panelName}`, + vscode.ViewColumn.Active, + options + ); + panel.iconPath = { + light: vscode.Uri.file(path.join(ext.context.extensionPath, assetsFolderName, 'light', 'export.svg')), + dark: vscode.Uri.file(path.join(ext.context.extensionPath, assetsFolderName, 'dark', 'export.svg')), + }; + panel.webview.html = await getWebViewHTML('vs-code-react', panel); + + let interval: NodeJS.Timeout; + return new Promise((resolve) => { + panel.webview.onDidReceiveMessage(async (message) => { + switch (message.command) { + case ExtensionCommand.initialize: { + panel.webview.postMessage({ + command: ExtensionCommand.initialize_frame, + data: { + apiVersion, + project: ProjectName.createWorkspaceStructure, + hostVersion: ext.extensionVersion, + }, + }); + break; + } + case ExtensionCommand.createWorkspaceStructure: { + await callWithTelemetryAndErrorHandling('CreateWorkspaceStructure', async (activateContext: IActionContext) => { + await createWorkspaceFile(activateContext, message.data); + }); + context.telemetry.properties.createContainingWorkspace = 'true'; + vscode.window.showInformationMessage(localize('finishedConvertingWorkspace', 'Finished converting to workspace.')); + // Close the webview panel after successful creation + panel.dispose(); + resolve(true); // Only resolve after workspace creation is done + break; + } + case ExtensionCommand.select_folder: { + vscode.window.showOpenDialog(workspaceParentDialogOptions).then((fileUri) => { + if (fileUri && fileUri[0]) { + panel.webview.postMessage({ + command: ExtensionCommand.update_workspace_path, + data: { + targetDirectory: { + fsPath: fileUri[0].fsPath, + path: fileUri[0].path, + }, + }, + }); + } + }); + break; + } + case ExtensionCommand.validatePath: { + const { path: pathToValidate, type } = message.data || {}; + let exists = false; + try { + if (pathToValidate && typeof pathToValidate === 'string') { + exists = fs.existsSync(pathToValidate); + if (exists) { + const stats = fs.statSync(pathToValidate); + if (!type) { + // For regular path validation, check if it's a directory + exists = stats.isDirectory(); + } else if (type === ExtensionCommand.workspace_folder) { + // For workspace folder, check if it's a directory + exists = stats.isDirectory(); + } else if (type === ExtensionCommand.workspace_file) { + // For workspace file, check if it's a file (not a directory) + exists = stats.isFile(); + } + } + } + } catch (_error) { + exists = false; + } + + if (type === ExtensionCommand.workspace_folder || type === ExtensionCommand.workspace_file) { + // Send specific workspace existence result + panel.webview.postMessage({ + command: 'workspaceExistenceResult', + data: { + project: ProjectName.createWorkspaceStructure, + workspacePath: pathToValidate, + exists: exists, + type: type, + }, + }); + } else { + // Send regular path validation result + panel.webview.postMessage({ + command: ExtensionCommand.validatePath, + data: { + project: ProjectName.createWorkspaceStructure, + path: pathToValidate, + isValid: exists, + }, + }); + } + break; + } + // case ExtensionCommand.logTelemetry: { + // const eventName = message.key; + // ext.telemetryReporter.sendTelemetryEvent(eventName, { value: message.value }); + // ext.logTelemetry(context, eventName, message.value); + // break; + // } + default: + break; + } + }, ext.context.subscriptions); + + panel.onDidDispose( + () => { + removeWebviewPanelFromCache(panelGroupKey, panelName); + clearInterval(interval); + resolve(false); // If panel is closed before workspace creation, resolve as false + }, + null, + ext.context.subscriptions + ); + cacheWebviewPanel(panelGroupKey, panelName, panel); + }); + } context.telemetry.properties.createContainingWorkspace = 'false'; - context.telemetry.measurements.convertToWorkspaceDuration = (Date.now() - convertToWorkspaceStartTime) / 1000; return false; } context.telemetry.properties.isWorkspace = 'true'; - context.telemetry.measurements.convertToWorkspaceDuration = (Date.now() - convertToWorkspaceStartTime) / 1000; return true; } } diff --git a/apps/vs-code-designer/src/app/commands/createCustomCodeFunction/createCustomCodeFunctionSteps/__test__/FunctionNameStep.test.ts b/apps/vs-code-designer/src/app/commands/createCustomCodeFunction/createCustomCodeFunctionSteps/__test__/FunctionNameStep.test.ts index deec8f15492..b1d73e9cef7 100644 --- a/apps/vs-code-designer/src/app/commands/createCustomCodeFunction/createCustomCodeFunctionSteps/__test__/FunctionNameStep.test.ts +++ b/apps/vs-code-designer/src/app/commands/createCustomCodeFunction/createCustomCodeFunctionSteps/__test__/FunctionNameStep.test.ts @@ -6,6 +6,7 @@ import { FunctionNameStep } from '../functionNameStep'; import { ext } from '../../../../../extensionVariables'; import { localize } from '../../../../../localize'; import { IProjectWizardContext, ProjectType } from '@microsoft/vscode-extension-logic-apps'; +import { vscodeFolderName } from '../../../../../constants'; describe('FunctionNameStep', () => { const existingFunctionName = 'Func1'; @@ -18,7 +19,7 @@ describe('FunctionNameStep', () => { [`${existingFunctionName}.cs`, vscode.FileType.File], [`${existingFunctionName}.csproj`, vscode.FileType.File], [`${existingFunctionName}.sln`, vscode.FileType.File], - ['.vscode', vscode.FileType.Directory], + [vscodeFolderName, vscode.FileType.Directory], ]; let functionNameStep: FunctionNameStep; let testContext: any; diff --git a/apps/vs-code-designer/src/app/commands/createCustomCodeFunction/createCustomCodeFunctionSteps/functionFileStep.ts b/apps/vs-code-designer/src/app/commands/createCustomCodeFunction/createCustomCodeFunctionSteps/functionFileStep.ts index f8896aa40dd..d96458245e2 100644 --- a/apps/vs-code-designer/src/app/commands/createCustomCodeFunction/createCustomCodeFunctionSteps/functionFileStep.ts +++ b/apps/vs-code-designer/src/app/commands/createCustomCodeFunction/createCustomCodeFunctionSteps/functionFileStep.ts @@ -10,6 +10,7 @@ import * as path from 'path'; import { ext } from '../../../../extensionVariables'; import { window } from 'vscode'; import { localize } from '../../../../localize'; +import { assetsFolderName } from '../../../../constants'; /** * Sets up a new function in an Azure Functions project. @@ -61,7 +62,7 @@ export class FunctionFileStep extends AzureWizardPromptStep { const customCodeTemplateFolderName = 'FunctionProjectTemplate'; const templateFile = this.csTemplateFileName[targetFramework]; - const templatePath = path.join(__dirname, 'assets', customCodeTemplateFolderName, templateFile); + const templatePath = path.join(__dirname, assetsFolderName, customCodeTemplateFolderName, templateFile); const templateContent = await fs.readFile(templatePath, 'utf-8'); const csFilePath = path.join(functionsFolderPath, `${functionName}.cs`); diff --git a/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/CreateFunctionAppFiles.ts b/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/CreateFunctionAppFiles.ts new file mode 100644 index 00000000000..ca5eb41be7c --- /dev/null +++ b/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/CreateFunctionAppFiles.ts @@ -0,0 +1,328 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { + dotnetExtensionId, + functionsExtensionId, + vscodeFolderName, + extensionsFileName, + launchFileName, + settingsFileName, + tasksFileName, + extensionCommand, + assetsFolderName, +} from '../../../../constants'; +import { FuncVersion, type IProjectWizardContext } from '@microsoft/vscode-extension-logic-apps'; +import { TargetFramework, ProjectType } from '@microsoft/vscode-extension-logic-apps'; +import * as fs from 'fs-extra'; +import * as path from 'path'; +import { getDebugConfigs, updateDebugConfigs } from '../../../utils/vsCodeConfig/launch'; +import { getContainingWorkspace, isMultiRootWorkspace } from '../../../utils/workspace'; +import { localize } from '../../../../localize'; +// import { tryGetLocalFuncVersion } from '../../../utils/funcCoreTools/funcVersion'; +import * as vscode from 'vscode'; +/** + * This class represents a prompt step that allows the user to set up an Azure Function project. + */ +export class CreateFunctionAppFiles { + // Hide the step count in the wizard UI + public hideStepCount = true; + + private csTemplateFileName = { + [TargetFramework.NetFx]: 'FunctionsFileNetFx', + [TargetFramework.Net8]: 'FunctionsFileNet8', + [ProjectType.rulesEngine]: 'RulesFunctionsFile', + }; + + private csprojTemplateFileName = { + [TargetFramework.NetFx]: 'FunctionsProjNetFx', + [TargetFramework.Net8]: 'FunctionsProjNet8New', + [ProjectType.rulesEngine]: 'RulesFunctionsProj', + }; + + private templateFolderName = { + [ProjectType.customCode]: 'FunctionProjectTemplate', + [ProjectType.rulesEngine]: 'RuleSetProjectTemplate', + }; + + /** + * Prompts the user to set up an Azure Function project. + * @param context The project wizard context. + */ + public async setup(context: IProjectWizardContext): Promise { + // Set the functionAppName and namespaceName properties from the context wizard + const functionAppName = context.functionAppName; + const namespace = context.functionAppNamespace; + const targetFramework = context.targetFramework; + const logicAppName = context.logicAppName || 'LogicApp'; + // const funcVersion = context.version ?? (await tryGetLocalFuncVersion()); + + // Define the functions folder path using the context property of the wizard + const functionFolderPath = path.join(context.workspacePath, context.functionFolderName); + await fs.ensureDir(functionFolderPath); + + // Define the type of project in the workspace + const projectType = context.projectType; + + // Create the .cs file inside the functions folder + await this.createCsFile(functionFolderPath, functionAppName, namespace, projectType, targetFramework); + + // Create the .cs files inside the functions folders for rule code projects + if (projectType === ProjectType.rulesEngine) { + await this.createRulesFiles(functionFolderPath); + } + + // Create the .csproj file inside the functions folder + await this.createCsprojFile(functionFolderPath, functionAppName, logicAppName, projectType, targetFramework); + + // Generate the Visual Studio Code configuration files in the specified folder. + // const isNewLogicAppProject = context.shouldCreateLogicAppProject; + await this.createVscodeConfigFiles(functionFolderPath, targetFramework /*, funcVersion, logicAppName, isNewLogicAppProject*/); + } + + /** + * Creates the .cs file inside the functions folder. + * @param functionFolderPath - The path to the functions folder. + * @param methodName - The name of the method. + * @param namespace - The name of the namespace. + * @param projectType - The workspace projet type. + * @param targetFramework - The target framework. + */ + private async createCsFile( + functionFolderPath: string, + methodName: string, + namespace: string, + projectType: ProjectType, + targetFramework: TargetFramework + ): Promise { + const templateFile = + projectType === ProjectType.rulesEngine ? this.csTemplateFileName[ProjectType.rulesEngine] : this.csTemplateFileName[targetFramework]; + const templatePath = path.join(__dirname, assetsFolderName, this.templateFolderName[projectType], templateFile); + const templateContent = await fs.readFile(templatePath, 'utf-8'); + + const csFilePath = path.join(functionFolderPath, `${methodName}.cs`); + const csFileContent = templateContent.replace(/<%= methodName %>/g, methodName).replace(/<%= namespace %>/g, namespace); + await fs.writeFile(csFilePath, csFileContent); + } + + /** + * Creates the rules files for the project. + * @param {string} functionFolderPath - The path of the function folder. + * @returns A promise that resolves when the rules files are created. + */ + private async createRulesFiles(functionFolderPath: string): Promise { + const csTemplatePath = path.join(__dirname, assetsFolderName, 'RuleSetProjectTemplate', 'ContosoPurchase'); + const csRuleSetPath = path.join(functionFolderPath, 'ContosoPurchase.cs'); + await fs.copyFile(csTemplatePath, csRuleSetPath); + } + + /** + * Creates a .csproj file for a specific Azure Function. + * @param functionFolderPath - The path to the folder where the .csproj file will be created. + * @param methodName - The name of the Azure Function. + * @param projectType - The workspace projet type. + * @param targetFramework - The target framework. + */ + private async createCsprojFile( + functionFolderPath: string, + methodName: string, + logicAppName: string, + projectType: ProjectType, + targetFramework: TargetFramework + ): Promise { + const templateFile = + projectType === ProjectType.rulesEngine + ? this.csprojTemplateFileName[ProjectType.rulesEngine] + : this.csprojTemplateFileName[targetFramework]; + const templatePath = path.join(__dirname, assetsFolderName, this.templateFolderName[projectType], templateFile); + const templateContent = await fs.readFile(templatePath, 'utf-8'); + + const csprojFilePath = path.join(functionFolderPath, `${methodName}.csproj`); + let csprojFileContent: string; + if (targetFramework === TargetFramework.Net8 && projectType === ProjectType.customCode) { + csprojFileContent = templateContent.replace( + /\$\(MSBuildProjectDirectory\)\\..\\LogicApp<\/LogicAppFolderToPublish>/g, + `$(MSBuildProjectDirectory)\\..\\${logicAppName}` + ); + } else { + csprojFileContent = templateContent.replace( + /LogicApp<\/LogicAppFolder>/g, + `${logicAppName}` + ); + } + await fs.writeFile(csprojFilePath, csprojFileContent); + } + + /** + * Creates the Visual Studio Code configuration files in the .vscode folder of the specified functions app. + * @param functionFolderPath The path to the functions folder. + * @param targetFramework The target framework of the functions app. + * @param funcVersion The version of the functions app. + * @param logicAppName The name of the logic app. + * @param isNewLogicAppProject Indicates if the logic app project is new. + */ + private async createVscodeConfigFiles( + functionFolderPath: string, + targetFramework: TargetFramework + // funcVersion: FuncVersion, + // logicAppName: string, + // isNewLogicAppProject: boolean + ): Promise { + await fs.ensureDir(functionFolderPath); + const vscodePath: string = path.join(functionFolderPath, vscodeFolderName); + await fs.ensureDir(vscodePath); + + await this.generateExtensionsJson(vscodePath); + + // Update launch config for existing logic app project (new projects will be created with the correct config) + // if (!isNewLogicAppProject) { + // await this.updateLogicAppLaunchJson(vscodePath, targetFramework, funcVersion, logicAppName); + // } + + await this.generateSettingsJson(vscodePath, targetFramework); + + await this.generateTasksJson(vscodePath); + } + + /** + * Generates the extensions.json file in the specified folder. + * @param folderPath The path to the folder where the extensions.json file should be generated. + */ + private async generateExtensionsJson(folderPath: string): Promise { + const filePath = path.join(folderPath, extensionsFileName); + const content = { + recommendations: [functionsExtensionId, dotnetExtensionId], + }; + await fs.writeJson(filePath, content, { spaces: 2 }); + } + + /** + * Updates the launch.json file for the logic app corresponding to this functions app. + * @param folderPath The functions app folder path. + * @param targetFramework The target framework of the functions app. + * @param funcVersion The version of the functions app. + * @param logicAppName The name of the logic app. + */ + private async updateLogicAppLaunchJson( + folderPath: string, + targetFramework: TargetFramework, + funcVersion: FuncVersion, + logicAppName: string + ): Promise { + const logicAppLaunchJsonPath = path.join(folderPath, '..', '..', logicAppName, vscodeFolderName, launchFileName); + let logicAppWorkspaceFolder = getContainingWorkspace(logicAppLaunchJsonPath); + if (logicAppWorkspaceFolder === undefined) { + // Traverse up the directory tree to find a .code-workspace file + let currentPath = path.dirname(logicAppLaunchJsonPath); + let workspaceFile: string | undefined; + + while (currentPath !== path.parse(currentPath).root) { + const files = await fs.readdir(currentPath); + const codeWorkspaceFile = files.find((file) => file.endsWith('.code-workspace')); + + if (codeWorkspaceFile) { + workspaceFile = path.join(currentPath, codeWorkspaceFile); + break; + } + + currentPath = path.dirname(currentPath); + } + + // If no workspace file found, cannot update launch.json + if (workspaceFile) { + logicAppWorkspaceFolder = { + uri: vscode.Uri.file(currentPath), + name: path.basename(currentPath), + index: 0, + }; + } + } + const debugConfigs = getDebugConfigs(logicAppWorkspaceFolder); + const updatedDebugConfigs = debugConfigs.some((debugConfig) => debugConfig.type === 'logicapp') + ? debugConfigs.map((debugConfig) => { + // Update the logic app debug configuration to use the correct runtime for custom code + if (debugConfig.type === 'logicapp') { + return { + ...debugConfig, + customCodeRuntime: targetFramework === TargetFramework.Net8 ? 'coreclr' : 'clr', + isCodeless: true, + }; + } + return debugConfig; + }) + : [ + { + name: localize('debugLogicApp', `Run/Debug logic app with local function ${logicAppName}`), + type: 'logicapp', + request: 'launch', + funcRuntime: funcVersion === FuncVersion.v1 ? 'clr' : 'coreclr', + customCodeRuntime: targetFramework === TargetFramework.Net8 ? 'coreclr' : 'clr', + isCodeless: true, + }, + ...debugConfigs.filter( + (debugConfig) => debugConfig.request !== 'attach' || debugConfig.processId !== `\${command:${extensionCommand.pickProcess}}` + ), + ]; + + if (isMultiRootWorkspace()) { + let launchJsonContent: any; + if (await fs.pathExists(logicAppLaunchJsonPath)) { + launchJsonContent = await fs.readJson(logicAppLaunchJsonPath); + launchJsonContent['configurations'] = updatedDebugConfigs; + } else { + launchJsonContent = { + version: '0.2.0', + configurations: updatedDebugConfigs, + }; + } + await fs.writeJson(logicAppLaunchJsonPath, launchJsonContent, { spaces: 2 }); + } else { + updateDebugConfigs(logicAppWorkspaceFolder, updatedDebugConfigs); + } + } + + /** + * Generates the settings.json file in the specified folder. + * @param folderPath The path to the folder where the settings.json file should be generated. + * @param targetFramework The target framework of the functions app. + */ + private async generateSettingsJson(folderPath: string, targetFramework: TargetFramework): Promise { + const filePath = path.join(folderPath, settingsFileName); + const content = { + 'azureFunctions.deploySubpath': `bin/Release/${targetFramework ?? TargetFramework.NetFx}/publish`, + 'azureFunctions.projectLanguage': 'C#', + 'azureFunctions.projectRuntime': '~4', + 'debug.internalConsoleOptions': 'neverOpen', + 'azureFunctions.preDeployTask': 'publish (functions)', + 'azureFunctions.templateFilter': 'Core', + 'azureFunctions.showTargetFrameworkWarning': false, + 'azureFunctions.projectSubpath': `bin\\Release\\${targetFramework ?? TargetFramework.NetFx}\\publish`, + }; + await fs.writeJson(filePath, content, { spaces: 2 }); + } + + /** + * Generates the tasks.json file in the specified folder. + * @param folderPath The path to the folder where the tasks.json file should be generated. + */ + private async generateTasksJson(folderPath: string): Promise { + const filePath = path.join(folderPath, tasksFileName); + const content = { + version: '2.0.0', + tasks: [ + { + label: 'build', + command: '${config:azureLogicAppsStandard.dotnetBinaryPath}', + type: 'process', + args: ['build', '${workspaceFolder}'], + group: { + kind: 'build', + isDefault: true, + }, + }, + ], + }; + await fs.writeJson(filePath, content, { spaces: 2 }); + } +} diff --git a/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/CreateLogicAppProjects.ts b/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/CreateLogicAppProjects.ts new file mode 100644 index 00000000000..015232c35b6 --- /dev/null +++ b/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/CreateLogicAppProjects.ts @@ -0,0 +1,87 @@ +import { localize } from '../../../../localize'; +import { createArtifactsFolder } from '../../../utils/codeless/artifacts'; +import { addLocalFuncTelemetry } from '../../../utils/funcCoreTools/funcVersion'; +import type { IActionContext } from '@microsoft/vscode-azext-utils'; +import * as fse from 'fs-extra'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { gitInit, isGitInstalled, isInsideRepo } from '../../../utils/git'; +import { CreateFunctionAppFiles } from './CreateFunctionAppFiles'; +import type { IFunctionWizardContext, IWebviewProjectContext } from '@microsoft/vscode-extension-logic-apps'; +import { ProjectType } from '@microsoft/vscode-extension-logic-apps'; +import { createLogicAppVsCodeContents } from './CreateLogicAppVSCodeContents'; +import { isLogicAppProject } from '../../../utils/verifyIsProject'; +import { + createLibFolder, + createLocalConfigurationFiles, + createLogicAppAndWorkflow, + createRulesFiles, + updateWorkspaceFile, +} from './CreateLogicAppWorkspace'; + +export async function createLogicAppProject(context: IActionContext, options: any, workspaceRootFolder: any): Promise { + addLocalFuncTelemetry(context); + + const myWebviewProjectContext: IWebviewProjectContext = options; + // Create the workspace folder + const workspaceFolder = workspaceRootFolder; + // Path to the logic app folder + const logicAppFolderPath = path.join(workspaceFolder, myWebviewProjectContext.logicAppName); + + // Check if the logic app directory already exists + const logicAppExists = await fse.pathExists(logicAppFolderPath); + let doesLogicAppExist = false; + if (logicAppExists) { + // Check if it's actually a Logic App project + doesLogicAppExist = await isLogicAppProject(logicAppFolderPath); + } + + // Check if we're in a workspace and get the workspace folder + if (vscode.workspace.workspaceFile) { + // Get the directory containing the .code-workspace file + const workspaceFilePath = vscode.workspace.workspaceFile.fsPath; + myWebviewProjectContext.workspaceFilePath = workspaceFilePath; + myWebviewProjectContext.shouldCreateLogicAppProject = !doesLogicAppExist; + // need to get logic app in projects + await updateWorkspaceFile(myWebviewProjectContext); + } else { + // Fall back to the newly created workspace folder if not in a workspace + vscode.window.showErrorMessage( + localize('notInWorkspace', 'Please open an existing logic app workspace before trying to add a new logic app project.') + ); + return; + } + + const mySubContext: IFunctionWizardContext = context as IFunctionWizardContext; + mySubContext.logicAppName = options.logicAppName; + mySubContext.projectPath = logicAppFolderPath; + mySubContext.projectType = myWebviewProjectContext.logicAppType as ProjectType; + mySubContext.functionFolderName = options.functionFolderName; + mySubContext.functionAppName = options.functionName; + mySubContext.functionAppNamespace = options.functionNamespace; + mySubContext.targetFramework = options.targetFramework; + mySubContext.workspacePath = workspaceFolder; + + if (!doesLogicAppExist) { + await createLogicAppAndWorkflow(myWebviewProjectContext, logicAppFolderPath); + + // .vscode folder + await createLogicAppVsCodeContents(myWebviewProjectContext, logicAppFolderPath); + + await createLocalConfigurationFiles(myWebviewProjectContext, logicAppFolderPath); + + if ((await isGitInstalled(workspaceFolder)) && !(await isInsideRepo(workspaceFolder))) { + await gitInit(workspaceFolder); + } + + await createArtifactsFolder(mySubContext); + await createRulesFiles(mySubContext); + await createLibFolder(mySubContext); + } + + if (myWebviewProjectContext.logicAppType !== ProjectType.logicApp) { + const createFunctionAppFilesStep = new CreateFunctionAppFiles(); + await createFunctionAppFilesStep.setup(mySubContext); + } + vscode.window.showInformationMessage(localize('finishedCreating', 'Finished creating project.')); +} diff --git a/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/CreateLogicAppVSCodeContents.ts b/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/CreateLogicAppVSCodeContents.ts new file mode 100644 index 00000000000..a08744f5901 --- /dev/null +++ b/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/CreateLogicAppVSCodeContents.ts @@ -0,0 +1,188 @@ +import { latestGAVersion, ProjectLanguage, ProjectType, TargetFramework } from '@microsoft/vscode-extension-logic-apps'; +import type { IExtensionsJson, ILaunchJson, ISettingToAdd, IWebviewProjectContext } from '@microsoft/vscode-extension-logic-apps'; +import { + deploySubpathSetting, + extensionCommand, + extensionsFileName, + funcVersionSetting, + launchFileName, + launchVersion, + logicAppsStandardExtensionId, + preDeployTaskSetting, + projectLanguageSetting, + settingsFileName, + tasksFileName, + vscodeFolderName, +} from '../../../../constants'; +import path from 'path'; +import * as fse from 'fs-extra'; +import type { DebugConfiguration } from 'vscode'; +import { confirmEditJsonFile } from '../../../utils/fs'; +import type { IActionContext } from '@microsoft/vscode-azext-utils'; +import { localize } from '../../../../localize'; +import { ext } from '../../../../extensionVariables'; +import { isDebugConfigEqual } from '../../../utils/vsCodeConfig/launch'; + +export async function writeSettingsJson( + context: IWebviewProjectContext, + theseSettings: ISettingToAdd[], + vscodePath: string +): Promise { + const settings: ISettingToAdd[] = theseSettings.concat( + { key: projectLanguageSetting, value: ProjectLanguage.JavaScript }, + { key: funcVersionSetting, value: latestGAVersion }, + // We want the terminal to open after F5, not the debug console because HTTP triggers are printed in the terminal. + { prefix: 'debug', key: 'internalConsoleOptions', value: 'neverOpen' }, + { prefix: 'azureFunctions', key: 'suppressProject', value: true } + ); + + if (this.preDeployTask) { + settings.push({ key: preDeployTaskSetting, value: this.preDeployTask }); + } + + // if (context.workspaceFolder) { + // // Use Visual Studio Code API to update config if folder is open + // for (const setting of settings) { + // await updateWorkspaceSetting(setting.key, setting.value, context.workspacePath, setting.prefix); + // } + // } else { + // otherwise manually edit json + const settingsJsonPath: string = path.join(vscodePath, settingsFileName); + await confirmEditJsonFile(context, settingsJsonPath, (data: Record): Record => { + for (const setting of settings) { + const key = `${setting.prefix || ext.prefix}.${setting.key}`; + data[key] = setting.value; + } + return data; + }); + // } +} + +export async function writeExtensionsJson(context: IActionContext, vscodePath: string): Promise { + const extensionsJsonPath: string = path.join(vscodePath, extensionsFileName); + await confirmEditJsonFile(context, extensionsJsonPath, (data: IExtensionsJson): Record => { + const recommendations: string[] = [logicAppsStandardExtensionId]; + // de-dupe array + data.recommendations = recommendations.filter((rec: string, index: number) => recommendations.indexOf(rec) === index); + return data; + }); +} + +export async function writeTasksJson(context: IActionContext, vscodePath: string): Promise { + const tasksJsonPath: string = path.join(vscodePath, tasksFileName); + const tasksJsonContent = `{ + "version": "2.0.0", + "tasks": [ + { + "label": "generateDebugSymbols", + "command": "\${config:azureLogicAppsStandard.dotnetBinaryPath}", + "args": [ + "\${input:getDebugSymbolDll}" + ], + "type": "process", + "problemMatcher": "$msCompile" + }, + { + "type": "shell", + "command": "\${config:azureLogicAppsStandard.funcCoreToolsBinaryPath}", + "args": [ + "host", + "start" + ], + "options": { + "env": { + "PATH": "\${config:azureLogicAppsStandard.autoRuntimeDependenciesPath}\\\\NodeJs;\${config:azureLogicAppsStandard.autoRuntimeDependenciesPath}\\\\DotNetSDK;$env:PATH" + } + }, + "problemMatcher": "$func-watch", + "isBackground": true, + "label": "func: host start", + "group": { + "kind": "build", + "isDefault": true + } + } + ], + "inputs": [ + { + "id": "getDebugSymbolDll", + "type": "command", + "command": "azureLogicAppsStandard.getDebugSymbolDll" + } + ] +}`; + + // if (await confirmOverwriteFile(context, tasksJsonPath)) { + await fse.writeFile(tasksJsonPath, tasksJsonContent); + // } +} + +export function getDebugConfiguration(logicAppName: string, customCodeTargetFramework?: TargetFramework): DebugConfiguration { + if (customCodeTargetFramework) { + return { + name: localize('debugLogicApp', `Run/Debug logic app with local function ${logicAppName}`), + type: 'logicapp', + request: 'launch', + funcRuntime: 'coreclr', + customCodeRuntime: customCodeTargetFramework === TargetFramework.Net8 ? 'coreclr' : 'clr', + isCodeless: true, + }; + } + + return { + name: localize('attachToNetFunc', `Run/Debug logic app ${logicAppName}`), + type: 'coreclr', + request: 'attach', + processId: `\${command:${extensionCommand.pickProcess}}`, + }; +} + +export async function writeLaunchJson( + context: IActionContext, + vscodePath: string, + logicAppName: string, + customCodeTargetFramework?: TargetFramework +): Promise { + const newDebugConfig: DebugConfiguration = getDebugConfiguration(logicAppName, customCodeTargetFramework); + + // otherwise manually edit json + const launchJsonPath: string = path.join(vscodePath, launchFileName); + await confirmEditJsonFile(context, launchJsonPath, (data: ILaunchJson): ILaunchJson => { + data.version = launchVersion; + data.configurations = insertLaunchConfig(data.configurations, newDebugConfig); + return data; + }); +} + +export function insertLaunchConfig(existingConfigs: DebugConfiguration[] | undefined, newConfig: DebugConfiguration): DebugConfiguration[] { + // tslint:disable-next-line: strict-boolean-expressions + existingConfigs = existingConfigs || []; + // Remove configs that match the one we're about to add + existingConfigs = existingConfigs.filter((l1) => !isDebugConfigEqual(l1, newConfig)); + existingConfigs.push(newConfig); + return existingConfigs; +} + +export async function createLogicAppVsCodeContents( + myWebviewProjectContext: IWebviewProjectContext, + logicAppFolderPath: string +): Promise { + const vscodePath: string = path.join(logicAppFolderPath, vscodeFolderName); + await fse.ensureDir(vscodePath); + + const theseSettings: ISettingToAdd[] = []; + + if (myWebviewProjectContext.logicAppType === ProjectType.logicApp) { + theseSettings.push({ key: deploySubpathSetting, value: '.' }); + } + + await writeSettingsJson(myWebviewProjectContext, theseSettings, vscodePath); + await writeExtensionsJson(myWebviewProjectContext, vscodePath); + await writeTasksJson(myWebviewProjectContext, vscodePath); + await writeLaunchJson( + myWebviewProjectContext, + vscodePath, + myWebviewProjectContext.logicAppName, + myWebviewProjectContext.targetFramework as TargetFramework + ); +} diff --git a/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/CreateLogicAppWorkspace.ts b/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/CreateLogicAppWorkspace.ts new file mode 100644 index 00000000000..917330928b6 --- /dev/null +++ b/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/CreateLogicAppWorkspace.ts @@ -0,0 +1,331 @@ +import { + appKindSetting, + artifactsDirectory, + assetsFolderName, + azureWebJobsFeatureFlagsKey, + azureWebJobsStorageKey, + defaultVersionRange, + extensionBundleId, + extensionCommand, + funcIgnoreFileName, + functionsInprocNet8Enabled, + functionsInprocNet8EnabledTrue, + gitignoreFileName, + hostFileName, + libDirectory, + localEmulatorConnectionString, + localSettingsFileName, + logicAppKind, + multiLanguageWorkerSetting, + ProjectDirectoryPathKey, + rulesDirectory, + schemasDirectory, + testsDirectoryName, + vscodeFolderName, + workerRuntimeKey, + workflowFileName, + type WorkflowType, +} from '../../../../constants'; +import { localize } from '../../../../localize'; +import { createArtifactsFolder } from '../../../utils/codeless/artifacts'; +import { addLocalFuncTelemetry } from '../../../utils/funcCoreTools/funcVersion'; +import type { IActionContext } from '@microsoft/vscode-azext-utils'; +import * as fse from 'fs-extra'; +import * as os from 'os'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { newGetGitIgnoreContent, gitInit, isGitInstalled, isInsideRepo } from '../../../utils/git'; +import { writeFormattedJson } from '../../../utils/fs'; +import { getCodelessWorkflowTemplate } from '../../../utils/codeless/templates'; +import { CreateFunctionAppFiles } from './CreateFunctionAppFiles'; +import type { + IFunctionWizardContext, + IHostJsonV2, + ILocalSettingsJson, + IWebviewProjectContext, + StandardApp, +} from '@microsoft/vscode-extension-logic-apps'; +import { WorkerRuntime, ProjectType } from '@microsoft/vscode-extension-logic-apps'; +import { createLogicAppVsCodeContents } from './CreateLogicAppVSCodeContents'; +import { logicAppPackageProcessing, unzipLogicAppPackageIntoWorkspace } from '../../../utils/cloudToLocalUtils'; +import { isLogicAppProject } from '../../../utils/verifyIsProject'; + +export async function createRulesFiles(context: IFunctionWizardContext): Promise { + if (context.projectType === ProjectType.rulesEngine) { + // SampleRuleSet.xml + const sampleRuleSetPath = path.join(__dirname, assetsFolderName, 'RuleSetProjectTemplate', 'SampleRuleSet'); + const sampleRuleSetXMLPath = path.join(context.projectPath, artifactsDirectory, rulesDirectory, 'SampleRuleSet.xml'); + const sampleRuleSetXMLContent = await fse.readFile(sampleRuleSetPath, 'utf-8'); + const sampleRuleSetXMLFileContent = sampleRuleSetXMLContent.replace(/<%= methodName %>/g, context.functionAppName); + await fse.writeFile(sampleRuleSetXMLPath, sampleRuleSetXMLFileContent); + + // SchemaUser.xsd + const schemaUserPath = path.join(__dirname, assetsFolderName, 'RuleSetProjectTemplate', 'SchemaUser'); + const schemaUserXSDPath = path.join(context.projectPath, artifactsDirectory, schemasDirectory, 'SchemaUser.xsd'); + const schemaUserXSDContent = await fse.readFile(schemaUserPath, 'utf-8'); + await fse.writeFile(schemaUserXSDPath, schemaUserXSDContent); + } +} + +export async function createLibFolder(context: IFunctionWizardContext): Promise { + fse.mkdirSync(path.join(context.projectPath, libDirectory, 'builtinOperationSdks', 'JAR'), { recursive: true }); + fse.mkdirSync(path.join(context.projectPath, libDirectory, 'builtinOperationSdks', 'net472'), { recursive: true }); +} + +export async function getHostContent(): Promise { + const hostJson: IHostJsonV2 = { + version: '2.0', + logging: { + applicationInsights: { + samplingSettings: { + isEnabled: true, + excludedTypes: 'Request', + }, + }, + }, + extensionBundle: { + id: extensionBundleId, + version: defaultVersionRange, + }, + }; + return hostJson; +} + +export async function createLogicAppAndWorkflow( + myWebviewProjectContext: IWebviewProjectContext, + logicAppFolderPath: string +): Promise { + const codelessDefinition: StandardApp = getCodelessWorkflowTemplate( + myWebviewProjectContext.logicAppType as ProjectType, + myWebviewProjectContext.workflowType as WorkflowType, + myWebviewProjectContext.functionName + ); + + await fse.ensureDir(logicAppFolderPath); + const logicAppWorkflowFolderPath = path.join(logicAppFolderPath, myWebviewProjectContext.workflowName); + await fse.ensureDir(logicAppWorkflowFolderPath); + + const workflowJsonFullPath: string = path.join(logicAppWorkflowFolderPath, workflowFileName); + await writeFormattedJson(workflowJsonFullPath, codelessDefinition); +} + +export async function createLocalConfigurationFiles( + myWebviewProjectContext: IWebviewProjectContext, + logicAppFolderPath: string +): Promise { + const funcignore: string[] = [ + '__blobstorage__', + '__queuestorage__', + '__azurite_db*__.json', + '.git*', + vscodeFolderName, + localSettingsFileName, + 'test', + '.debug', + 'workflow-designtime/', + ]; + const localSettingsJson: ILocalSettingsJson = { + IsEncrypted: false, + Values: { + [azureWebJobsStorageKey]: localEmulatorConnectionString, + [functionsInprocNet8Enabled]: functionsInprocNet8EnabledTrue, + [workerRuntimeKey]: WorkerRuntime.Dotnet, + [appKindSetting]: logicAppKind, + [ProjectDirectoryPathKey]: logicAppFolderPath, + }, + }; + const gitignore = ''; + + if (myWebviewProjectContext.logicAppType !== ProjectType.logicApp) { + funcignore.push('global.json'); + localSettingsJson.Values[azureWebJobsFeatureFlagsKey] = multiLanguageWorkerSetting; + } + + const hostJsonPath: string = path.join(logicAppFolderPath, hostFileName); + const hostJson: IHostJsonV2 = await getHostContent(); + await writeFormattedJson(hostJsonPath, hostJson); + + const localSettingsJsonPath: string = path.join(logicAppFolderPath, localSettingsFileName); + await writeFormattedJson(localSettingsJsonPath, localSettingsJson); + + const gitignorePath = path.join(logicAppFolderPath, gitignoreFileName); + await fse.writeFile(gitignorePath, gitignore.concat(newGetGitIgnoreContent())); + + const funcIgnorePath: string = path.join(logicAppFolderPath, funcIgnoreFileName); + await fse.writeFile(funcIgnorePath, funcignore.sort().join(os.EOL)); +} + +export async function createWorkspaceStructure(myWebviewProjectContext: IWebviewProjectContext): Promise { + //Create the workspace folder + const workspaceFolder = path.join(myWebviewProjectContext.workspaceProjectPath.fsPath, myWebviewProjectContext.workspaceName); + await fse.ensureDir(workspaceFolder); + + // Create the workspace .code-workspace file + const workspaceFilePath = path.join(workspaceFolder, `${myWebviewProjectContext.workspaceName}.code-workspace`); + const workspaceFolders = []; + workspaceFolders.push({ name: myWebviewProjectContext.logicAppName, path: `./${myWebviewProjectContext.logicAppName}` }); + + // push functions folder + if (myWebviewProjectContext.logicAppType !== ProjectType.logicApp) { + workspaceFolders.push({ name: myWebviewProjectContext.functionFolderName, path: `./${myWebviewProjectContext.functionFolderName}` }); + } + + const workspaceData = { + folders: workspaceFolders, + }; + await fse.writeJSON(workspaceFilePath, workspaceData, { spaces: 2 }); +} + +/** + * Updates a .code-workspace file to group project directories in VS Code + * @param context - Project wizard context + */ +export async function updateWorkspaceFile(context: IWebviewProjectContext): Promise { + const workspaceContent = await fse.readJson(context.workspaceFilePath); + + const workspaceFolders = []; + const logicAppName = context.logicAppName || 'LogicApp'; + if (context.shouldCreateLogicAppProject) { + workspaceFolders.push({ name: logicAppName, path: `./${logicAppName}` }); + } + + if (context.logicAppType !== ProjectType.logicApp) { + const functionsFolder = context.functionFolderName; + workspaceFolders.push({ name: functionsFolder, path: `./${functionsFolder}` }); + } + + workspaceContent.folders = [...workspaceContent.folders, ...workspaceFolders]; + + // Move the tests folder to the end of the workspace folders + const testsIndex = workspaceContent.folders.findIndex((folder) => folder.name === testsDirectoryName); + if (testsIndex !== -1 && testsIndex !== workspaceContent.folders.length - 1) { + const [testsFolder] = workspaceContent.folders.splice(testsIndex, 1); + workspaceContent.folders.push(testsFolder); + } + + await fse.writeJSON(context.workspaceFilePath, workspaceContent, { spaces: 2 }); +} + +export async function createLogicAppWorkspace(context: IActionContext, options: any, fromPackage: boolean): Promise { + addLocalFuncTelemetry(context); + + const myWebviewProjectContext: IWebviewProjectContext = options; + + await createWorkspaceStructure(myWebviewProjectContext); + + // Create the workspace folder + const workspaceFolder = path.join(myWebviewProjectContext.workspaceProjectPath.fsPath, myWebviewProjectContext.workspaceName); + // Path to the logic app folder + const logicAppFolderPath = path.join(workspaceFolder, myWebviewProjectContext.logicAppName); + const workspaceFilePath = path.join(workspaceFolder, `${myWebviewProjectContext.workspaceName}.code-workspace`); + + const mySubContext: IFunctionWizardContext = context as IFunctionWizardContext; + mySubContext.logicAppName = options.logicAppName; + mySubContext.projectPath = logicAppFolderPath; + mySubContext.projectType = myWebviewProjectContext.logicAppType as ProjectType; + mySubContext.functionFolderName = options.functionFolderName; + mySubContext.functionAppName = options.functionName; + mySubContext.functionAppNamespace = options.functionNamespace; + mySubContext.targetFramework = options.targetFramework; + mySubContext.workspacePath = workspaceFolder; + + if (fromPackage) { + mySubContext.packagePath = options.packagePath.fsPath; + await unzipLogicAppPackageIntoWorkspace(mySubContext); + } else { + await createLogicAppAndWorkflow(myWebviewProjectContext, logicAppFolderPath); + } + + // .vscode folder + await createLogicAppVsCodeContents(myWebviewProjectContext, logicAppFolderPath); + + await createLocalConfigurationFiles(myWebviewProjectContext, logicAppFolderPath); + + if ((await isGitInstalled(workspaceFolder)) && !(await isInsideRepo(workspaceFolder))) { + await gitInit(workspaceFolder); + } + + await createArtifactsFolder(mySubContext); + await createRulesFiles(mySubContext); + await createLibFolder(mySubContext); + + if (fromPackage) { + await logicAppPackageProcessing(mySubContext); + vscode.window.showInformationMessage(localize('finishedExtractingPackage', 'Finished extracting package into a logic app workspace.')); + } else { + if (myWebviewProjectContext.logicAppType !== ProjectType.logicApp) { + const createFunctionAppFilesStep = new CreateFunctionAppFiles(); + await createFunctionAppFilesStep.setup(mySubContext); + } + vscode.window.showInformationMessage(localize('finishedCreating', 'Finished creating project.')); + } + + await vscode.commands.executeCommand(extensionCommand.vscodeOpenFolder, vscode.Uri.file(workspaceFilePath), true /* forceNewWindow */); +} + +export async function createLogicAppProject(context: IActionContext, options: any, workspaceRootFolder: any): Promise { + addLocalFuncTelemetry(context); + + const myWebviewProjectContext: IWebviewProjectContext = options; + // Create the workspace folder + const workspaceFolder = workspaceRootFolder; + // Path to the logic app folder + const logicAppFolderPath = path.join(workspaceFolder, myWebviewProjectContext.logicAppName); + + // Check if the logic app directory already exists + const logicAppExists = await fse.pathExists(logicAppFolderPath); + let doesLogicAppExist = false; + if (logicAppExists) { + // Check if it's actually a Logic App project + doesLogicAppExist = await isLogicAppProject(logicAppFolderPath); + } + + // Check if we're in a workspace and get the workspace folder + if (vscode.workspace.workspaceFile) { + // Get the directory containing the .code-workspace file + const workspaceFilePath = vscode.workspace.workspaceFile.fsPath; + myWebviewProjectContext.workspaceFilePath = workspaceFilePath; + myWebviewProjectContext.shouldCreateLogicAppProject = !doesLogicAppExist; + // need to get logic app in projects + await updateWorkspaceFile(myWebviewProjectContext); + } else { + // Fall back to the newly created workspace folder if not in a workspace + vscode.window.showErrorMessage( + localize('notInWorkspace', 'Please open an existing logic app workspace before trying to add a new logic app project.') + ); + return; + } + + const mySubContext: IFunctionWizardContext = context as IFunctionWizardContext; + mySubContext.logicAppName = options.logicAppName; + mySubContext.projectPath = logicAppFolderPath; + mySubContext.projectType = myWebviewProjectContext.logicAppType as ProjectType; + mySubContext.functionFolderName = options.functionFolderName; + mySubContext.functionAppName = options.functionName; + mySubContext.functionAppNamespace = options.functionNamespace; + mySubContext.targetFramework = options.targetFramework; + mySubContext.workspacePath = workspaceFolder; + + if (!doesLogicAppExist) { + await createLogicAppAndWorkflow(myWebviewProjectContext, logicAppFolderPath); + + // .vscode folder + await createLogicAppVsCodeContents(myWebviewProjectContext, logicAppFolderPath); + + await createLocalConfigurationFiles(myWebviewProjectContext, logicAppFolderPath); + + if ((await isGitInstalled(workspaceFolder)) && !(await isInsideRepo(workspaceFolder))) { + await gitInit(workspaceFolder); + } + + await createArtifactsFolder(mySubContext); + await createRulesFiles(mySubContext); + await createLibFolder(mySubContext); + } + + if (myWebviewProjectContext.logicAppType !== ProjectType.logicApp) { + const createFunctionAppFilesStep = new CreateFunctionAppFiles(); + await createFunctionAppFilesStep.setup(mySubContext); + } + vscode.window.showInformationMessage(localize('finishedCreating', 'Finished creating project.')); +} diff --git a/apps/vs-code-designer/src/app/commands/createProject/createCustomCodeProjectSteps/customCodeProjectCreateStep.ts b/apps/vs-code-designer/src/app/commands/createProject/createCustomCodeProjectSteps/customCodeProjectCreateStep.ts deleted file mode 100644 index cae1a2ca0f5..00000000000 --- a/apps/vs-code-designer/src/app/commands/createProject/createCustomCodeProjectSteps/customCodeProjectCreateStep.ts +++ /dev/null @@ -1,26 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -import { ProjectCreateStep } from '../createProjectSteps/projectCreateStep'; -import type { IProjectWizardContext } from '@microsoft/vscode-extension-logic-apps'; -import type { Progress } from 'vscode'; - -/** - * This class represents a step that creates a new Workflow code project. - */ -export class CustomCodeProjectCreateStep extends ProjectCreateStep { - /** - * Executes the step to create a new Workflow code project. - * @param context The project wizard context. - * @param progress The progress reporter. - */ - public async executeCore( - context: IProjectWizardContext, - progress: Progress<{ message?: string | undefined; increment?: number | undefined }> - ): Promise { - this.funcignore.push('global.json'); - this.localSettingsJson.Values['AzureWebJobsFeatureFlags'] = 'EnableMultiLanguageWorker'; - await super.executeCore(context, progress); - } -} diff --git a/apps/vs-code-designer/src/app/commands/createProject/createCustomCodeProjectSteps/functionAppFilesStep.ts b/apps/vs-code-designer/src/app/commands/createProject/createCustomCodeProjectSteps/functionAppFilesStep.ts index 4b426177f27..f1a0fdb1159 100644 --- a/apps/vs-code-designer/src/app/commands/createProject/createCustomCodeProjectSteps/functionAppFilesStep.ts +++ b/apps/vs-code-designer/src/app/commands/createProject/createCustomCodeProjectSteps/functionAppFilesStep.ts @@ -11,6 +11,7 @@ import { settingsFileName, tasksFileName, extensionCommand, + assetsFolderName, } from '../../../../constants'; import { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; import type { FuncVersion, IProjectWizardContext } from '@microsoft/vscode-extension-logic-apps'; @@ -105,7 +106,7 @@ export class FunctionAppFilesStep extends AzureWizardPromptStep { const templateFile = projectType === ProjectType.rulesEngine ? this.csTemplateFileName[ProjectType.rulesEngine] : this.csTemplateFileName[targetFramework]; - const templatePath = path.join(__dirname, 'assets', this.templateFolderName[projectType], templateFile); + const templatePath = path.join(__dirname, assetsFolderName, this.templateFolderName[projectType], templateFile); const templateContent = await fs.readFile(templatePath, 'utf-8'); const csFilePath = path.join(functionFolderPath, `${methodName}.cs`); @@ -119,7 +120,7 @@ export class FunctionAppFilesStep extends AzureWizardPromptStep { - const csTemplatePath = path.join(__dirname, 'assets', 'RuleSetProjectTemplate', 'ContosoPurchase'); + const csTemplatePath = path.join(__dirname, assetsFolderName, 'RuleSetProjectTemplate', 'ContosoPurchase'); const csRuleSetPath = path.join(functionFolderPath, 'ContosoPurchase.cs'); await fs.copyFile(csTemplatePath, csRuleSetPath); } @@ -142,7 +143,7 @@ export class FunctionAppFilesStep extends AzureWizardPromptStep { - const tasksJsonPath: string = path.join(context.projectPath, vscodeFolderName, 'Tasks.json'); + const tasksJsonPath: string = path.join(context.projectPath, vscodeFolderName, tasksFileName); const tasksJsonContent = `{ "version": "2.0.0", "tasks": [ 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 154bf3d0f4a..5fd0fbbd355 100644 --- a/apps/vs-code-designer/src/app/commands/createProject/createProject.ts +++ b/apps/vs-code-designer/src/app/commands/createProject/createProject.ts @@ -2,51 +2,139 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { createProjectInternal } from './createProjectInternal'; -import { ExistingWorkspaceStep } from './createProjectSteps/existingWorkspaceStep'; -import { LogicAppTemplateStep } from './createProjectSteps/logicAppTemplateStep'; -import { LogicAppNameStep } from './createProjectSteps/logicAppNameStep'; -import { TargetFrameworkStep } from './createCustomCodeProjectSteps/targetFrameworkStep'; -import { ProjectTypeStep } from './createProjectSteps/projectTypeStep'; -import { WorkspaceSettingsStep } from '../createWorkspace/createWorkspaceSteps/workspaceSettingsStep'; -import { isString } from '@microsoft/logic-apps-shared'; -import type { IActionContext } from '@microsoft/vscode-azext-utils'; -import type { ProjectLanguage, ProjectVersion } from '@microsoft/vscode-extension-logic-apps'; + +import { callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils'; +import { ExtensionCommand, ProjectName } from '@microsoft/vscode-extension-logic-apps'; import { convertToWorkspace } from '../convertToWorkspace'; +import { localize } from '../../../localize'; +import { ext } from '../../../extensionVariables'; +import { cacheWebviewPanel, removeWebviewPanelFromCache, tryGetWebviewPanel } from '../../utils/codeless/common'; +import * as vscode from 'vscode'; +import path from 'path'; +import { getWebViewHTML } from '../../utils/codeless/getWebViewHTML'; +import { createLogicAppProject } from '../createNewCodeProject/CodeProjectBase/CreateLogicAppProjects'; +import { getLogicAppWithoutCustomCodeNew } from '../../utils/workspace'; +import { assetsFolderName } from '../../../constants'; + +const workspaceParentDialogOptions: vscode.OpenDialogOptions = { + canSelectMany: false, + openLabel: localize('selectWorkspaceParentFolder', 'Select workspace parent folder'), + canSelectFiles: false, + canSelectFolders: true, +}; -// TODO(aeldridge): TargetFrameworkStep should be in a subwizard on LogicAppTemplateStep -export async function createProject( - context: IActionContext, - folderPath?: string | undefined, - language?: ProjectLanguage, - version?: ProjectVersion, - openFolder = true, - templateId?: string, - functionName?: string, - functionSettings?: { [key: string]: string | undefined } -): Promise { - if (await convertToWorkspace(context)) { - await createProjectInternal( - context, - { - folderPath: isString(folderPath) ? folderPath : undefined, - templateId, - functionName, - functionSettings, - suppressOpenFolder: !openFolder, - language, - version, - }, - 'createProject', - 'Create new project', - [ - new ExistingWorkspaceStep(), - new LogicAppTemplateStep(), - new TargetFrameworkStep(), - new LogicAppNameStep(), - await ProjectTypeStep.create(context, templateId, functionSettings, false), - new WorkspaceSettingsStep(), - ] - ); +export async function createNewProjectFromCommand(context: IActionContext): Promise { + // determine if in workspace, if not in workspace but there is a logic app project found, + // promt to see if they want to move the project over to a logic app workspace + // if they cancel do nothing, if they say yes then generate a webview to convert their project over to a logic app workspace. + // Provide text that says logic app workspace created in new window + // if we are in a workspace then we want to generate the webview for creating a new project + let workspaceRootFolder = ''; + if (vscode.workspace.workspaceFile) { + // Get the directory containing the .code-workspace file + workspaceRootFolder = path.dirname(vscode.workspace.workspaceFile.fsPath); + // need to get logic app in projects + } else { + // Fall back to the newly created workspace folder if not in a workspace + // vscode.window.showErrorMessage(localize('notInWorkspace', 'Please open an existing logic app workspace before trying to add a new logic app project.')); + await convertToWorkspace(context); + + return; } + + const panelName: string = localize('createLogicAppProject', 'Create Project'); + const panelGroupKey = ext.webViewKey.createLogicApp; + const apiVersion = '2021-03-01'; + const existingPanel: vscode.WebviewPanel | undefined = tryGetWebviewPanel(panelGroupKey, panelName); + + const workspaceFileContent = await vscode.workspace.fs.readFile(vscode.workspace.workspaceFile); + const workspaceFileJson = JSON.parse(workspaceFileContent.toString()); + const logicAppsWithoutCustomCode = await getLogicAppWithoutCustomCodeNew(context); + + if (existingPanel) { + if (!existingPanel.active) { + existingPanel.reveal(vscode.ViewColumn.Active); + } + + return; + } + + const options: vscode.WebviewOptions & vscode.WebviewPanelOptions = { + enableScripts: true, + retainContextWhenHidden: true, + }; + + const panel: vscode.WebviewPanel = vscode.window.createWebviewPanel( + 'CreateLogicAppProject', + `${panelName}`, + vscode.ViewColumn.Active, + options + ); + panel.iconPath = { + light: vscode.Uri.file(path.join(ext.context.extensionPath, assetsFolderName, 'light', 'export.svg')), + dark: vscode.Uri.file(path.join(ext.context.extensionPath, assetsFolderName, 'dark', 'export.svg')), + }; + panel.webview.html = await getWebViewHTML('vs-code-react', panel); + + let interval: NodeJS.Timeout; + + panel.webview.onDidReceiveMessage(async (message) => { + switch (message.command) { + case ExtensionCommand.initialize: { + panel.webview.postMessage({ + command: ExtensionCommand.initialize_frame, + data: { + apiVersion, + project: ProjectName.createLogicApp, + hostVersion: ext.extensionVersion, + workspaceFileJson: workspaceFileJson, + logicAppsWithoutCustomCode: logicAppsWithoutCustomCode, + }, + }); + break; + } + case ExtensionCommand.createLogicApp: { + await callWithTelemetryAndErrorHandling('CreateLogicAppProject', async (activateContext: IActionContext) => { + await createLogicAppProject(activateContext, message.data, workspaceRootFolder); + }); + // Close the webview panel after successful creation + panel.dispose(); + break; + } + case ExtensionCommand.select_folder: { + vscode.window.showOpenDialog(workspaceParentDialogOptions).then((fileUri) => { + if (fileUri && fileUri[0]) { + panel.webview.postMessage({ + command: ExtensionCommand.update_workspace_path, + data: { + targetDirectory: { + fsPath: fileUri[0].fsPath, + path: fileUri[0].path, + }, + }, + }); + } + }); + break; + } + // case ExtensionCommand.logTelemetry: { + // const eventName = message.key; + // ext.telemetryReporter.sendTelemetryEvent(eventName, { value: message.value }); + // ext.logTelemetry(context, eventName, message.value); + // break; + // } + default: + break; + } + }, ext.context.subscriptions); + + panel.onDidDispose( + () => { + removeWebviewPanelFromCache(panelGroupKey, panelName); + clearInterval(interval); + }, + null, + ext.context.subscriptions + ); + cacheWebviewPanel(panelGroupKey, panelName, panel); } diff --git a/apps/vs-code-designer/src/app/commands/createProject/createProjectInternal.ts b/apps/vs-code-designer/src/app/commands/createProject/createProjectInternal.ts deleted file mode 100644 index 1f2c7538e9f..00000000000 --- a/apps/vs-code-designer/src/app/commands/createProject/createProjectInternal.ts +++ /dev/null @@ -1,96 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -import { - extensionCommand, - funcVersionSetting, - projectLanguageSetting, - projectOpenBehaviorSetting, - projectTemplateKeySetting, -} from '../../../constants'; -import { localize } from '../../../localize'; -import { createArtifactsFolder } from '../../utils/codeless/artifacts'; -import { getAllCustomCodeFunctionsProjects } from '../../utils/customCodeUtils'; -import { addLocalFuncTelemetry, tryGetLocalFuncVersion, tryParseFuncVersion } from '../../utils/funcCoreTools/funcVersion'; -import { getGlobalSetting, getWorkspaceSetting } from '../../utils/vsCodeConfig/settings'; -import { WorkspaceFolderStep } from '../createWorkspace/createWorkspaceSteps/workspaceFolderStep'; -import { OpenFolderStep } from '../createWorkspace/createWorkspaceSteps/openFolderStep'; -import { AzureWizard } from '@microsoft/vscode-azext-utils'; -import type { IActionContext } from '@microsoft/vscode-azext-utils'; -import { latestGAVersion, OpenBehavior, ProjectType } from '@microsoft/vscode-extension-logic-apps'; -import type { ICreateFunctionOptions, IFunctionWizardContext, ProjectLanguage } from '@microsoft/vscode-extension-logic-apps'; -import * as fse from 'fs-extra'; -import * as path from 'path'; -import { window, commands } from 'vscode'; - -export async function createProjectInternal( - context: IActionContext, - options: ICreateFunctionOptions, - title: string, - message: string, - promptSteps: any[] -): Promise { - addLocalFuncTelemetry(context); - - const language: ProjectLanguage | string = (options.language as ProjectLanguage) || getGlobalSetting(projectLanguageSetting); - const version: string = options.version || getGlobalSetting(funcVersionSetting) || (await tryGetLocalFuncVersion()) || latestGAVersion; - const projectTemplateKey: string | undefined = getGlobalSetting(projectTemplateKeySetting); - const wizardContext: Partial & IActionContext = Object.assign(context, options, { - language, - version: tryParseFuncVersion(version), - projectTemplateKey, - }); - - if (options.folderPath) { - WorkspaceFolderStep.setProjectPath(wizardContext, options.folderPath); - } - - if (options.suppressOpenFolder) { - wizardContext.openBehavior = OpenBehavior.dontOpen; - } else if (!wizardContext.openBehavior) { - wizardContext.openBehavior = getWorkspaceSetting(projectOpenBehaviorSetting); - context.telemetry.properties.openBehaviorFromSetting = String(!!wizardContext.openBehavior); - } - - const wizard: AzureWizard = new AzureWizard(wizardContext, { - title: localize(title, message), - promptSteps, - executeSteps: [new OpenFolderStep()], - }); - - await wizard.prompt(); - - await createArtifactsFolder(context as IFunctionWizardContext); - await createRulesFiles(context as IFunctionWizardContext); - await createLibFolder(context as IFunctionWizardContext); - await wizard.execute(); - - if (wizardContext.isWorkspaceWithFunctions) { - commands.executeCommand('setContext', extensionCommand.customCodeSetFunctionsFolders, await getAllCustomCodeFunctionsProjects(context)); - } - - window.showInformationMessage(localize('finishedCreating', 'Finished creating project.')); -} - -async function createRulesFiles(context: IFunctionWizardContext): Promise { - if (context.projectType === ProjectType.rulesEngine) { - // SampleRuleSet.xml - const sampleRuleSetPath = path.join(__dirname, 'assets', 'RuleSetProjectTemplate', 'SampleRuleSet'); - const sampleRuleSetXMLPath = path.join(context.projectPath, 'Artifacts', 'Rules', 'SampleRuleSet.xml'); - const sampleRuleSetXMLContent = await fse.readFile(sampleRuleSetPath, 'utf-8'); - const sampleRuleSetXMLFileContent = sampleRuleSetXMLContent.replace(/<%= methodName %>/g, context.functionAppName); - await fse.writeFile(sampleRuleSetXMLPath, sampleRuleSetXMLFileContent); - - // SchemaUser.xsd - const schemaUserPath = path.join(__dirname, 'assets', 'RuleSetProjectTemplate', 'SchemaUser'); - const schemaUserXSDPath = path.join(context.projectPath, 'Artifacts', 'Schemas', 'SchemaUser.xsd'); - const schemaUserXSDContent = await fse.readFile(schemaUserPath, 'utf-8'); - await fse.writeFile(schemaUserXSDPath, schemaUserXSDContent); - } -} - -async function createLibFolder(context: IFunctionWizardContext): Promise { - fse.mkdirSync(path.join(context.projectPath, 'lib', 'builtinOperationSdks', 'JAR'), { recursive: true }); - fse.mkdirSync(path.join(context.projectPath, 'lib', 'builtinOperationSdks', 'net472'), { recursive: true }); -} diff --git a/apps/vs-code-designer/src/app/commands/createProject/createProjectSteps/__test__/logicAppNameStep.test.ts b/apps/vs-code-designer/src/app/commands/createProject/createProjectSteps/__test__/logicAppNameStep.test.ts deleted file mode 100644 index 680ae101f7f..00000000000 --- a/apps/vs-code-designer/src/app/commands/createProject/createProjectSteps/__test__/logicAppNameStep.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import * as fse from 'fs-extra'; -import * as vscode from 'vscode'; -import * as path from 'path'; -import { LogicAppNameStep } from '../logicAppNameStep'; -import { ext } from '../../../../../extensionVariables'; -import { localize } from '../../../../../localize'; -import { IProjectWizardContext, ProjectType } from '@microsoft/vscode-extension-logic-apps'; - -describe('LogicAppNameStep', () => { - const testLogicAppName = 'LogicApp'; - const testWorkspaceName = 'TestWorkspace'; - const testWorkspaceFile = path.join('path', 'to', `${testWorkspaceName}.code-workspace`); - const testWorkspace = { - folders: [{ name: testLogicAppName }], - }; - let logicAppNameStep: LogicAppNameStep; - let testContext: any; - let existsSyncSpy: any; - let readFileSpy: any; - let appendLogSpy: any; - - beforeEach(() => { - logicAppNameStep = new LogicAppNameStep(); - testContext = { - projectType: ProjectType.logicApp, - workspaceCustomFilePath: testWorkspaceFile, - logicAppName: testLogicAppName, - shouldCreateLogicAppProject: true, - ui: { - showInputBox: vi.fn((options: any) => { - return options.validateInput(options.testInput).then((validationResult: string | undefined) => { - if (validationResult) { - return Promise.reject(new Error(validationResult)); - } - return Promise.resolve(options.testInput); - }); - }), - }, - }; - - appendLogSpy = vi.spyOn(ext.outputChannel, 'appendLog').mockImplementation(() => {}); - existsSyncSpy = vi.spyOn(fse, 'existsSync').mockReturnValue(false); - readFileSpy = vi.spyOn(vscode.workspace.fs, 'readFile').mockResolvedValue(Buffer.from(JSON.stringify(testWorkspace))); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('shouldPrompt', () => { - it('returns true when projectType is defined', () => { - expect(logicAppNameStep.shouldPrompt(testContext)).toBe(true); - }); - - it('returns false when projectType is undefined', () => { - testContext.projectType = undefined; - expect(logicAppNameStep.shouldPrompt(testContext)).toBe(false); - }); - }); - - describe('prompt', () => { - it('sets context.logicAppName and logs output for valid input', async () => { - const validName = 'ValidProject'; - testContext.ui.showInputBox = vi.fn((options: any) => { - options.testInput = validName; - return options.validateInput(options.testInput).then((result: string | undefined) => { - if (result) { - return Promise.reject(new Error(result)); - } - return Promise.resolve(validName); - }); - }); - await logicAppNameStep.prompt(testContext); - expect(testContext.logicAppName).toBe(validName); - expect(appendLogSpy).toHaveBeenCalledWith(localize('logicAppNameSet', `Logic App project name set to ${validName}`)); - }); - - it('rejects when input is invalid (empty)', async () => { - const emptyName = ''; - testContext.ui.showInputBox = vi.fn((options: any) => { - options.testInput = emptyName; - return options.validateInput(options.testInput).then((result: string | undefined) => { - if (result) { - return Promise.reject(new Error(result)); - } - return Promise.resolve(emptyName); - }); - }); - await expect(logicAppNameStep.prompt(testContext)).rejects.toThrow(localize('logicAppNameEmpty', 'Logic app name cannot be empty')); - }); - }); - - describe('validateLogicAppName (private method)', () => { - const callValidateLogicAppName = (name: string | undefined, context: IProjectWizardContext) => - (logicAppNameStep as any).validateLogicAppName(name, context); - - it('returns error message when name is empty', async () => { - const res = await callValidateLogicAppName('', testContext); - expect(res).toBe(localize('logicAppNameEmpty', 'Logic app name cannot be empty')); - }); - - it('returns error when name already exists in the workspace file', async () => { - existsSyncSpy.mockReturnValue(true); - - const res = await callValidateLogicAppName(testLogicAppName, testContext); - expect(res).toBe(localize('logicAppNameExists', 'A project with this name already exists in the workspace')); - }); - - it('returns error when name does not pass regex validation', async () => { - const invalidName = '1InvalidName'; - const res = await callValidateLogicAppName(invalidName, testContext); - expect(res).toBe( - localize('logicAppNameInvalidMessage', 'Logic app name must start with a letter and can only contain letters, digits, "_" and "-".') - ); - }); - - it('returns undefined for a valid name', async () => { - const validName = 'My_Valid-Name123'; - const res = await callValidateLogicAppName(validName, testContext); - expect(res).toBeUndefined(); - }); - }); -}); diff --git a/apps/vs-code-designer/src/app/commands/createProject/createProjectSteps/logicAppNameStep.ts b/apps/vs-code-designer/src/app/commands/createProject/createProjectSteps/logicAppNameStep.ts deleted file mode 100644 index 19203b2d206..00000000000 --- a/apps/vs-code-designer/src/app/commands/createProject/createProjectSteps/logicAppNameStep.ts +++ /dev/null @@ -1,74 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -import { ext } from '../../../../extensionVariables'; -import { localize } from '../../../../localize'; -import * as vscode from 'vscode'; -import * as fse from 'fs-extra'; -import * as path from 'path'; -import { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; -import { ProjectType, type IProjectWizardContext } from '@microsoft/vscode-extension-logic-apps'; -import { logicAppNameValidation } from '../../../../constants'; -import { promptForLogicAppWithoutCustomCode } from '../../../utils/workspace'; -import { isString } from '@microsoft/logic-apps-shared'; - -export class LogicAppNameStep extends AzureWizardPromptStep { - public shouldPrompt(context: IProjectWizardContext): boolean { - return context.projectType !== undefined; - } - - public async prompt(context: IProjectWizardContext): Promise { - if (context.projectType === ProjectType.logicApp || context.shouldCreateLogicAppProject) { - // For new workspaces or new logic app projects, prompt for a new logic app name - context.shouldCreateLogicAppProject = true; - context.logicAppName = await this.getLogicAppName(context); - } else { - // For custom code and rules engine projects, allow users to select from existing logic apps or create new logic app - const logicAppFolder = await promptForLogicAppWithoutCustomCode(context); - if (logicAppFolder === undefined) { - // This selection indicates that a new logic app should be created - context.shouldCreateLogicAppProject = true; - context.logicAppName = await this.getLogicAppName(context); - } else { - // This selection indicates that the custom code/rules engine should be created for an existing logic app project - context.shouldCreateLogicAppProject = false; - context.logicAppName = isString(logicAppFolder) ? path.basename(logicAppFolder) : logicAppFolder.name; - } - } - - ext.outputChannel.appendLog(localize('logicAppNameSet', `Logic App project name set to ${context.logicAppName}`)); - } - - private async getLogicAppName(context: IProjectWizardContext): Promise { - return await context.ui.showInputBox({ - placeHolder: localize('logicAppNamePlaceHolder', 'Logic App name'), - prompt: localize('logicAppNamePrompt', 'Enter a name for your Logic App project'), - validateInput: async (input: string): Promise => await this.validateLogicAppName(input, context), - }); - } - - private async validateLogicAppName(name: string | undefined, context: IProjectWizardContext): Promise { - if (!name || name.length === 0) { - return localize('logicAppNameEmpty', 'Logic app name cannot be empty'); - } - - if (fse.existsSync(context.workspaceFilePath)) { - const workspaceFileContent = await vscode.workspace.fs.readFile(vscode.Uri.file(context.workspaceFilePath)); - const workspaceFileJson = JSON.parse(workspaceFileContent.toString()); - - if (workspaceFileJson.folders && workspaceFileJson.folders.some((folder: { name: string }) => folder.name === name)) { - return localize('logicAppNameExists', 'A project with this name already exists in the workspace'); - } - } - - if (!logicAppNameValidation.test(name)) { - return localize( - 'logicAppNameInvalidMessage', - 'Logic app name must start with a letter and can only contain letters, digits, "_" and "-".' - ); - } - - return undefined; - } -} diff --git a/apps/vs-code-designer/src/app/commands/createProject/createProjectSteps/logicAppTemplateStep.ts b/apps/vs-code-designer/src/app/commands/createProject/createProjectSteps/logicAppTemplateStep.ts deleted file mode 100644 index f9cba7f24dc..00000000000 --- a/apps/vs-code-designer/src/app/commands/createProject/createProjectSteps/logicAppTemplateStep.ts +++ /dev/null @@ -1,29 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -import { localize } from '../../../../localize'; -import { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; -import type { IAzureQuickPickItem } from '@microsoft/vscode-azext-utils'; -import { ProjectType, TargetFramework, type IProjectWizardContext } from '@microsoft/vscode-extension-logic-apps'; - -export class LogicAppTemplateStep extends AzureWizardPromptStep { - public shouldPrompt(context: IProjectWizardContext): boolean { - return context.projectType === undefined; - } - - public async prompt(context: IProjectWizardContext): Promise { - const picks: IAzureQuickPickItem[] = [ - { label: localize('logicApp', 'Logic app'), data: ProjectType.logicApp }, - { label: localize('logicAppCustomCode', 'Logic app with custom code project'), data: ProjectType.customCode }, - { label: localize('logicAppRulesEngine', 'Logic app with rules engine project'), data: ProjectType.rulesEngine }, - ]; - - const placeHolder = localize('logicAppProjectTemplatePlaceHolder', 'Select a template for your new project'); - context.projectType = (await context.ui.showQuickPick(picks, { placeHolder })).data; - context.isWorkspaceWithFunctions = context.projectType !== ProjectType.logicApp; - if (context.projectType === ProjectType.rulesEngine) { - context.targetFramework = TargetFramework.NetFx; - } - } -} diff --git a/apps/vs-code-designer/src/app/commands/createProject/createProjectSteps/projectCreateStep.ts b/apps/vs-code-designer/src/app/commands/createProject/createProjectSteps/projectCreateStep.ts deleted file mode 100644 index 028777acb9c..00000000000 --- a/apps/vs-code-designer/src/app/commands/createProject/createProjectSteps/projectCreateStep.ts +++ /dev/null @@ -1,102 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -import { - ProjectDirectoryPathKey, - appKindSetting, - azureWebJobsStorageKey, - funcIgnoreFileName, - functionsInprocNet8Enabled, - functionsInprocNet8EnabledTrue, - gitignoreFileName, - hostFileName, - localEmulatorConnectionString, - localSettingsFileName, - logicAppKind, - vscodeFolderName, - workerRuntimeKey, -} from '../../../../constants'; -import { addDefaultBundle } from '../../../utils/bundleFeed'; -import { confirmOverwriteFile, writeFormattedJson } from '../../../utils/fs'; -import { ProjectCreateStepBase } from './projectCreateStepBase'; -import { nonNullProp } from '@microsoft/vscode-azext-utils'; -import type { IActionContext } from '@microsoft/vscode-azext-utils'; -import type { IHostJsonV1, IHostJsonV2, ILocalSettingsJson, IProjectWizardContext } from '@microsoft/vscode-extension-logic-apps'; -import { FuncVersion, WorkerRuntime } from '@microsoft/vscode-extension-logic-apps'; -import * as fse from 'fs-extra'; -import * as os from 'os'; -import * as path from 'path'; -import type { Progress } from 'vscode'; -import { getGitIgnoreContent } from '../../../utils/git'; - -export class ProjectCreateStep extends ProjectCreateStepBase { - protected funcignore: string[] = [ - '__blobstorage__', - '__queuestorage__', - '__azurite_db*__.json', - '.git*', - vscodeFolderName, - localSettingsFileName, - 'test', - '.debug', - ]; - protected localSettingsJson: ILocalSettingsJson = { - IsEncrypted: false, - Values: { - [azureWebJobsStorageKey]: localEmulatorConnectionString, - [functionsInprocNet8Enabled]: functionsInprocNet8EnabledTrue, - [workerRuntimeKey]: WorkerRuntime.Dotnet, - [appKindSetting]: logicAppKind, - }, - }; - protected gitignore = ''; - protected supportsManagedDependencies = false; - - public async executeCore( - context: IProjectWizardContext, - _progress: Progress<{ message?: string | undefined; increment?: number | undefined }> - ): Promise { - const version: FuncVersion = nonNullProp(context, 'version'); - const hostJsonPath: string = path.join(context.projectPath, hostFileName); - - if (await confirmOverwriteFile(context, hostJsonPath)) { - const hostJson: IHostJsonV2 | IHostJsonV1 = version === FuncVersion.v1 ? {} : await this.getHostContent(context); - await writeFormattedJson(hostJsonPath, hostJson); - } - - const localSettingsJsonPath: string = path.join(context.projectPath, localSettingsFileName); - if (await confirmOverwriteFile(context, localSettingsJsonPath)) { - this.localSettingsJson.Values[ProjectDirectoryPathKey] = path.join(context.projectPath); - await writeFormattedJson(localSettingsJsonPath, this.localSettingsJson); - } - - const gitignorePath = path.join(context.projectPath, gitignoreFileName); - if (await confirmOverwriteFile(context, gitignorePath)) { - await fse.writeFile(gitignorePath, this.gitignore.concat(getGitIgnoreContent())); - } - - const funcIgnorePath: string = path.join(context.projectPath, funcIgnoreFileName); - if (await confirmOverwriteFile(context, funcIgnorePath)) { - await fse.writeFile(funcIgnorePath, this.funcignore.sort().join(os.EOL)); - } - } - - protected async getHostContent(context: IActionContext): Promise { - const hostJson: IHostJsonV2 = { - version: '2.0', - logging: { - applicationInsights: { - samplingSettings: { - isEnabled: true, - excludedTypes: 'Request', - }, - }, - }, - }; - - await addDefaultBundle(context, hostJson); - - return hostJson; - } -} diff --git a/apps/vs-code-designer/src/app/commands/createProject/createProjectSteps/projectCreateStepBase.ts b/apps/vs-code-designer/src/app/commands/createProject/createProjectSteps/projectCreateStepBase.ts deleted file mode 100644 index 3668342b267..00000000000 --- a/apps/vs-code-designer/src/app/commands/createProject/createProjectSteps/projectCreateStepBase.ts +++ /dev/null @@ -1,49 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -import { localize } from '../../../../localize'; -import { gitInit, isGitInstalled, isInsideRepo } from '../../../utils/git'; -import { AzureWizardExecuteStep, callWithTelemetryAndErrorHandling } from '@microsoft/vscode-azext-utils'; -import type { IActionContext } from '@microsoft/vscode-azext-utils'; -import type { IProjectWizardContext } from '@microsoft/vscode-extension-logic-apps'; -import * as fse from 'fs-extra'; -import type { Progress } from 'vscode'; -import { workflowCodeTypeForTelemetry } from '../../../utils/codeful/utils'; - -export abstract class ProjectCreateStepBase extends AzureWizardExecuteStep { - public priority = 10; - - public abstract executeCore( - context: IProjectWizardContext, - progress: Progress<{ message?: string | undefined; increment?: number | undefined }> - ): Promise; - - public shouldExecute(_context: IProjectWizardContext): boolean { - return true; - } - - public async execute( - context: IProjectWizardContext, - progress: Progress<{ message?: string | undefined; increment?: number | undefined }> - ): Promise { - context.telemetry.properties.projectLanguage = context.language; - context.telemetry.properties.projectRuntime = context.version; - context.telemetry.properties.openBehavior = context.openBehavior; - - progress.report({ message: localize('creating', 'Creating new project...') }); - await fse.ensureDir(context.projectPath); - - await this.executeCore(context, progress); - - if ((await isGitInstalled(context.workspacePath)) && !(await isInsideRepo(context.workspacePath))) { - await gitInit(context.workspacePath); - } - - // OpenFolderStep sometimes restarts the extension host. Adding a second event here to see if we're losing any telemetry - await callWithTelemetryAndErrorHandling('azureLogicAppsStandard.createProjectStarted', (startedContext: IActionContext) => { - context.telemetry.properties.workflowCodeType = workflowCodeTypeForTelemetry(context.isCodeless); - Object.assign(startedContext, context); - }); - } -} diff --git a/apps/vs-code-designer/src/app/commands/createProject/createProjectSteps/projectTypeStep.ts b/apps/vs-code-designer/src/app/commands/createProject/createProjectSteps/projectTypeStep.ts deleted file mode 100644 index 237be6897fe..00000000000 --- a/apps/vs-code-designer/src/app/commands/createProject/createProjectSteps/projectTypeStep.ts +++ /dev/null @@ -1,172 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -import { ext } from '../../../../extensionVariables'; -import { WorkflowKindStep } from '../../createWorkflow/createCodelessWorkflow/createCodelessWorkflowSteps/workflowKindStep'; -import { ProjectCreateStep } from './projectCreateStep'; -import { WorkflowCodeTypeStep } from '../../createWorkflow/createWorkflowSteps/workflowCodeTypeStep'; -import { addInitVSCodeSteps } from '../../initProjectForVSCode/initProjectLanguageStep'; -import { FunctionAppFilesStep } from '../createCustomCodeProjectSteps/functionAppFilesStep'; -import { FunctionAppNameStep } from '../createCustomCodeProjectSteps/functionAppNameStep'; -import { FunctionAppNamespaceStep } from '../createCustomCodeProjectSteps/functionAppNamespaceStep'; -import { CustomCodeProjectCreateStep } from '../createCustomCodeProjectSteps/customCodeProjectCreateStep'; -import type { AzureWizardExecuteStep, IActionContext, IWizardOptions } from '@microsoft/vscode-azext-utils'; -import { AzureWizardPromptStep, nonNullProp } from '@microsoft/vscode-azext-utils'; -import { type IProjectWizardContext, ProjectLanguage, WorkflowProjectType } from '@microsoft/vscode-extension-logic-apps'; -import * as fs from 'fs-extra'; -import * as path from 'path'; - -// TODO(aeldridge): Move subwizard steps here into a separate "SetupProjectStep" or subwizard of LogicAppTemplateStep -export class ProjectTypeStep extends AzureWizardPromptStep { - public hideStepCount = true; - - private readonly templateId?: string; - private readonly functionSettings?: { [key: string]: string | undefined }; - private readonly skipWorkflowStateTypeStep: boolean; - - /** - * Initializes the ProjectTypeStep object with optional templateId and functionSettings parameters. - * @param templateId - The ID of the template for the code project. - * @param functionSettings - The settings for the functions in the code project. - * @param isImportedProject - A boolean indicating whether the project is imported (cloud to local). - */ - private constructor( - templateId: string | undefined, - functionSettings: { [key: string]: string | undefined } | undefined, - isImportedProject: boolean - ) { - super(); - this.templateId = templateId; - this.functionSettings = functionSettings; - // TODO(aeldridge): Remove this (should use context) - this.skipWorkflowStateTypeStep = isImportedProject; - } - - public static async create( - _context: IActionContext, - templateId: string | undefined, - functionSettings: { [key: string]: string | undefined } | undefined, - isImportedProject: boolean - ): Promise { - return new ProjectTypeStep(templateId, functionSettings, isImportedProject); - } - - /** - * Checks if this step should prompt the user - * @param context - Project wizard context containing user selections and settings - * @returns True if user should be prompted, otherwise false - */ - public shouldPrompt(): boolean { - return true; - } - - /** - * Prompts the user for project information and sets up directories - * @param context - Project wizard context containing user selections and settings - */ - public async prompt(context: IProjectWizardContext): Promise { - // Set default project type and language - // TODO(aeldridge): Add support for non-bundle-based project creation here - context.workflowProjectType = WorkflowProjectType.Bundle; - context.language = ProjectLanguage.JavaScript; - await this.setPaths(context); - } - - /** - * Gets the sub-wizard based on the context provided - * @param context - Project wizard context containing user selections and settings - * @returns Wizard options including prompt and execute steps - */ - public async getSubWizard(context: IProjectWizardContext): Promise> { - const promptSteps: AzureWizardPromptStep[] = []; - const executeSteps: AzureWizardExecuteStep[] = []; - - if (context.isWorkspaceWithFunctions) { - await this.setupCustomCodeLogicApp(context, executeSteps, promptSteps); - } else { - await this.setupLogicApp(context, executeSteps, promptSteps); - } - return { promptSteps, executeSteps }; - } - - /** - * Sets the paths required for the project - * @param context - Project wizard context - * @param isWorkspaceWithFunctions - Flag to check if it's a workspace with functions - */ - private async setPaths(context: IProjectWizardContext): Promise { - await fs.ensureDir(context.workspacePath); - - const logicAppFolderPath = path.join(context.workspacePath, context.logicAppName); - await fs.ensureDir(logicAppFolderPath); - context.logicAppFolderPath = logicAppFolderPath; - - context.projectPath = logicAppFolderPath; - } - - /** - * Configures steps for custom code Logic App - * @param context - Project wizard context - * @param executeSteps - List of steps to execute - * @param promptSteps - List of steps to prompt - */ - private async setupCustomCodeLogicApp( - context: IProjectWizardContext, - executeSteps: AzureWizardExecuteStep[], - promptSteps: AzureWizardPromptStep[] - ): Promise { - promptSteps.push(new FunctionAppNameStep(), new FunctionAppNamespaceStep(), new FunctionAppFilesStep()); - - if (context.shouldCreateLogicAppProject) { - context.projectPath = nonNullProp(context, 'logicAppFolderPath'); - executeSteps.push(new CustomCodeProjectCreateStep()); - await addInitVSCodeSteps(context, executeSteps, true); - - if (ext.codefulEnabled) { - promptSteps.push(new WorkflowCodeTypeStep()); - } else { - context.isCodeless = true; // default to codeless workflow, disabling codeful option - } - - promptSteps.push( - await WorkflowKindStep.create(context, { - isProjectWizard: true, - templateId: this.templateId, - triggerSettings: this.functionSettings, - }) - ); - } - } - - /** - * Configures steps for regular Logic App - * @param context - Project wizard context - * @param executeSteps - List of steps to execute - * @param promptSteps - List of steps to prompt - */ - private async setupLogicApp( - context: IProjectWizardContext, - executeSteps: AzureWizardExecuteStep[], - promptSteps: AzureWizardPromptStep[] - ): Promise { - executeSteps.push(new ProjectCreateStep()); - await addInitVSCodeSteps(context, executeSteps, false); - - if (!this.skipWorkflowStateTypeStep) { - if (ext.codefulEnabled) { - promptSteps.push(new WorkflowCodeTypeStep()); - } else { - context.isCodeless = true; // default to codeless workflow, disabling codeful option - } - - promptSteps.push( - await WorkflowKindStep.create(context, { - isProjectWizard: true, - templateId: this.templateId, - triggerSettings: this.functionSettings, - }) - ); - } - } -} diff --git a/apps/vs-code-designer/src/app/commands/createWorkspace/createWorkspace.ts b/apps/vs-code-designer/src/app/commands/createWorkspace/createWorkspace.ts index 5b461065400..15b7b4be66e 100644 --- a/apps/vs-code-designer/src/app/commands/createWorkspace/createWorkspace.ts +++ b/apps/vs-code-designer/src/app/commands/createWorkspace/createWorkspace.ts @@ -2,52 +2,156 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { createProjectInternal } from '../createProject/createProjectInternal'; -import { OpenBehaviorStep } from './createWorkspaceSteps/openBehaviorStep'; -import { WorkspaceFolderStep } from './createWorkspaceSteps/workspaceFolderStep'; -import { ProjectTypeStep } from '../createProject/createProjectSteps/projectTypeStep'; -import { WorkspaceSettingsStep } from './createWorkspaceSteps/workspaceSettingsStep'; -import { LogicAppNameStep } from '../createProject/createProjectSteps/logicAppNameStep'; -import { WorkspaceNameStep } from './createWorkspaceSteps/workspaceNameStep'; -import { isString } from '@microsoft/logic-apps-shared'; -import type { IActionContext } from '@microsoft/vscode-azext-utils'; -import type { ProjectLanguage, ProjectVersion } from '@microsoft/vscode-extension-logic-apps'; -import { TargetFrameworkStep } from '../createProject/createCustomCodeProjectSteps/targetFrameworkStep'; -import { LogicAppTemplateStep } from '../createProject/createProjectSteps/logicAppTemplateStep'; - -// TODO(aeldridge): TargetFrameworkStep should be in a subwizard on LogicAppTemplateStep -export async function createWorkspace( - context: IActionContext, - folderPath?: string | undefined, - language?: ProjectLanguage, - version?: ProjectVersion, - openFolder = true, - templateId?: string, - functionName?: string, - functionSettings?: { [key: string]: string | undefined } -): Promise { - await createProjectInternal( - context, - { - folderPath: isString(folderPath) ? folderPath : undefined, - templateId, - functionName, - functionSettings, - suppressOpenFolder: !openFolder, - language, - version, +import { callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils'; +import { ExtensionCommand, ProjectName } from '@microsoft/vscode-extension-logic-apps'; +import { ext } from '../../../extensionVariables'; +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import { cacheWebviewPanel, removeWebviewPanelFromCache, tryGetWebviewPanel } from '../../utils/codeless/common'; +import path from 'path'; +import { getWebViewHTML } from '../../utils/codeless/getWebViewHTML'; +import { localize } from '../../../localize'; +import { createLogicAppWorkspace } from '../createNewCodeProject/CodeProjectBase/CreateLogicAppWorkspace'; +import { assetsFolderName } from '../../../constants'; + +const workspaceParentDialogOptions: vscode.OpenDialogOptions = { + canSelectMany: false, + openLabel: localize('selectWorkspaceParentFolder', 'Select workspace parent folder'), + canSelectFiles: false, + canSelectFolders: true, +}; + +export async function createNewCodeProjectFromCommand(): Promise { + const panelName: string = localize('createWorkspace', 'Create Workspace'); + const panelGroupKey = ext.webViewKey.createWorkspace; + const apiVersion = '2021-03-01'; + const existingPanel: vscode.WebviewPanel | undefined = tryGetWebviewPanel(panelGroupKey, panelName); + + if (existingPanel) { + if (!existingPanel.active) { + existingPanel.reveal(vscode.ViewColumn.Active); + } + + return; + } + + const options: vscode.WebviewOptions & vscode.WebviewPanelOptions = { + enableScripts: true, + retainContextWhenHidden: true, + }; + + const panel: vscode.WebviewPanel = vscode.window.createWebviewPanel('CreateWorkspace', `${panelName}`, vscode.ViewColumn.Active, options); + panel.iconPath = { + light: vscode.Uri.file(path.join(ext.context.extensionPath, assetsFolderName, 'light', 'export.svg')), + dark: vscode.Uri.file(path.join(ext.context.extensionPath, assetsFolderName, 'dark', 'export.svg')), + }; + panel.webview.html = await getWebViewHTML('vs-code-react', panel); + + let interval: NodeJS.Timeout; + + panel.webview.onDidReceiveMessage(async (message) => { + switch (message.command) { + case ExtensionCommand.initialize: { + panel.webview.postMessage({ + command: ExtensionCommand.initialize_frame, + data: { + apiVersion, + project: ProjectName.createWorkspace, + hostVersion: ext.extensionVersion, + }, + }); + break; + } + case ExtensionCommand.createWorkspace: { + await callWithTelemetryAndErrorHandling('CreateWorkspace', async (activateContext: IActionContext) => { + await createLogicAppWorkspace(activateContext, message.data, false); + }); + // Close the webview panel after successful creation + panel.dispose(); + break; + } + case ExtensionCommand.select_folder: { + vscode.window.showOpenDialog(workspaceParentDialogOptions).then((fileUri) => { + if (fileUri && fileUri[0]) { + panel.webview.postMessage({ + command: ExtensionCommand.update_workspace_path, + data: { + targetDirectory: { + fsPath: fileUri[0].fsPath, + path: fileUri[0].path, + }, + }, + }); + } + }); + break; + } + case ExtensionCommand.validatePath: { + const { path: pathToValidate, type } = message.data || {}; + let exists = false; + try { + if (pathToValidate && typeof pathToValidate === 'string') { + exists = fs.existsSync(pathToValidate); + if (exists) { + const stats = fs.statSync(pathToValidate); + if (!type) { + // For regular path validation, check if it's a directory + exists = stats.isDirectory(); + } else if (type === ExtensionCommand.workspace_folder) { + // For workspace folder, check if it's a directory + exists = stats.isDirectory(); + } else if (type === ExtensionCommand.workspace_file) { + // For workspace file, check if it's a file (not a directory) + exists = stats.isFile(); + } + } + } + } catch (_error) { + exists = false; + } + + if (type === ExtensionCommand.workspace_folder || type === ExtensionCommand.workspace_file) { + // Send specific workspace existence result + panel.webview.postMessage({ + command: 'workspaceExistenceResult', + data: { + project: ProjectName.createWorkspace, + workspacePath: pathToValidate, + exists: exists, + type: type, + }, + }); + } else { + // Send regular path validation result + panel.webview.postMessage({ + command: ExtensionCommand.validatePath, + data: { + project: ProjectName.createWorkspace, + path: pathToValidate, + isValid: exists, + }, + }); + } + break; + } + // case ExtensionCommand.logTelemetry: { + // const eventName = message.key; + // ext.telemetryReporter.sendTelemetryEvent(eventName, { value: message.value }); + // ext.logTelemetry(context, eventName, message.value); + // break; + // } + default: + break; + } + }, ext.context.subscriptions); + + panel.onDidDispose( + () => { + removeWebviewPanelFromCache(panelGroupKey, panelName); + clearInterval(interval); }, - 'createWorkspace', - 'Create new logic app workspace', - [ - new WorkspaceFolderStep(), - new WorkspaceNameStep(), - new LogicAppTemplateStep(), - new TargetFrameworkStep(), - new LogicAppNameStep(), - await ProjectTypeStep.create(context, templateId, functionSettings, false), - new WorkspaceSettingsStep(), - new OpenBehaviorStep(), - ] + null, + ext.context.subscriptions ); + cacheWebviewPanel(panelGroupKey, panelName, panel); } diff --git a/apps/vs-code-designer/src/app/commands/createWorkspace/createWorkspaceSteps/openBehaviorStep.ts b/apps/vs-code-designer/src/app/commands/createWorkspace/createWorkspaceSteps/openBehaviorStep.ts deleted file mode 100644 index 8570d201b3d..00000000000 --- a/apps/vs-code-designer/src/app/commands/createWorkspace/createWorkspaceSteps/openBehaviorStep.ts +++ /dev/null @@ -1,26 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -import { localize } from '../../../../localize'; -import { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; -import type { IAzureQuickPickItem } from '@microsoft/vscode-azext-utils'; -import type { IProjectWizardContext } from '@microsoft/vscode-extension-logic-apps'; -import { OpenBehavior } from '@microsoft/vscode-extension-logic-apps'; - -export class OpenBehaviorStep extends AzureWizardPromptStep { - public shouldPrompt(context: IProjectWizardContext): boolean { - return !context.openBehavior && context.openBehavior !== OpenBehavior.alreadyOpen && context.openBehavior !== OpenBehavior.dontOpen; - } - - public async prompt(context: IProjectWizardContext): Promise { - const picks: IAzureQuickPickItem[] = [ - { label: localize('OpenInCurrentWindow', 'Open in current window'), data: OpenBehavior.openInCurrentWindow }, - { label: localize('OpenInNewWindow', 'Open in new window'), data: OpenBehavior.openInNewWindow }, - { label: localize('AddToWorkspace', 'Add to workspace'), data: OpenBehavior.addToWorkspace }, - ]; - - const placeHolder: string = localize('selectOpenBehavior', 'Select how you would like to open your project'); - context.openBehavior = (await context.ui.showQuickPick(picks, { placeHolder })).data; - } -} diff --git a/apps/vs-code-designer/src/app/commands/createWorkspace/createWorkspaceSteps/openFolderStep.ts b/apps/vs-code-designer/src/app/commands/createWorkspace/createWorkspaceSteps/openFolderStep.ts deleted file mode 100644 index 472b12e303e..00000000000 --- a/apps/vs-code-designer/src/app/commands/createWorkspace/createWorkspaceSteps/openFolderStep.ts +++ /dev/null @@ -1,61 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -import { getContainingWorkspace } from '../../../utils/workspace'; -import { AzureWizardExecuteStep } from '@microsoft/vscode-azext-utils'; -import type { IProjectWizardContext } from '@microsoft/vscode-extension-logic-apps'; -import { OpenBehavior } from '@microsoft/vscode-extension-logic-apps'; -import * as fs from 'fs'; -import { commands, Uri, workspace } from 'vscode'; -import { extensionCommand } from '../../../../constants'; - -export class OpenFolderStep extends AzureWizardExecuteStep { - public priority = 250; - - /** - * Determines whether this step should be executed based on the user's input. - * @param context The context object for the project wizard. - * @returns A boolean value indicating whether this step should be executed. - */ - public shouldExecute(context: IProjectWizardContext): boolean { - return !!context.openBehavior && context.openBehavior !== OpenBehavior.alreadyOpen && context.openBehavior !== OpenBehavior.dontOpen; - } - - /** - * Executes the step to open the folder in Visual Studio Code. - * @param context The context object for the project wizard. - * @returns A Promise that resolves to void. - */ - public async execute(context: IProjectWizardContext): Promise { - const openFolders = workspace.workspaceFolders || []; - let workspaceUri: Uri; - - // Check if .code-workspace file exists in project path - const workspaceFilePath = context.workspaceFilePath; - context.workspaceFolder = getContainingWorkspace(workspaceFilePath); - if (fs.existsSync(workspaceFilePath)) { - workspaceUri = Uri.file(workspaceFilePath); - } else { - workspaceUri = Uri.file(context.workspacePath); - } - - // Check if user has selected to add folder to workspace and update workspace accordingly - if (context.openBehavior === OpenBehavior.addToWorkspace && openFolders.length === 0) { - context.openBehavior = OpenBehavior.openInCurrentWindow; - } - - if (context.openBehavior === OpenBehavior.addToWorkspace) { - if (!workspaceUri.path.endsWith('.code-workspace')) { - workspace.updateWorkspaceFolders(openFolders.length, 0, { uri: workspaceUri }); - } - } else { - // Open folder using executeCommand method of commands object with vscode.openFolder command - await commands.executeCommand( - extensionCommand.vscodeOpenFolder, - workspaceUri, - context.openBehavior === OpenBehavior.openInNewWindow /* forceNewWindow */ - ); - } - } -} diff --git a/apps/vs-code-designer/src/app/commands/createWorkspace/createWorkspaceSteps/workspaceFileStep.ts b/apps/vs-code-designer/src/app/commands/createWorkspace/createWorkspaceSteps/workspaceFileStep.ts deleted file mode 100644 index 991c572264f..00000000000 --- a/apps/vs-code-designer/src/app/commands/createWorkspace/createWorkspaceSteps/workspaceFileStep.ts +++ /dev/null @@ -1,70 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -import { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; -import { type IProjectWizardContext, ProjectLanguage, WorkflowProjectType } from '@microsoft/vscode-extension-logic-apps'; -import * as fse from 'fs-extra'; -import * as path from 'path'; -import { OpenBehavior } from '@microsoft/vscode-extension-logic-apps'; -import * as vscode from 'vscode'; -import { isLogicAppProject } from '../../../utils/verifyIsProject'; - -export class WorkspaceFileStep extends AzureWizardPromptStep { - public hideStepCount = true; - - /** - * Checks if this step should prompt the user - * @param context - Project wizard context containing user selections and settings - * @returns True if user should be prompted, otherwise false - */ - public shouldPrompt(): boolean { - return true; - } - - /** - * Prompts the user for project information and sets up directories - * @param context - Project wizard context containing user selections and settings - */ - public async prompt(context: IProjectWizardContext): Promise { - // Set default project type and language - context.workflowProjectType = WorkflowProjectType.Bundle; - context.language = ProjectLanguage.JavaScript; - await this.createWorkspaceFile(context); - } - - /** - * Creates a .code-workspace file to group project directories in VS Code - * @param context - Project wizard context - */ - private async createWorkspaceFile(context: IProjectWizardContext): Promise { - // Start with an empty folders array - const workspaceFolders = []; - const foldersToAdd = vscode.workspace.workspaceFolders; - - if (foldersToAdd && foldersToAdd.length === 1) { - const folder = foldersToAdd[0]; - const folderPath = folder.uri.fsPath; - if (await isLogicAppProject(folderPath)) { - const destinationPath = path.join(context.workspacePath, folder.name); - await fse.copy(folderPath, destinationPath); - workspaceFolders.push({ name: folder.name, path: `./${folder.name}` }); - } else { - const subpaths: string[] = await fse.readdir(folderPath); - for (const subpath of subpaths) { - const fullPath = path.join(folderPath, subpath); - const destinationPath = path.join(context.workspacePath, subpath); - await fse.copy(fullPath, destinationPath); - workspaceFolders.push({ name: subpath, path: `./${subpath}` }); - } - } - } - - const workspaceData = { - folders: workspaceFolders, - }; - - await fse.writeJSON(context.workspaceFilePath, workspaceData, { spaces: 2 }); - context.openBehavior = OpenBehavior.openInCurrentWindow; - } -} diff --git a/apps/vs-code-designer/src/app/commands/createWorkspace/createWorkspaceSteps/workspaceFolderStep.ts b/apps/vs-code-designer/src/app/commands/createWorkspace/createWorkspaceSteps/workspaceFolderStep.ts deleted file mode 100644 index 5dddccd89c0..00000000000 --- a/apps/vs-code-designer/src/app/commands/createWorkspace/createWorkspaceSteps/workspaceFolderStep.ts +++ /dev/null @@ -1,31 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -import { localize } from '../../../../localize'; -import { getContainingWorkspace, selectWorkspaceFolder } from '../../../utils/workspace'; -import { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; -import { OpenBehavior } from '@microsoft/vscode-extension-logic-apps'; -import type { IProjectWizardContext } from '@microsoft/vscode-extension-logic-apps'; - -export class WorkspaceFolderStep extends AzureWizardPromptStep { - public hideStepCount = true; - - public shouldPrompt(context: IProjectWizardContext): boolean { - return !context.projectPath; - } - - public async prompt(context: IProjectWizardContext): Promise { - const placeHolder: string = localize('selectNewProjectFolder', 'Select the folder that will contain your logic app workspace'); - WorkspaceFolderStep.setProjectPath(context, await selectWorkspaceFolder(context, placeHolder)); - } - - public static setProjectPath(context: Partial, projectPath: string): void { - context.projectPath = projectPath; - context.workspaceFolder = getContainingWorkspace(projectPath); - context.workspacePath = (context.workspaceFolder && context.workspaceFolder.uri.fsPath) || projectPath; - if (context.workspaceFolder) { - context.openBehavior = OpenBehavior.alreadyOpen; - } - } -} diff --git a/apps/vs-code-designer/src/app/commands/createWorkspace/createWorkspaceSteps/workspaceNameStep.ts b/apps/vs-code-designer/src/app/commands/createWorkspace/createWorkspaceSteps/workspaceNameStep.ts deleted file mode 100644 index d7303a33bd3..00000000000 --- a/apps/vs-code-designer/src/app/commands/createWorkspace/createWorkspaceSteps/workspaceNameStep.ts +++ /dev/null @@ -1,53 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -import { localize } from '../../../../localize'; -import { getContainingWorkspace } from '../../../utils/workspace'; -import { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; -import * as fs from 'fs-extra'; -import { OpenBehavior } from '@microsoft/vscode-extension-logic-apps'; -import type { IProjectWizardContext } from '@microsoft/vscode-extension-logic-apps'; -import * as path from 'path'; -import { workspaceNameValidation } from '../../../../constants'; - -export class WorkspaceNameStep extends AzureWizardPromptStep { - public hideStepCount = true; - - public shouldPrompt(): boolean { - return true; - } - - public async prompt(context: IProjectWizardContext): Promise { - context.workspaceName = await context.ui.showInputBox({ - placeHolder: localize('setWorkspaceName', 'Workspace name'), - prompt: localize('workspaceNamePrompt', 'Provide a workspace name'), - validateInput: async (input: string): Promise => await this.validateWorkspaceName(input), - }); - // always need to create logic app project for new workspace (applies to custom code and rules engine) - context.shouldCreateLogicAppProject = true; - - // save uri variable for open project folder command - context.workspacePath = path.join(context.projectPath, context.workspaceName); - await fs.ensureDir(context.workspacePath); - context.workspaceFolder = getContainingWorkspace(context.workspacePath); - context.workspaceFilePath = path.join(context.workspacePath, `${context.workspaceName}.code-workspace`); - - if (context.workspaceFolder) { - context.openBehavior = OpenBehavior.alreadyOpen; - } - } - - private async validateWorkspaceName(name: string | undefined): Promise { - if (!name) { - return localize('emptyWorkspaceName', 'The workspace name cannot be empty.'); - } - if (!workspaceNameValidation.test(name)) { - return localize( - 'workspaceNameInvalidMessage', - 'Workspace name must start with a letter and can only contain letters, digits, "_" and "-".' - ); - } - return undefined; - } -} diff --git a/apps/vs-code-designer/src/app/commands/createWorkspace/createWorkspaceSteps/workspaceSettingsStep.ts b/apps/vs-code-designer/src/app/commands/createWorkspace/createWorkspaceSteps/workspaceSettingsStep.ts deleted file mode 100644 index 28d57ec5722..00000000000 --- a/apps/vs-code-designer/src/app/commands/createWorkspace/createWorkspaceSteps/workspaceSettingsStep.ts +++ /dev/null @@ -1,90 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -import { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; -import { type IProjectWizardContext, ProjectLanguage, WorkflowProjectType } from '@microsoft/vscode-extension-logic-apps'; -import * as fs from 'fs-extra'; -import { OpenBehavior } from '@microsoft/vscode-extension-logic-apps'; -import { testsDirectoryName } from '../../../../constants'; - -export class WorkspaceSettingsStep extends AzureWizardPromptStep { - public hideStepCount = true; - - /** - * Checks if this step should prompt the user - * @param context - Project wizard context containing user selections and settings - * @returns True if user should be prompted, otherwise false - */ - public shouldPrompt(): boolean { - return true; - } - - /** - * Prompts the user for project information and sets up directories - * @param context - Project wizard context containing user selections and settings - */ - public async prompt(context: IProjectWizardContext): Promise { - // Set default project type and language - context.workflowProjectType = WorkflowProjectType.Bundle; - context.language = ProjectLanguage.JavaScript; - - // Create directories based on user choices - if (context.workspacePath && context.workspaceFilePath.endsWith('.code-workspace') && fs.existsSync(context.workspaceFilePath)) { - await this.updateWorkspaceFile(context); - context.openBehavior = OpenBehavior.addToWorkspace; - } else { - await this.createWorkspaceFile(context); - } - } - - /** - * Creates a .code-workspace file to group project directories in VS Code - * @param context - Project wizard context - */ - private async createWorkspaceFile(context: IProjectWizardContext): Promise { - const workspaceFolders = []; - const logicAppName = context.logicAppName || 'LogicApp'; - workspaceFolders.push({ name: logicAppName, path: `./${logicAppName}` }); - const functionsFolder = context.functionAppName; - if (context.isWorkspaceWithFunctions) { - workspaceFolders.push({ name: functionsFolder, path: `./${functionsFolder}` }); - } - - const workspaceData = { - folders: workspaceFolders, - }; - - await fs.writeJSON(context.workspaceFilePath, workspaceData, { spaces: 2 }); - } - - /** - * Updates a .code-workspace file to group project directories in VS Code - * @param context - Project wizard context - */ - private async updateWorkspaceFile(context: IProjectWizardContext): Promise { - const workspaceContent = await fs.readJson(context.workspaceFilePath); - - const workspaceFolders = []; - const logicAppName = context.logicAppName || 'LogicApp'; - if (context.shouldCreateLogicAppProject) { - workspaceFolders.push({ name: logicAppName, path: `./${logicAppName}` }); - } - - if (context.isWorkspaceWithFunctions) { - const functionsFolder = context.functionAppName; - workspaceFolders.push({ name: functionsFolder, path: `./${functionsFolder}` }); - } - - workspaceContent.folders = [...workspaceContent.folders, ...workspaceFolders]; - - // Move the tests folder to the end of the workspace folders - const testsIndex = workspaceContent.folders.findIndex((folder) => folder.name === testsDirectoryName); - if (testsIndex !== -1 && testsIndex !== workspaceContent.folders.length - 1) { - const [testsFolder] = workspaceContent.folders.splice(testsIndex, 1); - workspaceContent.folders.push(testsFolder); - } - - await fs.writeJSON(context.workspaceFilePath, workspaceContent, { spaces: 2 }); - } -} diff --git a/apps/vs-code-designer/src/app/commands/dataMapper/DataMapperExt.ts b/apps/vs-code-designer/src/app/commands/dataMapper/DataMapperExt.ts index b04dcc47322..96884d5490c 100644 --- a/apps/vs-code-designer/src/app/commands/dataMapper/DataMapperExt.ts +++ b/apps/vs-code-designer/src/app/commands/dataMapper/DataMapperExt.ts @@ -13,7 +13,7 @@ import * as path from 'path'; import { Uri, ViewColumn, window } from 'vscode'; import { parse } from 'yaml'; import { localize } from '../../../localize'; -import { dataMapNameValidation } from '../../../constants'; +import { assetsFolderName, dataMapNameValidation } from '../../../constants'; export default class DataMapperExt { public static async openDataMapperPanel( @@ -94,8 +94,8 @@ export default class DataMapperExt { ext.dataMapPanelManagers[dataMapName] = new DataMapperPanel(panel, dataMapName); ext.dataMapPanelManagers[dataMapName].panel.iconPath = { - light: Uri.file(path.join(ext.context.extensionPath, 'assets', 'light', 'wand.png')), - dark: Uri.file(path.join(ext.context.extensionPath, 'assets', 'dark', 'wand.png')), + light: Uri.file(path.join(ext.context.extensionPath, assetsFolderName, 'light', 'wand.png')), + dark: Uri.file(path.join(ext.context.extensionPath, assetsFolderName, 'dark', 'wand.png')), }; ext.dataMapPanelManagers[dataMapName].updateWebviewPanelTitle(); ext.dataMapPanelManagers[dataMapName].mapDefinitionData = mapDefinitionData; diff --git a/apps/vs-code-designer/src/app/commands/deploy/deploy.ts b/apps/vs-code-designer/src/app/commands/deploy/deploy.ts index 9e1dd01ecb3..7a2bcdae35f 100644 --- a/apps/vs-code-designer/src/app/commands/deploy/deploy.ts +++ b/apps/vs-code-designer/src/app/commands/deploy/deploy.ts @@ -20,6 +20,7 @@ import { azureWebJobsStorageKey, isZipDeployEnabledSetting, useSmbDeployment, + libDirectory, } from '../../../constants'; import { ext } from '../../../extensionVariables'; import { localize } from '../../../localize'; @@ -90,7 +91,7 @@ async function deploy( if (!isNullOrUndefined(workspaceFolder)) { const logicAppNode = workspaceFolder.uri; - if (!(await fse.pathExists(path.join(logicAppNode.fsPath, 'lib', 'custom')))) { + if (!(await fse.pathExists(path.join(logicAppNode.fsPath, libDirectory, 'custom')))) { await buildCustomCodeFunctionsProject(actionContext, logicAppNode); } } diff --git a/apps/vs-code-designer/src/app/commands/deploy/hybridLogicApp/index.ts b/apps/vs-code-designer/src/app/commands/deploy/hybridLogicApp/index.ts index 45c701aebc7..3333d330862 100644 --- a/apps/vs-code-designer/src/app/commands/deploy/hybridLogicApp/index.ts +++ b/apps/vs-code-designer/src/app/commands/deploy/hybridLogicApp/index.ts @@ -12,8 +12,11 @@ import { getAuthorizationTokenFromNode } from '../../../utils/codeless/getAuthor import { getWorkspaceSetting } from '../../../utils/vsCodeConfig/settings'; import { azurePublicBaseUrl, + designTimeDirectoryName, driveLetterSMBSetting, hybridAppApiVersion, + localSettingsFileName, + vscodeFolderName, workflowAppAADClientId, workflowAppAADClientSecret, workflowAppAADTenantId, @@ -225,7 +228,7 @@ async function createZipFileOnDisk(sourceDir: string): Promise { const zipFile = new yazl.ZipFile(); // List of files and folders to ignore - const ignoreList = ['node_modules', '.git', '.env', '.vscode', 'workflow-designtime', 'local.settings.json']; + const ignoreList = ['node_modules', '.git', '.env', vscodeFolderName, designTimeDirectoryName, localSettingsFileName]; // Add all files in the directory to the zip const addDirectoryToZip = async (dir: string, basePath: string) => { diff --git a/apps/vs-code-designer/src/app/commands/generateDeploymentScripts/generateDeploymentScriptsSteps/deploymentCenterScriptsSteps/__test__/GenerateDeploymentCenterScriptsStep.test.ts b/apps/vs-code-designer/src/app/commands/generateDeploymentScripts/generateDeploymentScriptsSteps/deploymentCenterScriptsSteps/__test__/GenerateDeploymentCenterScriptsStep.test.ts index 8c4ef5add30..d636a8cdbe9 100644 --- a/apps/vs-code-designer/src/app/commands/generateDeploymentScripts/generateDeploymentScriptsSteps/deploymentCenterScriptsSteps/__test__/GenerateDeploymentCenterScriptsStep.test.ts +++ b/apps/vs-code-designer/src/app/commands/generateDeploymentScripts/generateDeploymentScriptsSteps/deploymentCenterScriptsSteps/__test__/GenerateDeploymentCenterScriptsStep.test.ts @@ -6,7 +6,7 @@ import * as vscode from 'vscode'; import * as syncCloudSettings from '../../../../syncCloudSettings'; import { ext } from '../../../../../../extensionVariables'; import { IAzureDeploymentScriptsContext } from '../../../generateDeploymentScripts'; -import { assetsFolderName } from '../../../../../../constants'; +import { assetsFolderName, deploymentDirectory, deploymentScriptTemplatesFolderName } from '../../../../../../constants'; describe('GenerateDeploymentCenterScriptsStep', () => { let context: IAzureDeploymentScriptsContext; @@ -28,11 +28,11 @@ describe('GenerateDeploymentCenterScriptsStep', () => { const realFs = await vi.importActual('fs-extra'); const rootDir = path.join(__dirname, '..', '..', '..', '..', '..', '..'); const assetsFolderPath = path.join(rootDir, assetsFolderName); - deploymentScriptTemplatePath = path.join(assetsFolderPath, 'DeploymentScriptTemplates', 'DeploymentCenterScript'); + deploymentScriptTemplatePath = path.join(assetsFolderPath, deploymentScriptTemplatesFolderName, 'DeploymentCenterScript'); deploymentScriptTemplate = await realFs.readFile(deploymentScriptTemplatePath, 'utf8'); - dotDeploymentTemplatePath = path.join(assetsFolderPath, 'DeploymentScriptTemplates', 'dotdeployment'); + dotDeploymentTemplatePath = path.join(assetsFolderPath, deploymentScriptTemplatesFolderName, 'dotdeployment'); dotDeploymentContent = await realFs.readFile(dotDeploymentTemplatePath, 'utf8'); - readmeTemplatePath = path.join(assetsFolderPath, 'DeploymentScriptTemplates', 'DeploymentCenterReadme'); + readmeTemplatePath = path.join(assetsFolderPath, deploymentScriptTemplatesFolderName, 'DeploymentCenterReadme'); readmeContent = await realFs.readFile(readmeTemplatePath, 'utf8'); }); @@ -70,7 +70,7 @@ describe('GenerateDeploymentCenterScriptsStep', () => { expect(fse.readFile).toHaveBeenCalledWith(expect.stringContaining('dotdeployment'), 'utf-8'); expect(fse.readFile).toHaveBeenCalledWith(expect.stringContaining('DeploymentCenterReadme'), 'utf-8'); - const deploymentDirectoryPath = path.join(context.workspacePath as string, 'deployment'); + const deploymentDirectoryPath = path.join(context.workspacePath as string, deploymentDirectory); expect(fse.ensureDir).toHaveBeenCalledWith(deploymentDirectoryPath); expect(writeFileSpy).toHaveBeenCalledWith(path.join(deploymentDirectoryPath, 'deploy.ps1'), expect.any(String)); diff --git a/apps/vs-code-designer/src/app/commands/registerCommands.ts b/apps/vs-code-designer/src/app/commands/registerCommands.ts index 1cf382f02ce..a7ae60b8b4c 100644 --- a/apps/vs-code-designer/src/app/commands/registerCommands.ts +++ b/apps/vs-code-designer/src/app/commands/registerCommands.ts @@ -19,8 +19,8 @@ import { configureDeploymentSource } from './configureDeploymentSource'; import { createChildNode } from './createChildNode'; import { createLogicApp, createLogicAppAdvanced } from './createLogicApp/createLogicApp'; import { cloudToLocal } from './cloudToLocal/cloudToLocal'; -import { createWorkspace } from './createWorkspace/createWorkspace'; -import { createProject } from './createProject/createProject'; +import { createNewCodeProjectFromCommand } from './createWorkspace/createWorkspace'; +import { createNewProjectFromCommand } from './createProject/createProject'; import { createCustomCodeFunction } from './createCustomCodeFunction/createCustomCodeFunction'; import { createSlot } from './createSlot'; import { createWorkflow } from './createWorkflow/createWorkflow'; @@ -85,8 +85,8 @@ export function registerCommands(): void { executeOnFunctions(openFile, context, context, node) ); registerCommandWithTreeNodeUnwrapping(extensionCommand.viewContent, viewContent); - registerCommand(extensionCommand.createProject, createProject); - registerCommand(extensionCommand.createWorkspace, createWorkspace); + registerCommand(extensionCommand.createProject, createNewProjectFromCommand); + registerCommand(extensionCommand.createWorkspace, createNewCodeProjectFromCommand); registerCommand(extensionCommand.cloudToLocal, cloudToLocal); registerCommand(extensionCommand.createWorkflow, createWorkflow); registerCommandWithTreeNodeUnwrapping(extensionCommand.createLogicApp, createLogicApp); diff --git a/apps/vs-code-designer/src/app/commands/workflows/exportLogicApp.ts b/apps/vs-code-designer/src/app/commands/workflows/exportLogicApp.ts index 7eca19e9bad..8f0695e14ca 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/exportLogicApp.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/exportLogicApp.ts @@ -10,6 +10,7 @@ import { workflowTenantIdKey, extensionCommand, localSettingsFileName, + assetsFolderName, } from '../../../constants'; import { ext } from '../../../extensionVariables'; import { localize } from '../../../localize'; @@ -345,8 +346,8 @@ export async function exportLogicApp(context: IActionContext): Promise { const panel: vscode.WebviewPanel = vscode.window.createWebviewPanel('ExportLA', `${panelName}`, vscode.ViewColumn.Active, options); panel.iconPath = { - light: vscode.Uri.file(path.join(ext.context.extensionPath, 'assets', 'light', 'export.svg')), - dark: vscode.Uri.file(path.join(ext.context.extensionPath, 'assets', 'dark', 'export.svg')), + light: vscode.Uri.file(path.join(ext.context.extensionPath, assetsFolderName, 'light', 'export.svg')), + dark: vscode.Uri.file(path.join(ext.context.extensionPath, assetsFolderName, 'dark', 'export.svg')), }; panel.webview.html = await getWebViewHTML('vs-code-react', panel); diff --git a/apps/vs-code-designer/src/app/commands/workflows/openDesigner/openDesignerForAzureResource.ts b/apps/vs-code-designer/src/app/commands/workflows/openDesigner/openDesignerForAzureResource.ts index f21131c5c38..dbb4f4e49d2 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/openDesigner/openDesignerForAzureResource.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/openDesigner/openDesignerForAzureResource.ts @@ -1,5 +1,5 @@ import { openUrl } from '@microsoft/vscode-azext-utils'; -import { workflowAppApiVersion } from '../../../../constants'; +import { assetsFolderName, workflowAppApiVersion } from '../../../../constants'; import { ext } from '../../../../extensionVariables'; import type { RemoteWorkflowTreeItem } from '../../../tree/remoteWorkflowsTree/RemoteWorkflowTreeItem'; import { @@ -46,8 +46,8 @@ export class OpenDesignerForAzureResource extends OpenDesignerBase { this.panel = vscode.window.createWebviewPanel(this.panelGroupKey, this.workflowName, vscode.ViewColumn.Active, this.getPanelOptions()); this.panel.iconPath = { - light: Uri.file(path.join(ext.context.extensionPath, 'assets', 'dark', 'workflow.svg')), - dark: Uri.file(path.join(ext.context.extensionPath, 'assets', 'light', 'workflow.svg')), + light: Uri.file(path.join(ext.context.extensionPath, assetsFolderName, 'dark', 'workflow.svg')), + dark: Uri.file(path.join(ext.context.extensionPath, assetsFolderName, 'light', 'workflow.svg')), }; this.panelMetadata = await this.getDesignerPanelMetadata(); diff --git a/apps/vs-code-designer/src/app/commands/workflows/openDesigner/openDesignerForLocalProject.ts b/apps/vs-code-designer/src/app/commands/workflows/openDesigner/openDesignerForLocalProject.ts index 90207140254..c0210fa94a5 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/openDesigner/openDesignerForLocalProject.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/openDesigner/openDesignerForLocalProject.ts @@ -1,4 +1,10 @@ -import { localSettingsFileName, managementApiPrefix, workflowAppApiVersion } from '../../../../constants'; +import { + assetsFolderName, + localSettingsFileName, + logicAppsStandardExtensionId, + managementApiPrefix, + workflowAppApiVersion, +} from '../../../../constants'; import { ext } from '../../../../extensionVariables'; import { localize } from '../../../../localize'; import { getLocalSettingsJson } from '../../../utils/appSettings/localSettings'; @@ -136,15 +142,13 @@ export default class OpenDesignerForLocalProject extends OpenDesignerBase { this.getPanelOptions() ); this.panel.iconPath = { - light: Uri.file(path.join(ext.context.extensionPath, 'assets', 'light', 'workflow.svg')), - dark: Uri.file(path.join(ext.context.extensionPath, 'assets', 'dark', 'workflow.svg')), + light: Uri.file(path.join(ext.context.extensionPath, assetsFolderName, 'light', 'workflow.svg')), + dark: Uri.file(path.join(ext.context.extensionPath, assetsFolderName, 'dark', 'workflow.svg')), }; this.migrationOptions = await this._getMigrationOptions(this.baseUrl); this.panelMetadata = await this._getDesignerPanelMetadata(this.migrationOptions); - const callbackUri: Uri = await (env as any).asExternalUri( - Uri.parse(`${env.uriScheme}://ms-azuretools.vscode-azurelogicapps/authcomplete`) - ); + const callbackUri: Uri = await (env as any).asExternalUri(Uri.parse(`${env.uriScheme}://${logicAppsStandardExtensionId}/authcomplete`)); this.context.telemetry.properties.extensionBundleVersion = this.panelMetadata.extensionBundleVersion; this.oauthRedirectUrl = callbackUri.toString(true); diff --git a/apps/vs-code-designer/src/app/commands/workflows/openMonitoringView/openMonitoringViewForAzureResource.ts b/apps/vs-code-designer/src/app/commands/workflows/openMonitoringView/openMonitoringViewForAzureResource.ts index b95515b815d..d043bb6619e 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/openMonitoringView/openMonitoringViewForAzureResource.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/openMonitoringView/openMonitoringViewForAzureResource.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import path from 'path'; -import { workflowAppApiVersion } from '../../../../constants'; +import { assetsFolderName, workflowAppApiVersion } from '../../../../constants'; import { ext } from '../../../../extensionVariables'; import { localize } from '../../../../localize'; import type { RemoteWorkflowTreeItem } from '../../../tree/remoteWorkflowsTree/RemoteWorkflowTreeItem'; @@ -56,8 +56,8 @@ export default class openMonitoringViewForAzureResource extends OpenMonitoringVi this.getPanelOptions() ); this.panel.iconPath = { - light: Uri.file(path.join(ext.context.extensionPath, 'assets', 'dark', 'workflow.svg')), - dark: Uri.file(path.join(ext.context.extensionPath, 'assets', 'light', 'workflow.svg')), + light: Uri.file(path.join(ext.context.extensionPath, assetsFolderName, 'dark', 'workflow.svg')), + dark: Uri.file(path.join(ext.context.extensionPath, assetsFolderName, 'light', 'workflow.svg')), }; this.panelMetadata = await this.getDesignerPanelMetadata(); diff --git a/apps/vs-code-designer/src/app/commands/workflows/openMonitoringView/openMonitoringViewForLocal.ts b/apps/vs-code-designer/src/app/commands/workflows/openMonitoringView/openMonitoringViewForLocal.ts index ae10b51ebb7..599ae56fc03 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/openMonitoringView/openMonitoringViewForLocal.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/openMonitoringView/openMonitoringViewForLocal.ts @@ -2,7 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { localSettingsFileName, managementApiPrefix } from '../../../../constants'; +import { assetsFolderName, localSettingsFileName, managementApiPrefix } from '../../../../constants'; import { ext } from '../../../../extensionVariables'; import { localize } from '../../../../localize'; import { getLocalSettingsJson } from '../../../utils/appSettings/localSettings'; @@ -63,13 +63,13 @@ export default class OpenMonitoringViewForLocal extends OpenMonitoringViewBase { this.getPanelOptions() ); this.panel.iconPath = { - light: Uri.file(path.join(ext.context.extensionPath, 'assets', 'dark', 'workflow.svg')), - dark: Uri.file(path.join(ext.context.extensionPath, 'assets', 'light', 'workflow.svg')), + light: Uri.file(path.join(ext.context.extensionPath, assetsFolderName, 'dark', 'workflow.svg')), + dark: Uri.file(path.join(ext.context.extensionPath, assetsFolderName, 'light', 'workflow.svg')), }; this.panel.iconPath = { - light: Uri.file(path.join(ext.context.extensionPath, 'assets', 'light', 'workflow.svg')), - dark: Uri.file(path.join(ext.context.extensionPath, 'assets', 'dark', 'workflow.svg')), + light: Uri.file(path.join(ext.context.extensionPath, assetsFolderName, 'light', 'workflow.svg')), + dark: Uri.file(path.join(ext.context.extensionPath, assetsFolderName, 'dark', 'workflow.svg')), }; this.projectPath = await getLogicAppProjectRoot(this.context, this.workflowFilePath); diff --git a/apps/vs-code-designer/src/app/commands/workflows/openOverview.ts b/apps/vs-code-designer/src/app/commands/workflows/openOverview.ts index 79cfd2a9511..1fac702a830 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/openOverview.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/openOverview.ts @@ -4,7 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import type { LogicAppsV2 } from '@microsoft/logic-apps-shared'; import { getRequestTriggerName, getTriggerName, HTTP_METHODS, isNullOrUndefined } from '@microsoft/logic-apps-shared'; -import { localSettingsFileName, managementApiPrefix, workflowAppApiVersion, workflowTenantIdKey } from '../../../constants'; +import { + assetsFolderName, + localSettingsFileName, + managementApiPrefix, + workflowAppApiVersion, + workflowTenantIdKey, +} from '../../../constants'; import { ext } from '../../../extensionVariables'; import { localize } from '../../../localize'; import { RemoteWorkflowTreeItem } from '../../tree/remoteWorkflowsTree/RemoteWorkflowTreeItem'; @@ -117,8 +123,8 @@ export async function openOverview(context: IAzureConnectorsContext, node: vscod ); panel.iconPath = { - light: vscode.Uri.file(path.join(ext.context.extensionPath, 'assets', 'light', 'Codeless.svg')), - dark: vscode.Uri.file(path.join(ext.context.extensionPath, 'assets', 'dark', 'Codeless.svg')), + light: vscode.Uri.file(path.join(ext.context.extensionPath, assetsFolderName, 'light', 'Codeless.svg')), + dark: vscode.Uri.file(path.join(ext.context.extensionPath, assetsFolderName, 'dark', 'Codeless.svg')), }; panel.webview.html = await getWebViewHTML('vs-code-react', panel); diff --git a/apps/vs-code-designer/src/app/commands/workflows/switchToDotnetProject.ts b/apps/vs-code-designer/src/app/commands/workflows/switchToDotnetProject.ts index 122a0ad99cc..73a371eebf4 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/switchToDotnetProject.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/switchToDotnetProject.ts @@ -12,6 +12,8 @@ import { workflowFileName, CodefulSDKs, CodefulSdkVersions, + artifactsDirectory, + libDirectory, } from '../../../constants'; import { localize } from '../../../localize'; import { initProjectForVSCode } from '../../commands/initProjectForVSCode/initProjectForVSCode'; @@ -228,7 +230,7 @@ async function updateBuildFile(context: IActionContext, target: vscode.Uri, dotn xmlBuildFile = addFileToBuildPath(xmlBuildFile, parametersFile); } - if (projectArtifacts['lib']) { + if (projectArtifacts[libDirectory]) { xmlBuildFile = addLibToPublishPath(xmlBuildFile); } @@ -295,13 +297,13 @@ async function getArtifactNamesFromProject(target: vscode.Uri): Promise ({ ext: { @@ -45,8 +46,8 @@ describe('saveBlankUnitTest', () => { logicAppName: 'LogicApp1', logicAppTestFolderPath: '/fake/project/myLogicApp', workflowTestFolderPath: path.join(dummyProjectPath, 'workflows', dummyWorkflowName), - mocksFolderPath: path.join(dummyProjectPath, 'workflows', dummyWorkflowName, 'MockOutputs'), - testsDirectory: path.join(dummyProjectPath, 'tests'), + mocksFolderPath: path.join(dummyProjectPath, 'workflows', dummyWorkflowName, testMockOutputsDirectory), + testsDirectory: path.join(dummyProjectPath, testsDirectoryName), }; const dummyMockOperations: { diff --git a/apps/vs-code-designer/src/app/commands/workflows/unitTest/openUnitTestResults.ts b/apps/vs-code-designer/src/app/commands/workflows/unitTest/openUnitTestResults.ts index 0c31a14252e..367cba8e7e8 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/unitTest/openUnitTestResults.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/unitTest/openUnitTestResults.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import type { UnitTestResult } from '@microsoft/vscode-extension-logic-apps'; import { ExtensionCommand, ProjectName } from '@microsoft/vscode-extension-logic-apps'; -import { testsDirectoryName, testResultsDirectoryName, workflowFileName } from '../../../../constants'; +import { testsDirectoryName, testResultsDirectoryName, workflowFileName, assetsFolderName } from '../../../../constants'; import { ext } from '../../../../extensionVariables'; import { localize } from '../../../../localize'; import { cacheWebviewPanel, removeWebviewPanelFromCache, tryGetWebviewPanel } from '../../../utils/codeless/common'; @@ -109,8 +109,8 @@ async function openResultsWebview( const panel: WebviewPanel = window.createWebviewPanel('UnitTestsResults', panelName, ViewColumn.Active, webviewOptions); panel.iconPath = { - light: Uri.file(path.join(ext.context.extensionPath, 'assets', 'light', 'Codeless.svg')), - dark: Uri.file(path.join(ext.context.extensionPath, 'assets', 'dark', 'Codeless.svg')), + light: Uri.file(path.join(ext.context.extensionPath, assetsFolderName, 'light', 'Codeless.svg')), + dark: Uri.file(path.join(ext.context.extensionPath, assetsFolderName, 'dark', 'Codeless.svg')), }; panel.webview.html = await getWebViewHTML('vs-code-react', panel); diff --git a/apps/vs-code-designer/src/app/templates/TemplateProviderBase.ts b/apps/vs-code-designer/src/app/templates/TemplateProviderBase.ts index 8ee9e4fe445..90108c36191 100644 --- a/apps/vs-code-designer/src/app/templates/TemplateProviderBase.ts +++ b/apps/vs-code-designer/src/app/templates/TemplateProviderBase.ts @@ -11,6 +11,7 @@ import { FuncVersion, TemplateType } from '@microsoft/vscode-extension-logic-app import * as path from 'path'; import * as vscode from 'vscode'; import { Disposable } from 'vscode'; +import { assetsFolderName } from '../../constants'; const v3BackupTemplatesVersion = '3.4.1'; const v2BackupTemplatesVersion = '2.47.1'; @@ -142,7 +143,7 @@ export abstract class TemplateProviderBase implements Disposable { } protected getBackupPath(): string { - return ext.context.asAbsolutePath(path.join('assets', 'backupTemplates', this.backupSubpath)); + return ext.context.asAbsolutePath(path.join(assetsFolderName, 'backupTemplates', this.backupSubpath)); } /** diff --git a/apps/vs-code-designer/src/app/tree/LogicAppResourceTree.ts b/apps/vs-code-designer/src/app/tree/LogicAppResourceTree.ts index 3dc3a68c755..571aae239a1 100644 --- a/apps/vs-code-designer/src/app/tree/LogicAppResourceTree.ts +++ b/apps/vs-code-designer/src/app/tree/LogicAppResourceTree.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import type { ContainerApp } from '@azure/arm-appcontainers'; -import { contextValuePrefix, localSettingsFileName } from '../../constants'; +import { artifactsDirectory, contextValuePrefix, localSettingsFileName } from '../../constants'; import { localize } from '../../localize'; import { parseHostJson } from '../funcConfig/host'; import { getLocalSettingsJson } from '../utils/appSettings/localSettings'; @@ -290,7 +290,7 @@ export class LogicAppResourceTree implements ResolvedAppResourceBase { if (!this._artifactsTreeItem) { try { - await getFileOrFolderContent(context, proxyTree, 'Artifacts'); + await getFileOrFolderContent(context, proxyTree, artifactsDirectory); } catch (error) { if (error.statusCode === 404) { return children; diff --git a/apps/vs-code-designer/src/app/tree/slotsTree/artifactsTree/ArtifactsTreeItem.ts b/apps/vs-code-designer/src/app/tree/slotsTree/artifactsTree/ArtifactsTreeItem.ts index 18803f160ad..de2e3ee6f94 100644 --- a/apps/vs-code-designer/src/app/tree/slotsTree/artifactsTree/ArtifactsTreeItem.ts +++ b/apps/vs-code-designer/src/app/tree/slotsTree/artifactsTree/ArtifactsTreeItem.ts @@ -2,13 +2,14 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { artifactsDirectory } from '../../../../constants'; import { localize } from '../../../../localize'; import type { SlotTreeItem } from '../SlotTreeItem'; import type { ParsedSite } from '@microsoft/vscode-azext-azureappservice'; import { createSiteFilesUrl, FolderTreeItem } from '@microsoft/vscode-azext-azureappservice'; export class ArtifactsTreeItem extends FolderTreeItem { - public static contextValue = 'Artifacts'; + public static contextValue = artifactsDirectory; private readonly _contextValue: string = ArtifactsTreeItem.contextValue; public get contextValue(): string { @@ -20,7 +21,7 @@ export class ArtifactsTreeItem extends FolderTreeItem { constructor(parent: SlotTreeItem, client: ParsedSite) { super(parent, { site: client, - label: localize('Artifacts', 'Artifacts'), + label: localize(artifactsDirectory, artifactsDirectory), url: createSiteFilesUrl(client, 'site/wwwroot/Artifacts/'), isReadOnly: true, }); 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 4bed4268f85..c6d2a1a8ead 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 @@ -14,7 +14,7 @@ import { } from '../customCodeUtils'; import { TargetFramework } from '@microsoft/vscode-extension-logic-apps'; import { ext } from '../../../extensionVariables'; -import { assetsFolderName } from '../../../constants'; +import { assetsFolderName, hostFileName, localSettingsFileName } from '../../../constants'; vi.mock('fs-extra', () => ({ statSync: vi.fn(), @@ -295,7 +295,7 @@ describe('customCodeUtils', () => { vi.spyOn(fse, 'readdir').mockImplementation(async (p: string) => { if (p === testWorkspacePath) return testWorkspaceSubDirs; if (p === path.join(testWorkspacePath, testFuncProject)) return [testFuncProjectCsproj]; - if (p === path.join(testWorkspacePath, testLAProject)) return ['host.json', 'local.settings.json']; + if (p === path.join(testWorkspacePath, testLAProject)) return [hostFileName, localSettingsFileName]; return []; }); vi.spyOn(fse, 'statSync').mockImplementation((p: string) => { @@ -319,7 +319,7 @@ describe('customCodeUtils', () => { vi.spyOn(fse, 'pathExists').mockResolvedValue(true); vi.spyOn(fse, 'readdir').mockImplementation(async (p: string) => { if (p === testWorkspacePath) return testWorkspaceSubDirs; - if (p === path.join(testWorkspacePath, testLAProject)) return ['host.json']; + if (p === path.join(testWorkspacePath, testLAProject)) return [hostFileName]; return []; }); vi.spyOn(fse, 'statSync').mockImplementation((p: string) => { @@ -355,7 +355,7 @@ describe('customCodeUtils', () => { vi.spyOn(fse, 'readdir').mockImplementation(async (p: string) => { if (p === testWorkspacePath) return testWorkspaceSubDirs; if (p === path.join(testWorkspacePath, testFuncProject)) return [testFuncProjectCsproj]; - if (p === path.join(testWorkspacePath, testLAProject)) return ['host.json']; + if (p === path.join(testWorkspacePath, testLAProject)) return [hostFileName]; return []; }); vi.spyOn(fse, 'statSync').mockImplementation((p: string) => { @@ -379,7 +379,7 @@ describe('customCodeUtils', () => { vi.spyOn(fse, 'pathExists').mockResolvedValue(true); vi.spyOn(fse, 'readdir').mockImplementation(async (p: string) => { if (p === testWorkspacePath) return testWorkspaceSubDirs; - if (p === path.join(testWorkspacePath, testLAProject)) return ['host.json']; + if (p === path.join(testWorkspacePath, testLAProject)) return [hostFileName]; return []; }); vi.spyOn(fse, 'statSync').mockImplementation((p: string) => { diff --git a/apps/vs-code-designer/src/app/utils/__test__/unitTestUtils.test.ts b/apps/vs-code-designer/src/app/utils/__test__/unitTestUtils.test.ts index 3f645589e88..4eab2bbf4e8 100644 --- a/apps/vs-code-designer/src/app/utils/__test__/unitTestUtils.test.ts +++ b/apps/vs-code-designer/src/app/utils/__test__/unitTestUtils.test.ts @@ -39,6 +39,7 @@ import { validateUnitTestName, } from '../unitTests'; import type { IActionContext } from '@microsoft/vscode-azext-utils'; +import { testsDirectoryName, workflowFileName } from '../../../constants'; // ============================================================================ // Global Constants and Test Hooks @@ -1946,7 +1947,7 @@ namespace <%= LogicAppName %>.Tests it('should update the solution with the project when solution file exists', async () => { pathExistsSpy = vi.spyOn(fse, 'pathExists').mockResolvedValue(true); - const testsDirectory = path.join(projectPath, 'Tests'); + const testsDirectory = path.join(projectPath, testsDirectoryName); const logicAppCsprojPath = path.join(testsDirectory, `${fakeLogicAppName}.csproj`); await updateTestsSln(testsDirectory, logicAppCsprojPath); @@ -1962,7 +1963,7 @@ namespace <%= LogicAppName %>.Tests it('should create a new solution file when it does not exist', async () => { pathExistsSpy = vi.spyOn(fse, 'pathExists').mockResolvedValue(false); - const testsDirectory = path.join(projectPath, 'Tests'); + const testsDirectory = path.join(projectPath, testsDirectoryName); const logicAppCsprojPath = path.join(testsDirectory, `${fakeLogicAppName}.csproj`); await updateTestsSln(testsDirectory, logicAppCsprojPath); @@ -1979,14 +1980,14 @@ namespace <%= LogicAppName %>.Tests describe('validateWorkflowPath', () => { it('should throw an error if the workflow node is not valid', () => { - const invalidWorkflowPath = path.join(projectPath, '..', fakeLogicAppName, 'workflow1', 'workflow.json'); + const invalidWorkflowPath = path.join(projectPath, '..', fakeLogicAppName, 'workflow1', workflowFileName); expect(() => validateWorkflowPath(projectPath, invalidWorkflowPath)).toThrowError( "doesn't belong to the Logic Apps Standard Project" ); }); it('should not throw an error if the workflow node is valid', () => { - const validWorkflowPath = path.join(projectPath, fakeLogicAppName, 'workflow1', 'workflow.json'); + const validWorkflowPath = path.join(projectPath, fakeLogicAppName, 'workflow1', workflowFileName); expect(() => validateWorkflowPath(projectPath, validWorkflowPath)).not.toThrowError(); }); }); diff --git a/apps/vs-code-designer/src/app/utils/__test__/verifyIsProject.test.ts b/apps/vs-code-designer/src/app/utils/__test__/verifyIsProject.test.ts index 172a4a3218b..886de52b7f9 100644 --- a/apps/vs-code-designer/src/app/utils/__test__/verifyIsProject.test.ts +++ b/apps/vs-code-designer/src/app/utils/__test__/verifyIsProject.test.ts @@ -3,6 +3,7 @@ import type { WorkspaceFolder } from 'vscode'; import * as fse from 'fs-extra'; import * as path from 'path'; import * as verifyIsProject from '../verifyIsProject'; +import { hostFileName, workflowFileName } from '../../../constants'; describe('tryGetAllLogicAppProjectRoots', () => { const testWorkspaceFolderPath = path.join('test', 'workspace', 'LogicApp1'); @@ -30,15 +31,15 @@ describe('tryGetAllLogicAppProjectRoots', () => { it('should return the folderPath if it is a logic app project', async () => { vi.spyOn(fse, 'pathExists').mockResolvedValue(true); vi.spyOn(fse, 'readdir').mockImplementation(async (filePath: fse.PathLike) => { - if (filePath === testWorkspaceFolderPath) return ['host.json', 'workflow1']; - if (filePath === path.join(testWorkspaceFolderPath, 'workflow1')) return ['workflow.json']; + if (filePath === testWorkspaceFolderPath) return [hostFileName, 'workflow1']; + if (filePath === path.join(testWorkspaceFolderPath, 'workflow1')) return [workflowFileName]; return []; }); vi.spyOn(fse, 'readFile').mockImplementation(async (filePath: fse.PathLike) => { - if (filePath === path.join(testWorkspaceFolderPath, 'host.json')) { + if (filePath === path.join(testWorkspaceFolderPath, hostFileName)) { return JSON.stringify({ version: '2.0', extensionBundle: { id: 'Microsoft.Azure.Functions.ExtensionBundle.Workflows' } }); } - if (filePath === path.join(testWorkspaceFolderPath, 'workflow1', 'workflow.json')) { + if (filePath === path.join(testWorkspaceFolderPath, 'workflow1', workflowFileName)) { return JSON.stringify({ definition: { $schema: 'https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#', @@ -59,27 +60,27 @@ describe('tryGetAllLogicAppProjectRoots', () => { vi.spyOn(fse, 'pathExists').mockResolvedValue(true); vi.spyOn(fse, 'readdir').mockImplementation(async (filePath: fse.PathLike) => { if (filePath === testWorkspaceFolderPath) return ['LogicApp1', 'LogicApp2']; - if (filePath === testLogicAppProjectPath1) return ['host.json', 'workflow1']; - if (filePath === testLogicAppProjectPath2) return ['host.json', 'workflow1']; - if (filePath === path.join(testLogicAppProjectPath1, 'workflow1')) return ['workflow.json']; - if (filePath === path.join(testLogicAppProjectPath2, 'workflow1')) return ['workflow.json']; + if (filePath === testLogicAppProjectPath1) return [hostFileName, 'workflow1']; + if (filePath === testLogicAppProjectPath2) return [hostFileName, 'workflow1']; + if (filePath === path.join(testLogicAppProjectPath1, 'workflow1')) return [workflowFileName]; + if (filePath === path.join(testLogicAppProjectPath2, 'workflow1')) return [workflowFileName]; return []; }); vi.spyOn(fse, 'readFile').mockImplementation(async (filePath: fse.PathLike) => { - if (filePath === path.join(testLogicAppProjectPath1, 'host.json')) { + if (filePath === path.join(testLogicAppProjectPath1, hostFileName)) { return JSON.stringify({ version: '2.0', extensionBundle: { id: 'Microsoft.Azure.Functions.ExtensionBundle.Workflows' } }); } - if (filePath === path.join(testLogicAppProjectPath2, 'host.json')) { + if (filePath === path.join(testLogicAppProjectPath2, hostFileName)) { return JSON.stringify({ version: '2.0', extensionBundle: { id: 'Microsoft.Azure.Functions.ExtensionBundle.Workflows' } }); } - if (filePath === path.join(testLogicAppProjectPath1, 'workflow1', 'workflow.json')) { + if (filePath === path.join(testLogicAppProjectPath1, 'workflow1', workflowFileName)) { return JSON.stringify({ definition: { $schema: 'https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#', }, }); } - if (filePath === path.join(testLogicAppProjectPath2, 'workflow1', 'workflow.json')) { + if (filePath === path.join(testLogicAppProjectPath2, 'workflow1', workflowFileName)) { return JSON.stringify({ definition: { $schema: 'https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#', @@ -97,11 +98,11 @@ describe('tryGetAllLogicAppProjectRoots', () => { vi.spyOn(fse, 'pathExists').mockResolvedValue(true); vi.spyOn(fse, 'readdir').mockImplementation(async (filePath: fse.PathLike) => { if (filePath === testWorkspaceFolderPath) return ['sub1', 'sub2']; - if (filePath === path.join(testWorkspaceFolderPath, 'sub1')) return ['workflow.json']; + if (filePath === path.join(testWorkspaceFolderPath, 'sub1')) return [workflowFileName]; return []; }); vi.spyOn(fse, 'readFile').mockImplementation(async (filePath: fse.PathLike) => { - if (filePath === path.join(testWorkspaceFolderPath, 'sub1', 'workflow.json')) { + if (filePath === path.join(testWorkspaceFolderPath, 'sub1', workflowFileName)) { return JSON.stringify({ definition: { $schema: 'https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#', diff --git a/apps/vs-code-designer/src/app/utils/cloudToLocalUtils.ts b/apps/vs-code-designer/src/app/utils/cloudToLocalUtils.ts index e3b3b9a3693..dc9f94a9fc9 100644 --- a/apps/vs-code-designer/src/app/utils/cloudToLocalUtils.ts +++ b/apps/vs-code-designer/src/app/utils/cloudToLocalUtils.ts @@ -3,21 +3,45 @@ import type { ConnectionsData, IFunctionWizardContext, AzureConnectorDetails, + ILocalSettingsJson, } from '@microsoft/vscode-extension-logic-apps'; import { getAzureConnectorDetailsForLocalProject } from './codeless/common'; import { getConnectionsAndSettingsToUpdate, getConnectionsJson, saveConnectionReferences } from './codeless/connection'; -import { isEmptyString } from '@microsoft/logic-apps-shared'; +import { extend, isEmptyString } from '@microsoft/logic-apps-shared'; import { ext } from '../../extensionVariables'; import { localize } from '../../localize'; import { getParametersJson } from './codeless/parameter'; import { areAllConnectionsParameterized, parameterizeConnection } from './codeless/parameterizer'; import * as path from 'path'; -import * as fs from 'fs'; +import * as fse from 'fs-extra'; import { isCSharpProject } from './detectProjectLanguage'; -import { azureWebJobsStorageKey, parametersFileName } from '../../constants'; +import { + assetsFolderName, + azureWebJobsStorageKey, + connectionsFileName, + funcIgnoreFileName, + hostFileName, + localSettingsFileName, + parameterizeConnectionsInProjectLoadSetting, + parametersFileName, + vscodeFolderName, +} from '../../constants'; import { addNewFileInCSharpProject } from './codeless/updateBuildFile'; import { writeFormattedJson } from './fs'; -import { window } from 'vscode'; +import { Uri, window, workspace } from 'vscode'; +import { unzipLogicAppArtifacts } from './taskUtils'; +import { getGlobalSetting } from './vsCodeConfig/settings'; +import { getLocalSettingsJson } from './appSettings/localSettings'; +import { callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils'; +import { getContainingWorkspace } from './workspace'; +import AdmZip from 'adm-zip'; + +interface ICachedTextDocument { + projectPath: string; + textDocumentPath: string; +} + +const cacheKey = 'azLAPostExtractReadMe'; export async function extractConnectionDetails(connections: any): Promise { const SUBSCRIPTION_INDEX = 2; @@ -53,12 +77,12 @@ export async function extractConnectionDetails(connections: any): Promise { export async function extractConnectionSettings(context: IFunctionWizardContext): Promise> { const logicAppPath = path.join(context.workspacePath, context.logicAppName || 'LogicApp'); - const localSettingsPath = path.join(logicAppPath, 'local.settings.json'); + const localSettingsPath = path.join(logicAppPath, localSettingsFileName); if (logicAppPath) { try { const connectionsJson = await getConnectionsJson(logicAppPath); - const localSettings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf8')); + const localSettings = JSON.parse(fse.readFileSync(localSettingsPath, 'utf8')); if (isEmptyString(connectionsJson)) { return; } @@ -87,8 +111,8 @@ export async function extractConnectionSettings(context: IFunctionWizardContext) export async function getParametersArtifactData(projectRoot: string): Promise { const connectionFilePath: string = path.join(projectRoot, parametersFileName); - if (await fs.existsSync(connectionFilePath)) { - const data: string = (await fs.readFileSync(connectionFilePath, 'utf-8')).toString(); + if (await fse.existsSync(connectionFilePath)) { + const data: string = (await fse.readFileSync(connectionFilePath, 'utf-8')).toString(); if (/[^\s]/.test(data)) { return data; } @@ -99,8 +123,8 @@ export async function getParametersArtifactData(projectRoot: string): Promise { const logicAppPath = path.join(context.workspacePath, context.logicAppName || 'LogicApp'); - const connectionsPath = path.join(logicAppPath, 'connections.json'); - const parametersPath = path.join(logicAppPath, 'parameters.json'); + const connectionsPath = path.join(logicAppPath, connectionsFileName); + const parametersPath = path.join(logicAppPath, parametersFileName); let connectionsData: ConnectionsData = {}; let parametersJson: ParametersData = {}; @@ -200,7 +224,7 @@ export async function parameterizeConnectionsDuringImport( ): Promise { const logicAppPath = path.join(context.workspacePath, context.logicAppName || 'LogicApp'); const parametersFilePath = path.join(logicAppPath, parametersFileName); - const parametersFileExists = fs.existsSync(parametersFilePath); + const parametersFileExists = fse.existsSync(parametersFilePath); if (logicAppPath) { try { @@ -257,8 +281,8 @@ export async function parameterizeConnectionsDuringImport( export async function cleanLocalSettings(context: IFunctionWizardContext): Promise { const logicAppPath = path.join(context.workspacePath, context.logicAppName || 'LogicApp'); - const localSettingsPath = path.join(logicAppPath, 'local.settings.json'); - const localSettings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf8')); + const localSettingsPath = path.join(logicAppPath, localSettingsFileName); + const localSettings = JSON.parse(fse.readFileSync(localSettingsPath, 'utf8')); if (localSettings.Values) { Object.keys(localSettings.Values).forEach((key) => { @@ -279,3 +303,126 @@ export function mergeAppSettings(targetSettings: Record, sourceSett const newValues = Object.assign({}, targetSettings.Values, sourceSettings.Values); return { IsEncrypted: targetSettings.IsEncrypted, Values: newValues }; } + +export async function unzipLogicAppPackageIntoWorkspace(context: IFunctionWizardContext): Promise { + try { + const data: Buffer | Buffer[] = fse.readFileSync(context.packagePath); + await unzipLogicAppArtifacts(data, context.projectPath); + + const projectFiles = fse.readdirSync(context.projectPath); + const filesToExclude = []; + const excludedFiles = [vscodeFolderName, 'obj', 'bin', localSettingsFileName, hostFileName, funcIgnoreFileName]; + const excludedExt = ['.csproj']; + + projectFiles.forEach((fileName) => { + if (excludedExt.includes(path.extname(fileName))) { + filesToExclude.push(path.join(context.projectPath, fileName)); + } + }); + + excludedFiles.forEach((excludedFile) => { + if (fse.existsSync(path.join(context.projectPath, excludedFile))) { + filesToExclude.push(path.join(context.projectPath, excludedFile)); + } + }); + + filesToExclude.forEach((path) => { + fse.removeSync(path); + context.telemetry.properties.excludedFile = `Excluded ${path.basename} from package`; + }); + + // Create README.md file + const readMePath = path.join(__dirname, assetsFolderName, 'readmes', 'importReadMe.md'); + const readMeContent = fse.readFileSync(readMePath, 'utf8'); + fse.writeFileSync(path.join(context.projectPath, 'README.md'), readMeContent); + } catch (error) { + context.telemetry.properties.error = error.message; + console.error(`Failed to extract contents of package to ${context.projectPath}`, error); + } +} + +export async function logicAppPackageProcessing(context: IFunctionWizardContext): Promise { + const localSettingsPath = path.join(context.projectPath, localSettingsFileName); + const parameterizeConnectionsSetting = getGlobalSetting(parameterizeConnectionsInProjectLoadSetting); + + let appSettings: ILocalSettingsJson = {}; + let zipSettings: ILocalSettingsJson = {}; + let connectionsData: any = {}; + + try { + const connectionsString = await getConnectionsJson(context.projectPath); + + // merge the app settings from local.settings.json and the settings from the zip file + appSettings = await getLocalSettingsJson(context, localSettingsPath, false); + const zipEntries = await getPackageEntries(context.packagePath); + const zipSettingsBuffer = zipEntries.find((entry) => entry.entryName === localSettingsFileName); + if (zipSettingsBuffer) { + context.telemetry.properties.localSettingsInZip = 'Local settings found in the zip file'; + zipSettings = JSON.parse(zipSettingsBuffer.getData().toString('utf8')); + await writeFormattedJson(localSettingsPath, mergeAppSettings(appSettings, zipSettings)); + } + + if (isEmptyString(connectionsString)) { + context.telemetry.properties.noConnectionsInZip = 'No connections found in the zip file'; + return; + } + + connectionsData = JSON.parse(connectionsString); + if (Object.keys(connectionsData).length && connectionsData.managedApiConnections) { + /** Extract details from connections and add to local.settings.json + * independent of the parameterizeConnectionsInProject setting */ + appSettings = await getLocalSettingsJson(context, localSettingsPath, false); + await writeFormattedJson(localSettingsPath, extend(appSettings, await extractConnectionSettings(context))); + + if (parameterizeConnectionsSetting) { + await parameterizeConnectionsDuringImport(context as IFunctionWizardContext, appSettings.Values); + } + + await changeAuthTypeToRaw(context, parameterizeConnectionsSetting); + await updateConnectionKeys(context); + await cleanLocalSettings(context); + } + + // OpenFolder will restart the extension host so we will cache README to open on next activation + const readMePath = path.join(context.projectPath, 'README.md'); + const postExtractCache: ICachedTextDocument = { projectPath: context.projectPath, textDocumentPath: readMePath }; + ext.context.globalState.update(cacheKey, postExtractCache); + // Delete cached information if the extension host was not restarted after 5 seconds + setTimeout(() => { + ext.context.globalState.update(cacheKey, undefined); + }, 5 * 1000); + runPostExtractSteps(postExtractCache); + } catch (error) { + context.telemetry.properties.error = error.message; + } +} + +export async function getPackageEntries(zipFilePath: string) { + const zip = new AdmZip(zipFilePath); + return zip.getEntries(); +} + +export function runPostExtractStepsFromCache(): void { + const cachedDocument: ICachedTextDocument | undefined = ext.context.globalState.get(cacheKey); + if (cachedDocument) { + try { + runPostExtractSteps(cachedDocument); + } finally { + ext.context.globalState.update(cacheKey, undefined); + } + } +} + +function runPostExtractSteps(cache: ICachedTextDocument): void { + callWithTelemetryAndErrorHandling('postExtractPackage', async (context: IActionContext) => { + context.telemetry.suppressIfSuccessful = true; + + if (getContainingWorkspace(cache.projectPath)) { + if (await fse.pathExists(cache.textDocumentPath)) { + window.showTextDocument(await workspace.openTextDocument(Uri.file(cache.textDocumentPath))); + } + } + context.telemetry.properties.finishedImportingProject = 'Finished importing project'; + window.showInformationMessage(localize('finishedImporting', 'Finished importing project.')); + }); +} diff --git a/apps/vs-code-designer/src/app/utils/codeless/common.ts b/apps/vs-code-designer/src/app/utils/codeless/common.ts index 976fc5b84d1..58fff7a4f53 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/common.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/common.ts @@ -12,6 +12,7 @@ import { schemasDirectory, azurePublicBaseUrl, rulesDirectory, + funcIgnoreFileName, } from '../../../constants'; import { ext } from '../../../extensionVariables'; import { localize } from '../../../localize'; @@ -99,7 +100,7 @@ export function getWorkflowParameters(parameters: Record): Re } export async function updateFuncIgnore(projectPath: string, variables: string[]) { - const funcIgnorePath: string = path.join(projectPath, '.funcignore'); + const funcIgnorePath: string = path.join(projectPath, funcIgnoreFileName); let funcIgnoreContents: string | undefined; if (await fse.pathExists(funcIgnorePath)) { funcIgnoreContents = (await fse.readFile(funcIgnorePath)).toString(); diff --git a/apps/vs-code-designer/src/app/utils/codeless/customcode.ts b/apps/vs-code-designer/src/app/utils/codeless/customcode.ts index df2802f27ed..e3b04db1f09 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/customcode.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/customcode.ts @@ -53,7 +53,7 @@ export async function getCustomCodeAppFilesToUpdate( hostFile.managedDependency = { enabled: true, }; - appFiles['host.json'] = JSON.stringify(hostFile, null, 2); + appFiles[hostFileName] = JSON.stringify(hostFile, null, 2); } } catch (error) { const message: string = localize('failedToParse', 'Failed to parse "{0}": {1}.', hostFileName, parseError(error).message); @@ -63,7 +63,7 @@ export async function getCustomCodeAppFilesToUpdate( } const requirementsFilePath: string = path.join(workflowFilePath, powershellRequirementsFileName); if (!(await fse.pathExists(requirementsFilePath))) { - appFiles['requirements.psd1'] = getAppFileForFileExtension('.ps1'); + appFiles[powershellRequirementsFileName] = getAppFileForFileExtension('.ps1'); } return appFiles; } diff --git a/apps/vs-code-designer/src/app/utils/codeless/templates.ts b/apps/vs-code-designer/src/app/utils/codeless/templates.ts index 15badce79dd..8fac2bd934f 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/templates.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/templates.ts @@ -1,5 +1,5 @@ import { ProjectType, type StandardApp } from '@microsoft/vscode-extension-logic-apps'; -import { WorkflowKind, WorkflowType } from '../../../constants'; +import { assetsFolderName, WorkflowKind, WorkflowType } from '../../../constants'; import * as fs from 'fs-extra'; import * as path from 'path'; import { equals } from '@microsoft/logic-apps-shared'; @@ -170,7 +170,7 @@ export function getCodelessWorkflowTemplate(projectType: ProjectType, workflowTy * @returns {Promise} - A promise that resolves to the codeful workflow template string. */ export async function getCodefulWorkflowTemplate(): Promise { - const templatePath = path.join(__dirname, 'assets', 'CodefulWorkflowTemplate', 'codefulTemplate.cs'); + const templatePath = path.join(__dirname, assetsFolderName, 'CodefulWorkflowTemplate', 'codefulTemplate.cs'); const templateContent = await fs.readFile(templatePath, 'utf-8'); return templateContent; diff --git a/apps/vs-code-designer/src/app/utils/dotnet/executeDotnetTemplateCommand.ts b/apps/vs-code-designer/src/app/utils/dotnet/executeDotnetTemplateCommand.ts index be483db6b21..84a301d2b6c 100644 --- a/apps/vs-code-designer/src/app/utils/dotnet/executeDotnetTemplateCommand.ts +++ b/apps/vs-code-designer/src/app/utils/dotnet/executeDotnetTemplateCommand.ts @@ -2,6 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { assetsFolderName } from '../../../constants'; import { ext } from '../../../extensionVariables'; import { localize } from '../../../localize'; import { useBinariesDependencies } from '../binaries'; @@ -35,7 +36,7 @@ export async function executeDotnetTemplateCommand( ): Promise { const framework: string = await getFramework(context, workingDirectory); const jsonDllPath: string = ext.context.asAbsolutePath( - path.join('assets', 'dotnetJsonCli', framework, 'Microsoft.TemplateEngine.JsonCli.dll') + path.join(assetsFolderName, 'dotnetJsonCli', framework, 'Microsoft.TemplateEngine.JsonCli.dll') ); return await executeCommand( diff --git a/apps/vs-code-designer/src/app/utils/git.ts b/apps/vs-code-designer/src/app/utils/git.ts index 7de9ffb6fb8..ac2149e77cb 100644 --- a/apps/vs-code-designer/src/app/utils/git.ts +++ b/apps/vs-code-designer/src/app/utils/git.ts @@ -70,3 +70,20 @@ workflow-designtime/ .vscode/ *.code-workspace`; }; + +export const newGetGitIgnoreContent = () => { + return ` +# Azure logic apps artifacts +bin +obj +appsettings.json +local.settings.json +__blobstorage__ +.debug +__queuestorage__ +__azurite_db*__.json + +# Added folders and file patterns +workflow-designtime/ +*.code-workspace`; +}; diff --git a/apps/vs-code-designer/src/app/utils/tree/__test__/assets.test.ts b/apps/vs-code-designer/src/app/utils/tree/__test__/assets.test.ts index cbe9c05bbc0..b9b6a073994 100644 --- a/apps/vs-code-designer/src/app/utils/tree/__test__/assets.test.ts +++ b/apps/vs-code-designer/src/app/utils/tree/__test__/assets.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { getIconPath, getThemedIconPath } from '../assets'; import path from 'path'; +import { assetsFolderName } from '../../../../constants'; vi.mock('../../../../extensionVariables', () => ({ ext: { @@ -14,7 +15,7 @@ describe('assets utility functions', () => { describe('getIconPath', () => { it('should return the correct icon path', () => { const iconName = 'testIcon'; - const expectedPath = path.join('mocked', 'path', 'assets', 'testIcon.svg'); + const expectedPath = path.join('mocked', 'path', assetsFolderName, 'testIcon.svg'); const result = getIconPath(iconName); expect(result).toBe(expectedPath); }); @@ -24,8 +25,8 @@ describe('assets utility functions', () => { it('should return the correct themed icon path', () => { const iconName = 'testIcon'; const expectedPath = { - light: path.join('mocked', 'path', 'assets', 'light', 'testIcon.svg'), - dark: path.join('mocked', 'path', 'assets', 'dark', 'testIcon.svg'), + light: path.join('mocked', 'path', assetsFolderName, 'light', 'testIcon.svg'), + dark: path.join('mocked', 'path', assetsFolderName, 'dark', 'testIcon.svg'), }; const result = getThemedIconPath(iconName); expect(result).toEqual(expectedPath); diff --git a/apps/vs-code-designer/src/app/utils/tree/assets.ts b/apps/vs-code-designer/src/app/utils/tree/assets.ts index 5a81effc5e4..597649b2987 100644 --- a/apps/vs-code-designer/src/app/utils/tree/assets.ts +++ b/apps/vs-code-designer/src/app/utils/tree/assets.ts @@ -2,6 +2,7 @@ import { ext } from '../../../extensionVariables'; import { Theme } from '@microsoft/logic-apps-shared'; import type { TreeItemIconPath } from '@microsoft/vscode-azext-utils'; import * as path from 'path'; +import { assetsFolderName } from '../../../constants'; /** * Gets icon path by name. @@ -29,5 +30,5 @@ export function getThemedIconPath(iconName: string): TreeItemIconPath { * @returns {string} assets folder path. */ function getResourcesPath(): string { - return ext.context.asAbsolutePath('assets'); + return ext.context.asAbsolutePath(assetsFolderName); } diff --git a/apps/vs-code-designer/src/app/utils/unitTests.ts b/apps/vs-code-designer/src/app/utils/unitTests.ts index 21eb037b7c4..2e39d79d934 100644 --- a/apps/vs-code-designer/src/app/utils/unitTests.ts +++ b/apps/vs-code-designer/src/app/utils/unitTests.ts @@ -124,7 +124,7 @@ export async function getUnitTestInLocalProject(projectPath: string): Promise { + if (isNullOrUndefined(workspaceFolder)) { + return undefined; + } + let subpath: string | undefined = getWorkspaceSetting(projectSubpathKey, workspaceFolder); + const folderPath = isString(workspaceFolder) ? workspaceFolder : workspaceFolder.uri.fsPath; + if (!(await fse.pathExists(folderPath))) { + return undefined; + } + if (await isLogicAppProject(folderPath)) { + return folderPath; + } + const subpaths: string[] = await fse.readdir(folderPath); + const matchingSubpaths: string[] = []; + await Promise.all( + subpaths.map(async (s) => { + if (await isLogicAppProject(path.join(folderPath, s))) { + matchingSubpaths.push(s); + } + }) + ); + + if (matchingSubpaths.length !== 0) { + subpath = matchingSubpaths[0]; + } else { + return undefined; + } + + return path.join(folderPath, subpath); +} + async function promptForProjectSubpath(context: IActionContext, workspacePath: string, matchingSubpaths: string[]): Promise { const message: string = localize( 'detectedMultipleProject', diff --git a/apps/vs-code-designer/src/app/utils/vsCodeConfig/tasks.ts b/apps/vs-code-designer/src/app/utils/vsCodeConfig/tasks.ts index cd9e0724283..2f7c350c5f1 100644 --- a/apps/vs-code-designer/src/app/utils/vsCodeConfig/tasks.ts +++ b/apps/vs-code-designer/src/app/utils/vsCodeConfig/tasks.ts @@ -13,7 +13,7 @@ import * as fse from 'fs-extra'; import * as path from 'path'; import { workspace } from 'vscode'; import type { MessageItem, TaskDefinition, WorkspaceConfiguration, WorkspaceFolder } from 'vscode'; -import { vscodeFolderName } from '../../../constants'; +import { tasksFileName, vscodeFolderName } from '../../../constants'; const tasksKey = 'tasks'; const inputsKey = 'inputs'; @@ -95,7 +95,7 @@ export async function validateTasksJson(context: IActionContext, folders: readon const projectPath: string | undefined = await tryGetLogicAppProjectRoot(context, folder); context.telemetry.properties.projectPath = projectPath; if (projectPath) { - const tasksJsonPath: string = path.join(projectPath, vscodeFolderName, 'tasks.json'); + const tasksJsonPath: string = path.join(projectPath, vscodeFolderName, tasksFileName); if (!fse.existsSync(tasksJsonPath)) { throw new Error(localize('noTaskJson', `Failed to find: ${tasksJsonPath}`)); @@ -138,7 +138,7 @@ async function overwriteTasksJson(context: IActionContext, projectPath: string): '\n\nSelecting "Cancel" leaves the file unchanged, but shows this message when you open this project again.' + '\n\nContinue with the update?'; - const tasksJsonPath: string = path.join(projectPath, vscodeFolderName, 'tasks.json'); + const tasksJsonPath: string = path.join(projectPath, vscodeFolderName, tasksFileName); let tasksJsonContent: any; const projectFiles = [ diff --git a/apps/vs-code-designer/src/app/utils/vsCodeConfig/verifyInitForVSCode.ts b/apps/vs-code-designer/src/app/utils/vsCodeConfig/verifyInitForVSCode.ts index d00f3f80f85..44289aa2e7a 100644 --- a/apps/vs-code-designer/src/app/utils/vsCodeConfig/verifyInitForVSCode.ts +++ b/apps/vs-code-designer/src/app/utils/vsCodeConfig/verifyInitForVSCode.ts @@ -2,7 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { projectLanguageSetting, funcVersionSetting } from '../../../constants'; +import { projectLanguageSetting, funcVersionSetting, vscodeFolderName, settingsFileName } from '../../../constants'; import * as path from 'path'; import * as fse from 'fs-extra'; import { localize } from '../../../localize'; @@ -29,7 +29,7 @@ export async function verifyInitForVSCode( version?: string ): Promise<[ProjectLanguage, FuncVersion]> { let settings: { [key: string]: any } = {}; - const settingsPath = path.join(fsPath, '.vscode', 'settings.json'); + const settingsPath = path.join(fsPath, vscodeFolderName, settingsFileName); if (await fse.pathExists(settingsPath)) { settings = JSON.parse(await fse.readFile(settingsPath, 'utf8')); } diff --git a/apps/vs-code-designer/src/app/utils/workspace.ts b/apps/vs-code-designer/src/app/utils/workspace.ts index 2ef32f266f8..3c07d2ccec1 100644 --- a/apps/vs-code-designer/src/app/utils/workspace.ts +++ b/apps/vs-code-designer/src/app/utils/workspace.ts @@ -11,6 +11,7 @@ import { promptOpenProjectOrWorkspace, tryGetAllLogicAppProjectRoots, tryGetLogicAppProjectRoot, + tryGetLogicAppProjectRoots2, } from './verifyIsProject'; import { isNullOrUndefined, isString } from '@microsoft/logic-apps-shared'; import { UserCancelledError, nonNullValue } from '@microsoft/vscode-azext-utils'; @@ -180,13 +181,68 @@ export async function getWorkspaceFolder( const folderContents = await fse.readdir(workspaceFolderPath, { withFileTypes: true }); const subFolders = folderContents.filter((dirent) => dirent.isDirectory()).map((dirent) => path.join(workspaceFolderPath, dirent.name)); - return await selectLogicAppWorkspaceFolder(context, subFolders, skipPromptOnMultipleFolders); + return await getLogicAppWorkspaceFolder(context, subFolders, skipPromptOnMultipleFolders); } - return await selectLogicAppWorkspaceFolder(context, null, skipPromptOnMultipleFolders); + return await getLogicAppWorkspaceFolder(context, null, skipPromptOnMultipleFolders); } -async function selectLogicAppWorkspaceFolder( +/** + * Gets workspace folder of project. + * @param {IActionContext} context - Command context. + * @param {string} message - The message to display to the user if workspace is not open. + * @param {string} skipPromptOnMultipleFolders - The boolean to skip prompt to select logic app folder if there are multiple. + * @returns {Promise} Returns either the new project workspace, the already open workspace or the selected workspace. + */ +export async function getWorkspaceFolder2(): 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) { + // there is nothing open so give a warning message + vscode.window.showErrorMessage( + localize('notInWorkspace', 'Please open an existing logic app workspace before trying to add a new logic app project.') + ); + // await promptOpenProjectOrWorkspace(context, promptMessage); + return undefined; + } + + if (vscode.workspace.workspaceFolders.length === 1) { + const workspaceFolder = vscode.workspace.workspaceFolders[0]; + if (vscode.workspace.workspaceFile) { + return workspaceFolder; + } + + const workspaceFolderPath = workspaceFolder.uri.fsPath; + if (await isLogicAppProject(workspaceFolderPath)) { + return workspaceFolder; + } + const folderContents = await fse.readdir(workspaceFolderPath, { withFileTypes: true }); + const subFolders = folderContents.filter((dirent) => dirent.isDirectory()).map((dirent) => path.join(workspaceFolderPath, dirent.name)); + + return await logicAppFoundInFolders(subFolders); + } + + return await logicAppFoundInFolders(null); +} + +async function logicAppFoundInFolders(subFolders: string[]): Promise { + const logicAppProjectRoots: string[] = []; + for (const folder of subFolders ?? vscode.workspace.workspaceFolders) { + const projectRoot = await tryGetLogicAppProjectRoots2(folder); + if (projectRoot) { + logicAppProjectRoots.push(projectRoot); + } + } + + if (logicAppProjectRoots.length === 0) { + return undefined; + } + // return path.dirname(logicAppProjectRoots[0]); + + return getContainingWorkspace(logicAppProjectRoots[0]); +} + +async function getLogicAppWorkspaceFolder( context: IActionContext, subFolders: string[], skipPromptOnMultipleFolders?: boolean @@ -296,6 +352,63 @@ async function selectLogicAppWorkspaceFolderWithoutCustomCode( 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 getLogicAppWithoutCustomCodeNew(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 getLogicAppWorkspaceFolderWithoutCustomCodeNew(context, false, subFolders); + } + } + + return await getLogicAppWorkspaceFolderWithoutCustomCodeNew(context, true, null); +} + +export async function getLogicAppWorkspaceFolderWithoutCustomCodeNew( + context: IActionContext, + returnsWorkspaceFolder: boolean, + subFolders: string[] +): Promise { + const logicAppsWorkspaces = []; + for (const folder of returnsWorkspaceFolder ? vscode.workspace.workspaceFolders : subFolders) { + const projectRoot = await tryGetLogicAppProjectRoot(context, folder); + 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 08372efc803..6f8f2399dda 100644 --- a/apps/vs-code-designer/src/constants.ts +++ b/apps/vs-code-designer/src/constants.ts @@ -270,6 +270,9 @@ export const ProjectDirectoryPathKey = 'ProjectDirectoryPath'; export const extensionVersionKey = 'FUNCTIONS_EXTENSION_VERSION'; export const azureStorageTypeSetting = 'Files'; export const isZipDeployEnabledSetting = 'IS_ZIP_DEPLOY_ENABLED'; +export const azureWebJobsFeatureFlagsKey = 'AzureWebJobsFeatureFlags'; +export const multiLanguageWorkerSetting = 'EnableMultiLanguageWorker'; + // Project export const defaultVersionRange = '[1.*, 2.0.0)'; // Might need to be changed export const funcWatchProblemMatcher = '$func-watch'; diff --git a/apps/vs-code-designer/src/extensionVariables.ts b/apps/vs-code-designer/src/extensionVariables.ts index 1ff985d033f..987afe914fe 100644 --- a/apps/vs-code-designer/src/extensionVariables.ts +++ b/apps/vs-code-designer/src/extensionVariables.ts @@ -93,6 +93,10 @@ export namespace ext { export: 'export', overview: 'overview', unitTest: 'unitTest', + createWorkspace: 'createWorkspace', + createWorkspaceFromPackage: 'createWorkspaceFromPackage', + createLogicApp: 'createLogicApp', + createWorkspaceStructure: 'createWorkspaceStructure', } as const; export type webViewKey = keyof typeof webViewKey; @@ -101,6 +105,10 @@ export namespace ext { [webViewKey.designerAzure]: {}, [webViewKey.monitoring]: {}, [webViewKey.export]: {}, + [webViewKey.createWorkspace]: {}, + [webViewKey.createWorkspaceFromPackage]: {}, + [webViewKey.createWorkspaceStructure]: {}, + [webViewKey.createLogicApp]: {}, [webViewKey.overview]: {}, }; @@ -148,8 +156,12 @@ export const ExtensionCommand = { initialize_frame: 'initialize-frame', update_access_token: 'update-access-token', update_export_path: 'update-export-path', + update_workspace_path: 'update-workspace-path', + update_package_path: 'update-package-path', export_package: 'export-package', add_status: 'add-status', set_final_status: 'set-final-status', + workspace_folder: 'workspace-folder', + workspace_file: 'workspace-file', }; export type ExtensionCommand = keyof typeof ExtensionCommand; diff --git a/apps/vs-code-designer/src/main.ts b/apps/vs-code-designer/src/main.ts index 4132382362b..5cb8f9c2486 100644 --- a/apps/vs-code-designer/src/main.ts +++ b/apps/vs-code-designer/src/main.ts @@ -1,6 +1,6 @@ import { LogicAppResolver } from './LogicAppResolver'; import { runPostWorkflowCreateStepsFromCache } from './app/commands/createWorkflow/createWorkflowSteps/workflowCreateStepBase'; -import { runPostExtractStepsFromCache } from './app/commands/cloudToLocal/cloudToLocalSteps/processPackageStep'; +import { runPostExtractStepsFromCache } from './app/utils/cloudToLocalUtils'; import { supportedDataMapDefinitionFileExts, supportedDataMapperFolders, @@ -36,6 +36,8 @@ import { createVSCodeAzureSubscriptionProvider } from './app/utils/services/VSCo import { logExtensionSettings, logSubscriptions } from './app/utils/telemetry'; import { registerAzureUtilsExtensionVariables } from '@microsoft/vscode-azext-azureutils'; import { getAzExtResourceType, getAzureResourcesExtensionApi } from '@microsoft/vscode-azureresources-api'; +import { getWorkspaceFolder2 } from './app/utils/workspace'; +import { isLogicAppProjectInRoot } from './app/utils/verifyIsProject'; const perfStats = { loadStartTime: Date.now(), @@ -45,6 +47,12 @@ const perfStats = { const telemetryString = 'setInGitHubBuild'; export async function activate(context: vscode.ExtensionContext) { + await updateLogicAppsContext(); + const workspaceWatcher = vscode.workspace.onDidChangeWorkspaceFolders(() => { + updateLogicAppsContext(); + }); + context.subscriptions.push(workspaceWatcher); + // Data Mapper context vscode.commands.executeCommand( 'setContext', @@ -162,3 +170,13 @@ export function deactivate(): Promise { ext.telemetryReporter.dispose(); return undefined; } + +export async function updateLogicAppsContext() { + if (!vscode.workspace.workspaceFolders || vscode.workspace.workspaceFolders.length === 0) { + await vscode.commands.executeCommand('setContext', 'logicApps.hasProject', false); + } else { + const workspaceFolder = await getWorkspaceFolder2(); + const logicAppOpened = await isLogicAppProjectInRoot(workspaceFolder); + await vscode.commands.executeCommand('setContext', 'logicApps.hasProject', logicAppOpened); + } +} diff --git a/apps/vs-code-designer/src/package.json b/apps/vs-code-designer/src/package.json index d3853999b95..e3106862b2b 100644 --- a/apps/vs-code-designer/src/package.json +++ b/apps/vs-code-designer/src/package.json @@ -405,15 +405,18 @@ }, { "command": "azureLogicAppsStandard.createProject", - "group": "1_create@2" + "group": "1_create@2", + "when": "logicApps.hasProject" }, { "command": "azureLogicAppsStandard.createWorkflow", - "group": "1_create@3" + "group": "1_create@3", + "when": "logicApps.hasProject" }, { "command": "azureLogicAppsStandard.deploy", - "group": "2_deploy@1" + "group": "2_deploy@1", + "when": "logicApps.hasProject" }, { "command": "azureLogicAppsStandard.exportLogicApp", @@ -661,94 +664,94 @@ "explorer/context": [ { "command": "azureLogicAppsStandard.dataMap.createNewDataMap", - "when": "resourceFilename in azureLogicAppsStandard.dataMap.setDmFolders", + "when": "logicApps.hasProject && resourceFilename in azureLogicAppsStandard.dataMap.setDmFolders", "group": "navigation" }, { "command": "azureLogicAppsStandard.deploy", - "when": "explorerResourceIsFolder == true", + "when": "logicApps.hasProject && explorerResourceIsFolder == true", "group": "zzz_LogicApptools@1" }, { "command": "azureLogicAppsStandard.generateDeploymentScripts", - "when": "explorerResourceIsRoot == true", + "when": "logicApps.hasProject && explorerResourceIsRoot == true", "group": "zzz_LogicApptools@1" }, { "command": "azureLogicAppsStandard.createProject", - "when": "explorerResourceIsRoot == true", + "when": "logicApps.hasProject && explorerResourceIsRoot == true", "group": "zzz_LogicApptools@1" }, { "command": "azureLogicAppsStandard.createWorkflow", - "when": "explorerResourceIsRoot == true", + "when": "logicApps.hasProject && explorerResourceIsRoot == true", "group": "zzz_LogicApptools@1" }, { "command": "azureLogicAppsStandard.createCustomCodeFunction", - "when": "explorerResourceIsRoot == true && resourcePath in azureLogicAppsStandard.customCode.setFunctionsFolders", + "when": "logicApps.hasProject && explorerResourceIsRoot == true && resourcePath in azureLogicAppsStandard.customCode.setFunctionsFolders", "group": "navigation@1" }, { "command": "azureLogicAppsStandard.buildCustomCodeFunctionsProject", - "when": "explorerResourceIsRoot == true && resourcePath in azureLogicAppsStandard.customCode.setFunctionsFolders", + "when": "logicApps.hasProject && explorerResourceIsRoot == true && resourcePath in azureLogicAppsStandard.customCode.setFunctionsFolders", "group": "zzz_LogicApptools@1" }, { "command": "azureLogicAppsStandard.openOverview", - "when": "resourceFilename==workflow.json", + "when": "logicApps.hasProject && resourceFilename==workflow.json", "group": "navigation@1" }, { "command": "azureLogicAppsStandard.openDesigner", - "when": "resourceFilename==workflow.json", + "when": "logicApps.hasProject && resourceFilename==workflow.json", "group": "navigation@3" }, { "command": "azureLogicAppsStandard.switchToDotnetProject", - "when": "explorerResourceIsRoot == true", + "when": "logicApps.hasProject && explorerResourceIsRoot == true", "group": "zzz_LogicApptools@3" }, { "command": "azureLogicAppsStandard.reviewValidation", - "when": "resourceDirname=~/\\.logs\\/export/ && resourceFilename=~/exportValidation.json/", + "when": "logicApps.hasProject && resourceDirname=~/\\.logs\\/export/ && resourceFilename=~/exportValidation.json/", "group": "navigation@1" }, { "command": "azureLogicAppsStandard.switchDebugMode", - "when": "explorerResourceIsRoot == true", + "when": "logicApps.hasProject && explorerResourceIsRoot == true", "group": "zzz_LogicApptools@2" }, { "command": "azureLogicAppsStandard.useSQLStorage", - "when": "explorerResourceIsRoot == true", + "when": "logicApps.hasProject && explorerResourceIsRoot == true", "group": "zzz_LogicApptools@3" }, { "command": "azureLogicAppsStandard.syncCloudSettings", - "when": "resourceFilename==cloud.settings.json", + "when": "logicApps.hasProject && resourceFilename==cloud.settings.json", "group": "zzz_appSettings@3" }, { "command": "azureLogicAppsStandard.configureWebhookRedirectEndpoint", - "when": "resourceFilename==local.settings.json", + "when": "logicApps.hasProject && resourceFilename==local.settings.json", "group": "zzz_appSettings@3" }, { "command": "azureLogicAppsStandard.enableAzureConnectors", - "when": "resourceFilename==workflow.json", + "when": "logicApps.hasProject && resourceFilename==workflow.json", "group": "navigation@2" }, { "command": "azureLogicAppsStandard.dataMap.loadDataMapFile", "group": "navigation", - "when": "resourceExtname in azureLogicAppsStandard.dataMap.setSupportedDataMapDefinitionFileExts" + "when": "logicApps.hasProject && resourceExtname in azureLogicAppsStandard.dataMap.setSupportedDataMapDefinitionFileExts" } ], "commandPalette": [ { "command": "azureLogicAppsStandard.openDesigner", - "when": "resourceFilename==workflow.json" + "when": "logicApps.hasProject && resourceFilename==workflow.json" }, { "command": "azureLogicAppsStandard.viewContent", @@ -764,7 +767,7 @@ }, { "command": "azureLogicAppsStandard.openOverview", - "when": "resourceFilename==workflow.json" + "when": "logicApps.hasProject && resourceFilename==workflow.json" }, { "command": "azureLogicAppsStandard.toggleAppSettingVisibility", @@ -772,7 +775,7 @@ }, { "command": "azureLogicAppsStandard.startRemoteDebug", - "when": "config.azureLogicAppsStandard.enableRemoteDebugging == true" + "when": "logicApps.hasProject && config.azureLogicAppsStandard.enableRemoteDebugging == true" }, { "command": "azureLogicAppsStandard.viewProperties", diff --git a/apps/vs-code-react/src/app/createLogicApp/createLogicAppSetupStep.tsx b/apps/vs-code-react/src/app/createLogicApp/createLogicAppSetupStep.tsx new file mode 100644 index 00000000000..aea47af2723 --- /dev/null +++ b/apps/vs-code-react/src/app/createLogicApp/createLogicAppSetupStep.tsx @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import type React from 'react'; +import { useSelector } from 'react-redux'; +import type { RootState } from '../../state/store'; +import type { CreateWorkspaceState } from '../../state/createWorkspaceSlice'; +import { useCreateWorkspaceStyles } from '../createWorkspace/createWorkspaceStyles'; +import { LogicAppTypeStep } from '../createWorkspace/steps/logicAppTypeStep'; +import { WorkflowTypeStep } from '../createWorkspace/steps/workflowTypeStep'; +import { DotNetFrameworkStep } from '../createWorkspace/steps/dotNetFrameworkStep'; + +export const CreateLogicAppSetupStep: React.FC = () => { + const styles = useCreateWorkspaceStyles(); + const createWorkspaceState = useSelector((state: RootState) => state.createWorkspace) as CreateWorkspaceState; + const { logicAppType, logicAppName, logicAppsWithoutCustomCode } = createWorkspaceState; + + // Check if an existing logic app is selected + const isExistingLogicApp = + (logicAppType === 'customCode' || logicAppType === 'rulesEngine') && + logicAppsWithoutCustomCode?.some((app: { label: string }) => app.label === logicAppName); + + return ( +
+ + + {!isExistingLogicApp && } +
+ ); +}; diff --git a/apps/vs-code-react/src/app/createLogicApp/createWorkspaceStructSetupStep.tsx b/apps/vs-code-react/src/app/createLogicApp/createWorkspaceStructSetupStep.tsx new file mode 100644 index 00000000000..fe57d8c2500 --- /dev/null +++ b/apps/vs-code-react/src/app/createLogicApp/createWorkspaceStructSetupStep.tsx @@ -0,0 +1,17 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import type React from 'react'; +import { useCreateWorkspaceStyles } from '../createWorkspace/createWorkspaceStyles'; +import { WorkspaceNameStep } from '../createWorkspace/steps'; + +export const CreateWorkspaceStructSetupStep: React.FC = () => { + const styles = useCreateWorkspaceStyles(); + + return ( +
+ +
+ ); +}; diff --git a/apps/vs-code-react/src/app/createWorkspace/createWorkspace.tsx b/apps/vs-code-react/src/app/createWorkspace/createWorkspace.tsx new file mode 100644 index 00000000000..dce901ae466 --- /dev/null +++ b/apps/vs-code-react/src/app/createWorkspace/createWorkspace.tsx @@ -0,0 +1,714 @@ +import type { OutletContext } from '../../run-service'; +import { useCreateWorkspaceStyles } from './createWorkspaceStyles'; +import { useIntl } from 'react-intl'; +import { useOutletContext } from 'react-router-dom'; +import { ProjectSetupStep, PackageSetupStep, ReviewCreateStep } from './steps/'; +import { CreateWorkspaceStructSetupStep } from '../createLogicApp/createWorkspaceStructSetupStep'; +import { CreateLogicAppSetupStep } from '../createLogicApp/createLogicAppSetupStep'; +import { Button, Spinner, Text } from '@fluentui/react-components'; +import { VSCodeContext } from '../../webviewCommunication'; +import type { RootState } from '../../state/store'; +import type { CreateWorkspaceState } from '../../state/createWorkspaceSlice'; +import { nextStep, previousStep, setCurrentStep, setFlowType } from '../../state/createWorkspaceSlice'; +import { useContext, useEffect } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +// Import validation patterns and functions for navigation blocking +import { workspaceNameValidation } from './steps/workspaceNameStep'; +import { logicAppNameValidation } from './steps/logicAppTypeStep'; +import { workflowNameValidation } from './steps/workflowTypeStep'; +import { functionNameValidation, namespaceValidation } from './steps/dotNetFrameworkStep'; + +export const CreateWorkspace: React.FC = () => { + const intl = useIntl(); + const vscode = useContext(VSCodeContext); + const dispatch = useDispatch(); + const styles = useCreateWorkspaceStyles(); + + const createWorkspaceState = useSelector((state: RootState) => state.createWorkspace) as CreateWorkspaceState; + const { + currentStep, + isLoading, + isComplete, + error, + flowType, + packagePath, + workspaceProjectPath, + workspaceName, + logicAppType, + functionFolderName, + functionNamespace, + functionName, + workflowType, + workflowName, + targetFramework, + logicAppName, + projectType, + workspaceFileJson, + pathValidationResults, + workspaceExistenceResults, + packageValidationResults, + logicAppsWithoutCustomCode, + } = createWorkspaceState; + + // Set flow type when component mounts + useEffect(() => { + dispatch(setFlowType('createWorkspace')); + }, [dispatch]); + + // Calculate total steps - always 2: Setup and Review + Create + const totalSteps = 2; + const isFirstStep = currentStep === 0; + const isLastStep = currentStep === totalSteps - 1; + + // Helper function to get flow-specific messages + const getCreateWorkspaceMessage = () => { + switch (flowType) { + case 'createWorkspaceFromPackage': + return intl.formatMessage({ + defaultMessage: 'Create logic app workspace from package', + id: 'RZZxs+', + description: 'Create logic app workspace from package text.', + }); + case 'convertToWorkspace': + return intl.formatMessage({ + defaultMessage: 'Create logic app workspace', + id: 'eagv8j', + description: 'Create logic app workspace text.', + }); + case 'createLogicApp': + return intl.formatMessage({ + defaultMessage: 'Create Project', + id: 'RmJRES', + description: 'Create logic app project text.', + }); + default: + return intl.formatMessage({ + defaultMessage: 'Create logic app workspace', + id: 'eagv8j', + description: 'Create logic app workspace text.', + }); + } + }; + + const getCreateButtonMessage = () => { + if (flowType === 'createLogicApp') { + return intl.formatMessage({ + defaultMessage: 'Create project', + id: 'u+VFmh', + description: 'Create logic app project button', + }); + } + return intl.formatMessage({ + defaultMessage: 'Create Workspace', + id: 'XZfauP', + description: 'Create workspace button', + }); + }; + + const getCreatingMessage = () => { + if (flowType === 'createWorkspaceFromPackage') { + return intl.formatMessage({ + defaultMessage: 'Creating...', + id: 'e8iBzO', + description: 'Creating workspace from package in progress', + }); + } + return intl.formatMessage({ + defaultMessage: 'Creating...', + id: 'k6MqI+', + description: 'Creating workspace in progress', + }); + }; + + const getSuccessTitle = () => { + switch (flowType) { + case 'createWorkspaceFromPackage': + return intl.formatMessage({ + defaultMessage: 'Workspace From Package Created Successfully!', + id: 'vDfUt4', + description: 'Workspace from package creation success message', + }); + case 'convertToWorkspace': + case 'createLogicApp': + return intl.formatMessage({ + defaultMessage: 'Logic App Created Successfully!', + id: '8bXaOe', + description: 'Logic app creation success message', + }); + default: + return intl.formatMessage({ + defaultMessage: 'Workspace Created Successfully!', + id: '4fdozy', + description: 'Workspace creation success message', + }); + } + }; + + const getSuccessDescription = () => { + switch (flowType) { + case 'createWorkspaceFromPackage': + return intl.formatMessage({ + defaultMessage: 'Your logic app workspace from package has been created is ready to use.', + id: 'rGWwuB', + description: 'Workspace package creation success description', + }); + case 'convertToWorkspace': + case 'createLogicApp': + return intl.formatMessage({ + defaultMessage: 'Your logic app has been created and is ready to use.', + id: 'ECHpxE', + description: 'Logic app creation success description', + }); + default: + return intl.formatMessage({ + defaultMessage: 'Your logic app workspace has been created and is ready to use.', + id: 'OdrYKo', + description: 'Workspace creation success description', + }); + } + }; + + const getProjectSetupStepLabel = () => { + if (flowType === 'convertToWorkspace' || flowType === 'createLogicApp') { + return intl.formatMessage({ + defaultMessage: 'Logic App Setup', + id: 'dELTC6', + description: 'Logic App setup step label', + }); + } + return intl.formatMessage({ + defaultMessage: 'Project Setup', + id: '1d8W/S', + description: 'Project setup step label', + }); + }; + + const intlText = { + CREATE_WORKSPACE: getCreateWorkspaceMessage(), + CREATE_BUTTON: getCreateButtonMessage(), + CREATING: getCreatingMessage(), + NEXT: intl.formatMessage({ + defaultMessage: 'Next', + id: '3Wcqsy', + description: 'Next button', + }), + BACK: intl.formatMessage({ + defaultMessage: 'Back', + id: '2XH9oW', + description: 'Back button', + }), + STEP_INDICATOR: intl.formatMessage( + { + defaultMessage: 'Step {current} of {total}', + id: '4IV3/7', + description: 'Step indicator text', + }, + { + current: currentStep + 1, + total: totalSteps, + } + ), + SUCCESS_TITLE: getSuccessTitle(), + SUCCESS_DESCRIPTION: getSuccessDescription(), + STEP_PROJECT_SETUP: getProjectSetupStepLabel(), + STEP_REVIEW_CREATE: intl.formatMessage({ + defaultMessage: 'Review + Create', + id: '4dze5/', + description: 'Review and create step label', + }), + }; + + // Helper function to check if a name already exists in workspace folders + const isNameAlreadyInWorkspace = (name: string): boolean => { + return workspaceFileJson?.folders && workspaceFileJson.folders.some((folder: { name: string }) => folder.name === name); + }; + + // Helper function to validate logic app name with support for existing logic apps + const validateLogicAppNameForNavigation = (name: string): boolean => { + if (!name.trim() || !logicAppNameValidation.test(name.trim())) { + return false; + } + + // If custom code or rules engine is selected and the name is from the existing logic apps list, it's valid + const isCustomCodeOrRulesEngine = logicAppType === 'customCode' || logicAppType === 'rulesEngine'; + const isExistingLogicApp = logicAppsWithoutCustomCode?.some((app: { label: string }) => app.label === name); + + if (isCustomCodeOrRulesEngine && isExistingLogicApp) { + return true; // Valid - existing logic app for custom code/rules engine + } + + // Check for workspace folder collision (only if not using existing logic app) + return !isNameAlreadyInWorkspace(name.trim()); + }; + + // Get validation requirements based on flow type + const getValidationRequirements = () => { + const requirements = { + needsPackagePath: flowType === 'createWorkspaceFromPackage', + needsWorkspacePath: flowType !== 'createLogicApp', + needsWorkspaceName: flowType !== 'createLogicApp', + needsLogicAppType: true, + needsLogicAppName: true, + needsWorkflowFields: flowType === 'createWorkspace' || flowType === 'createLogicApp', + needsFunctionFields: logicAppType === 'customCode' || logicAppType === 'rulesEngine', + }; + return requirements; + }; + + const canProceed = () => { + switch (currentStep) { + case 0: { + const requirements = getValidationRequirements(); + + // Package path validation (only for createWorkspaceFromPackage) + if (requirements.needsPackagePath) { + const packagePathValid = packagePath.fsPath !== '' && packageValidationResults[packagePath.fsPath] === true; + if (!packagePathValid) { + return false; + } + } + + // Workspace path validation (not needed for createLogicApp) + if (requirements.needsWorkspacePath) { + const workspacePathValid = workspaceProjectPath.fsPath !== '' && pathValidationResults[workspaceProjectPath.fsPath] === true; + if (!workspacePathValid) { + return false; + } + } + + // Workspace name validation (not needed for createLogicApp) + if (requirements.needsWorkspaceName) { + const separator = workspaceProjectPath.fsPath?.includes('/') ? '/' : '\\'; + const workspaceFolder = `${workspaceProjectPath.fsPath}${separator}${workspaceName}`; + const workspaceNameValid = + workspaceName.trim() !== '' && + workspaceNameValidation.test(workspaceName.trim()) && + workspaceExistenceResults[workspaceFolder] !== true; + if (!workspaceNameValid) { + return false; + } + } + + // Logic app type validation + if (requirements.needsLogicAppType) { + const logicAppTypeValid = logicAppType !== ''; + if (!logicAppTypeValid) { + return false; + } + } + + // Logic app name validation + if (requirements.needsLogicAppName) { + const logicAppNameValid = + flowType === 'createLogicApp' + ? validateLogicAppNameForNavigation(logicAppName) + : logicAppName.trim() !== '' && + logicAppNameValidation.test(logicAppName.trim()) && + !isNameAlreadyInWorkspace(logicAppName.trim()); + if (!logicAppNameValid) { + return false; + } + } + + // Workflow fields validation + if (requirements.needsWorkflowFields) { + // For createLogicApp, check if using existing logic app + if (flowType === 'createLogicApp') { + const isCustomCodeOrRulesEngine = logicAppType === 'customCode' || logicAppType === 'rulesEngine'; + const isExistingLogicApp = logicAppsWithoutCustomCode?.some((app: { label: string }) => app.label === logicAppName); + const usingExistingLogicApp = isCustomCodeOrRulesEngine && isExistingLogicApp; + + if (!usingExistingLogicApp) { + const workflowTypeValid = workflowType !== ''; + const workflowNameValid = workflowName.trim() !== '' && workflowNameValidation.test(workflowName.trim()); + if (!workflowTypeValid || !workflowNameValid) { + return false; + } + } + } else { + // For other flows, always validate workflow fields + const workflowTypeValid = workflowType !== ''; + const workflowNameValid = + workflowName.trim() !== '' && + workflowNameValidation.test(workflowName.trim()) && + !isNameAlreadyInWorkspace(workflowName.trim()); + if (!workflowTypeValid || !workflowNameValid) { + return false; + } + } + } + + // Function fields validation (for custom code and rules engine) + if (requirements.needsFunctionFields) { + // Function folder name validation + const functionFolderNameValid = + functionFolderName.trim() !== '' && + functionNameValidation.test(functionFolderName.trim()) && + !isNameAlreadyInWorkspace(functionFolderName.trim()) && + functionFolderName.trim().toLowerCase() !== logicAppName.trim().toLowerCase(); + if (!functionFolderNameValid) { + return false; + } + const functionNamespaceValid = functionNamespace.trim() !== '' && namespaceValidation.test(functionNamespace.trim()); + const functionNameValid = functionName.trim() !== '' && functionNameValidation.test(functionName.trim()); + + if (!functionNamespaceValid || !functionNameValid) { + return false; + } + + // Target framework validation (only for custom code) + if (logicAppType === 'customCode') { + const targetFrameworkValid = targetFramework !== ''; + if (!targetFrameworkValid) { + return false; + } + } + } + + return true; + } + case 1: { + // Review + Create - all fields should already be validated + return true; + } + default: + return false; + } + }; + + const getStepLabels = () => { + return [intlText.STEP_PROJECT_SETUP, intlText.STEP_REVIEW_CREATE]; + }; + + const isStepCompleted = (stepIndex: number) => { + switch (stepIndex) { + case 0: { + // Project Setup step - validate all required fields with regex validation + const workspacePathValid = workspaceProjectPath.fsPath !== '' && pathValidationResults[workspaceProjectPath.fsPath] === true; + const separator = workspaceProjectPath.fsPath?.includes('/') ? '/' : '\\'; + const workspaceFolder = `${workspaceProjectPath.fsPath}${separator}${workspaceName}`; + const workspaceNameValid = + workspaceName.trim() !== '' && + workspaceNameValidation.test(workspaceName.trim()) && + workspaceExistenceResults[workspaceFolder] !== true; + const logicAppTypeValid = logicAppType !== ''; + const logicAppNameValid = + logicAppName.trim() !== '' && logicAppNameValidation.test(logicAppName.trim()) && !isNameAlreadyInWorkspace(logicAppName.trim()); + const workflowTypeValid = workflowType !== ''; + const workflowNameValid = + workflowName.trim() !== '' && workflowNameValidation.test(workflowName.trim()) && !isNameAlreadyInWorkspace(workflowName.trim()); + + const baseFieldsValid = + workspacePathValid && workspaceNameValid && logicAppTypeValid && logicAppNameValid && workflowTypeValid && workflowNameValid; + + // If custom code or rules engine is selected, validate function fields + if (logicAppType === 'customCode' || logicAppType === 'rulesEngine') { + const functionFolderNameValid = + functionFolderName.trim() !== '' && + functionNameValidation.test(functionFolderName.trim()) && + !isNameAlreadyInWorkspace(functionFolderName.trim()) && + functionFolderName.trim().toLowerCase() !== logicAppName.trim().toLowerCase(); + const functionNamespaceValid = functionNamespace.trim() !== '' && namespaceValidation.test(functionNamespace.trim()); + const functionNameValid = functionName.trim() !== '' && functionNameValidation.test(functionName.trim()); + + const functionFieldsValid = functionNamespaceValid && functionNameValid && functionFolderNameValid; + + // Custom code additionally requires target framework + if (logicAppType === 'customCode') { + const targetFrameworkValid = targetFramework !== ''; + return baseFieldsValid && functionFieldsValid && targetFrameworkValid; + } + + // Rules engine doesn't need target framework + return baseFieldsValid && functionFieldsValid; + } + + return baseFieldsValid; + } + case 1: + // Review + Create step - considered complete if we can create + return isStepCompleted(0); // Depends on previous step being complete + default: + return false; + } + }; + + const canNavigateToStep = (stepIndex: number) => { + // Can always navigate to current or previous steps + if (stepIndex <= currentStep) { + return true; + } + + // For future steps, check if all intermediate steps can be completed + for (let i = currentStep; i < stepIndex; i++) { + if (!isStepCompleted(i)) { + return false; + } + } + + return true; + }; + + const handleStepClick = (stepIndex: number) => { + if (canNavigateToStep(stepIndex) && !isLoading) { + dispatch(setCurrentStep(stepIndex)); + } + }; + + const renderStepNavigation = () => { + const stepLabels = getStepLabels(); + + return ( +
+ {stepLabels.map((label, index) => { + const isActive = index === currentStep; + const isCompleted = isStepCompleted(index); + const canNavigate = canNavigateToStep(index); + + return ( +
+
handleStepClick(index)} + > +
+ {isCompleted && !isActive ? '✓' : index + 1} +
+
{label}
+
+ {index < stepLabels.length - 1 && ( +
+ )} +
+ ); + })} +
+ ); + }; + + const handleBack = () => { + if (!isFirstStep && !isLoading) { + dispatch(previousStep()); + } + }; + + const handleNext = () => { + if (canProceed() && !isLoading) { + dispatch(nextStep()); + } + }; + + const handleCreate = () => { + const baseData = { + workspaceProjectPath, + workspaceName, + projectType, + }; + + // Add flow-specific data + let data: any = { ...baseData }; + + if (flowType === 'createWorkspaceFromPackage') { + data = { + ...data, + packagePath, + logicAppType, + logicAppName, + }; + } else if (flowType === 'convertToWorkspace') { + data = { + ...data, + logicAppType, + logicAppName, + workflowType, + workflowName, + targetFramework, + ...(logicAppType === 'customCode' && { + functionFolderName, + functionNamespace, + functionName, + }), + ...(logicAppType === 'rulesEngine' && { + functionFolderName, + functionNamespace, + functionName, + }), + }; + } else if (flowType === 'createLogicApp') { + data = { + workspaceProjectPath, + workspaceName, + logicAppType, + logicAppName, + workflowType, + workflowName, + targetFramework, + projectType, + ...(logicAppType === 'customCode' && { + functionNamespace, + functionName, + functionFolderName, + }), + ...(logicAppType === 'rulesEngine' && { + functionNamespace, + functionName, + functionFolderName, + }), + }; + } else { + // createWorkspace + data = { + ...data, + logicAppType, + logicAppName, + workflowType, + workflowName, + targetFramework, + ...(logicAppType === 'customCode' && { + functionFolderName, + functionNamespace, + functionName, + }), + ...(logicAppType === 'rulesEngine' && { + functionFolderName, + functionNamespace, + functionName, + }), + }; + } + + // Send the appropriate command based on flow type + const command = + flowType === 'createWorkspaceFromPackage' + ? 'createWorkspaceFromPackage' + : flowType === 'convertToWorkspace' + ? 'createWorkspaceStructure' + : flowType === 'createLogicApp' + ? 'createLogicApp' + : 'createWorkspace'; + + vscode.postMessage({ command, data }); + }; + + const renderCurrentStep = () => { + switch (currentStep) { + case 0: { + // Render different setup steps based on flow type + if (flowType === 'createWorkspaceFromPackage') { + return ; + } + if (flowType === 'convertToWorkspace') { + return ; + } + if (flowType === 'createLogicApp') { + return ; + } + return ; + } + case 1: + return ; + default: { + // Default to first step based on flow type + if (flowType === 'createWorkspaceFromPackage') { + return ; + } + if (flowType === 'convertToWorkspace') { + return ; + } + if (flowType === 'createLogicApp') { + return ; + } + return ; + } + } + }; + + if (isComplete) { + return ( +
+
+ {intlText.SUCCESS_TITLE} + {intlText.SUCCESS_DESCRIPTION} +
+
+ ); + } + + return ( +
+ + {intlText.CREATE_WORKSPACE} + + + {renderStepNavigation()} + +
+ {renderCurrentStep()} + + {error &&
{error}
} +
+ +
+
+ {intlText.STEP_INDICATOR} +
+
+ + {isLastStep ? ( + + ) : ( + + )} +
+
+
+ ); +}; + +// Separate components for each flow type that set their flowType +export const CreateWorkspaceFromPackage: React.FC = () => { + const dispatch = useDispatch(); + + useEffect(() => { + dispatch(setFlowType('createWorkspaceFromPackage')); + }, [dispatch]); + + return ; +}; + +export const CreateWorkspaceStructure: React.FC = () => { + const dispatch = useDispatch(); + + useEffect(() => { + dispatch(setFlowType('convertToWorkspace')); + }, [dispatch]); + + return ; +}; + +export const CreateLogicApp: React.FC = () => { + const dispatch = useDispatch(); + + useEffect(() => { + dispatch(setFlowType('createLogicApp')); + }, [dispatch]); + + return ; +}; + +export function useOutlet() { + return useOutletContext(); +} diff --git a/apps/vs-code-react/src/app/createWorkspace/createWorkspaceStyles.ts b/apps/vs-code-react/src/app/createWorkspace/createWorkspaceStyles.ts new file mode 100644 index 00000000000..4ae0111b1f5 --- /dev/null +++ b/apps/vs-code-react/src/app/createWorkspace/createWorkspaceStyles.ts @@ -0,0 +1,435 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { makeStyles, tokens } from '@fluentui/react-components'; + +export const useCreateWorkspaceStyles = makeStyles({ + createWorkspaceContainer: { + height: '100vh', + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', // Prevent container from scrolling + }, + + createWorkspaceTitle: { + fontSize: tokens.fontSizeBase600, + padding: '15px 24px', + flexShrink: 0, // Prevent title from shrinking + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + }, + + stepNavigation: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + gap: tokens.spacingHorizontalM, + padding: `${tokens.spacingVerticalL} 0`, + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + flexShrink: 0, // Prevent step navigation from shrinking + backgroundColor: tokens.colorNeutralBackground1, + }, + + stepItem: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + cursor: 'pointer', + padding: tokens.spacingVerticalS, + borderRadius: tokens.borderRadiusMedium, + minWidth: '80px', + transition: 'background-color 0.2s ease', + '&:hover': { + backgroundColor: tokens.colorNeutralBackground1Hover, + }, + }, + + stepItemActive: { + backgroundColor: tokens.colorBrandBackground2, + color: tokens.colorBrandForeground2, + '&:hover': { + backgroundColor: tokens.colorBrandBackground2Hover, + }, + }, + + stepItemCompleted: { + backgroundColor: tokens.colorPaletteGreenBackground2, + color: tokens.colorPaletteGreenForeground2, + '&:hover': { + backgroundColor: tokens.colorPaletteGreenBackground2, + }, + }, + + stepItemDisabled: { + cursor: 'not-allowed', + opacity: 0.6, + '&:hover': { + backgroundColor: 'transparent', + }, + }, + + stepNumber: { + width: '24px', + height: '24px', + borderRadius: '50%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: tokens.fontSizeBase200, + fontWeight: tokens.fontWeightSemibold, + backgroundColor: tokens.colorNeutralBackground3, + color: tokens.colorNeutralForeground1, + marginBottom: tokens.spacingVerticalXS, + }, + + stepNumberActive: { + backgroundColor: tokens.colorBrandBackground, + color: tokens.colorNeutralForegroundOnBrand, + }, + + stepNumberCompleted: { + backgroundColor: tokens.colorPaletteGreenBackground1, + color: tokens.colorNeutralForegroundOnBrand, + }, + + stepLabel: { + fontSize: tokens.fontSizeBase200, + textAlign: 'center', + lineHeight: '1.2', + }, + + stepConnector: { + width: '40px', + height: '2px', + backgroundColor: tokens.colorNeutralStroke2, + marginTop: '12px', + }, + + stepConnectorCompleted: { + backgroundColor: tokens.colorPaletteGreenBorder1, + }, + + createWorkspaceContent: { + flex: 1, + display: 'flex', + flexDirection: 'column', + overflow: 'auto', // Only the content area scrolls + minHeight: 0, // Allow flexbox to shrink properly + padding: `${tokens.spacingVerticalL} ${tokens.spacingVerticalXL}`, + }, + + stepContainer: { + maxWidth: '800px', + width: '100%', + margin: '0', + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalL, + }, + + stepTitle: { + marginBottom: tokens.spacingVerticalM, + color: tokens.colorNeutralForeground1, + fontSize: tokens.fontSizeBase500, + fontWeight: tokens.fontWeightSemibold, + }, + + stepDescription: { + marginBottom: tokens.spacingVerticalL, + color: tokens.colorNeutralForeground2, + lineHeight: '1.5', + fontSize: tokens.fontSizeBase300, + }, + + inputField: { + width: '100%', + maxWidth: '800px', // Increased to accommodate longer paths + }, + + radioGroup: { + marginTop: tokens.spacingVerticalM, + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalS, + }, + + navigationContainer: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: `${tokens.spacingVerticalL} ${tokens.spacingVerticalXL}`, + borderTop: `1px solid ${tokens.colorNeutralStroke2}`, + backgroundColor: tokens.colorNeutralBackground1, + flexShrink: 0, // Prevent navigation from shrinking + minHeight: '72px', // Ensure consistent height + }, + + navigationLeft: { + display: 'flex', + alignItems: 'center', + }, + + navigationRight: { + display: 'flex', + alignItems: 'center', + gap: tokens.spacingHorizontalM, + }, + + stepIndicator: { + color: tokens.colorNeutralForeground2, + fontSize: tokens.fontSizeBase300, + fontWeight: tokens.fontWeightRegular, + }, + + errorMessage: { + color: tokens.colorPaletteRedForeground1, + marginTop: tokens.spacingVerticalS, + fontSize: tokens.fontSizeBase200, + }, + + loadingSpinner: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: tokens.spacingHorizontalS, + }, + + completionMessage: { + textAlign: 'center', + padding: tokens.spacingVerticalXXL, + color: tokens.colorPaletteGreenForeground1, + }, + + formGroup: { + display: 'flex', + flexDirection: 'column', + gap: `${tokens.spacingVerticalXL}`, + width: '100%', + maxWidth: '600px', + marginBottom: tokens.spacingVerticalXXL, + }, + + radioOption: { + marginBottom: tokens.spacingVerticalS, + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + }, + + browseButton: { + alignSelf: 'flex-start', + }, + + pathDisplay: { + marginTop: tokens.spacingVerticalM, + padding: tokens.spacingVerticalM, + backgroundColor: tokens.colorNeutralBackground2, + border: `1px solid ${tokens.colorNeutralStroke2}`, + borderRadius: tokens.borderRadiusMedium, + fontFamily: 'monospace', + fontSize: tokens.fontSizeBase200, + wordBreak: 'break-all', + overflowWrap: 'break-word', + maxWidth: '800px', // Increased to match fieldContainer + whiteSpace: 'pre-wrap', // Allow wrapping while preserving formatting + }, + + fieldContainer: { + width: '100%', + maxWidth: '800px', // Increased from 500px to accommodate longer paths + marginBottom: tokens.spacingVerticalM, + }, + + inputControl: { + width: '100%', + maxWidth: '800px', // Increased to match the fieldContainer + }, + + radioGroupContainer: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalM, + maxWidth: '800px', // Increased for consistency + }, + + formSection: { + padding: `${tokens.spacingVerticalL} 0`, + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + width: '100%', + maxWidth: '800px', // Increased to accommodate longer paths + '&:last-child': { + borderBottom: 'none', + }, + }, + + sectionTitle: { + marginBottom: tokens.spacingVerticalM, + fontSize: tokens.fontSizeBase600, + fontWeight: tokens.fontWeightSemibold, + color: tokens.colorNeutralForeground1, + display: 'block', + lineHeight: tokens.lineHeightBase600, + }, + + workflowTable: { + width: '100%', + borderCollapse: 'collapse', + marginTop: tokens.spacingVerticalL, + }, + + workflowTableHeader: { + borderBottom: `2px solid ${tokens.colorNeutralStroke2}`, + textAlign: 'left', + padding: tokens.spacingVerticalM, + backgroundColor: tokens.colorNeutralBackground2, + fontWeight: tokens.fontWeightSemibold, + fontSize: tokens.fontSizeBase300, + verticalAlign: 'top', + }, + + workflowTableRow: { + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + '&:hover': { + backgroundColor: tokens.colorNeutralBackground1Hover, + }, + }, + + workflowTableCell: { + padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`, + verticalAlign: 'middle', + fontSize: tokens.fontSizeBase300, + lineHeight: '1.4', + }, + + workflowColumnHeader: { + display: 'flex', + flexDirection: 'column', + alignItems: 'stretch', + gap: tokens.spacingVerticalS, + minHeight: '120px', + padding: tokens.spacingVerticalM, + }, + + workflowRadioContainer: { + display: 'flex', + justifyContent: 'flex-end', + marginBottom: tokens.spacingVerticalS, + }, + + workflowTypeContent: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + textAlign: 'center', + flex: 1, + }, + + workflowTypeCell: { + cursor: 'pointer', + '&:hover': { + backgroundColor: tokens.colorNeutralBackground1Hover, + }, + }, + + workflowTypeTitle: { + fontWeight: tokens.fontWeightSemibold, + fontSize: tokens.fontSizeBase400, + marginBottom: tokens.spacingVerticalXS, + color: tokens.colorNeutralForeground1, + }, + + workflowTypeDescription: { + color: tokens.colorNeutralForeground2, + lineHeight: '1.4', + fontSize: tokens.fontSizeBase300, + }, + + checkmarkCell: { + textAlign: 'center', + width: '60px', + color: tokens.colorPaletteGreenForeground1, + fontSize: tokens.fontSizeBase400, + }, + + emptyCell: { + textAlign: 'center', + width: '60px', + color: tokens.colorNeutralForeground3, + }, + + radioCell: { + textAlign: 'center', + width: '60px', + }, + + workflowNameField: { + marginBottom: tokens.spacingVerticalL, + }, + + reviewContainer: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalL, + maxWidth: '800px', + width: '100%', + }, + + reviewSection: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalS, + padding: tokens.spacingVerticalM, + backgroundColor: tokens.colorNeutralBackground2, + borderRadius: tokens.borderRadiusMedium, + border: `1px solid ${tokens.colorNeutralStroke2}`, + }, + + reviewSectionTitle: { + fontSize: tokens.fontSizeBase400, + fontWeight: tokens.fontWeightSemibold, + color: tokens.colorNeutralForeground1, + marginBottom: tokens.spacingVerticalS, + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + paddingBottom: tokens.spacingVerticalXS, + }, + + reviewRow: { + display: 'flex', + alignItems: 'center', + padding: `${tokens.spacingVerticalXS} 0`, + borderBottom: `1px solid ${tokens.colorNeutralStroke3}`, + gap: tokens.spacingHorizontalM, + '&:last-child': { + borderBottom: 'none', + }, + }, + + reviewLabel: { + fontSize: tokens.fontSizeBase300, + fontWeight: tokens.fontWeightMedium, + color: tokens.colorNeutralForeground2, + minWidth: '150px', + textAlign: 'left', + flexShrink: 0, + }, + + reviewValue: { + fontSize: tokens.fontSizeBase300, + color: tokens.colorNeutralForeground1, + textAlign: 'right', + wordBreak: 'break-word', + flex: 1, + }, + + reviewValueMissing: { + color: tokens.colorPaletteRedForeground1, + fontStyle: 'italic', + }, + + sectionDivider: { + marginTop: tokens.spacingVerticalXL, + marginBottom: tokens.spacingVerticalM, + borderTop: `1px solid ${tokens.colorNeutralStroke2}`, + paddingTop: tokens.spacingVerticalM, + }, +}); diff --git a/apps/vs-code-react/src/app/createWorkspace/steps/dotNetFrameworkStep.tsx b/apps/vs-code-react/src/app/createWorkspace/steps/dotNetFrameworkStep.tsx new file mode 100644 index 00000000000..6502ddea2ad --- /dev/null +++ b/apps/vs-code-react/src/app/createWorkspace/steps/dotNetFrameworkStep.tsx @@ -0,0 +1,284 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { Text, Dropdown, Option, Field, Input, Label, useId } from '@fluentui/react-components'; +import type { InputOnChangeData, DropdownProps } from '@fluentui/react-components'; +import { useState } from 'react'; +import { useCreateWorkspaceStyles } from '../createWorkspaceStyles'; +import type { RootState } from '../../../state/store'; +import type { CreateWorkspaceState } from '../../../state/createWorkspaceSlice'; +import { setTargetFramework, setFunctionNamespace, setFunctionName, setFunctionFolderName } from '../../../state/createWorkspaceSlice'; +import { useIntl } from 'react-intl'; +import { useSelector, useDispatch } from 'react-redux'; + +// Function name validation regex (similar to logic app name) +export const functionNameValidation = /^[a-z][a-z0-9]*(?:[_-][a-z0-9]+)*$/i; +export const namespaceValidation = /^([A-Za-z_][A-Za-z0-9_]*)(\.[A-Za-z_][A-Za-z0-9_]*)*$/; + +export const DotNetFrameworkStep: React.FC = () => { + const dispatch = useDispatch(); + const intl = useIntl(); + const styles = useCreateWorkspaceStyles(); + const createWorkspaceState = useSelector((state: RootState) => state.createWorkspace) as CreateWorkspaceState; + const { targetFramework, functionNamespace, functionName, functionFolderName, logicAppType, logicAppName, workspaceFileJson } = + createWorkspaceState; + + const functionNamespaceId = useId(); + const functionNameId = useId(); + const functionFolderNameId = useId(); + + // Validation state + const [functionNamespaceError, setFunctionNamespaceError] = useState(undefined); + const [functionNameError, setFunctionNameError] = useState(undefined); + const [functionFolderNameError, setFunctionFolderNameError] = useState(undefined); + + const intlText = { + TITLE: intl.formatMessage({ + defaultMessage: 'Custom Code Configuration', + id: 'um0VMI', + description: 'Custom code configuration step title', + }), + RULES_ENGINE_TITLE: intl.formatMessage({ + defaultMessage: 'Rules Engine Configuration', + id: 'dTzRAM', + description: 'Rules engine configuration step title', + }), + DESCRIPTION: intl.formatMessage({ + defaultMessage: 'Configure the settings for your custom code logic app', + id: 'esTnYd', + description: 'Custom code configuration step description', + }), + NET_VERSION_LABEL: intl.formatMessage({ + defaultMessage: '.NET Version', + id: 'Sc6upt', + description: '.NET version dropdown label', + }), + NET_FRAMEWORK_LABEL: intl.formatMessage({ + defaultMessage: '.NET Framework', + id: 'xQHAPW', + description: '.NET Framework option', + }), + NET_FRAMEWORK_DESCRIPTION: intl.formatMessage({ + defaultMessage: 'Use the traditional .NET Framework for legacy compatibility', + id: 'VLHQ4L', + description: '.NET Framework description', + }), + NET_8_LABEL: intl.formatMessage({ + defaultMessage: '.NET 8', + id: 't2nswK', + description: '.NET 8 option', + }), + NET_8_DESCRIPTION: intl.formatMessage({ + defaultMessage: 'Use the latest .NET 8 for modern development and performance', + id: 'q1dxkD', + description: '.NET 8 description', + }), + FUNCTION_NAMESPACE_LABEL: intl.formatMessage({ + defaultMessage: 'Function Namespace', + id: '2XLzvL', + description: 'Function namespace input label', + }), + FUNCTION_NAME_LABEL: intl.formatMessage({ + defaultMessage: 'Function Name', + id: 'q8vsUq', + description: 'Function name input label', + }), + CUSTOM_CODE_FOLDER_NAME_LABEL: intl.formatMessage({ + defaultMessage: 'Custom Code Folder Name', + id: '6tG5dm', + description: 'Custom code folder name input label', + }), + RULES_ENGINE_FOLDER_NAME_LABEL: intl.formatMessage({ + defaultMessage: 'Rules Engine Folder Name', + id: 'jaa2Wl', + description: 'Rules engine folder name input label', + }), + }; + + const handleDotNetFrameworkChange: DropdownProps['onOptionSelect'] = (event, data) => { + if (data.optionValue) { + dispatch(setTargetFramework(data.optionValue)); + } + }; + + const validateFunctionNamespace = (namespace: string) => { + if (!namespace) { + return 'Function namespace cannot be empty.'; + } + if (!namespaceValidation.test(namespace)) { + return 'The namespace must start with a letter or underscore, contain only letters, digits, underscores, and periods, and must not end with a period.'; + } + return undefined; + }; + + const validateFunctionName = (name: string) => { + if (!name) { + return 'Function name cannot be empty.'; + } + if (!functionNameValidation.test(name)) { + return 'Function name must start with a letter and can only contain letters, digits, "_" and "-".'; + } + return undefined; + }; + + const validateFunctionFolderName = (name: string) => { + if (!name) { + return 'Function folder name cannot be empty.'; + } + if (!functionNameValidation.test(name)) { + return 'Function folder name must start with a letter and can only contain letters, digits, "_" and "-".'; + } + // Check if function name is the same as logic app name + if (logicAppName && name.trim().toLowerCase() === logicAppName.trim().toLowerCase()) { + return 'Function name cannot be the same as the logic app name.'; + } + // Check if the function name already exists in workspace folders + if (workspaceFileJson?.folders && workspaceFileJson.folders.some((folder: { name: string }) => folder.name === name)) { + return 'A project with this name already exists in the workspace.'; + } + return undefined; + }; + + const handleFunctionNamespaceChange = (event: React.FormEvent, data: InputOnChangeData) => { + dispatch(setFunctionNamespace(data.value)); + setFunctionNamespaceError(validateFunctionNamespace(data.value)); + }; + + const handleFunctionNameChange = (event: React.FormEvent, data: InputOnChangeData) => { + dispatch(setFunctionName(data.value)); + setFunctionNameError(validateFunctionName(data.value)); + }; + + const handleFunctionFolderNameChange = (event: React.FormEvent, data: InputOnChangeData) => { + dispatch(setFunctionFolderName(data.value)); + setFunctionFolderNameError(validateFunctionFolderName(data.value)); + }; + + if (logicAppType === 'customCode') { + return ( +
+ + {intlText.TITLE} + + +
+ + + + + + + {targetFramework && ( + + {targetFramework === 'net472' && intlText.NET_FRAMEWORK_DESCRIPTION} + {targetFramework === 'net8' && intlText.NET_8_DESCRIPTION} + + )} + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+
+ ); + } + if (logicAppType === 'rulesEngine') { + return ( +
+ + {intlText.RULES_ENGINE_TITLE} + + +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+
+ ); + } + return null; +}; diff --git a/apps/vs-code-react/src/app/createWorkspace/steps/index.ts b/apps/vs-code-react/src/app/createWorkspace/steps/index.ts new file mode 100644 index 00000000000..59fc17481eb --- /dev/null +++ b/apps/vs-code-react/src/app/createWorkspace/steps/index.ts @@ -0,0 +1,12 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +export { WorkspaceNameStep } from './workspaceNameStep'; +export { LogicAppTypeStep } from './logicAppTypeStep'; +export { DotNetFrameworkStep } from './dotNetFrameworkStep'; +export { WorkflowTypeStep } from './workflowTypeStep'; +export { ReviewCreateStep } from './reviewCreateStep'; +export { ProjectSetupStep } from './projectSetupStep'; +export { PackageNameStep } from './packageNameStep'; +export { PackageSetupStep } from './packageSetupStep'; diff --git a/apps/vs-code-react/src/app/createWorkspace/steps/logicAppTypeStep.tsx b/apps/vs-code-react/src/app/createWorkspace/steps/logicAppTypeStep.tsx new file mode 100644 index 00000000000..3e0ba6f1b9b --- /dev/null +++ b/apps/vs-code-react/src/app/createWorkspace/steps/logicAppTypeStep.tsx @@ -0,0 +1,227 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { Text, RadioGroup, Radio, Field, Input, Combobox, Option } from '@fluentui/react-components'; +import { useState, useCallback, useEffect } from 'react'; +import { useCreateWorkspaceStyles } from '../createWorkspaceStyles'; +import type { RootState } from '../../../state/store'; +import type { CreateWorkspaceState } from '../../../state/createWorkspaceSlice'; +import { setLogicAppType, setLogicAppName, setTargetFramework } from '../../../state/createWorkspaceSlice'; +import { useIntl } from 'react-intl'; +import { useSelector, useDispatch } from 'react-redux'; + +// Logic App name validation regex +export const logicAppNameValidation = /^[a-z][a-z0-9]*(?:[_-][a-z0-9]+)*$/i; + +export const LogicAppTypeStep: React.FC = () => { + const dispatch = useDispatch(); + const intl = useIntl(); + const styles = useCreateWorkspaceStyles(); + const createWorkspaceState = useSelector((state: RootState) => state.createWorkspace) as CreateWorkspaceState; + const { logicAppType, logicAppName, workspaceName, workspaceProjectPath, workspaceFileJson, logicAppsWithoutCustomCode, flowType } = + createWorkspaceState; + const separator = workspaceProjectPath.fsPath?.includes('/') ? '/' : '\\'; + + const shouldShowLogicAppSection = flowType === 'createWorkspace' || flowType === 'createLogicApp'; + + // Validation state + const [logicAppNameError, setLogicAppNameError] = useState(undefined); + + const intlText = { + TITLE: intl.formatMessage({ + defaultMessage: 'Logic App Details', + id: 'XJ1S7E', + description: 'Logic app details step title', + }), + DESCRIPTION: intl.formatMessage({ + defaultMessage: 'Enter the logic app name and select the type of logic app to create', + id: 'VPcN7p', + description: 'Logic app details step description', + }), + LOGIC_APP_NAME_LABEL: intl.formatMessage({ + defaultMessage: 'Logic App Name', + id: 'JS7xBY', + description: 'Logic app name field label', + }), + LOGIC_APP_NAME_PLACEHOLDER: intl.formatMessage({ + defaultMessage: 'Enter logic app name', + id: 'ceM0tn', + description: 'Logic app name field placeholder', + }), + STANDARD_LABEL: intl.formatMessage({ + defaultMessage: 'Logic App (Standard)', + id: 'xnJNZH', + description: 'Standard logic app option', + }), + STANDARD_DESCRIPTION: intl.formatMessage({ + defaultMessage: 'Standard logic app with built-in connectors and triggers', + id: 'CfXSvL', + description: 'Standard logic app description', + }), + CUSTOM_CODE_LABEL: intl.formatMessage({ + defaultMessage: 'Logic App with Custom Code', + id: '2ivADw', + description: 'Logic app with custom code option', + }), + CUSTOM_CODE_DESCRIPTION: intl.formatMessage({ + defaultMessage: 'Logic app that allows custom code integration and advanced scenarios', + id: 'kkKTEH', + description: 'Logic app with custom code description', + }), + RULES_ENGINE_LABEL: intl.formatMessage({ + defaultMessage: 'Logic App with Rules Engine', + id: 'yoH8Yw', + description: 'Logic app with rules engine option', + }), + RULES_ENGINE_DESCRIPTION: intl.formatMessage({ + defaultMessage: 'Logic app with built-in business rules engine for complex decision logic', + id: 'Fsc9ZE', + description: 'Logic app with rules engine description', + }), + }; + + const handleLogicAppTypeChange = (event: React.FormEvent, data: { value: string }) => { + dispatch(setLogicAppType(data.value)); + if (data.value === 'rulesEngine') { + dispatch(setTargetFramework('net472')); + } + }; + + const validateLogicAppName = useCallback( + (name: string) => { + if (!name) { + return 'Logic app name cannot be empty.'; + } + if (!logicAppNameValidation.test(name)) { + return 'Logic app name must start with a letter and can only contain letters, digits, "_" and "-".'; + } + + // If custom code or rules engine is selected and the name is from the existing logic apps list, allow it + const isCustomCodeOrRulesEngine = logicAppType === 'customCode' || logicAppType === 'rulesEngine'; + const isExistingLogicApp = logicAppsWithoutCustomCode?.some((app: { label: string }) => app.label === name); + + if (isCustomCodeOrRulesEngine && isExistingLogicApp) { + return undefined; // Valid - existing logic app for custom code/rules engine + } + + // Check if the logic app name already exists in workspace folders (for new names) + if (workspaceFileJson?.folders && workspaceFileJson.folders.some((folder: { name: string }) => folder.name === name)) { + return 'A project with this name already exists in the workspace.'; + } + return undefined; + }, + [logicAppType, logicAppsWithoutCustomCode, workspaceFileJson] + ); + + // Re-validate logic app name when dependencies change + useEffect(() => { + if (logicAppName) { + setLogicAppNameError(validateLogicAppName(logicAppName)); + } + }, [logicAppType, logicAppsWithoutCustomCode, workspaceFileJson, logicAppName, validateLogicAppName]); + + const handleLogicAppNameChange = (event: React.ChangeEvent) => { + dispatch(setLogicAppName(event.target.value)); + setLogicAppNameError(validateLogicAppName(event.target.value)); + }; + + const handleComboboxOptionSelect = (_event: any, data: any) => { + const value = data.optionValue || ''; + dispatch(setLogicAppName(value)); + setLogicAppNameError(validateLogicAppName(value)); + }; + + const handleComboboxChange = (event: any) => { + const value = event.target.value || ''; + dispatch(setLogicAppName(value)); + setLogicAppNameError(validateLogicAppName(value)); + }; + + // Determine if we should show combobox (when custom code or rules engine is selected and logicAppsWithoutCustomCode is available) + const shouldShowCombobox = + (logicAppType === 'customCode' || logicAppType === 'rulesEngine') && + logicAppsWithoutCustomCode && + logicAppsWithoutCustomCode.length > 0; + + return ( +
+ + {intlText.TITLE} + + {intlText.DESCRIPTION} + +
+ + {shouldShowCombobox ? ( + + {logicAppsWithoutCustomCode.map((app: { label: string }) => ( + + ))} + + ) : ( + + )} + {logicAppName && workspaceName && workspaceProjectPath.fsPath && ( + + {`${workspaceProjectPath.fsPath}${separator}${workspaceName}${separator}${logicAppName}`} + + )} + +
+ + {shouldShowLogicAppSection && ( +
+ +
+ + + {intlText.STANDARD_DESCRIPTION} + +
+
+ + + {intlText.CUSTOM_CODE_DESCRIPTION} + +
+
+ + + {intlText.RULES_ENGINE_DESCRIPTION} + +
+
+
+ )} +
+ ); +}; diff --git a/apps/vs-code-react/src/app/createWorkspace/steps/packageNameStep.tsx b/apps/vs-code-react/src/app/createWorkspace/steps/packageNameStep.tsx new file mode 100644 index 00000000000..b8f4a7d1315 --- /dev/null +++ b/apps/vs-code-react/src/app/createWorkspace/steps/packageNameStep.tsx @@ -0,0 +1,168 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { Button, Text, Field, Input, Label, useId } from '@fluentui/react-components'; +import type { InputOnChangeData } from '@fluentui/react-components'; +import { useCreateWorkspaceStyles } from '../createWorkspaceStyles'; +import type { RootState } from '../../../state/store'; +import type { CreateWorkspaceState } from '../../../state/createWorkspaceSlice'; +import { setPackagePath } from '../../../state/createWorkspaceSlice'; +import { useIntl } from 'react-intl'; +import { useSelector, useDispatch } from 'react-redux'; +import { VSCodeContext } from '../../../webviewCommunication'; +import { useContext, useState, useCallback, useEffect } from 'react'; +import { ExtensionCommand } from '@microsoft/vscode-extension-logic-apps'; + +export const PackageNameStep: React.FC = () => { + const dispatch = useDispatch(); + const intl = useIntl(); + const vscode = useContext(VSCodeContext); + const styles = useCreateWorkspaceStyles(); + const createWorkspaceState = useSelector((state: RootState) => state.createWorkspace) as CreateWorkspaceState; + const { packagePath, packageValidationResults } = createWorkspaceState; + const packagePathInputId = useId(); + + // Validation state + const [packagePathError, setPackagePathError] = useState(undefined); + const [isValidatingPath, setIsValidatingPath] = useState(false); + + const intlText = { + TITLE: intl.formatMessage({ + defaultMessage: 'Package Setup', + id: 'Wxhsgj', + description: 'Package setup step title', + }), + DESCRIPTION: intl.formatMessage({ + defaultMessage: 'Package', + id: 'aExfWG', + description: 'Package setup step description', + }), + PACKAGE_PATH_LABEL: intl.formatMessage({ + defaultMessage: 'Package Path', + id: 'pyYxP0', + description: 'Package path input label', + }), + BROWSE_BUTTON: intl.formatMessage({ + defaultMessage: 'Browse...', + id: 'cR0MlP', + description: 'Browse folder button', + }), + }; + + const validatePackagePath = useCallback( + (path: string) => { + if (!path) { + return 'Package path cannot be empty.'; + } + + // Check if we have a validation result for this path + const isPathValid = packageValidationResults[path]; + if (isPathValid === false) { + return 'The specified path does not exist or is not accessible.'; + } + + return undefined; + }, + [packageValidationResults] + ); + + // Debounced path validation function + const validatePathWithExtension = useCallback( + (path: string, validationResults: Record) => { + if (path && path.trim() !== '') { + if (path in validationResults) { + setIsValidatingPath(false); + const validationError = validatePackagePath(path); + setPackagePathError(validationError); + return; + } + setIsValidatingPath(true); + vscode.postMessage({ + command: ExtensionCommand.validatePath, + data: { + path: path.trim(), + type: ExtensionCommand.package_file, + }, + }); + } + }, + [vscode, validatePackagePath] + ); + + // Effect to trigger path validation when path changes + useEffect(() => { + if (packagePath.fsPath && packagePath.fsPath.trim() !== '') { + const timeoutId = setTimeout(() => { + validatePathWithExtension(packagePath.fsPath, packageValidationResults); + }, 500); + + return () => clearTimeout(timeoutId); + } + return undefined; + }, [packagePath.fsPath, packageValidationResults, validatePathWithExtension]); + + const handlePackagePathChange = (event: React.FormEvent, data: InputOnChangeData) => { + dispatch(setPackagePath(data.value)); + setPackagePathError(validatePackagePath(data.value)); + }; + + const onOpenExplorer = () => { + vscode.postMessage({ + command: ExtensionCommand.update_package_path, + }); + }; + + return ( +
+ + {intlText.TITLE} + + +
+ + +
+ + +
+ {packagePath.fsPath && ( + + {packagePath.fsPath} + {packageValidationResults[packagePath.fsPath] === true && ' ✓ Valid path'} + {packageValidationResults[packagePath.fsPath] === false && ' ✗ Invalid path'} + + )} +
+
+
+ ); +}; diff --git a/apps/vs-code-react/src/app/createWorkspace/steps/packageSetupStep.tsx b/apps/vs-code-react/src/app/createWorkspace/steps/packageSetupStep.tsx new file mode 100644 index 00000000000..33dccc9b0e7 --- /dev/null +++ b/apps/vs-code-react/src/app/createWorkspace/steps/packageSetupStep.tsx @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import type React from 'react'; +import { useCreateWorkspaceStyles } from '../createWorkspaceStyles'; +import { LogicAppTypeStep } from './logicAppTypeStep'; +import { WorkspaceNameStep } from './workspaceNameStep'; +import { PackageNameStep } from './packageNameStep'; + +export const PackageSetupStep: React.FC = () => { + const styles = useCreateWorkspaceStyles(); + + return ( +
+ + + +
+ ); +}; diff --git a/apps/vs-code-react/src/app/createWorkspace/steps/projectSetupStep.tsx b/apps/vs-code-react/src/app/createWorkspace/steps/projectSetupStep.tsx new file mode 100644 index 00000000000..5b4795cf715 --- /dev/null +++ b/apps/vs-code-react/src/app/createWorkspace/steps/projectSetupStep.tsx @@ -0,0 +1,23 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import type React from 'react'; +import { useCreateWorkspaceStyles } from '../createWorkspaceStyles'; +import { LogicAppTypeStep } from './logicAppTypeStep'; +import { WorkflowTypeStep } from './workflowTypeStep'; +import { DotNetFrameworkStep } from './dotNetFrameworkStep'; +import { WorkspaceNameStep } from './workspaceNameStep'; + +export const ProjectSetupStep: React.FC = () => { + const styles = useCreateWorkspaceStyles(); + + return ( +
+ + + + +
+ ); +}; diff --git a/apps/vs-code-react/src/app/createWorkspace/steps/reviewCreateStep.tsx b/apps/vs-code-react/src/app/createWorkspace/steps/reviewCreateStep.tsx new file mode 100644 index 00000000000..0bf6dd0af01 --- /dev/null +++ b/apps/vs-code-react/src/app/createWorkspace/steps/reviewCreateStep.tsx @@ -0,0 +1,308 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { useCreateWorkspaceStyles } from '../createWorkspaceStyles'; +import type { RootState } from '../../../state/store'; +import type { CreateWorkspaceState } from '../../../state/createWorkspaceSlice'; +import { useIntl } from 'react-intl'; +import { useSelector } from 'react-redux'; +import { Text } from '@fluentui/react-components'; + +export const ReviewCreateStep: React.FC = () => { + const intl = useIntl(); + const styles = useCreateWorkspaceStyles(); + const createWorkspaceState = useSelector((state: RootState) => state.createWorkspace) as CreateWorkspaceState; + + const { + packagePath, + workspaceProjectPath, + workspaceName, + logicAppType, + targetFramework, + functionFolderName, + functionNamespace, + functionName, + workflowType, + workflowName, + logicAppName, + flowType, + logicAppsWithoutCustomCode, + } = createWorkspaceState; + + const needsDotNetFrameworkStep = logicAppType === 'customCode'; + const needsFunctionConfiguration = logicAppType === 'rulesEngine'; + const separator = workspaceProjectPath.fsPath?.includes('/') ? '/' : '\\'; + + // Determine if we're using an existing logic app + const isUsingExistingLogicApp = + (logicAppType === 'customCode' || logicAppType === 'rulesEngine') && + logicAppsWithoutCustomCode?.some((app: { label: string }) => app.label === logicAppName); + + // Determine what sections to show based on flow type + const shouldShowPackageSection = flowType === 'createWorkspaceFromPackage'; + const shouldShowWorkspaceSection = + flowType === 'createWorkspace' || flowType === 'convertToWorkspace' || flowType === 'createWorkspaceFromPackage'; + const shouldShowLogicAppSection = + flowType === 'createWorkspace' || flowType === 'createLogicApp' || flowType === 'createWorkspaceFromPackage'; + const shouldShowWorkflowSection = (flowType === 'createWorkspace' || flowType === 'createLogicApp') && !isUsingExistingLogicApp; + + const intlText = { + TITLE: intl.formatMessage({ + defaultMessage: 'Review + Create', + id: 'GH0CLv', + description: 'Review and create step title', + }), + DESCRIPTION: intl.formatMessage({ + defaultMessage: 'Review your configuration and create your Logic App workspace.', + id: 'XepQZn', + description: 'Review step description', + }), + PROJECT_SETUP: intl.formatMessage({ + defaultMessage: 'Project Setup', + id: 'mAeD3g', + description: 'Project setup section title', + }), + PROJECT_PATH_LABEL: intl.formatMessage({ + defaultMessage: 'Project Path', + id: 'ff1WLC', + description: 'Project path label', + }), + PACKAGE_SETUP: intl.formatMessage({ + defaultMessage: 'Package Setup', + id: '9VC1hu', + description: 'Package setup section title', + }), + PACKAGE_PATH_LABEL: intl.formatMessage({ + defaultMessage: 'Package Path', + id: '5H8ULg', + description: 'Package path label', + }), + WORKSPACE_NAME_LABEL: intl.formatMessage({ + defaultMessage: 'Workspace Name', + id: 'Jbo5DB', + description: 'Workspace name label', + }), + WORKSPACE_FOLDER_LABEL: intl.formatMessage({ + defaultMessage: 'Workspace Folder', + id: 'Eu6UGm', + description: 'Workspace folder path label', + }), + WORKSPACE_FILE_LABEL: intl.formatMessage({ + defaultMessage: 'Workspace File', + id: '+fM/eg', + description: 'Workspace file path label', + }), + LOGIC_APP_TYPE_LABEL: intl.formatMessage({ + defaultMessage: 'Logic App Type', + id: 'n/eWQU', + description: 'Logic app type label', + }), + LOGIC_APP_NAME_LABEL: intl.formatMessage({ + defaultMessage: 'Logic App Name', + id: 'i9+YCM', + description: 'Logic app name label', + }), + LOGIC_APP_LOCATION_LABEL: intl.formatMessage({ + defaultMessage: 'Logic App Location', + id: 'zMexS3', + description: 'Logic app location path label', + }), + DOTNET_FRAMEWORK_LABEL: intl.formatMessage({ + defaultMessage: '.NET Framework', + id: 'kv8ROl', + description: 'Dot net framework label', + }), + CUSTOM_CODE_FOLDER_LABEL: intl.formatMessage({ + defaultMessage: 'Custom Code Folder', + id: 'LltDjL', + description: 'Custom code folder label', + }), + RULES_ENGINE_FOLDER_LABEL: intl.formatMessage({ + defaultMessage: 'Rules Engine Folder', + id: 'VE1WHE', + description: 'Rules engine folder label', + }), + CUSTOM_CODE_LOCATION_LABEL: intl.formatMessage({ + defaultMessage: 'Custom Code Location', + id: 'oOFc/f', + description: 'Custom code location path label', + }), + RULES_ENGINE_LOCATION_LABEL: intl.formatMessage({ + defaultMessage: 'Rules Engine Location', + id: 'YhPs4e', + description: 'Rules Engine location path label', + }), + FUNCTION_WORKSPACE_LABEL: intl.formatMessage({ + defaultMessage: 'Function Workspace', + id: 'aXShs8', + description: 'Function workspace label', + }), + FUNCTION_NAME_LABEL: intl.formatMessage({ + defaultMessage: 'Function Name', + id: '6I6s5I', + description: 'Function name label', + }), + WORKFLOW_TYPE_LABEL: intl.formatMessage({ + defaultMessage: 'Workflow Type', + id: 'JdYNQ+', + description: 'Workflow type label', + }), + WORKFLOW_NAME_LABEL: intl.formatMessage({ + defaultMessage: 'Workflow Name', + id: 'HC2d/m', + description: 'Workflow name label', + }), + MISSING_VALUE: intl.formatMessage({ + defaultMessage: 'Not specified', + id: 'KJLHaU', + description: 'Missing value indicator', + }), + }; + + const getWorkspaceFilePath = () => { + if (!workspaceProjectPath.fsPath || !workspaceName) { + return ''; + } + return `${workspaceProjectPath.fsPath}${separator}${workspaceName}${separator}${workspaceName}.code-workspace`; + }; + + const getWorkspaceFolderPath = () => { + if (!workspaceProjectPath.fsPath || !workspaceName) { + return ''; + } + return `${workspaceProjectPath.fsPath}${separator}${workspaceName}`; + }; + + const getLogicAppLocationPath = () => { + if (!workspaceProjectPath.fsPath || !workspaceName || !logicAppName) { + return ''; + } + return `${workspaceProjectPath.fsPath}${separator}${workspaceName}${separator}${logicAppName}`; + }; + + const getFunctionLocationPath = () => { + if (!workspaceProjectPath.fsPath || !workspaceName || !functionFolderName) { + return ''; + } + return `${workspaceProjectPath.fsPath}${separator}${workspaceName}${separator}${functionFolderName}`; + }; + + const getDotNetFrameworkDisplay = (framework: string) => { + switch (framework) { + case 'net472': + return '.NET Framework'; + case 'net8': + return '.NET 8'; + default: + return framework; + } + }; + + const getLogicAppTypeDisplay = (type: string) => { + switch (type) { + case 'standard': + return 'Standard Logic App'; + case 'customCode': + return 'Logic App with Custom Code'; + case 'rulesEngine': + return 'Logic App with Rules Engine'; + default: + return type || intlText.MISSING_VALUE; + } + }; + + const getWorkflowTypeDisplay = (type: string) => { + switch (type) { + case 'Stateful-Codeless': + return 'Stateful'; + case 'Stateless-Codeless': + return 'Stateless'; + case 'Agentic-Codeless': + return 'Autonomous Agents (Preview)'; + case 'Agent-Codeless': + return 'Conversational Agents (Preview)'; + default: + return type || intlText.MISSING_VALUE; + } + }; + + const renderSettingRow = (label: string, value: string, isRequired = true) => { + const displayValue = value?.trim() || intlText.MISSING_VALUE; + const isMissing = !value?.trim(); + + return ( +
+
{label}:
+
{displayValue}
+
+ ); + }; + + return ( +
+ + {intlText.TITLE} + + + {intlText.DESCRIPTION} + + +
+ {shouldShowPackageSection && ( +
+
{intlText.PACKAGE_SETUP}
+ {renderSettingRow(intlText.PACKAGE_PATH_LABEL, packagePath.fsPath)} +
+ )} + + {shouldShowWorkspaceSection && ( +
+
{intlText.PROJECT_SETUP}
+ {renderSettingRow(intlText.WORKSPACE_NAME_LABEL, workspaceName)} + {renderSettingRow(intlText.WORKSPACE_FOLDER_LABEL, getWorkspaceFolderPath())} + {renderSettingRow(intlText.WORKSPACE_FILE_LABEL, getWorkspaceFilePath())} +
+ )} + + {shouldShowLogicAppSection && ( +
+
Logic App Details
+ {renderSettingRow(intlText.LOGIC_APP_NAME_LABEL, logicAppName)} + {flowType !== 'createLogicApp' && renderSettingRow(intlText.LOGIC_APP_LOCATION_LABEL, getLogicAppLocationPath())} + {renderSettingRow(intlText.LOGIC_APP_TYPE_LABEL, getLogicAppTypeDisplay(logicAppType))} +
+ )} + + {needsDotNetFrameworkStep && ( +
+
Custom Code Configuration
+ {renderSettingRow(intlText.DOTNET_FRAMEWORK_LABEL, getDotNetFrameworkDisplay(targetFramework))} + {renderSettingRow(intlText.CUSTOM_CODE_FOLDER_LABEL, functionFolderName)} + {renderSettingRow(intlText.CUSTOM_CODE_LOCATION_LABEL, getFunctionLocationPath())} + {renderSettingRow(intlText.FUNCTION_WORKSPACE_LABEL, functionNamespace)} + {renderSettingRow(intlText.FUNCTION_NAME_LABEL, functionName)} +
+ )} + + {needsFunctionConfiguration && ( +
+
Function Configuration
+ {renderSettingRow(intlText.RULES_ENGINE_FOLDER_LABEL, functionFolderName)} + {renderSettingRow(intlText.RULES_ENGINE_LOCATION_LABEL, getFunctionLocationPath())} + {renderSettingRow(intlText.FUNCTION_WORKSPACE_LABEL, functionNamespace)} + {renderSettingRow(intlText.FUNCTION_NAME_LABEL, functionName)} +
+ )} + + {shouldShowWorkflowSection && ( +
+
Workflow Configuration
+ {renderSettingRow(intlText.WORKFLOW_NAME_LABEL, workflowName)} + {renderSettingRow(intlText.WORKFLOW_TYPE_LABEL, getWorkflowTypeDisplay(workflowType))} +
+ )} +
+
+ ); +}; diff --git a/apps/vs-code-react/src/app/createWorkspace/steps/workflowTypeStep.tsx b/apps/vs-code-react/src/app/createWorkspace/steps/workflowTypeStep.tsx new file mode 100644 index 00000000000..7598086d529 --- /dev/null +++ b/apps/vs-code-react/src/app/createWorkspace/steps/workflowTypeStep.tsx @@ -0,0 +1,183 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { Option, Text, Field, Input, Label, Dropdown } from '@fluentui/react-components'; +import type { DropdownProps } from '@fluentui/react-components'; +import { useState } from 'react'; +import { useCreateWorkspaceStyles } from '../createWorkspaceStyles'; +import type { RootState } from '../../../state/store'; +import type { CreateWorkspaceState } from '../../../state/createWorkspaceSlice'; +import { setWorkflowType, setWorkflowName } from '../../../state/createWorkspaceSlice'; +import { useIntl } from 'react-intl'; +import { useSelector, useDispatch } from 'react-redux'; + +// Workflow name validation regex +export const workflowNameValidation = /^[a-z][a-z0-9]*(?:[_-][a-z0-9]+)*$/i; + +export const WorkflowTypeStep: React.FC = () => { + const dispatch = useDispatch(); + const intl = useIntl(); + const styles = useCreateWorkspaceStyles(); + const createWorkspaceState = useSelector((state: RootState) => state.createWorkspace) as CreateWorkspaceState; + const { workflowType, workflowName } = createWorkspaceState; + + // Validation state + const [workflowNameError, setWorkflowNameError] = useState(undefined); + + const intlText = { + TITLE: intl.formatMessage({ + defaultMessage: 'Workflow Configuration', + id: '81liT7', + description: 'Workflow configuration step title', + }), + WORKFLOW_NAME_LABEL: intl.formatMessage({ + defaultMessage: 'Workflow Name', + id: 'OwjR0o', + description: 'Workflow name field label', + }), + WORKFLOW_TYPE_LABEL: intl.formatMessage({ + defaultMessage: 'Workflow Type', + id: 'JdYNQ+', + description: 'Workflow type label', + }), + WORKFLOW_NAME_PLACEHOLDER: intl.formatMessage({ + defaultMessage: 'Enter workflow name', + id: 'nVhDGu', + description: 'Workflow name field placeholder', + }), + STATEFUL_TITLE: intl.formatMessage({ + defaultMessage: 'Stateful', + id: 'p4Mgce', + description: 'Stateful workflow option', + }), + STATEFUL_DESCRIPTION: intl.formatMessage({ + defaultMessage: 'Optimized for high reliability, ideal for process business transitional data.', + id: 'otRX33', + description: 'Stateful workflow description', + }), + STATELESS_TITLE: intl.formatMessage({ + defaultMessage: 'Stateless', + id: 'R7gB/3', + description: 'Stateless workflow option', + }), + STATELESS_DESCRIPTION: intl.formatMessage({ + defaultMessage: 'Optimized for low latency, ideal for request-response and processing IoT events.', + id: 'b0wO2+', + description: 'Stateless workflow description', + }), + AUTONOMOUS_TITLE: intl.formatMessage({ + defaultMessage: 'Autonomous Agents (Preview)', + id: 'qs798U', + description: 'Autonomous agents workflow option', + }), + AUTONOMOUS_DESCRIPTION: intl.formatMessage({ + defaultMessage: 'All the benefits of Stateful, plus the option to build AI agents in your workflow to automate complex tasks.', + id: 'Bft/H3', + description: 'Autonomous agents workflow description', + }), + AGENT_TITLE: intl.formatMessage({ + defaultMessage: 'Conversational Agents', + id: 'fg89hL', + description: 'Conversational agent workflow option', + }), + AGENT_DESCRIPTION: intl.formatMessage({ + defaultMessage: 'Workflow that supports natural language, human interaction, and agents connected to LLMs', + id: '+P+nuy', + description: 'Conversational agents workflow description', + }), + }; + + const handleWorkflowTypeChange: DropdownProps['onOptionSelect'] = (event, data) => { + if (data.optionValue) { + dispatch(setWorkflowType(data.optionValue)); + } + }; + + const validateWorkflowName = (name: string) => { + if (!name) { + return 'The workflow name cannot be empty.'; + } + if (!workflowNameValidation.test(name)) { + return 'Workflow name must start with a letter and can only contain letters, digits, "_" and "-".'; + } + return undefined; + }; + + const handleWorkflowNameChange = (event: React.ChangeEvent) => { + dispatch(setWorkflowName(event.target.value)); + setWorkflowNameError(validateWorkflowName(event.target.value)); + }; + + return ( +
+ + {intlText.TITLE} + + + + + + + + + + + + + + + {workflowType && ( + + {workflowType === 'Stateful-Codeless' && intlText.STATEFUL_DESCRIPTION} + {workflowType === 'Stateless-Codeless' && intlText.STATELESS_DESCRIPTION} + {workflowType === 'Agentic-Codeless' && intlText.AUTONOMOUS_DESCRIPTION} + {workflowType === 'Agent-Codeless' && intlText.AGENT_DESCRIPTION} + + )} + +
+ ); +}; diff --git a/apps/vs-code-react/src/app/createWorkspace/steps/workspaceNameStep.tsx b/apps/vs-code-react/src/app/createWorkspace/steps/workspaceNameStep.tsx new file mode 100644 index 00000000000..172ce07cae8 --- /dev/null +++ b/apps/vs-code-react/src/app/createWorkspace/steps/workspaceNameStep.tsx @@ -0,0 +1,297 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { Button, Text, Field, Input, Label, useId } from '@fluentui/react-components'; +import type { InputOnChangeData } from '@fluentui/react-components'; +import { useCreateWorkspaceStyles } from '../createWorkspaceStyles'; +import type { RootState } from '../../../state/store'; +import type { CreateWorkspaceState } from '../../../state/createWorkspaceSlice'; +import { setProjectPath, setWorkspaceName } from '../../../state/createWorkspaceSlice'; +import { useIntl } from 'react-intl'; +import { useSelector, useDispatch } from 'react-redux'; +import { VSCodeContext } from '../../../webviewCommunication'; +import { useContext, useState, useCallback, useEffect } from 'react'; +import { ExtensionCommand } from '@microsoft/vscode-extension-logic-apps'; + +// Regex validation constants +export const workspaceNameValidation = /^[a-z][a-z0-9]*(?:[_-][a-z0-9]+)*$/i; + +export const WorkspaceNameStep: React.FC = () => { + const dispatch = useDispatch(); + const intl = useIntl(); + const vscode = useContext(VSCodeContext); + const styles = useCreateWorkspaceStyles(); + const createWorkspaceState = useSelector((state: RootState) => state.createWorkspace) as CreateWorkspaceState; + const { workspaceName, workspaceProjectPath, pathValidationResults, workspaceExistenceResults, isValidatingWorkspace } = + createWorkspaceState; + const projectPathInputId = useId(); + const workspaceNameId = useId(); + + // Validation state + const [workspaceNameError, setWorkspaceNameError] = useState(undefined); + const [projectPathError, setProjectPathError] = useState(undefined); + const [isValidatingPath, setIsValidatingPath] = useState(false); + const [isValidatingWorkspaceName, setIsValidatingWorkspaceName] = useState(false); + + const separator = workspaceProjectPath.fsPath?.includes('/') ? '/' : '\\'; + + const intlText = { + TITLE: intl.formatMessage({ + defaultMessage: 'Project Setup', + id: 'blShaR', + description: 'Project setup step title', + }), + DESCRIPTION: intl.formatMessage({ + defaultMessage: 'Configure your logic app workspace settings', + id: 'JS4ajl', + description: 'Project setup step description', + }), + PROJECT_PATH_LABEL: intl.formatMessage({ + defaultMessage: 'Workspace Parent Folder Path', + id: '3KYXwl', + description: 'Workspace Parent Folder path input label', + }), + BROWSE_BUTTON: intl.formatMessage({ + defaultMessage: 'Browse...', + id: 'cR0MlP', + description: 'Browse folder button', + }), + WORKSPACE_NAME_LABEL: intl.formatMessage({ + defaultMessage: 'Workspace Name', + id: 'uNvoPg', + description: 'Workspace name input label', + }), + }; + + const validateProjectPath = useCallback( + (path: string) => { + if (!path) { + return 'Workspace parent folder path cannot be empty.'; + } + + // Check if we have a validation result for this path + const isPathValid = pathValidationResults[path]; + if (isPathValid === false) { + return 'The specified path does not exist or is not accessible.'; + } + + return undefined; + }, + [pathValidationResults] + ); + + const validateWorkspaceName = useCallback( + (name: string) => { + if (!name) { + return 'The workspace name cannot be empty.'; + } + if (!workspaceNameValidation.test(name)) { + return 'Workspace name must start with a letter and can only contain letters, digits, "_" and "-".'; + } + + // Check if workspace folder or file already exists + if (workspaceProjectPath.fsPath && name) { + const workspaceFolder = `${workspaceProjectPath.fsPath}${separator}${name}`; + const workspaceFile = `${workspaceFolder}${separator}${name}.code-workspace`; + + if (workspaceExistenceResults[workspaceFolder] === true) { + return `A folder named "${name}" already exists in the selected location.`; + } + if (workspaceExistenceResults[workspaceFile] === true) { + return `A workspace file "${name}.code-workspace" already exists.`; + } + } + + return undefined; + }, + [workspaceProjectPath.fsPath, separator, workspaceExistenceResults] + ); + + // Debounced path validation function + const validatePathWithExtension = useCallback( + (path: string, validationResults: Record) => { + if (path && path.trim() !== '') { + if (path in validationResults) { + setIsValidatingPath(false); + const validationError = validateProjectPath(path); + setProjectPathError(validationError); + return; + } + setIsValidatingPath(true); + vscode.postMessage({ + command: ExtensionCommand.validatePath, + data: { path: path.trim() }, + }); + } + }, + [vscode, validateProjectPath] + ); + + // Function to validate workspace existence + const validateWorkspaceExistence = useCallback( + (parentPath: string, name: string, workspaceExistenceResults: Record) => { + if (parentPath && name) { + const workspaceFolder = `${parentPath}${separator}${name}`; + const workspaceFile = `${workspaceFolder}${separator}${name}.code-workspace`; + + // Check if we already have results for these paths + if (workspaceFolder in workspaceExistenceResults || workspaceFile in workspaceExistenceResults) { + setIsValidatingWorkspaceName(false); + const validationError = validateWorkspaceName(name); + setWorkspaceNameError(validationError); + return; + } + + setIsValidatingWorkspaceName(true); + // Validate both the workspace folder and file + vscode.postMessage({ + command: ExtensionCommand.validatePath, + data: { + path: workspaceFolder, + type: ExtensionCommand.workspace_folder, + }, + }); + vscode.postMessage({ + command: ExtensionCommand.validatePath, + data: { + path: workspaceFile, + type: ExtensionCommand.workspace_file, + }, + }); + } + }, + [vscode, separator, validateWorkspaceName] + ); + + // Effect to trigger path validation when path changes + useEffect(() => { + if (workspaceProjectPath.fsPath && workspaceProjectPath.fsPath.trim() !== '') { + const timeoutId = setTimeout(() => { + validatePathWithExtension(workspaceProjectPath.fsPath, pathValidationResults); + }, 500); + + return () => clearTimeout(timeoutId); + } + return undefined; + }, [workspaceProjectPath.fsPath, pathValidationResults, validatePathWithExtension]); + + // Effect to validate workspace existence when both path and name are available + useEffect(() => { + if (workspaceProjectPath.fsPath && workspaceName) { + const timeoutId = setTimeout(() => { + validateWorkspaceExistence(workspaceProjectPath.fsPath, workspaceName, workspaceExistenceResults); + }, 300); + + return () => clearTimeout(timeoutId); + } + return undefined; + }, [workspaceProjectPath.fsPath, workspaceName, validateWorkspaceExistence, workspaceExistenceResults]); + + const handleProjectPathChange = (event: React.FormEvent, data: InputOnChangeData) => { + dispatch(setProjectPath(data.value)); + setProjectPathError(validateProjectPath(data.value)); + }; + + const handleWorkspaceNameChange = (event: React.FormEvent, data: InputOnChangeData) => { + dispatch(setWorkspaceName(data.value)); + setWorkspaceNameError(validateWorkspaceName(data.value)); + }; + + const onOpenExplorer = () => { + vscode.postMessage({ + command: ExtensionCommand.select_folder, + }); + }; + + return ( +
+ + {intlText.TITLE} + + + {intlText.DESCRIPTION} + + +
+ + +
+ + +
+ {workspaceProjectPath.fsPath && ( + + {workspaceProjectPath.fsPath} + {pathValidationResults[workspaceProjectPath.fsPath] === true && ' ✓ Valid path'} + {pathValidationResults[workspaceProjectPath.fsPath] === false && ' ✗ Invalid path'} + + )} +
+
+
+ + + + {workspaceName && workspaceProjectPath.fsPath && ( + + {`${workspaceProjectPath.fsPath}${separator}${workspaceName}${separator}${workspaceName}.code-workspace`} + {isValidatingWorkspace && ' (Checking availability...)'} + {!workspaceNameError && + !isValidatingWorkspace && + pathValidationResults[workspaceProjectPath.fsPath] === true && + ' ✓ Available'} + + )} + +
+
+ ); +}; diff --git a/apps/vs-code-react/src/router/index.tsx b/apps/vs-code-react/src/router/index.tsx index af0473e27fe..9d47966c68f 100644 --- a/apps/vs-code-react/src/router/index.tsx +++ b/apps/vs-code-react/src/router/index.tsx @@ -10,6 +10,12 @@ import { WorkflowsSelection } from '../app/export/workflowsSelection/workflowsSe import { OverviewApp } from '../app/overview/app'; import { ReviewApp } from '../app/review'; import { UnitTestResults } from '../app/unitTest'; +import { + CreateWorkspace, + CreateWorkspaceFromPackage, + CreateLogicApp, + CreateWorkspaceStructure, +} from '../app/createWorkspace/createWorkspace'; import { RouteName } from '../run-service'; import { StateWrapper } from '../stateWrapper'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; @@ -31,6 +37,10 @@ export const Router: React.FC = () => { } /> } /> } /> + } /> + } /> + } /> + } /> ); diff --git a/apps/vs-code-react/src/run-service/types.ts b/apps/vs-code-react/src/run-service/types.ts index f7128fe41d7..9f86c973c6e 100644 --- a/apps/vs-code-react/src/run-service/types.ts +++ b/apps/vs-code-react/src/run-service/types.ts @@ -152,6 +152,10 @@ export const RouteName = { designer: 'designer', dataMapper: 'dataMapper', unitTest: 'unitTest', + createWorkspace: 'createWorkspace', + createWorkspaceFromPackage: 'createWorkspaceFromPackage', + createLogicApp: 'createLogicApp', + createWorkspaceStructure: 'createWorkspaceStructure', }; export type RouteNameType = (typeof RouteName)[keyof typeof RouteName]; @@ -307,6 +311,28 @@ export interface UpdateExportPathMessage { }; } +export interface UpdateWorkspacePathMessage { + command: typeof ExtensionCommand.update_workspace_path; + data: { + targetDirectory: ITargetDirectory; + }; +} + +export interface UpdateWorkspacePackageMessage { + command: typeof ExtensionCommand.update_package_path; + data: { + targetDirectory: ITargetDirectory; + }; +} + +export interface ValidateWorkspacePathMessage { + command: typeof ExtensionCommand.validatePath; + data: { + path: any; + isValid: boolean; + }; +} + export interface AddStatusMessage { command: typeof ExtensionCommand.add_status; data: { diff --git a/apps/vs-code-react/src/state/createWorkspaceSlice.ts b/apps/vs-code-react/src/state/createWorkspaceSlice.ts new file mode 100644 index 00000000000..ecce6a42736 --- /dev/null +++ b/apps/vs-code-react/src/state/createWorkspaceSlice.ts @@ -0,0 +1,220 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import type { PayloadAction } from '@reduxjs/toolkit'; +import { createSlice } from '@reduxjs/toolkit'; +import type { ITargetDirectory } from 'run-service'; + +export interface CreateWorkspaceState { + currentStep: number; + packagePath: ITargetDirectory; + workspaceProjectPath: ITargetDirectory; + workspaceName: string; + logicAppType: string; + functionNamespace: string; + functionName: string; + functionFolderName: string; + workflowType: string; + workflowName: string; + targetFramework: string; + logicAppName: string; + projectType: string; + openBehavior: string; + isLoading: boolean; + error?: string; + isComplete: boolean; + workspaceFileJson: any; + logicAppsWithoutCustomCode: any | undefined; + flowType: 'createWorkspace' | 'createWorkspaceFromPackage' | 'createLogicApp' | 'convertToWorkspace'; + pathValidationResults: Record; + packageValidationResults: Record; + workspaceExistenceResults: Record; + isValidatingWorkspace: boolean; + isValidatingPackage: boolean; +} + +const initialState: CreateWorkspaceState = { + currentStep: 0, + packagePath: { + fsPath: '', + path: '', + }, + workspaceProjectPath: { + fsPath: '', + path: '', + }, + workspaceName: '', + logicAppType: '', + functionNamespace: '', + functionName: '', + functionFolderName: '', + workflowType: '', + workflowName: '', + targetFramework: '', + logicAppName: '', + projectType: '', + openBehavior: '', + isLoading: false, + isComplete: false, + workspaceFileJson: '', + logicAppsWithoutCustomCode: undefined, + flowType: 'createWorkspace', + pathValidationResults: {}, + packageValidationResults: {}, + workspaceExistenceResults: {}, + isValidatingWorkspace: false, + isValidatingPackage: false, +}; + +export const createWorkspaceSlice = createSlice({ + name: 'createWorkspace', + initialState, + reducers: { + initializeProject: (state, action: PayloadAction) => { + const { workspaceFileJson, logicAppsWithoutCustomCode } = action.payload; + state.workspaceFileJson = workspaceFileJson; + state.logicAppsWithoutCustomCode = logicAppsWithoutCustomCode; + }, + setCurrentStep: (state, action: PayloadAction) => { + state.currentStep = action.payload; + }, + setPackagePath: (state, action: PayloadAction<{ targetDirectory: ITargetDirectory } | string>) => { + if (typeof action.payload === 'string') { + state.packagePath = { path: action.payload, fsPath: action.payload }; + } else if (action.payload && typeof action.payload === 'object') { + const { targetDirectory } = action.payload; + state.packagePath = targetDirectory; + } else { + state.packagePath = { path: '', fsPath: '' }; + } + state.logicAppType = 'logicApp'; + }, + setProjectPath: (state, action: PayloadAction<{ targetDirectory: ITargetDirectory } | string>) => { + if (typeof action.payload === 'string') { + state.workspaceProjectPath = { path: action.payload, fsPath: action.payload }; + } else if (action.payload && typeof action.payload === 'object') { + const { targetDirectory } = action.payload; + state.workspaceProjectPath = targetDirectory; + } else { + state.workspaceProjectPath = { path: '', fsPath: '' }; + } + }, + setWorkspaceName: (state, action: PayloadAction) => { + state.workspaceName = action.payload; + }, + setLogicAppType: (state, action: PayloadAction) => { + state.logicAppType = action.payload; + }, + setFunctionNamespace: (state, action: PayloadAction) => { + state.functionNamespace = action.payload; + }, + setFunctionName: (state, action: PayloadAction) => { + state.functionName = action.payload; + }, + setFunctionFolderName: (state, action: PayloadAction) => { + state.functionFolderName = action.payload; + }, + setWorkflowType: (state, action: PayloadAction) => { + state.workflowType = action.payload; + }, + setWorkflowName: (state, action: PayloadAction) => { + state.workflowName = action.payload; + }, + setTargetFramework: (state, action: PayloadAction) => { + state.targetFramework = action.payload; + }, + setLogicAppName: (state, action: PayloadAction) => { + state.logicAppName = action.payload; + }, + setProjectType: (state, action: PayloadAction) => { + state.projectType = action.payload; + }, + setOpenBehavior: (state, action: PayloadAction) => { + state.openBehavior = action.payload; + }, + setFlowType: ( + state, + action: PayloadAction<'createWorkspace' | 'createWorkspaceFromPackage' | 'createLogicApp' | 'convertToWorkspace'> + ) => { + state.flowType = action.payload; + }, + setPathValidationResult: (state, action: PayloadAction<{ path: string; isValid: boolean }>) => { + const { path, isValid } = action.payload; + state.pathValidationResults[path] = isValid; + }, + setPackageValidationResult: (state, action: PayloadAction<{ path: string; isValid: boolean }>) => { + const { path, isValid } = action.payload; + state.packageValidationResults[path] = isValid; + state.isValidatingPackage = false; + }, + setValidatingPackage: (state, action: PayloadAction) => { + state.isValidatingPackage = action.payload; + }, + setWorkspaceExistenceResult: (state, action: PayloadAction<{ workspacePath: string; exists: boolean }>) => { + const { workspacePath, exists } = action.payload; + state.workspaceExistenceResults[workspacePath] = exists; + state.isValidatingWorkspace = false; + }, + setValidatingWorkspace: (state, action: PayloadAction) => { + state.isValidatingWorkspace = action.payload; + }, + clearWorkspaceExistenceResults: (state) => { + state.workspaceExistenceResults = {}; + }, + setLoading: (state, action: PayloadAction) => { + state.isLoading = action.payload; + }, + setError: (state, action: PayloadAction) => { + state.error = action.payload; + }, + setComplete: (state, action: PayloadAction) => { + state.isComplete = action.payload; + }, + resetState: () => initialState, + nextStep: (state) => { + if (state.currentStep < 7) { + // Maximum of 8 steps (0-7) for custom code, 7 steps (0-6) for others + state.currentStep += 1; + } + }, + previousStep: (state) => { + if (state.currentStep > 0) { + state.currentStep -= 1; + } + }, + }, +}); + +export const { + initializeProject, + setCurrentStep, + setProjectPath, + setPackagePath, + setWorkspaceName, + setLogicAppType, + setFunctionNamespace, + setFunctionName, + setFunctionFolderName, + setWorkflowType, + setWorkflowName, + setTargetFramework, + setLogicAppName, + setProjectType, + setOpenBehavior, + setFlowType, + setPathValidationResult, + setPackageValidationResult, + setValidatingPackage, + setWorkspaceExistenceResult, + setValidatingWorkspace, + clearWorkspaceExistenceResults, + setLoading, + setError, + setComplete, + resetState, + nextStep, + previousStep, +} = createWorkspaceSlice.actions; + +export default createWorkspaceSlice.reducer; diff --git a/apps/vs-code-react/src/state/store.ts b/apps/vs-code-react/src/state/store.ts index b328bc6e27f..8859c1460c1 100644 --- a/apps/vs-code-react/src/state/store.ts +++ b/apps/vs-code-react/src/state/store.ts @@ -4,6 +4,7 @@ import { designerSlice } from './DesignerSlice'; import { unitTestSlice } from './UnitTestSlice'; import { workflowSlice } from './WorkflowSlice'; import { projectSlice } from './projectSlice'; +import { createWorkspaceSlice } from './createWorkspaceSlice'; import { configureStore } from '@reduxjs/toolkit'; export const store = configureStore({ @@ -14,6 +15,7 @@ export const store = configureStore({ unitTest: unitTestSlice.reducer, dataMapDataLoader: dataMapSliceV1.reducer, // Data Mapper V1 dataMap: dataMapSliceV2.reducer, // Data Mapper V2 + createWorkspace: createWorkspaceSlice.reducer, }, }); diff --git a/apps/vs-code-react/src/stateWrapper.tsx b/apps/vs-code-react/src/stateWrapper.tsx index 5fb8ec5a6d0..82e6f0397ce 100644 --- a/apps/vs-code-react/src/stateWrapper.tsx +++ b/apps/vs-code-react/src/stateWrapper.tsx @@ -35,6 +35,22 @@ export const StateWrapper: React.FC = () => { navigate(`/${ProjectName.unitTest}`, { replace: true }); break; } + case ProjectName.createWorkspace: { + navigate(`/${ProjectName.createWorkspace}`, { replace: true }); + break; + } + case ProjectName.createWorkspaceFromPackage: { + navigate(`/${ProjectName.createWorkspaceFromPackage}`, { replace: true }); + break; + } + case ProjectName.createLogicApp: { + navigate(`/${ProjectName.createLogicApp}`, { replace: true }); + break; + } + case ProjectName.createWorkspaceStructure: { + navigate(`/${ProjectName.createWorkspaceStructure}`, { replace: true }); + break; + } default: { break; } diff --git a/apps/vs-code-react/src/webviewCommunication.tsx b/apps/vs-code-react/src/webviewCommunication.tsx index b1c5868842e..ba6fe54b71b 100644 --- a/apps/vs-code-react/src/webviewCommunication.tsx +++ b/apps/vs-code-react/src/webviewCommunication.tsx @@ -19,6 +19,9 @@ import type { GetTestFeatureEnablementStatus, GetAvailableCustomXsltPathsMessageV2, ResetDesignerDirtyStateMessage, + UpdateWorkspacePathMessage, + UpdateWorkspacePackageMessage, + ValidateWorkspacePathMessage, } from './run-service'; import { changeCustomXsltPathList, @@ -59,6 +62,14 @@ import React, { useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import type { WebviewApi } from 'vscode-webview'; import { store as DesignerStore, resetDesignerDirtyState } from '@microsoft/logic-apps-designer'; +import { + initializeProject, + setProjectPath, + setPathValidationResult, + setWorkspaceExistenceResult, + setPackagePath, + setPackageValidationResult, +} from './state/createWorkspaceSlice'; const vscode: WebviewApi = acquireVsCodeApi(); export const VSCodeContext = React.createContext(vscode); @@ -80,7 +91,14 @@ type DataMapperMessageType = | GetConfigurationSettingMessage | GetDataMapperVersionMessage | GetTestFeatureEnablementStatus; -type WorkflowMessageType = UpdateAccessTokenMessage | UpdateExportPathMessage | AddStatusMessage | SetFinalStatusMessage; +type WorkflowMessageType = + | UpdateAccessTokenMessage + | UpdateExportPathMessage + | UpdateWorkspacePathMessage + | UpdateWorkspacePackageMessage + | AddStatusMessage + | SetFinalStatusMessage + | ValidateWorkspacePathMessage; type MessageType = InjectValuesMessage | DesignerMessageType | DataMapperMessageType | WorkflowMessageType; export const WebViewCommunication: React.FC<{ children: ReactNode }> = ({ children }) => { @@ -91,6 +109,30 @@ export const WebViewCommunication: React.FC<{ children: ReactNode }> = ({ childr useEventListener('message', (event: MessageEvent) => { const message = event.data; // The JSON data our extension sent + + // Handle workspace existence validation results (for any project type) + if ((message as any).command === 'workspaceExistenceResult') { + const { workspacePath, exists } = (message as any).data; + dispatch(setWorkspaceExistenceResult({ workspacePath, exists })); + return; + } + + if ((message as any).command === 'packageExistenceResult') { + const { path, isValid } = (message as any).data; + dispatch(setPackageValidationResult({ path, isValid })); + return; + } + + // Handle folder selection for create workspace projects + if ((message as any).command === 'folder-selected' && (message as any).data?.path) { + // Only handle this for create workspace related projects + const currentProject = projectState?.project ?? message?.data?.project; + if (currentProject === ProjectName.createWorkspace || currentProject === ProjectName.createWorkspaceStructure) { + dispatch(setProjectPath((message as any).data.path)); + } + return; + } + if (message.command === ExtensionCommand.initialize_frame) { dispatch(initialize(message.data.project)); } @@ -222,6 +264,38 @@ export const WebViewCommunication: React.FC<{ children: ReactNode }> = ({ childr } break; } + case ProjectName.createWorkspace: + case ProjectName.createWorkspaceFromPackage: + case ProjectName.createWorkspaceStructure: { + switch (message.command) { + case ExtensionCommand.update_workspace_path: { + dispatch(setProjectPath(message.data)); + break; + } + case ExtensionCommand.validatePath: { + dispatch(setPathValidationResult(message.data)); + break; + } + case ExtensionCommand.update_package_path: { + dispatch(setPackagePath(message.data)); + break; + } + default: + throw new Error('Unknown post message received'); + } + break; + } + case ProjectName.createLogicApp: { + switch (message.command) { + case ExtensionCommand.initialize_frame: { + dispatch(initializeProject(message.data)); + break; + } + default: + throw new Error('Unknown post message received'); + } + break; + } default: switch (message.command) { case ExtensionCommand.initialize_frame: { diff --git a/e2e/designer/fixtures/real-api.ts b/e2e/designer/fixtures/real-api.ts index d0b6f712645..29ba4f95144 100644 --- a/e2e/designer/fixtures/real-api.ts +++ b/e2e/designer/fixtures/real-api.ts @@ -1,6 +1,7 @@ import { test as base, expect } from '@playwright/test'; import type { APIRequestContext, Page } from '@playwright/test'; import * as Constants from '../utils/Constants'; +import { connectionsFileName } from '../../../apps/vs-code-designer/src/constants'; export class RealDataApi { private workflowName: string; @@ -116,14 +117,14 @@ export class RealDataApi { } ); console.log('getConnectionsJSON response', response.json()); - return (await response.json()).properties.files['connections.json']; + return (await response.json()).properties.files[connectionsFileName]; } async deployConnectionsJSON(connectionsData: any) { return this.request.post(`${Constants.managementUrl}${this.siteId}/deployWorkflowArtifacts?api-version=${Constants.siteApiVersion}`, { data: { files: { - ['connections.json']: connectionsData, + [connectionsFileName]: connectionsData, }, }, headers: { diff --git a/libs/designer-v2/src/lib/core/BJSWorkflowProvider.tsx b/libs/designer-v2/src/lib/core/BJSWorkflowProvider.tsx index 07abab83dc0..4694525ecff 100644 --- a/libs/designer-v2/src/lib/core/BJSWorkflowProvider.tsx +++ b/libs/designer-v2/src/lib/core/BJSWorkflowProvider.tsx @@ -7,7 +7,7 @@ import { useAreDesignerOptionsInitialized, useAreServicesInitialized, useMonitoringView, - useReadOnly, + useReadOnly, } from './state/designerOptions/designerOptionsSelectors'; import { initializeServices } from './state/designerOptions/designerOptionsSlice'; import { initUnitTestDefinition } from './state/unitTest/unitTestSlice'; @@ -48,7 +48,7 @@ const DataProviderInner: React.FC = ({ }) => { const dispatch = useDispatch(); - const isReadOnly = useReadOnly(); + const isReadOnly = useReadOnly(); const isMonitoringView = useMonitoringView(); useDeepCompareEffect(() => { @@ -60,7 +60,7 @@ const DataProviderInner: React.FC = ({ dispatch(initCustomCode(customCode)); dispatch(initializeGraphState({ workflowDefinition: workflow, runInstance, isMultiVariableEnabled })); dispatch(initUnitTestDefinition(deserializeUnitTestDefinition(unitTestDefinition ?? null, workflow))); - }, [workflowId, runInstance, workflow, customCode, unitTestDefinition, isReadOnly, isMonitoringView]); + }, [workflowId, runInstance, workflow, customCode, unitTestDefinition, isReadOnly, isMonitoringView]); // Store app settings in query to access outside of functional components useQuery({ diff --git a/libs/vscode-extension/src/lib/models/extensioncommand.ts b/libs/vscode-extension/src/lib/models/extensioncommand.ts index 4e99c9cb186..b287205a8cd 100644 --- a/libs/vscode-extension/src/lib/models/extensioncommand.ts +++ b/libs/vscode-extension/src/lib/models/extensioncommand.ts @@ -53,6 +53,16 @@ export const ExtensionCommand = { resetDesignerDirtyState: 'resetDesignerDirtyState', switchToDataMapperV2: 'switchToDataMapperV2', pickProcess: 'pickProcess', + createWorkspace: 'createWorkspace', + update_workspace_path: 'update-workspace-path', + validatePath: 'validatePath', + createLogicApp: 'createLogicApp', + createWorkspaceStructure: 'createWorkspaceStructure', + createWorkspaceFromPackage: 'createWorkspaceFromPackage', + workspace_folder: 'workspace-folder', + workspace_file: 'workspace-file', + update_package_path: 'update-package-path', + package_file: 'package-file', } as const; export type ExtensionCommand = (typeof ExtensionCommand)[keyof typeof ExtensionCommand]; diff --git a/libs/vscode-extension/src/lib/models/project.ts b/libs/vscode-extension/src/lib/models/project.ts index 7c5f9310bec..e4c51870179 100644 --- a/libs/vscode-extension/src/lib/models/project.ts +++ b/libs/vscode-extension/src/lib/models/project.ts @@ -13,6 +13,10 @@ export const ProjectName = { designer: 'designer', dataMapper: 'dataMapper', unitTest: 'unitTest', + createWorkspace: 'createWorkspace', + createWorkspaceFromPackage: 'createWorkspaceFromPackage', + createLogicApp: 'createLogicApp', + createWorkspaceStructure: 'createWorkspaceStructure', } as const; export type ProjectNameType = (typeof ProjectName)[keyof typeof ProjectName]; @@ -57,6 +61,7 @@ export interface IProjectTreeItem { } export interface IProjectWizardContext extends IActionContext { + functionFolderName?: string; functionAppNamespace?: string; functionAppName?: string; customCodeFunctionName?: string; @@ -86,6 +91,22 @@ export interface IProjectWizardContext extends IActionContext { deploymentScriptType?: DeploymentScriptType; } +export interface IWebviewProjectContext extends IActionContext { + workspaceFilePath: string; + workspaceProjectPath: ITargetDirectory; + workspaceName: string; + logicAppName: string; + logicAppType: string; + projectType: string; + targetFramework: string; + workflowName: string; + workflowType: string; + functionFolderName?: string; + functionName?: string; + functionNamespace?: string; + shouldCreateLogicAppProject: boolean; +} + export const OpenBehavior = { addToWorkspace: 'AddToWorkspace', openInNewWindow: 'OpenInNewWindow', @@ -107,3 +128,8 @@ export const DeploymentScriptType = { azureDeploymentCenter: 'azureDeploymentCenter', } as const; export type DeploymentScriptType = (typeof DeploymentScriptType)[keyof typeof DeploymentScriptType]; + +export interface ITargetDirectory { + fsPath: string; + path: string; +}