diff --git a/Localize/lang/strings.json b/Localize/lang/strings.json index 5b1175f1782..84e18c20378 100644 --- a/Localize/lang/strings.json +++ b/Localize/lang/strings.json @@ -1142,6 +1142,7 @@ "KY5eNe": "On", "KYX5Do": "Underline (⌘U)", "KZOa5l": "Cancel", + "Karw2Q": "Workspace location cannot be inside the currently open project folder. Choose the current folder or a location outside it.", "Kasmd1": "Go to workflow", "Kb5u9F": "About tab", "KlDW+5": "(UTC+02:00) Beirut", @@ -3099,6 +3100,7 @@ "_KY5eNe.comment": "Label for the enabled switch", "_KYX5Do.comment": "Command for underline text for Mac users", "_KZOa5l.comment": "Label for cancel button", + "_Karw2Q.comment": "Workspace location is a descendant of the currently open folder error text", "_Kasmd1.comment": "Label to indicate go to the workflow", "_Kb5u9F.comment": "An accessibility label that describes the about tab", "_KlDW+5.comment": "Time zone value ", diff --git a/apps/vs-code-designer/src/app/commands/__test__/ensureWorkspace.test.ts b/apps/vs-code-designer/src/app/commands/__test__/ensureWorkspace.test.ts index 8b53d2c13be..bd4f4cc7a75 100644 --- a/apps/vs-code-designer/src/app/commands/__test__/ensureWorkspace.test.ts +++ b/apps/vs-code-designer/src/app/commands/__test__/ensureWorkspace.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { ensureWorkspace } from '../ensureWorkspace'; +import { ensureWorkspace, createWorkspaceFile } from '../ensureWorkspace'; import * as vscode from 'vscode'; import * as workspaceUtils from '../../utils/workspace'; import * as verifyProject from '../../utils/verifyIsProject'; @@ -35,6 +35,18 @@ vi.mock('../shared/workspaceWebviewCommandHandler', () => ({ createWorkspaceWebviewCommandHandler: vi.fn(), })); +vi.mock('fs-extra', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + writeJson: vi.fn(), + copy: vi.fn(), + ensureDir: vi.fn(), + readdir: vi.fn(), + pathExists: vi.fn(), + }; +}); + describe('ensureWorkspace', () => { const testWorkspaceName = 'TestWorkspace'; const testWorkspaceFolder: vscode.WorkspaceFolder = { @@ -133,13 +145,13 @@ describe('ensureWorkspace', () => { vi.spyOn(verifyProject, 'getFirstLogicAppProjectRoot').mockImplementation(async (f: vscode.WorkspaceFolder | string | undefined) => { return f === testLogicAppChildFolder ? testLogicAppChildFolder : undefined; }); - vi.spyOn(fse, 'readdir').mockImplementation(async (p: fse.PathLike) => { + vi.mocked(fse.readdir).mockImplementation(async (p: fse.PathLike) => { if (p === testWorkspaceFolder.uri.fsPath) { - return [new MockDirent(testLogicAppName, true)]; + return [new MockDirent(testLogicAppName, true)] as any; } return []; }); - vi.spyOn(fse, 'pathExists').mockImplementation(async (p: fse.PathLike) => { + vi.mocked(fse.pathExists).mockImplementation(async (p: fse.PathLike) => { return p === testWorkspaceFolder.uri.fsPath || p === testLogicAppChildFolder; }); const isLogicAppProjectInRootSpy = vi @@ -209,3 +221,114 @@ describe('ensureWorkspace', () => { expect(result).toBe(true); }); }); + +describe('createWorkspaceFile', () => { + let context: any; + + beforeEach(() => { + context = { + telemetry: { + properties: {}, + measurements: {}, + }, + }; + vi.spyOn(funcCoreTools, 'addLocalFuncTelemetry').mockImplementation(() => {}); + vi.mocked(fse.writeJson).mockResolvedValue(undefined); + vi.mocked(fse.copy).mockResolvedValue(undefined); + vi.mocked(fse.ensureDir).mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('in-place Logic App root: writes .code-workspace without copying', async () => { + const projectPath = path.resolve('/users/dev/MyLogicApp'); + (vscode.workspace as any).workspaceFolders = [{ name: 'MyLogicApp', uri: { fsPath: projectPath } as vscode.Uri, index: 0 }]; + vi.spyOn(verifyProject, 'isLogicAppProject').mockResolvedValue(true); + const executeCommandSpy = vi.spyOn(vscode.commands, 'executeCommand').mockResolvedValue(undefined); + + await createWorkspaceFile(context, { + workspaceProjectPath: { fsPath: path.dirname(projectPath), path: path.dirname(projectPath) }, + workspaceName: 'MyLogicApp', + }); + + expect(fse.copy).not.toHaveBeenCalled(); + expect(fse.writeJson).toHaveBeenCalledWith( + path.join(projectPath, 'MyLogicApp.code-workspace'), + { folders: [{ name: 'MyLogicApp', path: '.' }] }, + { spaces: 2 } + ); + expect(executeCommandSpy).toHaveBeenCalledWith( + vscodeCommand.openFolder, + expect.objectContaining({ fsPath: path.join(projectPath, 'MyLogicApp.code-workspace') }), + true + ); + }); + + it('in-place container with child directories: lists children without copying', async () => { + const containerPath = path.resolve('/users/dev/workspace-root'); + (vscode.workspace as any).workspaceFolders = [{ name: 'workspace-root', uri: { fsPath: containerPath } as vscode.Uri, index: 0 }]; + vi.spyOn(verifyProject, 'isLogicAppProject').mockResolvedValue(false); + vi.mocked(fse.readdir).mockResolvedValue([ + new MockDirent('app1', true), + new MockDirent('app2', true), + new MockDirent('readme.md', false), + ] as any); + const executeCommandSpy = vi.spyOn(vscode.commands, 'executeCommand').mockResolvedValue(undefined); + + await createWorkspaceFile(context, { + workspaceProjectPath: { fsPath: path.dirname(containerPath), path: path.dirname(containerPath) }, + workspaceName: 'workspace-root', + }); + + expect(fse.copy).not.toHaveBeenCalled(); + expect(fse.writeJson).toHaveBeenCalledWith( + path.join(containerPath, 'workspace-root.code-workspace'), + { + folders: [ + { name: 'app1', path: './app1' }, + { name: 'app2', path: './app2' }, + ], + }, + { spaces: 2 } + ); + expect(executeCommandSpy).toHaveBeenCalled(); + }); + + it('external copy: copies project to new location', async () => { + const currentPath = path.resolve('/users/dev/MyLogicApp'); + const externalParent = path.resolve('/users/workspaces'); + const externalDest = path.join(externalParent, 'NewWorkspace'); + + (vscode.workspace as any).workspaceFolders = [{ name: 'MyLogicApp', uri: { fsPath: currentPath } as vscode.Uri, index: 0 }]; + vi.spyOn(verifyProject, 'isLogicAppProject').mockResolvedValue(true); + const executeCommandSpy = vi.spyOn(vscode.commands, 'executeCommand').mockResolvedValue(undefined); + + await createWorkspaceFile(context, { + workspaceProjectPath: { fsPath: externalParent, path: externalParent }, + workspaceName: 'NewWorkspace', + }); + + expect(fse.ensureDir).toHaveBeenCalledWith(externalDest); + expect(fse.copy).toHaveBeenCalledWith(currentPath, path.join(externalDest, 'MyLogicApp')); + expect(fse.writeJson).toHaveBeenCalledWith( + path.join(externalDest, 'NewWorkspace.code-workspace'), + { folders: [{ name: 'MyLogicApp', path: './MyLogicApp' }] }, + { spaces: 2 } + ); + expect(executeCommandSpy).toHaveBeenCalled(); + }); + + it('descendant rejection: throws when workspace target is inside current folder', async () => { + const currentPath = path.resolve('/users/dev/MyLogicApp'); + (vscode.workspace as any).workspaceFolders = [{ name: 'MyLogicApp', uri: { fsPath: currentPath } as vscode.Uri, index: 0 }]; + + await expect( + createWorkspaceFile(context, { + workspaceProjectPath: { fsPath: currentPath, path: currentPath }, + workspaceName: 'subdir', + }) + ).rejects.toThrow(/inside the currently open folder/); + }); +}); diff --git a/apps/vs-code-designer/src/app/commands/__test__/pickCustomCodeWorkerProcess.test.ts b/apps/vs-code-designer/src/app/commands/__test__/pickCustomCodeWorkerProcess.test.ts index cd4300cb2ad..0e61694ef46 100644 --- a/apps/vs-code-designer/src/app/commands/__test__/pickCustomCodeWorkerProcess.test.ts +++ b/apps/vs-code-designer/src/app/commands/__test__/pickCustomCodeWorkerProcess.test.ts @@ -1,9 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as vscode from 'vscode'; -import { - pickCustomCodeNetHostProcessInternal, - pickCustomCodeWorkerChildProcess, -} from '../pickCustomCodeWorkerProcess'; +import { pickCustomCodeNetHostProcessInternal, pickCustomCodeWorkerChildProcess } from '../pickCustomCodeWorkerProcess'; import * as validatePreDebug from '../../debug/validatePreDebug'; import { IRunningFuncTask, runningFuncTaskMap } from '../../utils/funcCoreTools/funcHostTask'; import * as pickFuncProcessModule from '../pickFuncProcess'; @@ -21,6 +18,14 @@ vi.mock('vscode', () => ({ }, })); +vi.mock('../../utils/delay', () => ({ + delay: vi.fn((ms: number) => { + // Advance fake timers so the polling loop's Date.now() check terminates + vi.advanceTimersByTime(ms); + return Promise.resolve(); + }), +})); + describe('pickCustomCodeNetHostProcessInternal', () => { const testLogicAppName = 'LogicApp'; const testLogicAppPath = path.join('path', 'to', testLogicAppName); @@ -31,15 +36,17 @@ describe('pickCustomCodeNetHostProcessInternal', () => { name: testLogicAppName, index: 0, }; - const testActionContext = { - telemetry: { properties: {} }, - } as IActionContext; + let testActionContext: IActionContext; const testFuncTask: IRunningFuncTask = { startTime: Date.now(), processId: Number(testFuncPid), }; beforeEach(() => { + vi.useFakeTimers(); + testActionContext = { + telemetry: { properties: {}, measurements: {} }, + } as IActionContext; (vscode.workspace as any).workspaceFolders = [testLogicAppWorkspaceFolder]; vi.spyOn(validatePreDebug, 'getMatchingWorkspaceFolder').mockReturnValue(testLogicAppWorkspaceFolder); @@ -49,15 +56,17 @@ describe('pickCustomCodeNetHostProcessInternal', () => { }); afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); runningFuncTaskMap.clear(); }); - it('should return undefined when no child dotnet process exists on the logic app functions host', async () => { + it('should throw when no child dotnet process exists on the logic app functions host after polling', async () => { runningFuncTaskMap.set(testLogicAppWorkspaceFolder, testFuncTask); - await expect( - pickCustomCodeNetHostProcessInternal(testActionContext, testLogicAppWorkspaceFolder, testLogicAppPath) - ).resolves.toBeUndefined(); + await expect(pickCustomCodeNetHostProcessInternal(testActionContext, testLogicAppWorkspaceFolder, testLogicAppPath)).rejects.toThrow( + /Failed to find the .NET host child process/ + ); + expect(testActionContext.telemetry.properties.result).toBe('Failed'); expect(testActionContext.telemetry.properties.lastStep).toBe('pickNetHostChildProcess'); }); diff --git a/apps/vs-code-designer/src/app/commands/binaries/__test__/validateAndInstallBinaries.test.ts b/apps/vs-code-designer/src/app/commands/binaries/__test__/validateAndInstallBinaries.test.ts index ddcc2986129..994ca05d341 100644 --- a/apps/vs-code-designer/src/app/commands/binaries/__test__/validateAndInstallBinaries.test.ts +++ b/apps/vs-code-designer/src/app/commands/binaries/__test__/validateAndInstallBinaries.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; import { defaultDependencyPathValue } from '../../../../constants'; import { ext } from '../../../../extensionVariables'; import { getDependencyTimeout, ensureRuntimeDependenciesDir } from '../../../utils/binaries'; -import { ensureExtensionBundleHealthy, getDependenciesVersion } from '../../../utils/bundleFeed'; +import { ensureExtensionBundleHealthy, getBundleDependencyFeed } from '../../../utils/bundleFeed'; import { recordDependencyUpdateCheck, shouldCheckForDependencyUpdates } from '../../../state/dependencies'; import { setDotNetCommand } from '../../../utils/dotnet/dotnet'; import { setFunctionsCommand } from '../../../utils/funcCoreTools/funcVersion'; @@ -28,7 +28,7 @@ vi.mock('../../../utils/binaries', () => ({ })); vi.mock('../../../utils/bundleFeed', () => ({ - getDependenciesVersion: vi.fn(), + getBundleDependencyFeed: vi.fn(), ensureExtensionBundleHealthy: vi.fn().mockResolvedValue(undefined), })); @@ -106,7 +106,7 @@ describe('validateAndInstallBinaries', () => { (shouldRequireStrictDependencyValidation as Mock).mockReturnValue(false); (shouldCheckForDependencyUpdates as Mock).mockReturnValue(true); (recordDependencyUpdateCheck as Mock).mockResolvedValue(undefined); - (getDependenciesVersion as Mock).mockResolvedValue({ + (getBundleDependencyFeed as Mock).mockResolvedValue({ nodejs: '18.0.0', funcCoreTools: '4.0.0', dotnetVersions: '8.0.100', diff --git a/apps/vs-code-designer/src/app/commands/binaries/validateAndInstallBinaries.ts b/apps/vs-code-designer/src/app/commands/binaries/validateAndInstallBinaries.ts index 2dcb29c9005..02be2831221 100644 --- a/apps/vs-code-designer/src/app/commands/binaries/validateAndInstallBinaries.ts +++ b/apps/vs-code-designer/src/app/commands/binaries/validateAndInstallBinaries.ts @@ -5,7 +5,7 @@ import { ext } from '../../../extensionVariables'; import { localize } from '../../../localize'; import { ensureRuntimeDependenciesDir, getDependencyTimeout } from '../../utils/binaries'; -import { getDependenciesVersion, ensureExtensionBundleHealthy } from '../../utils/bundleFeed'; +import { ensureExtensionBundleHealthy, getBundleDependencyFeed } from '../../utils/bundleFeed'; import { recordDependencyUpdateCheck, shouldCheckForDependencyUpdates } from '../../state/dependencies'; import { setDotNetCommand } from '../../utils/dotnet/dotnet'; import { setFunctionsCommand } from '../../utils/funcCoreTools/funcVersion'; @@ -17,7 +17,7 @@ import { validateDotNetIsLatest } from '../dotnet/validateDotNetIsLatest'; import { validateFuncCoreToolsIsLatest } from '../funcCoreTools/validateFuncCoreToolsIsLatest'; import { validateNodeJsIsLatest } from '../nodeJs/validateNodeJsIsLatest'; import { callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils'; -import type { IBundleDependencyFeed } from '@microsoft/vscode-extension-logic-apps'; +import type { IRuntimeDependencyVersions } from '@microsoft/vscode-extension-logic-apps'; import * as vscode from 'vscode'; export async function validateAndInstallBinaries(context: IActionContext) { @@ -50,11 +50,11 @@ export async function validateAndInstallBinaries(context: IActionContext) { const performedUpdateCheck = shouldCheckForDependencyUpdates(); context.telemetry.properties.performedDependencyUpdateCheck = `${performedUpdateCheck}`; - context.telemetry.properties.lastStep = 'getDependenciesVersion'; + context.telemetry.properties.lastStep = 'getBundleDependencyFeed'; progress.report({ increment: 10, message: 'Get dependency version from CDN' }); - let dependenciesVersions: IBundleDependencyFeed; + let dependenciesVersions: IRuntimeDependencyVersions; try { - dependenciesVersions = await getDependenciesVersion(context); + dependenciesVersions = await getBundleDependencyFeed(context); context.telemetry.properties.dependenciesVersions = JSON.stringify(dependenciesVersions); } catch (error) { // Unable to get dependency.json, will default to fallback versions diff --git a/apps/vs-code-designer/src/app/commands/buildCustomCodeFunctionsProject.ts b/apps/vs-code-designer/src/app/commands/buildCustomCodeFunctionsProject.ts index 4edffbc7fa1..cb7971a3640 100644 --- a/apps/vs-code-designer/src/app/commands/buildCustomCodeFunctionsProject.ts +++ b/apps/vs-code-designer/src/app/commands/buildCustomCodeFunctionsProject.ts @@ -9,6 +9,7 @@ import { getWorkspaceRoot } from '../utils/workspace'; import { isCustomCodeFunctionsProject, tryGetLogicAppCustomCodeFunctionsProjects } from '../utils/customCodeUtils'; import * as vscode from 'vscode'; import { isNullOrUndefined } from '@microsoft/logic-apps-shared'; +import { isPathEqual } from '../utils/fs'; /** * Builds a custom code functions project if exists. @@ -65,7 +66,7 @@ async function buildCustomCodeProject(functionsProjectPath: string): Promise { const currTaskPath = (task.scope as vscode.WorkspaceFolder)?.uri.fsPath; - return task.name === 'build' && currTaskPath === functionsProjectPath; + return task.name === 'build' && !!currTaskPath && isPathEqual(currTaskPath, functionsProjectPath); }); if (!buildTask) { @@ -75,7 +76,8 @@ async function buildCustomCodeProject(functionsProjectPath: string): Promise((resolve, reject) => { const disposable: vscode.Disposable = vscode.tasks.onDidEndTaskProcess((e) => { const isMatchingTask = - (e.execution.task.scope as vscode.WorkspaceFolder)?.uri.fsPath === functionsProjectPath && e.execution.task.name === buildTask.name; + isPathEqual((e.execution.task.scope as vscode.WorkspaceFolder)?.uri.fsPath ?? '', functionsProjectPath) && + e.execution.task.name === buildTask.name; if (isMatchingTask) { disposable.dispose(); diff --git a/apps/vs-code-designer/src/app/commands/createCustomCodeFunction/createCustomCodeFunction.ts b/apps/vs-code-designer/src/app/commands/createCustomCodeFunction/createCustomCodeFunction.ts index 79aa55eb524..0a5940f78bf 100644 --- a/apps/vs-code-designer/src/app/commands/createCustomCodeFunction/createCustomCodeFunction.ts +++ b/apps/vs-code-designer/src/app/commands/createCustomCodeFunction/createCustomCodeFunction.ts @@ -107,6 +107,5 @@ export async function createCustomCodeFunction(context: IActionContext, folderPa context.telemetry.properties.errorMessage = err.message; throw err; } - context.telemetry.properties.result = 'Succeeded'; } } diff --git a/apps/vs-code-designer/src/app/commands/dataMapper/dataMapper.ts b/apps/vs-code-designer/src/app/commands/dataMapper/dataMapper.ts index ac35ee1288f..afd4b5feaac 100644 --- a/apps/vs-code-designer/src/app/commands/dataMapper/dataMapper.ts +++ b/apps/vs-code-designer/src/app/commands/dataMapper/dataMapper.ts @@ -138,8 +138,6 @@ export async function loadDataMapFile(context: IActionContext, uri: Uri): Promis if (fileUris && fileUris.length > 0) { // Copy the schema file they selected to the Schemas folder (can safely continue map definition loading) await fs.copyFile(fileUris[0].fsPath, schemaPath); - context.telemetry.properties.result = 'Succeeded'; - return true; } } diff --git a/apps/vs-code-designer/src/app/commands/debugLogicApp.ts b/apps/vs-code-designer/src/app/commands/debugLogicApp.ts index 6a549518dcf..3545e4be30b 100644 --- a/apps/vs-code-designer/src/app/commands/debugLogicApp.ts +++ b/apps/vs-code-designer/src/app/commands/debugLogicApp.ts @@ -10,7 +10,6 @@ import { localize } from '../../localize'; import { ext } from '../../extensionVariables'; import { tryGetLogicAppProjectRoot } from '../utils/verifyIsProject'; import { pickCustomCodeNetFxWorkerProcessInternal, pickCustomCodeNetHostProcessInternal } from './pickCustomCodeWorkerProcess'; -import { extensionCommand } from '../../constants'; export async function debugLogicApp( context: IActionContext, @@ -142,5 +141,4 @@ export async function debugLogicApp( ) ); } - context.telemetry.properties.result = 'Succeeded'; } 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 4c488b63132..ad6e34f4838 100644 --- a/apps/vs-code-designer/src/app/commands/deploy/deploy.ts +++ b/apps/vs-code-designer/src/app/commands/deploy/deploy.ts @@ -101,16 +101,16 @@ async function deploy( addLocalFuncTelemetry(context); let deployProjectPathForWorkflowApp: string | undefined; - const settingsToExclude: string[] = [ + const settingsToExclude: (RegExp | string)[] = [ webhookRedirectHostUri, azureWebJobsStorageKey, ProjectDirectoryPathKey, workflowAuthenticationMethodKey, 'REMOTEDEBUGGINGVERSION', - // Local-debug-only: absolute path to the local Node.js binary used by the inline-code - // language worker. Deploying it points the cloud worker at a non-existent local path, - // which hangs "Execute JavaScript Code" actions in a Running state indefinitely. inlineCodeNodeExecutablePathKey, + /^WEBSITE_/, + /^FUNCTIONS_RUNTIME/, + 'ScmType', ]; context.telemetry.properties.lastStep = 'getDeployFsPath'; @@ -410,7 +410,7 @@ async function managedApiConnectionsExists(workspaceFolder: WorkspaceFolder): Pr async function getProjectPathToDeploy( node: SlotTreeItem, workspaceFolder: WorkspaceFolder, - settingsToExclude: string[], + settingsToExclude: (RegExp | string)[], originalDeployFsPath: string, identityWizardContext: IIdentityWizardContext, actionContext: IActionContext diff --git a/apps/vs-code-designer/src/app/commands/dotnet/__test__/validateDotNetIsLatest.test.ts b/apps/vs-code-designer/src/app/commands/dotnet/__test__/validateDotNetIsLatest.test.ts index c4e8b7e09da..3e30229d4d0 100644 --- a/apps/vs-code-designer/src/app/commands/dotnet/__test__/validateDotNetIsLatest.test.ts +++ b/apps/vs-code-designer/src/app/commands/dotnet/__test__/validateDotNetIsLatest.test.ts @@ -93,4 +93,14 @@ describe('validateDotNetIsLatest', () => { expect(installDotNet).toHaveBeenCalledWith(context, '8'); expect(getLatestDotNetVersion).not.toHaveBeenCalled(); }); + + it('does not throw when majorVersion is undefined (CDN feed unavailable)', async () => { + vi.mocked(binariesExist).mockResolvedValue(true); + vi.mocked(getLocalDotNetVersionFromBinaries).mockResolvedValue('8.0.318'); + vi.mocked(getLatestDotNetVersion).mockResolvedValue('8.0.318'); + + await expect(validateDotNetIsLatest(context, undefined)).resolves.not.toThrow(); + expect(getLocalDotNetVersionFromBinaries).toHaveBeenCalledWith('8'); + expect(context.telemetry.properties.dotnetVersionSource).toBe('fallback'); + }); }); diff --git a/apps/vs-code-designer/src/app/commands/dotnet/validateDotNetIsLatest.ts b/apps/vs-code-designer/src/app/commands/dotnet/validateDotNetIsLatest.ts index c40505a5ca0..6d721143b9b 100644 --- a/apps/vs-code-designer/src/app/commands/dotnet/validateDotNetIsLatest.ts +++ b/apps/vs-code-designer/src/app/commands/dotnet/validateDotNetIsLatest.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { isNullOrUndefined } from '@microsoft/logic-apps-shared'; -import { dotnetDependencyName } from '../../../constants'; +import { defaultDotnetMajorVersion, dotnetDependencyName } from '../../../constants'; import { binariesExist, getLatestDotNetVersion } from '../../utils/binaries'; import { shouldCheckForDependencyUpdates } from '../../state/dependencies'; import { getDotNetCommand, getLocalDotNetVersionFromBinaries } from '../../utils/dotnet/dotnet'; @@ -14,6 +14,12 @@ import type { IActionContext } from '@microsoft/vscode-azext-utils'; export async function validateDotNetIsLatest(context: IActionContext, majorVersion?: string): Promise { context.errorHandling.suppressDisplay = true; context.telemetry.properties.isActivationEvent = 'true'; + + if (!majorVersion) { + context.telemetry.properties.dotnetVersionSource = 'fallback'; + majorVersion = defaultDotnetMajorVersion; + } + const majorVersions = majorVersion.split(','); const binaries = await binariesExist(dotnetDependencyName); diff --git a/apps/vs-code-designer/src/app/commands/enableDevContainer/enableDevContainer.ts b/apps/vs-code-designer/src/app/commands/enableDevContainer/enableDevContainer.ts index 2a3502f0344..914aba0035c 100644 --- a/apps/vs-code-designer/src/app/commands/enableDevContainer/enableDevContainer.ts +++ b/apps/vs-code-designer/src/app/commands/enableDevContainer/enableDevContainer.ts @@ -73,7 +73,6 @@ export async function enableDevContainer(context: IActionContext, workspaceFileP // Add .devcontainer folder to workspace file await addDevContainerToWorkspace(workspaceFile, devContainerFolderName); context.telemetry.properties.step = 'devcontainerAddedToWorkspace'; - context.telemetry.properties.result = 'Succeeded'; const message = localize( diff --git a/apps/vs-code-designer/src/app/commands/ensureWorkspace.ts b/apps/vs-code-designer/src/app/commands/ensureWorkspace.ts index fdfab5ce9be..e7b2a7e4100 100644 --- a/apps/vs-code-designer/src/app/commands/ensureWorkspace.ts +++ b/apps/vs-code-designer/src/app/commands/ensureWorkspace.ts @@ -21,6 +21,7 @@ import { ext } from '../../extensionVariables'; import * as fse from 'fs-extra'; import * as path from 'path'; import { createWorkspaceWebviewCommandHandler } from './shared/workspaceWebviewCommandHandler'; +import { isPathEqual, isSubpath } from '../utils/fs'; /** * Ensures that the current workspace is properly set up for Azure Logic Apps (Standard) projects. @@ -84,6 +85,9 @@ export async function ensureWorkspace(context: IActionContext): Promise } async function createWorkspaceStructureWebview(): Promise { + const currentFolder = vscode.workspace.workspaceFolders?.[0]; + const currentFolderPath = currentFolder?.uri.fsPath ?? ''; + return new Promise((resolve) => { createWorkspaceWebviewCommandHandler({ panelName: localize('createWorkspaceStructure', 'Create workspace structure'), @@ -95,6 +99,9 @@ async function createWorkspaceStructureWebview(): Promise { await createWorkspaceFile(actionContext, data); }); }, + extraInitializeData: { + currentFolderPath, + }, onResolve: resolve, }); }); @@ -103,65 +110,115 @@ async function createWorkspaceStructureWebview(): Promise { export async function createWorkspaceFile(context: IActionContext, options: any): Promise { addLocalFuncTelemetry(context); - const webviewProjectContext: IWebviewProjectContext = options; + const webviewProjectContext = validateWorkspaceProjectPath(options); - // Add telemetry properties for debugging - context.telemetry.properties.hasWorkspaceProjectPath = String(!!webviewProjectContext.workspaceProjectPath); - context.telemetry.properties.workspaceProjectPathType = typeof webviewProjectContext.workspaceProjectPath; + context.telemetry.properties.hasWorkspaceProjectPath = 'true'; context.telemetry.properties.receivedOptionsKeys = Object.keys(options || {}).join(','); - // Validate that workspaceProjectPath exists and has required properties - if (!webviewProjectContext.workspaceProjectPath || !webviewProjectContext.workspaceProjectPath.fsPath) { - const errorMessage = `[EnsureWorkspace] Invalid workspaceProjectPath: ${JSON.stringify( - { - hasWorkspaceProjectPath: !!webviewProjectContext.workspaceProjectPath, - workspaceProjectPathType: typeof webviewProjectContext.workspaceProjectPath, - workspaceProjectPathValue: webviewProjectContext.workspaceProjectPath, - contextKeys: Object.keys(options || {}), - }, - null, - 2 - )}`; - ext.outputChannel.appendLog(errorMessage); + const workspaceFolderPath = path.join(webviewProjectContext.workspaceProjectPath.fsPath, webviewProjectContext.workspaceName); + const currentFolder = vscode.workspace.workspaceFolders?.[0]; + const currentFolderPath = currentFolder?.uri.fsPath; + + if (currentFolderPath && isSubpath(currentFolderPath, workspaceFolderPath)) { throw new Error( - `workspaceProjectPath is required and must have an fsPath property. Received: ${JSON.stringify(webviewProjectContext.workspaceProjectPath)}` + localize( + 'workspaceLocationInsideCurrentFolder', + 'Workspace location "{0}" is inside the currently open folder "{1}". Choose the current folder (in-place) or a location outside it.', + workspaceFolderPath, + currentFolderPath + ) ); } - const workspaceFolderPath = path.join(webviewProjectContext.workspaceProjectPath.fsPath, webviewProjectContext.workspaceName); - - await fse.ensureDir(workspaceFolderPath); - const workspaceFilePath = path.join(workspaceFolderPath, `${webviewProjectContext.workspaceName}.code-workspace`); + const isInPlace = currentFolderPath !== undefined && isPathEqual(workspaceFolderPath, currentFolderPath); + + context.telemetry.properties.isInPlace = String(isInPlace); + + if (isInPlace) { + // In-place: the workspace folder IS the current project folder. + // Just write the .code-workspace file — no copying needed. + const workspaceFolders = await buildInPlaceWorkspaceFolders(currentFolderPath); + const workspaceFilePath = path.join(workspaceFolderPath, `${webviewProjectContext.workspaceName}.code-workspace`); + await fse.writeJson(workspaceFilePath, { folders: workspaceFolders }, { spaces: 2 }); + await vscode.commands.executeCommand(vscodeCommand.openFolder, vscode.Uri.file(workspaceFilePath), true); + } else { + // Different location: copy project files into the new workspace folder. + await fse.ensureDir(workspaceFolderPath); + const workspaceFolders = await copyWorkspaceFolders(workspaceFolderPath); + const workspaceFilePath = path.join(workspaceFolderPath, `${webviewProjectContext.workspaceName}.code-workspace`); + await fse.writeJson(workspaceFilePath, { folders: workspaceFolders }, { spaces: 2 }); + await vscode.commands.executeCommand(vscodeCommand.openFolder, vscode.Uri.file(workspaceFilePath), true); + } +} - // Start with an empty folders array - const workspaceFolders = []; - const foldersToAdd = vscode.workspace.workspaceFolders; +/** + * Builds workspace folder descriptors for the in-place case (no copying). + * If the current folder is a Logic App project, it becomes a single entry referencing ".". + * Otherwise, each child directory becomes a workspace entry. + */ +async function buildInPlaceWorkspaceFolders(currentFolderPath: string): Promise> { + if (await isLogicAppProject(currentFolderPath)) { + return [{ name: path.basename(currentFolderPath), path: '.' }]; + } - 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}` }); - } + // Each child is a separate workspace entry + const entries: Array<{ name: string; path: string }> = []; + const children = await fse.readdir(currentFolderPath, { withFileTypes: true }); + for (const child of children) { + if (child.isDirectory()) { + entries.push({ name: child.name, path: `./${child.name}` }); } } + return entries; +} - const workspaceData = { - folders: workspaceFolders, - }; +/** + * Copies workspace folders from the current VS Code workspace into a new location + * and returns workspace folder descriptors. + */ +async function copyWorkspaceFolders(workspaceFolderPath: string): Promise> { + const foldersToAdd = vscode.workspace.workspaceFolders; + if (!foldersToAdd || foldersToAdd.length !== 1) { + return []; + } - await fse.writeJson(workspaceFilePath, workspaceData, { spaces: 2 }); + const folder = foldersToAdd[0]; + const sourcePath = folder.uri.fsPath; - const uri = vscode.Uri.file(workspaceFilePath); + if (await isLogicAppProject(sourcePath)) { + const destPath = path.join(workspaceFolderPath, folder.name); + await fse.copy(sourcePath, destPath); + return [{ name: folder.name, path: `./${folder.name}` }]; + } - await vscode.commands.executeCommand(vscodeCommand.openFolder, uri, true /* forceNewWindow */); + // Each child becomes a separate workspace entry + const entries: Array<{ name: string; path: string }> = []; + const children = await fse.readdir(sourcePath); + for (const child of children) { + const fullPath = path.join(sourcePath, child); + await fse.copy(fullPath, path.join(workspaceFolderPath, child)); + entries.push({ name: child, path: `./${child}` }); + } + return entries; +} + +function validateWorkspaceProjectPath(options: any): IWebviewProjectContext { + const ctx: IWebviewProjectContext = options; + if (!ctx.workspaceProjectPath?.fsPath) { + const detail = JSON.stringify({ + hasWorkspaceProjectPath: !!ctx.workspaceProjectPath, + type: typeof ctx.workspaceProjectPath, + value: ctx.workspaceProjectPath, + keys: Object.keys(options || {}), + }); + ext.outputChannel.appendLog(`[EnsureWorkspace] Invalid workspaceProjectPath: ${detail}`); + throw new Error( + localize( + 'invalidWorkspaceProjectPath', + 'Invalid workspaceProjectPath: {0}.', + detail + ) + ); + } + return ctx; } diff --git a/apps/vs-code-designer/src/app/commands/funcCoreTools/validateFuncCoreToolsIsLatest.ts b/apps/vs-code-designer/src/app/commands/funcCoreTools/validateFuncCoreToolsIsLatest.ts index 06a00dd2263..e2188283ac1 100644 --- a/apps/vs-code-designer/src/app/commands/funcCoreTools/validateFuncCoreToolsIsLatest.ts +++ b/apps/vs-code-designer/src/app/commands/funcCoreTools/validateFuncCoreToolsIsLatest.ts @@ -13,7 +13,6 @@ import { getBrewPackageName } from '../../utils/funcCoreTools/getBrewPackageName import { getFuncPackageManagers } from '../../utils/funcCoreTools/getFuncPackageManagers'; import { getNpmDistTag } from '../../utils/funcCoreTools/getNpmDistTag'; import { sendRequestWithExtTimeout } from '../../utils/requestUtils'; -import { getWorkspaceSetting } from '../../utils/vsCodeConfig/settings'; import { isMultiCoreToolsWarningSuppressed, suppressMultiCoreToolsWarning } from '../../state/notifications'; import { installFuncCoreToolsBinaries } from './installFuncCoreTools'; import { uninstallFuncCoreTools } from './uninstallFuncCoreTools'; diff --git a/apps/vs-code-designer/src/app/commands/generateDeploymentScripts/generateDeploymentScripts.ts b/apps/vs-code-designer/src/app/commands/generateDeploymentScripts/generateDeploymentScripts.ts index 09aa349e018..805104480fb 100644 --- a/apps/vs-code-designer/src/app/commands/generateDeploymentScripts/generateDeploymentScripts.ts +++ b/apps/vs-code-designer/src/app/commands/generateDeploymentScripts/generateDeploymentScripts.ts @@ -95,7 +95,6 @@ export async function generateDeploymentScripts(context: IActionContext, node?: await wizard.prompt(); await wizard.execute(); - context.telemetry.properties.result = 'Succeeded'; ext.outputChannel.appendLog(localize('completeAzureDeploymentScriptsWizard', 'Azure deployment scripts wizard executed successfully.')); } catch (error) { context.telemetry.properties.pinnedBundleVersion = ext.pinnedBundleVersion.has(projectPath) diff --git a/apps/vs-code-designer/src/app/commands/pickCustomCodeWorkerProcess.ts b/apps/vs-code-designer/src/app/commands/pickCustomCodeWorkerProcess.ts index c56dcc3bf80..f5d46d4b161 100644 --- a/apps/vs-code-designer/src/app/commands/pickCustomCodeWorkerProcess.ts +++ b/apps/vs-code-designer/src/app/commands/pickCustomCodeWorkerProcess.ts @@ -9,17 +9,17 @@ import type * as vscode from 'vscode'; import * as path from 'path'; import { getUnixChildren, getWindowsChildren, pickChildProcess } from './pickFuncProcess'; import { localize } from '../../localize'; -import { ext } from '../../extensionVariables'; +import { delay } from '../utils/delay'; import { Platform } from '@microsoft/vscode-extension-logic-apps'; type OSAgnosticProcess = { command: string | undefined; pid: number | string }; +const WORKER_POLL_INTERVAL_MS = 2000; +const WORKER_POLL_TIMEOUT_MS = 30000; + /** * Picks the .NET host child process of the running function task for the custom code project. - * @param context The action context. - * @param workspaceFolder The workspace folder containing the logic app. - * @param projectPath The path to the logic app project root. - * @returns A promise that resolves to the .NET host child process ID or undefined if not found. + * Polls with a timeout because the worker is spawned lazily by the Functions host. */ export async function pickCustomCodeNetHostProcessInternal( context: IActionContext, @@ -40,22 +40,22 @@ export async function pickCustomCodeNetHostProcessInternal( } context.telemetry.properties.lastStep = 'pickNetHostChildProcess'; - const customCodeNetHostProcess = await pickCustomCodeWorkerChildProcess(taskInfo, false, isCodeless); + const customCodeNetHostProcess = await pollForWorkerProcess(context, taskInfo, false, isCodeless); if (!customCodeNetHostProcess) { + const errorMessage = + 'Failed to find the .NET host child process for the functions project for logic app "{0}". This may be due to the logic app not having a custom code action.'; context.telemetry.properties.result = 'Failed'; - ext.outputChannel.appendLog( - localize( - 'customCodeNet8ChildProcessNotFound', - `Failed to find the .NET host child process for the functions project for logic app "${logicAppName}". This may be due to the logic app not having a custom code action.` - ) - ); - return undefined; + context.telemetry.properties.errorMessage = errorMessage.replace('{0}', logicAppName); + throw new Error(localize('customCodeNet8ChildProcessNotFound', errorMessage, logicAppName)); } - context.telemetry.properties.result = 'Succeeded'; return customCodeNetHostProcess; } +/** + * Picks the CustomCodeNetFxWorker child process of the running function task. + * Polls with a timeout because the worker is spawned lazily by the Functions host. + */ export async function pickCustomCodeNetFxWorkerProcessInternal( context: IActionContext, workspaceFolder: vscode.WorkspaceFolder, @@ -74,22 +74,44 @@ export async function pickCustomCodeNetFxWorkerProcessInternal( } context.telemetry.properties.lastStep = 'pickNetFxWorkerChildProcess'; - const customCodeNetFxWorkerProcess = await pickCustomCodeWorkerChildProcess(taskInfo, true); + const customCodeNetFxWorkerProcess = await pollForWorkerProcess(context, taskInfo, true, true); if (!customCodeNetFxWorkerProcess) { + const errorMessage = + 'Failed to find the CustomCodeNetFxWorker process for logic app "{0}". This may be due to the logic app not having a custom code action.'; context.telemetry.properties.result = 'Failed'; - ext.outputChannel.appendLog( - localize( - 'customCodeNetFxChildProcessNotFound', - `Failed to find the CustomCodeNetFxWorker process for logic app "${logicAppName}". This may be due to the logic app not having a custom code action.` - ) - ); - return undefined; + context.telemetry.properties.errorMessage = errorMessage.replace('{0}', logicAppName); + throw new Error(localize('customCodeNetFxChildProcessNotFound', errorMessage, logicAppName)); } - context.telemetry.properties.result = 'Succeeded'; return customCodeNetFxWorkerProcess; } +/** + * Polls for a custom code worker child process until it appears or the timeout elapses. + * Worker processes are spawned lazily by the Functions host, so a single snapshot may + * miss them if they haven't started yet. + */ +async function pollForWorkerProcess( + context: IActionContext, + taskInfo: IRunningFuncTask, + isNetFxWorker: boolean, + isCodeless: boolean +): Promise { + const startTime = Date.now(); + + while (Date.now() - startTime < WORKER_POLL_TIMEOUT_MS) { + const pid = await pickCustomCodeWorkerChildProcess(taskInfo, isNetFxWorker, isCodeless); + if (pid) { + context.telemetry.measurements.workerWaitDuration = (Date.now() - startTime) / 1000; + return pid; + } + await delay(WORKER_POLL_INTERVAL_MS); + } + + context.telemetry.measurements.workerWaitDuration = (Date.now() - startTime) / 1000; + return undefined; +} + export async function pickCustomCodeWorkerChildProcess( taskInfo: IRunningFuncTask, isNetFxWorker: boolean, diff --git a/apps/vs-code-designer/src/app/utils/__test__/bundleFeed.test.ts b/apps/vs-code-designer/src/app/utils/__test__/bundleFeed.test.ts index 0b81af9fa68..a180078fa14 100644 --- a/apps/vs-code-designer/src/app/utils/__test__/bundleFeed.test.ts +++ b/apps/vs-code-designer/src/app/utils/__test__/bundleFeed.test.ts @@ -1,6 +1,6 @@ import { getBundleVersionNumber, - getDependenciesVersion, + getBundleDependencyFeed, getExtensionBundleFolder, getLatestVersionRange, addDefaultBundle, @@ -582,7 +582,7 @@ describe('getLatestVersionRange', () => { }); }); -describe('getDependenciesVersion', () => { +describe('getBundleDependencyFeed', () => { it('loads dependency feed using a local settings source URI override', async () => { const verifyIsProjectMod = await import('../verifyIsProject'); vi.mocked(verifyIsProjectMod.tryGetLogicAppProjectRoot).mockResolvedValue('/mock/project/path'); @@ -595,7 +595,7 @@ describe('getDependenciesVersion', () => { vi.mocked(feedModule.getJsonFeed).mockResolvedValue({ id: extensionBundleId } as any); const context = { telemetry: { properties: {}, measurements: {} } }; - await expect(getDependenciesVersion(context as any)).resolves.toEqual({ id: extensionBundleId }); + await expect(getBundleDependencyFeed(context as any)).resolves.toEqual({ id: extensionBundleId }); expect(feedModule.getJsonFeed).toHaveBeenCalledWith( context, diff --git a/apps/vs-code-designer/src/app/utils/bundleFeed.ts b/apps/vs-code-designer/src/app/utils/bundleFeed.ts index ca2a29861a9..78d0e81c62e 100644 --- a/apps/vs-code-designer/src/app/utils/bundleFeed.ts +++ b/apps/vs-code-designer/src/app/utils/bundleFeed.ts @@ -20,7 +20,7 @@ import { getJsonFeed } from './feed'; import { recordDependencyUpdateCheck, shouldCheckForDependencyUpdates } from '../state/dependencies'; import { getGlobalSetting } from './vsCodeConfig/settings'; import type { IActionContext } from '@microsoft/vscode-azext-utils'; -import type { IBundleDependencyFeed, IBundleMetadata, IHostJsonV2 } from '@microsoft/vscode-extension-logic-apps'; +import type { IRuntimeDependencyVersions, IBundleMetadata, IHostJsonV2 } from '@microsoft/vscode-extension-logic-apps'; import { AsyncLocalStorage } from 'node:async_hooks'; import * as path from 'path'; import * as semver from 'semver'; @@ -124,12 +124,12 @@ async function getWorkflowBundleFeed(context: IActionContext): Promise * Gets extension bundle dependency feed. * @param {IActionContext} context - Command context. * @param {IBundleMetadata | undefined} bundleMetadata - Bundle meta data. - * @returns {Promise} Returns bundle extension object. + * @returns {Promise} Returns bundle extension object. */ -async function getBundleDependencyFeed( +export async function getBundleDependencyFeed( context: IActionContext, - bundleMetadata: IBundleMetadata | undefined -): Promise { + bundleMetadata?: IBundleMetadata +): Promise { const bundleId: string = (bundleMetadata && bundleMetadata?.id) || extensionBundleId; const { baseUrl } = await getExtensionBundleBaseUrl(context); const url = `${baseUrl}/ExtensionBundles/${bundleId}/dependency.json`; @@ -144,16 +144,6 @@ export function getLatestVersionRange(): string { return defaultVersionRange; } -/** - * Gets latest bundle extension dependencies versions. - * @param {IActionContext} context - Command context. - * @returns {Promise} Returns dependency versions. - */ -export async function getDependenciesVersion(context: IActionContext): Promise { - const feed: IBundleDependencyFeed = await getBundleDependencyFeed(context, undefined); - return feed; -} - /** * Add bundle extension version to host.json configuration. * @param {IHostJsonV2} hostJson - Host.json configuration. diff --git a/apps/vs-code-designer/src/app/utils/cloudToLocalUtils.ts b/apps/vs-code-designer/src/app/utils/cloudToLocalUtils.ts index 85d66af0561..4d16ee2c2d1 100644 --- a/apps/vs-code-designer/src/app/utils/cloudToLocalUtils.ts +++ b/apps/vs-code-designer/src/app/utils/cloudToLocalUtils.ts @@ -417,7 +417,6 @@ function runPostExtractSteps(cache: { projectPath: string; textDocumentPath: str if (getContainingWorkspaceFolder(cache.projectPath) && (await fse.pathExists(cache.textDocumentPath))) { window.showTextDocument(await workspace.openTextDocument(Uri.file(cache.textDocumentPath))); } - context.telemetry.properties.result = 'Succeeded'; ext.outputChannel.appendLog(localize('finishedImporting', 'Successfully imported project.')); }); } diff --git a/apps/vs-code-designer/src/constants.ts b/apps/vs-code-designer/src/constants.ts index 6291f74a912..913946cfcdc 100644 --- a/apps/vs-code-designer/src/constants.ts +++ b/apps/vs-code-designer/src/constants.ts @@ -391,6 +391,8 @@ export const DependencyVersion = { } as const; export type DependencyVersion = (typeof DependencyVersion)[keyof typeof DependencyVersion]; +export const defaultDotnetMajorVersion = '8'; + export const hostFileContent = { version: '2.0', extensionBundle: { diff --git a/apps/vs-code-designer/src/test/ui/nugetDebugConversion.test.ts b/apps/vs-code-designer/src/test/ui/nugetDebugConversion.test.ts index 07857400aa5..31cfc4b84be 100644 --- a/apps/vs-code-designer/src/test/ui/nugetDebugConversion.test.ts +++ b/apps/vs-code-designer/src/test/ui/nugetDebugConversion.test.ts @@ -753,12 +753,12 @@ describe('NuGet conversion debug lifecycle', function () { const workbench = new Workbench(); const driver = workbench.getDriver(); + seedBundleProjectFilesIfNeeded(entry); + seedRunnableWorkflow(entry); await openWorkspaceFileInSession(workbench, entry.wsFilePath); if (process.env.LA_E2E_SKIP_VALIDATION_WAIT !== '1') { await waitForDependencyValidation(driver); } - seedBundleProjectFilesIfNeeded(entry); - seedRunnableWorkflow(entry); await startDebugging(workbench, driver); await runAndVerifyWorkflow('bundle', workbench, driver, entry); diff --git a/apps/vs-code-react/src/app/createWorkspace/__test__/createWorkspace.test.tsx b/apps/vs-code-react/src/app/createWorkspace/__test__/createWorkspace.test.tsx index 07deb94a7c6..d41ed90a87b 100644 --- a/apps/vs-code-react/src/app/createWorkspace/__test__/createWorkspace.test.tsx +++ b/apps/vs-code-react/src/app/createWorkspace/__test__/createWorkspace.test.tsx @@ -80,6 +80,7 @@ const createDefaultState = (overrides: Partial = {}): Crea availableProjects: [], isAddCustomCodeFlow: false, workspaceRootFolder: '', + currentFolderPath: '', ...overrides, }; }; diff --git a/apps/vs-code-react/src/app/createWorkspace/createWorkspace.tsx b/apps/vs-code-react/src/app/createWorkspace/createWorkspace.tsx index 3b6309b111c..c07278edc9f 100644 --- a/apps/vs-code-react/src/app/createWorkspace/createWorkspace.tsx +++ b/apps/vs-code-react/src/app/createWorkspace/createWorkspace.tsx @@ -7,12 +7,29 @@ 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, setLoading, resetState } from '../../state/createWorkspaceSlice'; +import { + nextStep, + previousStep, + setCurrentStep, + setFlowType, + setLoading, + resetState, + setProjectPath, + setWorkspaceName, +} from '../../state/createWorkspaceSlice'; import { useContext, useEffect } from 'react'; import { useSelector, useDispatch } from 'react-redux'; // Import validation patterns and functions for navigation blocking import { ExtensionCommand, ProjectType } from '@microsoft/vscode-extension-logic-apps'; -import { functionNameValidation, getValidationRequirements, nameValidation, namespaceValidation } from './utils/validation'; +import { + functionNameValidation, + getValidationRequirements, + isWorkspaceDescendantOfCurrentFolder, + joinPath, + nameValidation, + namespaceValidation, + pathsEqual, +} from './utils/validation'; import { useIntlMessages, useIntlFormatters, workspaceMessages } from '../../intl'; import { CreateWorkflowSetup } from '../createWorkflow/createWorkflowSetup'; @@ -60,8 +77,10 @@ const CreateWorkspaceInternal = () => { logicAppsWithoutCustomCode, existingFolders, separator, + platform, isDevContainerProject, availableProjects, + currentFolderPath, } = createWorkspaceState; // Calculate total steps - always 2: Setup and Review + Create @@ -161,14 +180,17 @@ const CreateWorkspaceInternal = () => { }; const getWorkspaceExistencePaths = () => { - const workspaceFolder = `${workspaceProjectPath.fsPath}${separator}${workspaceName}`; + const workspaceFolder = joinPath(workspaceProjectPath.fsPath, workspaceName, separator); const workspaceFile = `${workspaceFolder}${separator}${workspaceName}.code-workspace`; return { workspaceFolder, workspaceFile }; }; const isWorkspaceNameAvailable = () => { const { workspaceFolder, workspaceFile } = getWorkspaceExistencePaths(); - return workspaceExistenceResults[workspaceFolder] === false && workspaceExistenceResults[workspaceFile] === false; + // In the in-place case the folder already exists by definition — only block on .code-workspace file + const isInPlace = currentFolderPath && pathsEqual(workspaceFolder, currentFolderPath, platform); + const folderAvailable = isInPlace || workspaceExistenceResults[workspaceFolder] === false; + return folderAvailable && workspaceExistenceResults[workspaceFile] === false; }; // Helper function to validate logic app name with support for existing logic apps @@ -333,7 +355,14 @@ const CreateWorkspaceInternal = () => { if (flowType === FLOW_TYPES.ENSURE_WORKSPACE) { const workspacePathValid = workspaceProjectPath.fsPath !== '' && pathValidationResults[workspaceProjectPath.fsPath] === true; const workspaceNameValid = workspaceName.trim() !== '' && nameValidation.test(workspaceName.trim()) && isWorkspaceNameAvailable(); - return workspacePathValid && workspaceNameValid; + const workspaceLocationValid = !isWorkspaceDescendantOfCurrentFolder( + workspaceProjectPath.fsPath, + workspaceName.trim(), + currentFolderPath, + separator, + platform + ); + return workspacePathValid && workspaceNameValid && workspaceLocationValid; } // For other flow types, use the full validation @@ -747,11 +776,24 @@ export const CreateWorkspaceFromPackage = () => { export const CreateWorkspaceStructure = () => { const dispatch = useDispatch(); + const { currentFolderPath, separator } = useSelector((state: RootState) => state.createWorkspace) as CreateWorkspaceState; useEffect(() => { dispatch(resetState(undefined)); dispatch(setFlowType(FLOW_TYPES.ENSURE_WORKSPACE)); - }, [dispatch]); + + // Auto-populate defaults from the current folder path (preserved across resetState). + // Default: workspaceProjectPath = parent of current folder, workspaceName = folder name. + if (currentFolderPath) { + const lastSep = currentFolderPath.lastIndexOf(separator); + if (lastSep > 0) { + const parentPath = currentFolderPath.substring(0, lastSep); + const folderName = currentFolderPath.substring(lastSep + 1); + dispatch(setProjectPath(parentPath)); + dispatch(setWorkspaceName(folderName)); + } + } + }, [dispatch, currentFolderPath, separator]); return ; }; diff --git a/apps/vs-code-react/src/app/createWorkspace/steps/__test__/dotNetFrameworkStep.test.tsx b/apps/vs-code-react/src/app/createWorkspace/steps/__test__/dotNetFrameworkStep.test.tsx index 3baa6a1bd37..0142773da10 100644 --- a/apps/vs-code-react/src/app/createWorkspace/steps/__test__/dotNetFrameworkStep.test.tsx +++ b/apps/vs-code-react/src/app/createWorkspace/steps/__test__/dotNetFrameworkStep.test.tsx @@ -51,6 +51,7 @@ const createTestStore = (overrides: Partial = {}) => { availableProjects: [], isAddCustomCodeFlow: false, workspaceRootFolder: '', + currentFolderPath: '', ...overrides, }; diff --git a/apps/vs-code-react/src/app/createWorkspace/steps/__test__/logicAppTypeStep.test.tsx b/apps/vs-code-react/src/app/createWorkspace/steps/__test__/logicAppTypeStep.test.tsx index 45d973aef41..2eb122c2b74 100644 --- a/apps/vs-code-react/src/app/createWorkspace/steps/__test__/logicAppTypeStep.test.tsx +++ b/apps/vs-code-react/src/app/createWorkspace/steps/__test__/logicAppTypeStep.test.tsx @@ -114,6 +114,7 @@ function createState(overrides: Partial = {}): CreateWorks availableProjects: [], isAddCustomCodeFlow: false, workspaceRootFolder: '', + currentFolderPath: '', ...overrides, }; } diff --git a/apps/vs-code-react/src/app/createWorkspace/steps/__test__/reviewCreateStep.test.tsx b/apps/vs-code-react/src/app/createWorkspace/steps/__test__/reviewCreateStep.test.tsx index 74465543fa9..0dca00250e5 100644 --- a/apps/vs-code-react/src/app/createWorkspace/steps/__test__/reviewCreateStep.test.tsx +++ b/apps/vs-code-react/src/app/createWorkspace/steps/__test__/reviewCreateStep.test.tsx @@ -51,6 +51,7 @@ const createTestStore = (overrides: Partial = {}) => { availableProjects: [], isAddCustomCodeFlow: false, workspaceRootFolder: '', + currentFolderPath: '', ...overrides, }; diff --git a/apps/vs-code-react/src/app/createWorkspace/steps/__test__/workflowTypeStep.test.tsx b/apps/vs-code-react/src/app/createWorkspace/steps/__test__/workflowTypeStep.test.tsx index 978ed37a2a3..37f153f589b 100644 --- a/apps/vs-code-react/src/app/createWorkspace/steps/__test__/workflowTypeStep.test.tsx +++ b/apps/vs-code-react/src/app/createWorkspace/steps/__test__/workflowTypeStep.test.tsx @@ -96,6 +96,7 @@ function createState(overrides: Partial = {}): CreateWorks availableProjects: [], isAddCustomCodeFlow: false, workspaceRootFolder: '', + currentFolderPath: '', ...overrides, }; } diff --git a/apps/vs-code-react/src/app/createWorkspace/steps/__test__/workspaceNameStep.test.tsx b/apps/vs-code-react/src/app/createWorkspace/steps/__test__/workspaceNameStep.test.tsx index 829efa37eee..b8e012340f7 100644 --- a/apps/vs-code-react/src/app/createWorkspace/steps/__test__/workspaceNameStep.test.tsx +++ b/apps/vs-code-react/src/app/createWorkspace/steps/__test__/workspaceNameStep.test.tsx @@ -61,6 +61,7 @@ const createTestStore = (overrides: Partial = {}) => { availableProjects: [], isAddCustomCodeFlow: false, workspaceRootFolder: '', + currentFolderPath: '', ...overrides, }; diff --git a/apps/vs-code-react/src/app/createWorkspace/steps/workspaceNameStep.tsx b/apps/vs-code-react/src/app/createWorkspace/steps/workspaceNameStep.tsx index 6df1cd027ba..52dd7d3ac70 100644 --- a/apps/vs-code-react/src/app/createWorkspace/steps/workspaceNameStep.tsx +++ b/apps/vs-code-react/src/app/createWorkspace/steps/workspaceNameStep.tsx @@ -13,7 +13,7 @@ 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'; -import { nameValidation } from '../utils/validation'; +import { nameValidation, isWorkspaceDescendantOfCurrentFolder, pathsEqual, joinPath } from '../utils/validation'; export const WorkspaceNameStep: React.FC = () => { const dispatch = useDispatch(); @@ -22,8 +22,16 @@ export const WorkspaceNameStep: React.FC = () => { const vscode = useContext(VSCodeContext); const styles = useCreateWorkspaceStyles(); const createWorkspaceState = useSelector((state: RootState) => state.createWorkspace) as CreateWorkspaceState; - const { workspaceName, workspaceProjectPath, pathValidationResults, workspaceExistenceResults, isValidatingWorkspace, separator } = - createWorkspaceState; + const { + workspaceName, + workspaceProjectPath, + pathValidationResults, + workspaceExistenceResults, + isValidatingWorkspace, + separator, + currentFolderPath, + platform, + } = createWorkspaceState; const projectPathInputId = useId(); const workspaceNameId = useId(); @@ -58,12 +66,18 @@ export const WorkspaceNameStep: React.FC = () => { return intlText.WORKSPACE_NAME_VALIDATION; } - // Check if workspace folder or file already exists + // Block workspace locations that are descendants of the currently open folder + if (isWorkspaceDescendantOfCurrentFolder(workspaceProjectPath.fsPath, name, currentFolderPath, separator, platform)) { + return intlText.WORKSPACE_LOCATION_INSIDE_PROJECT; + } + + // Check if workspace folder or file already exists (skip folder check for in-place case) if (workspaceProjectPath.fsPath && name) { - const workspaceFolder = `${workspaceProjectPath.fsPath}${separator}${name}`; + const workspaceFolder = joinPath(workspaceProjectPath.fsPath, name, separator); const workspaceFile = `${workspaceFolder}${separator}${name}.code-workspace`; + const isInPlace = currentFolderPath && pathsEqual(workspaceFolder, currentFolderPath, platform); - if (workspaceExistenceResults[workspaceFolder] === true) { + if (!isInPlace && workspaceExistenceResults[workspaceFolder] === true) { return format.FOLDER_EXISTS_MESSAGE({ name }); } if (workspaceExistenceResults[workspaceFile] === true) { @@ -77,7 +91,10 @@ export const WorkspaceNameStep: React.FC = () => { workspaceProjectPath.fsPath, intlText.WORKSPACE_NAME_EMPTY, intlText.WORKSPACE_NAME_VALIDATION, + intlText.WORKSPACE_LOCATION_INSIDE_PROJECT, separator, + currentFolderPath, + platform, workspaceExistenceResults, format, ] diff --git a/apps/vs-code-react/src/app/createWorkspace/utils/validation.ts b/apps/vs-code-react/src/app/createWorkspace/utils/validation.ts index 60a1944b625..6b8fc341041 100644 --- a/apps/vs-code-react/src/app/createWorkspace/utils/validation.ts +++ b/apps/vs-code-react/src/app/createWorkspace/utils/validation.ts @@ -61,8 +61,30 @@ export const validateFunctionName = (name: string, intlText: any) => { return undefined; }; +/** + * Checks whether the workspace path (parentPath + separator + name) is a strict + * descendant of the currently open folder. Equal paths are allowed (in-place case). + * Uses platform-aware comparison for case sensitivity and normalizes trailing separators. + */ +export function isWorkspaceDescendantOfCurrentFolder( + parentPath: string, + name: string, + currentFolderPath: string, + separator: string, + platform: string | null = null +): boolean { + if (!currentFolderPath || !parentPath || !name) { + return false; + } + const workspacePath = joinPath(parentPath, name, separator); + if (pathsEqual(workspacePath, currentFolderPath, platform)) { + return false; + } + return pathStartsWith(workspacePath, currentFolderPath, separator, platform); +} + // Get validation requirements based on flow type -export const getValidationRequirements = (flowType: string, logicAppType: string) => { +export function getValidationRequirements(flowType: string, logicAppType: string) { const requirements = { needsPackagePath: flowType === 'createWorkspaceFromPackage', needsWorkspacePath: flowType !== 'createLogicApp', @@ -91,4 +113,45 @@ export const getValidationRequirements = (flowType: string, logicAppType: string } return requirements; -}; +} + +/** + * Joins a parent path and a name with the given separator, + * handling trailing separators on the parent to avoid doubling. + */ +export function joinPath(parentPath: string, name: string, separator: string): string { + return `${stripTrailingSeparator(parentPath, separator)}${separator}${name}`; +} + +/** + * Compares two path strings with platform-aware case sensitivity. + * Windows/macOS are case-insensitive; Linux is case-sensitive. + */ +export function pathsEqual(a: string, b: string, platform: string | null): boolean { + if (platform === 'linux') { + return a === b; + } + return a.toLowerCase() === b.toLowerCase(); +} + +/** + * Checks whether `child` starts with `parent` + separator, using platform-aware comparison. + */ +function pathStartsWith(child: string, parent: string, separator: string, platform: string | null): boolean { + const prefix = `${stripTrailingSeparator(parent, separator)}${separator}`; + if (platform === 'linux') { + return child.startsWith(prefix); + } + return child.toLowerCase().startsWith(prefix.toLowerCase()); +} + +/** + * Strips trailing separator(s) from a path string. + * Preserves root paths like "/" or "C:\". + */ +function stripTrailingSeparator(p: string, sep: string): string { + while (p.length > 1 && p.endsWith(sep)) { + p = p.slice(0, -sep.length); + } + return p; +} diff --git a/apps/vs-code-react/src/intl/messages.ts b/apps/vs-code-react/src/intl/messages.ts index 8c6442fac47..4f53e9a378e 100644 --- a/apps/vs-code-react/src/intl/messages.ts +++ b/apps/vs-code-react/src/intl/messages.ts @@ -245,6 +245,12 @@ export const workspaceMessages = defineMessages({ id: 'RRuHNc', description: 'Workspace name validation message text', }, + WORKSPACE_LOCATION_INSIDE_PROJECT: { + defaultMessage: + 'Workspace location cannot be inside the currently open project folder. Choose the current folder or a location outside it.', + id: 'Karw2Q', + description: 'Workspace location is a descendant of the currently open folder error text', + }, USE_DEV_CONTAINER_LABEL: { defaultMessage: 'Use Dev Container', id: '0Va6gs', diff --git a/apps/vs-code-react/src/state/createWorkspaceSlice.ts b/apps/vs-code-react/src/state/createWorkspaceSlice.ts index 9f8615e80bc..a3cb81220bf 100644 --- a/apps/vs-code-react/src/state/createWorkspaceSlice.ts +++ b/apps/vs-code-react/src/state/createWorkspaceSlice.ts @@ -48,6 +48,7 @@ export interface CreateWorkspaceState { availableProjects: AvailableProject[]; isAddCustomCodeFlow: boolean; workspaceRootFolder: string; + currentFolderPath: string; } const initialState: CreateWorkspaceState = { @@ -88,6 +89,7 @@ const initialState: CreateWorkspaceState = { availableProjects: [], isAddCustomCodeFlow: false, workspaceRootFolder: '', + currentFolderPath: '', }; export const createWorkspaceSlice = createSlice, 'createWorkspace'>({ @@ -130,12 +132,15 @@ export const createWorkspaceSlice = createSlice) => { - const { separator, platform, logicAppType, logicAppName, availableProjects } = action.payload; + const { separator, platform, logicAppType, logicAppName, availableProjects, currentFolderPath } = action.payload; state.separator = separator; state.platform = platform; state.logicAppType = logicAppType || ''; state.logicAppName = logicAppName || ''; state.availableProjects = availableProjects || []; + if (currentFolderPath) { + state.currentFolderPath = currentFolderPath; + } }, setCurrentStep: (state, action: PayloadAction) => { state.currentStep = action.payload; @@ -248,6 +253,7 @@ export const createWorkspaceSlice = createSlice = { platform: state.platform, separator: state.separator, + currentFolderPath: state.currentFolderPath, ...(preserveLogicAppData ? { logicAppType: state.logicAppType, diff --git a/libs/vscode-extension/src/graphify-out/GRAPH_REPORT.md b/libs/vscode-extension/src/graphify-out/GRAPH_REPORT.md index ff1b01e2ca6..a93f88206ee 100644 --- a/libs/vscode-extension/src/graphify-out/GRAPH_REPORT.md +++ b/libs/vscode-extension/src/graphify-out/GRAPH_REPORT.md @@ -68,7 +68,7 @@ Nodes (18): Artifacts, FileDetails, IArtifactFile, IGitHubReleaseInfo, IParamete ### Community 3 - "Community 3" Cohesion: 0.15 -Nodes (16): IBundleDependencyFeed, IBundleFeed, BindingSettingValue, IBindingSetting, IBindingTemplate, IEnumValue, ResourceType, ValueType (+8 more) +Nodes (16): IRuntimeDependencyVersions, IBundleFeed, BindingSettingValue, IBindingSetting, IBindingTemplate, IEnumValue, ResourceType, ValueType (+8 more) ### Community 4 - "Community 4" Cohesion: 0.11 @@ -91,13 +91,13 @@ Cohesion: 0.20 Nodes (9): FetchSchemaData, InitializeData, MapDefinitionData, MessageToVsix, MessageToWebview, SchemaPathData, XsltData, ExtensionCommand (+1 more) ## Knowledge Gaps -- **91 isolated node(s):** `IArtifactFile`, `IGitHubReleaseInfo`, `IBundleDependencyFeed`, `ICliFeed`, `IRelease` (+86 more) +- **91 isolated node(s):** `IArtifactFile`, `IGitHubReleaseInfo`, `IRuntimeDependencyVersions`, `ICliFeed`, `IRelease` (+86 more) These have ≤1 connection - possible missing edges or undocumented components. ## Suggested Questions _Questions this graph is uniquely positioned to answer:_ -- **What connects `IArtifactFile`, `IGitHubReleaseInfo`, `IBundleDependencyFeed` to the rest of the system?** +- **What connects `IArtifactFile`, `IGitHubReleaseInfo`, `IRuntimeDependencyVersions` to the rest of the system?** _91 weakly-connected nodes found - possible documentation gaps or missing edges._ - **Should `Community 0` be split into smaller, more focused modules?** _Cohesion score 0.07881773399014778 - nodes in this community are weakly interconnected._ diff --git a/libs/vscode-extension/src/graphify-out/graph.json b/libs/vscode-extension/src/graphify-out/graph.json index 3ed9a9118f4..8087b327a21 100644 --- a/libs/vscode-extension/src/graphify-out/graph.json +++ b/libs/vscode-extension/src/graphify-out/graph.json @@ -124,14 +124,14 @@ "norm_label": "ibundlefeed" }, { - "label": "IBundleDependencyFeed", + "label": "IRuntimeDependencyVersions", "file_type": "code", "source_file": "lib/models/bundleFeed.ts", "source_location": "L17", "_origin": "ast", - "id": "models_bundlefeed_ibundledependencyfeed", + "id": "models_bundlefeed_IRuntimeDependencyVersions", "community": 3, - "norm_label": "ibundledependencyfeed" + "norm_label": "IRuntimeDependencyVersions" }, { "label": "cliFeed.ts", @@ -1894,7 +1894,7 @@ "source_location": "L17", "weight": 1.0, "source": "models_bundlefeed", - "target": "models_bundlefeed_ibundledependencyfeed", + "target": "models_bundlefeed_IRuntimeDependencyVersions", "confidence_score": 1.0 }, { diff --git a/libs/vscode-extension/src/lib/models/bundleFeed.ts b/libs/vscode-extension/src/lib/models/bundleFeed.ts index 4692bf50dde..07d11b4bbb1 100644 --- a/libs/vscode-extension/src/lib/models/bundleFeed.ts +++ b/libs/vscode-extension/src/lib/models/bundleFeed.ts @@ -14,7 +14,7 @@ export interface IBundleFeed { }; } -export interface IBundleDependencyFeed { +export interface IRuntimeDependencyVersions { dotnet?: string; funcCoreTools?: string; nodejs?: string;