From de6ac4639c71b707de39387a55ff1601a03acb25 Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Sat, 1 Mar 2025 04:01:22 +0530 Subject: [PATCH 01/58] Show mocked diff in a tree view --- package.json | 8 + src/client/extension.ts | 4 + src/common/ecs-features/ecsFeatureGates.ts | 10 + .../power-pages/metadata-diff/Constants.ts | 12 ++ .../power-pages/metadata-diff/MetadataDiff.ts | 55 ++++++ .../MetadataDiffTreeDataProvider.ts | 174 ++++++++++++++++++ .../tree-items/MetadataDiffFileItem.ts | 24 +++ .../tree-items/MetadataDiffFolderItem.ts | 14 ++ .../tree-items/MetadataDiffTreeItem.ts | 37 ++++ 9 files changed, 338 insertions(+) create mode 100644 src/common/power-pages/metadata-diff/Constants.ts create mode 100644 src/common/power-pages/metadata-diff/MetadataDiff.ts create mode 100644 src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts create mode 100644 src/common/power-pages/metadata-diff/tree-items/MetadataDiffFileItem.ts create mode 100644 src/common/power-pages/metadata-diff/tree-items/MetadataDiffFolderItem.ts create mode 100644 src/common/power-pages/metadata-diff/tree-items/MetadataDiffTreeItem.ts diff --git a/package.json b/package.json index d6fcd7c6b..c423d1821 100644 --- a/package.json +++ b/package.json @@ -1141,6 +1141,14 @@ "icon": "./src/client/assets/powerPages.svg", "contextualTitle": "%microsoft.powerplatform.pages.actionsHub.title%", "visibility": "visible" + }, + { + "id": "microsoft.powerplatform.pages.metadataDiff", + "name": "%microsoft.powerplatform.pages.metadataDiff.title%", + "when": "microsoft.powerplatform.pages.metadataDiffEnabled", + "icon": "./src/client/assets/powerPages.svg", + "contextualTitle": "%microsoft.powerplatform.pages.metadataDiff.title%", + "visibility": "visible" } ] }, diff --git a/src/client/extension.ts b/src/client/extension.ts index 56f18103a..6f5c4dec1 100644 --- a/src/client/extension.ts +++ b/src/client/extension.ts @@ -47,6 +47,8 @@ import { ActionsHub } from "./power-pages/actions-hub/ActionsHub"; import { extractAuthInfo, extractOrgInfo } from "./power-pages/commonUtility"; import PacContext from "./pac/PacContext"; import ArtemisContext from "./ArtemisContext"; +import { MetadataDiff } from "../common/power-pages/metadata-diff/MetadataDiff"; +import { MetadataDiffTreeDataProvider } from "../common/power-pages/metadata-diff/MetadataDiffTreeDataProvider"; let client: LanguageClient; let _context: vscode.ExtensionContext; @@ -260,6 +262,8 @@ export async function activate( const workspaceFolderWatcher = vscode.workspace.onDidChangeWorkspaceFolders(handleWorkspaceFolderChange); _context.subscriptions.push(workspaceFolderWatcher); + MetadataDiff.initialize(context) + if (shouldEnableDebugger()) { activateDebugger(context); } diff --git a/src/common/ecs-features/ecsFeatureGates.ts b/src/common/ecs-features/ecsFeatureGates.ts index aa09ffea2..fd0a33924 100644 --- a/src/common/ecs-features/ecsFeatureGates.ts +++ b/src/common/ecs-features/ecsFeatureGates.ts @@ -68,3 +68,13 @@ export const { enableActionsHub: false, }, }); + +export const { + feature: EnableMetadataDiff +} = getFeatureConfigs({ + teamName: PowerPagesClientName, + description: 'Enable Metadata Diff comparison in VS Code', + fallback: { + enableMetadataDiff: false, + }, +}); diff --git a/src/common/power-pages/metadata-diff/Constants.ts b/src/common/power-pages/metadata-diff/Constants.ts new file mode 100644 index 000000000..caf9d1755 --- /dev/null +++ b/src/common/power-pages/metadata-diff/Constants.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 const Constants = { + EventNames: { + METADATA_DIFF_INITIALIZED: "metadataDiffInitialized", + METADATA_DIFF_INITIALIZATION_FAILED: "metadataDiffInitializationFailed", + METADATA_DIFF_CURRENT_ENV_FETCH_FAILED: "metadataDiffCurrentEnvFetchFailed", + } +}; diff --git a/src/common/power-pages/metadata-diff/MetadataDiff.ts b/src/common/power-pages/metadata-diff/MetadataDiff.ts new file mode 100644 index 000000000..559eb5286 --- /dev/null +++ b/src/common/power-pages/metadata-diff/MetadataDiff.ts @@ -0,0 +1,55 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import * as vscode from "vscode"; +import { ECSFeaturesClient } from "../../ecs-features/ecsFeatureClient"; +import { EnableMetadataDiff } from "../../ecs-features/ecsFeatureGates"; +import { MetadataDiffTreeDataProvider } from "./MetadataDiffTreeDataProvider"; +import { oneDSLoggerWrapper } from "../../OneDSLoggerTelemetry/oneDSLoggerWrapper"; +import { Constants } from "./Constants"; + +export class MetadataDiff { + private static _isInitialized = false; + + static isEnabled(): boolean { + const enableMetadataDiff = ECSFeaturesClient.getConfig(EnableMetadataDiff).enableMetadataDiff + + if (enableMetadataDiff === undefined) { + return false; + } + + return enableMetadataDiff; + } + + static async initialize(context: vscode.ExtensionContext): Promise { + if (MetadataDiff._isInitialized) { + return; + } + + try { + const isMetadataDiffEnabled = MetadataDiff.isEnabled(); + + oneDSLoggerWrapper.getLogger().traceInfo("EnableMetadataDiff", { + isEnabled: isMetadataDiffEnabled.toString() + }); + + vscode.commands.executeCommand("setContext", "microsoft.powerplatform.pages.metadataDiffEnabled", isMetadataDiffEnabled); + + if (!isMetadataDiffEnabled) { + return; + } + const treeDataProvider = MetadataDiffTreeDataProvider.initialize(context); + context.subscriptions.push( + vscode.window.registerTreeDataProvider("microsoft.powerplatform.pages.metadataDiff", treeDataProvider) + ); + + MetadataDiff._isInitialized = true; + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_INITIALIZED); + } catch (exception) { + const exceptionError = exception as Error; + oneDSLoggerWrapper.getLogger().traceError(Constants.EventNames.METADATA_DIFF_INITIALIZATION_FAILED, exceptionError.message, exceptionError); + } + } +} diff --git a/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts b/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts new file mode 100644 index 000000000..5a6d9b3ef --- /dev/null +++ b/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts @@ -0,0 +1,174 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import * as vscode from "vscode"; +import * as fs from "fs"; +import * as path from "path"; +import { MetadataDiffTreeItem } from "./tree-items/MetadataDiffTreeItem"; +import { Constants } from "./Constants"; +import { oneDSLoggerWrapper } from "../../OneDSLoggerTelemetry/oneDSLoggerWrapper"; +import { MetadataDiffFileItem } from "./tree-items/MetadataDiffFileItem"; +import { MetadataDiffFolderItem } from "./tree-items/MetadataDiffFolderItem"; + +export class MetadataDiffTreeDataProvider implements vscode.TreeDataProvider { + private readonly _disposables: vscode.Disposable[] = []; + private readonly _context: vscode.ExtensionContext; + private _onDidChangeTreeData: vscode.EventEmitter = new vscode.EventEmitter(); + readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event; + + private constructor(context: vscode.ExtensionContext) { + this._context = context; + } + + private refresh(): void { + this._onDidChangeTreeData.fire(); + } + + public static initialize(context: vscode.ExtensionContext): MetadataDiffTreeDataProvider { + return new MetadataDiffTreeDataProvider(context); + } + + getTreeItem(element: MetadataDiffTreeItem): vscode.TreeItem | Thenable { + return element; + } + + private getAllFilesRecursively(dir: string, fileList: string[] = []): string[] { + const files = fs.readdirSync(dir); + + for (const file of files) { + const fullPath = path.join(dir, file); + if (fs.statSync(fullPath).isDirectory()) { + this.getAllFilesRecursively(fullPath, fileList); + } else { + fileList.push(fullPath); + } + } + + return fileList; + } + + private async getDiffFilesWithMockChanges(): Promise { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders) { + return []; + } + + const workspacePath = workspaceFolders[0].uri.fsPath; + const storagePath = this._context.storageUri?.fsPath; + if (!storagePath) { + return []; + } + + const diffFiles: string[] = []; + const allFiles = this.getAllFilesRecursively(workspacePath); + + // Create copies in storagePath if they don't exist + for (const file of allFiles) { + const relativePath = path.relative(workspacePath, file); + const storageFile = path.join(storagePath, relativePath); + + // Ensure directory exists + fs.mkdirSync(path.dirname(storageFile), { recursive: true }); + + if (!fs.existsSync(storageFile)) { + fs.copyFileSync(file, storageFile); + } + } + + // Mock a random file change + const randomFile = allFiles[Math.floor(Math.random() * allFiles.length)]; + fs.writeFileSync(path.join(storagePath, path.relative(workspacePath, randomFile)), ""); + + // Compare files + for (const file of allFiles) { + const relativePath = path.relative(workspacePath, file); + const storageFile = path.join(storagePath, relativePath); + + if (fs.existsSync(storageFile)) { + const workspaceContent = fs.readFileSync(file, "utf8"); + const storageContent = fs.readFileSync(storageFile, "utf8"); + + if (workspaceContent !== storageContent) { + diffFiles.push(relativePath); + } + } else { + diffFiles.push(relativePath); + } + } + + return diffFiles; + } + + async getChildren(element?: MetadataDiffTreeItem): Promise { + if (element) { + return element.getChildren(); + } + + try { + const diffFiles = await this.getDiffFilesWithMockChanges(); + if (diffFiles.length === 0) { + return []; + } + + const filePathMap = new Map(); + + diffFiles.forEach(relativePath => { + const storedFileUri = vscode.Uri.joinPath(this._context.storageUri!, relativePath); + filePathMap.set(relativePath, storedFileUri.fsPath); + }); + + return this.buildTreeHierarchy(filePathMap); + } catch (error) { + oneDSLoggerWrapper.getLogger().traceError( + Constants.EventNames.METADATA_DIFF_CURRENT_ENV_FETCH_FAILED, + error as string, + error as Error, + { methodName: this.getChildren }, + {} + ); + return null; + } + } + + private buildTreeHierarchy(filePathMap: Map): MetadataDiffTreeItem[] { + const rootItems: Map = new Map(); + const workspaceRoot = vscode.workspace.workspaceFolders![0].uri.fsPath; + + filePathMap.forEach((storedFilePath, relativePath) => { + const parts = relativePath.split(path.sep); + let currentLevel = rootItems; + let parentItem: MetadataDiffTreeItem | undefined; + + for (let i = 0; i < parts.length; i++) { + const isFile = i === parts.length - 1; + const name = parts[i]; + + if (!currentLevel.has(name)) { + let newItem: MetadataDiffTreeItem; + + if (isFile) { + const absoluteWorkspaceFilePath = path.join(workspaceRoot, relativePath); + newItem = new MetadataDiffFileItem(name, absoluteWorkspaceFilePath, storedFilePath); + } else { + newItem = new MetadataDiffFolderItem(name); + } + + if (parentItem) { + (parentItem as MetadataDiffFolderItem).getChildrenMap().set(name, newItem); + } + + currentLevel.set(name, newItem); + } + + parentItem = currentLevel.get(name); + if (!isFile) { + currentLevel = (parentItem as MetadataDiffFolderItem).getChildrenMap(); + } + } + }); + + return Array.from(rootItems.values()); + } +} diff --git a/src/common/power-pages/metadata-diff/tree-items/MetadataDiffFileItem.ts b/src/common/power-pages/metadata-diff/tree-items/MetadataDiffFileItem.ts new file mode 100644 index 000000000..5f61e05e1 --- /dev/null +++ b/src/common/power-pages/metadata-diff/tree-items/MetadataDiffFileItem.ts @@ -0,0 +1,24 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import * as vscode from "vscode"; +import { MetadataDiffTreeItem } from "./MetadataDiffTreeItem"; + +export class MetadataDiffFileItem extends MetadataDiffTreeItem { + constructor(label: string, workspaceFilePath: string, storedFilePath: string) { + super(label, vscode.TreeItemCollapsibleState.None, "file", workspaceFilePath); + + const workspaceUri = vscode.Uri.file(workspaceFilePath); + const storedUri = vscode.Uri.file(storedFilePath); + + this.resourceUri = workspaceUri; + this.command = { + command: "vscode.diff", + title: "Compare Changes", + arguments: [storedUri, workspaceUri, `Diff: ${label}`] + }; + this.iconPath = new vscode.ThemeIcon("file"); + } +} diff --git a/src/common/power-pages/metadata-diff/tree-items/MetadataDiffFolderItem.ts b/src/common/power-pages/metadata-diff/tree-items/MetadataDiffFolderItem.ts new file mode 100644 index 000000000..334da6e3c --- /dev/null +++ b/src/common/power-pages/metadata-diff/tree-items/MetadataDiffFolderItem.ts @@ -0,0 +1,14 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import * as vscode from "vscode"; +import { MetadataDiffTreeItem } from "./MetadataDiffTreeItem"; + +export class MetadataDiffFolderItem extends MetadataDiffTreeItem { + constructor(label: string) { + super(label, vscode.TreeItemCollapsibleState.Collapsed, "folder"); + this.iconPath = new vscode.ThemeIcon("folder"); + } +} diff --git a/src/common/power-pages/metadata-diff/tree-items/MetadataDiffTreeItem.ts b/src/common/power-pages/metadata-diff/tree-items/MetadataDiffTreeItem.ts new file mode 100644 index 000000000..8aa2a9570 --- /dev/null +++ b/src/common/power-pages/metadata-diff/tree-items/MetadataDiffTreeItem.ts @@ -0,0 +1,37 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import * as vscode from "vscode"; + +export abstract class MetadataDiffTreeItem extends vscode.TreeItem { + protected _children: Map = new Map(); + + constructor( + public readonly label: string, + public readonly collapsibleState: vscode.TreeItemCollapsibleState, + public readonly contextValue: string, + public readonly filePath?: string, // Workspace file path + public readonly storedFilePath?: string // Backup copy path + ) { + super(label, collapsibleState); + this.tooltip = this.label; + + if (filePath && storedFilePath) { + this.command = { + command: "metadataDiff.openDiff", + title: "Open Diff", + arguments: [filePath, storedFilePath] // Pass file paths to the command + }; + } + } + + public getChildren(): MetadataDiffTreeItem[] { + return Array.from(this._children.values()); + } + + public getChildrenMap(): Map { + return this._children; + } +} From 9ca62049069412768e701612a7db65761e2a26f9 Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Sat, 1 Mar 2025 16:58:52 +0530 Subject: [PATCH 02/58] refactored data provider --- .../MetadataDiffTreeDataProvider.ts | 78 ++++++++++++------- 1 file changed, 51 insertions(+), 27 deletions(-) diff --git a/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts b/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts index 5a6d9b3ef..dfe989530 100644 --- a/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts +++ b/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts @@ -49,19 +49,31 @@ export class MetadataDiffTreeDataProvider implements vscode.TreeDataProvider { - const workspaceFolders = vscode.workspace.workspaceFolders; - if (!workspaceFolders) { - return []; - } + private async getDiffFiles(workspacePath: string, storagePath: string): Promise { + const diffFiles: string[] = []; + const allFiles = this.getAllFilesRecursively(workspacePath); - const workspacePath = workspaceFolders[0].uri.fsPath; - const storagePath = this._context.storageUri?.fsPath; - if (!storagePath) { - return []; + // Compare files + for (const file of allFiles) { + const relativePath = path.relative(workspacePath, file); + const storageFile = path.join(storagePath, relativePath); + + if (fs.existsSync(storageFile)) { + const workspaceContent = fs.readFileSync(file, "utf8"); + const storageContent = fs.readFileSync(storageFile, "utf8"); + + if (workspaceContent !== storageContent) { + diffFiles.push(relativePath); + } + } else { + diffFiles.push(relativePath); + } } + return diffFiles; + } + + private async mockChangesAndCopyFiles(workspacePath: string, storagePath: string): Promise { - const diffFiles: string[] = []; const allFiles = this.getAllFilesRecursively(workspacePath); // Create copies in storagePath if they don't exist @@ -81,24 +93,30 @@ export class MetadataDiffTreeDataProvider implements vscode.TreeDataProvider { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders) { + return []; + } - if (workspaceContent !== storageContent) { - diffFiles.push(relativePath); - } - } else { - diffFiles.push(relativePath); - } + const workspacePath = workspaceFolders[0].uri.fsPath; + const storagePath = this._context.storageUri?.fsPath; + if (!storagePath) { + throw new Error("Storage path is not defined"); } - return diffFiles; + // Clean out any existing files in storagePath (so we have a "fresh" download) + if (fs.existsSync(storagePath)) { + fs.rmSync(storagePath, { recursive: true, force: true }); + } + fs.mkdirSync(storagePath, { recursive: true }); + + await this.mockChangesAndCopyFiles(workspacePath, storagePath); + + return this.getDiffFiles(workspacePath, storagePath); } async getChildren(element?: MetadataDiffTreeItem): Promise { @@ -115,8 +133,10 @@ export class MetadataDiffTreeDataProvider implements vscode.TreeDataProvider(); diffFiles.forEach(relativePath => { - const storedFileUri = vscode.Uri.joinPath(this._context.storageUri!, relativePath); - filePathMap.set(relativePath, storedFileUri.fsPath); + if (this._context.storageUri) { + const storedFileUri = vscode.Uri.joinPath(this._context.storageUri, relativePath); + filePathMap.set(relativePath, storedFileUri.fsPath); + } }); return this.buildTreeHierarchy(filePathMap); @@ -134,7 +154,11 @@ export class MetadataDiffTreeDataProvider implements vscode.TreeDataProvider): MetadataDiffTreeItem[] { const rootItems: Map = new Map(); - const workspaceRoot = vscode.workspace.workspaceFolders![0].uri.fsPath; + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders) { + throw new Error("No workspace folders found"); + } + const workspaceRoot = workspaceFolders[0].uri.fsPath; filePathMap.forEach((storedFilePath, relativePath) => { const parts = relativePath.split(path.sep); From ce9b048e76f7817ac79dfdf49eb7e6c2ce20ad79 Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Tue, 4 Mar 2025 23:10:12 +0530 Subject: [PATCH 03/58] Download from pac --- package.json | 9 + package.nls.json | 10 +- src/client/extension.ts | 5 +- src/client/pac/PacWrapper.ts | 4 + .../metadata-diff/MetadataDiffDesktop.ts | 162 ++++++++++++++++++ .../power-pages/metadata-diff/Constants.ts | 5 + .../power-pages/metadata-diff/MetadataDiff.ts | 55 ------ .../MetadataDiffTreeDataProvider.ts | 88 ++++------ src/common/utilities/PacAuthUtil.ts | 31 ++-- 9 files changed, 243 insertions(+), 126 deletions(-) create mode 100644 src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts delete mode 100644 src/common/power-pages/metadata-diff/MetadataDiff.ts diff --git a/package.json b/package.json index c423d1821..eef1a5aae 100644 --- a/package.json +++ b/package.json @@ -170,6 +170,11 @@ "view": "microsoft.powerplatform.pages.actionsHub", "contents": "%microsoft.powerplatform.pages.actionsHub.login%", "when": "!virtualWorkspace && pacCLI.authPanel.interactiveLoginSupported" + }, + { + "view": "microsoft.powerplatform.pages.metadataDiff", + "contents": "%microsoft.powerplatform.pages.metadataDiff.login%", + "when": "!virtualWorkspace && pacCLI.authPanel.interactiveLoginSupported" } ], "commands": [ @@ -439,6 +444,10 @@ { "command": "microsoft.powerplatform.pages.actionsHub.activeSite.uploadSite", "title": "%microsoft.powerplatform.pages.actionsHub.activeSite.uploadSite.title%" + }, + { + "command": "microsoft.powerplatform.pages.metadataDiff.triggerFlow", + "title": "%microsoft.powerplatform.pages.metadataDiff.login.title%" } ], "configuration": { diff --git a/package.nls.json b/package.nls.json index 89a2ac4b3..b2e24d7db 100644 --- a/package.nls.json +++ b/package.nls.json @@ -110,5 +110,13 @@ "microsoft.powerplatform.pages.actionsHub.currentActiveSite.revealInOS.mac.title": "Reveal in Finder", "microsoft.powerplatform.pages.actionsHub.currentActiveSite.revealInOS.linux.title": "Open Containing Folder", "microsoft.powerplatform.pages.actionsHub.inactiveSite.openSiteManagement.title": "Open site management", - "microsoft.powerplatform.pages.actionsHub.activeSite.uploadSite.title": "Upload Site" + "microsoft.powerplatform.pages.actionsHub.activeSite.uploadSite.title": "Upload Site", + "microsoft.powerplatform.pages.metadataDiff.title": "POWER PAGES METADATA COMPARATOR", + "microsoft.powerplatform.pages.metadataDiff.login":{ + "message": "Compare your Power Pages website against a Power Pages environment to view any differences. [Learn more](https://go.microsoft.com/fwlink/?linkid=2305702).\n[Get Started](command:microsoft.powerplatform.pages.metadataDiff.triggerFlow)", + "comment": [ + "This is a Markdown formatted string, and the formatting must persist across translations.", + "The second line should be '[TRANSLATION HERE](command:microsoft.powerplatform.pages.metadataDiff.triggerFlow).', keeping brackets and the text in the parentheses unmodified" + ] + } } diff --git a/src/client/extension.ts b/src/client/extension.ts index 6f5c4dec1..ff0ef7699 100644 --- a/src/client/extension.ts +++ b/src/client/extension.ts @@ -47,8 +47,7 @@ import { ActionsHub } from "./power-pages/actions-hub/ActionsHub"; import { extractAuthInfo, extractOrgInfo } from "./power-pages/commonUtility"; import PacContext from "./pac/PacContext"; import ArtemisContext from "./ArtemisContext"; -import { MetadataDiff } from "../common/power-pages/metadata-diff/MetadataDiff"; -import { MetadataDiffTreeDataProvider } from "../common/power-pages/metadata-diff/MetadataDiffTreeDataProvider"; +import { MetadataDiffDesktop } from "./power-pages/metadata-diff/MetadataDiffDesktop"; let client: LanguageClient; let _context: vscode.ExtensionContext; @@ -262,7 +261,7 @@ export async function activate( const workspaceFolderWatcher = vscode.workspace.onDidChangeWorkspaceFolders(handleWorkspaceFolderChange); _context.subscriptions.push(workspaceFolderWatcher); - MetadataDiff.initialize(context) + MetadataDiffDesktop.initialize(context, pacTerminal) if (shouldEnableDebugger()) { activateDebugger(context); diff --git a/src/client/pac/PacWrapper.ts b/src/client/pac/PacWrapper.ts index d8803e2ac..273e02bf3 100644 --- a/src/client/pac/PacWrapper.ts +++ b/src/client/pac/PacWrapper.ts @@ -180,6 +180,10 @@ export class PacWrapper { return this.executeCommandAndParseResults(new PacArguments("telemetry", "disable")); } + public async pagesDownload(path: string, websiteId: string): Promise { + return this.pacInterop.executeCommand(new PacArguments("pages", "download", "--path", path, "--webSiteId", websiteId)); + } + public exit(): void { this.pacInterop.exit(); } diff --git a/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts b/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts new file mode 100644 index 000000000..39533bac0 --- /dev/null +++ b/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts @@ -0,0 +1,162 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import * as vscode from "vscode"; +import * as fs from "fs"; +import { ECSFeaturesClient } from "../../../common/ecs-features/ecsFeatureClient"; +import { EnableMetadataDiff } from "../../../common/ecs-features/ecsFeatureGates"; +import { oneDSLoggerWrapper } from "../../../common/OneDSLoggerTelemetry/oneDSLoggerWrapper"; +import { Constants, SUCCESS } from "../../../common/power-pages/metadata-diff/Constants"; +import { MetadataDiffTreeDataProvider } from "../../../common/power-pages/metadata-diff/MetadataDiffTreeDataProvider"; +import { PacTerminal } from "../../lib/PacTerminal"; +import { createAuthProfileExp } from "../../../common/utilities/PacAuthUtil"; +import path from "path"; +import { getWebsiteRecordId } from "../../../common/utilities/WorkspaceInfoFinderUtil"; + +export class MetadataDiffDesktop { + //private readonly _disposables: vscode.Disposable[] = []; + private static _isInitialized = false; + + static isEnabled(): boolean { + const enableMetadataDiff = ECSFeaturesClient.getConfig(EnableMetadataDiff).enableMetadataDiff + + if (enableMetadataDiff === undefined) { + return false; + } + + return enableMetadataDiff; + } + + static async initialize(context: vscode.ExtensionContext, pacTerminal: PacTerminal): Promise { + if (MetadataDiffDesktop._isInitialized) { + return; + } + + vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.triggerFlow", async () => { + try { + const orgUrl = await vscode.window.showInputBox({ + prompt: "Enter the organization URL", + placeHolder: "https://your-org.crm.dynamics.com" + }); + + if (!orgUrl) { + vscode.window.showErrorMessage("Organization URL is required to trigger the metadata diff flow."); + return; + } + + const urlPattern = /^https:\/\/[a-zA-Z0-9.-]+\d*\.crm\.dynamics\.com\/?$/; + if (!urlPattern.test(orgUrl)) { + vscode.window.showErrorMessage("Invalid organization URL. Please enter a valid URL in the format: https://your-org.crm.dynamics.com", { modal: true }); + return; + } + + const pacWrapper = pacTerminal.getWrapper() + const pacActiveOrg = await pacWrapper.activeOrg(); + if(pacActiveOrg){ + if (pacActiveOrg.Status === SUCCESS) { + if(pacActiveOrg.Results.OrgUrl == orgUrl){ + vscode.window.showInformationMessage("Already connected to the specified environment."); + } + else{ + const pacOrgSelect = await pacWrapper.orgSelect(orgUrl); + if(pacOrgSelect && pacOrgSelect.Status === SUCCESS){ + vscode.window.showInformationMessage("Environment switched successfully."); + } + else{ + vscode.window.showErrorMessage("Failed to switch the environment."); + return; + } + } + } + else{ + await createAuthProfileExp(pacWrapper, orgUrl); + vscode.window.showInformationMessage("Auth profile created successfully."); + } + } + else { + vscode.window.showErrorMessage("Failed to fetch the current environment details."); + return; + } + + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + return []; + } + + const currentWorkspaceFolder = workspaceFolders[0].uri.fsPath; + const websiteId = getWebsiteRecordId(currentWorkspaceFolder); + path.join(websiteId, "metadataDiffStorage"); + const storagePath = context.storageUri?.fsPath; + if (!storagePath) { + throw new Error("Storage path is not defined"); + } + + // Clean out any existing files in storagePath (so we have a "fresh" download) + if (fs.existsSync(storagePath)) { + fs.rmSync(storagePath, { recursive: true, force: true }); + } + fs.mkdirSync(storagePath, { recursive: true }); + const progressOptions: vscode.ProgressOptions = { + location: vscode.ProgressLocation.Notification, + title: "Downloading website metadata", + cancellable: false + }; + let pacPagesDownload; + await vscode.window.withProgress(progressOptions, async (progress) => { + progress.report({ message: "This may take a few minutes..." }); + pacPagesDownload = await pacWrapper.pagesDownload(storagePath, websiteId); + vscode.window.showInformationMessage("Download completed."); + }); + if (pacPagesDownload) { + const treeDataProvider = MetadataDiffTreeDataProvider.initialize(context); + context.subscriptions.push( + vscode.window.registerTreeDataProvider("microsoft.powerplatform.pages.metadataDiff", treeDataProvider) + ); + } + else{ + vscode.window.showErrorMessage("Failed to download metadata."); + } + + } + catch (error) { + oneDSLoggerWrapper.getLogger().traceError(Constants.EventNames.METADATA_DIFF_REFRESH_FAILED, error as string, error as Error, { methodName: null }, {}); + } + }) + + try { + const isMetadataDiffEnabled = MetadataDiffDesktop.isEnabled(); + + oneDSLoggerWrapper.getLogger().traceInfo("EnableMetadataDiff", { + isEnabled: isMetadataDiffEnabled.toString() + }); + + vscode.commands.executeCommand("setContext", "microsoft.powerplatform.pages.metadataDiffEnabled", isMetadataDiffEnabled); + + if (!isMetadataDiffEnabled) { + return; + } + + const storagePath = context.storageUri?.fsPath; + if (!storagePath) { + throw new Error("Storage path is not defined"); + } + if (fs.existsSync(storagePath)) { + fs.rmSync(storagePath, { recursive: true, force: true }); + } + fs.mkdirSync(storagePath, { recursive: true }); + + const treeDataProvider = MetadataDiffTreeDataProvider.initialize(context); + context.subscriptions.push( + vscode.window.registerTreeDataProvider("microsoft.powerplatform.pages.metadataDiff", treeDataProvider) + ); + + MetadataDiffDesktop._isInitialized = true; + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_INITIALIZED); + } catch (exception) { + const exceptionError = exception as Error; + oneDSLoggerWrapper.getLogger().traceError(Constants.EventNames.METADATA_DIFF_INITIALIZATION_FAILED, exceptionError.message, exceptionError); + } + } +} diff --git a/src/common/power-pages/metadata-diff/Constants.ts b/src/common/power-pages/metadata-diff/Constants.ts index caf9d1755..24b18d542 100644 --- a/src/common/power-pages/metadata-diff/Constants.ts +++ b/src/common/power-pages/metadata-diff/Constants.ts @@ -6,7 +6,12 @@ export const Constants = { EventNames: { METADATA_DIFF_INITIALIZED: "metadataDiffInitialized", + METADATA_DIFF_REFRESH_FAILED: "metadataDiffRefreshFailed", METADATA_DIFF_INITIALIZATION_FAILED: "metadataDiffInitializationFailed", METADATA_DIFF_CURRENT_ENV_FETCH_FAILED: "metadataDiffCurrentEnvFetchFailed", + ORGANIZATION_URL_MISSING: "Organization URL is missing in the results.", + EMPTY_RESULTS_ARRAY: "Results array is empty or not an array.", + PAC_AUTH_OUTPUT_FAILURE: "pacAuthCreateOutput is missing or unsuccessful.", } }; +export const SUCCESS = "Success"; diff --git a/src/common/power-pages/metadata-diff/MetadataDiff.ts b/src/common/power-pages/metadata-diff/MetadataDiff.ts deleted file mode 100644 index 559eb5286..000000000 --- a/src/common/power-pages/metadata-diff/MetadataDiff.ts +++ /dev/null @@ -1,55 +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 * as vscode from "vscode"; -import { ECSFeaturesClient } from "../../ecs-features/ecsFeatureClient"; -import { EnableMetadataDiff } from "../../ecs-features/ecsFeatureGates"; -import { MetadataDiffTreeDataProvider } from "./MetadataDiffTreeDataProvider"; -import { oneDSLoggerWrapper } from "../../OneDSLoggerTelemetry/oneDSLoggerWrapper"; -import { Constants } from "./Constants"; - -export class MetadataDiff { - private static _isInitialized = false; - - static isEnabled(): boolean { - const enableMetadataDiff = ECSFeaturesClient.getConfig(EnableMetadataDiff).enableMetadataDiff - - if (enableMetadataDiff === undefined) { - return false; - } - - return enableMetadataDiff; - } - - static async initialize(context: vscode.ExtensionContext): Promise { - if (MetadataDiff._isInitialized) { - return; - } - - try { - const isMetadataDiffEnabled = MetadataDiff.isEnabled(); - - oneDSLoggerWrapper.getLogger().traceInfo("EnableMetadataDiff", { - isEnabled: isMetadataDiffEnabled.toString() - }); - - vscode.commands.executeCommand("setContext", "microsoft.powerplatform.pages.metadataDiffEnabled", isMetadataDiffEnabled); - - if (!isMetadataDiffEnabled) { - return; - } - const treeDataProvider = MetadataDiffTreeDataProvider.initialize(context); - context.subscriptions.push( - vscode.window.registerTreeDataProvider("microsoft.powerplatform.pages.metadataDiff", treeDataProvider) - ); - - MetadataDiff._isInitialized = true; - oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_INITIALIZED); - } catch (exception) { - const exceptionError = exception as Error; - oneDSLoggerWrapper.getLogger().traceError(Constants.EventNames.METADATA_DIFF_INITIALIZATION_FAILED, exceptionError.message, exceptionError); - } - } -} diff --git a/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts b/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts index dfe989530..572f6c1e4 100644 --- a/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts +++ b/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts @@ -50,11 +50,13 @@ export class MetadataDiffTreeDataProvider implements vscode.TreeDataProvider { - const diffFiles: string[] = []; - const allFiles = this.getAllFilesRecursively(workspacePath); + const diffFiles: Set = new Set(); - // Compare files - for (const file of allFiles) { + const workspaceFiles = this.getAllFilesRecursively(workspacePath); + const storageFiles = this.getAllFilesRecursively(storagePath); + + // Check workspace files against storage files + for (const file of workspaceFiles) { const relativePath = path.relative(workspacePath, file); const storageFile = path.join(storagePath, relativePath); @@ -63,60 +65,24 @@ export class MetadataDiffTreeDataProvider implements vscode.TreeDataProvider { - - const allFiles = this.getAllFilesRecursively(workspacePath); - - // Create copies in storagePath if they don't exist - for (const file of allFiles) { - const relativePath = path.relative(workspacePath, file); - const storageFile = path.join(storagePath, relativePath); - // Ensure directory exists - fs.mkdirSync(path.dirname(storageFile), { recursive: true }); + // Check storage files against workspace files (to detect deletions) + for (const file of storageFiles) { + const relativePath = path.relative(storagePath, file); + const workspaceFile = path.join(workspacePath, relativePath); - if (!fs.existsSync(storageFile)) { - fs.copyFileSync(file, storageFile); + if (!fs.existsSync(workspaceFile)) { + diffFiles.add(relativePath); } } - // Mock a random file change - const randomFile = allFiles[Math.floor(Math.random() * allFiles.length)]; - fs.writeFileSync(path.join(storagePath, path.relative(workspacePath, randomFile)), ""); - - return storagePath; - } - - private async getDiffFilesWithMockChanges(): Promise { - const workspaceFolders = vscode.workspace.workspaceFolders; - if (!workspaceFolders) { - return []; - } - - const workspacePath = workspaceFolders[0].uri.fsPath; - const storagePath = this._context.storageUri?.fsPath; - if (!storagePath) { - throw new Error("Storage path is not defined"); - } - - // Clean out any existing files in storagePath (so we have a "fresh" download) - if (fs.existsSync(storagePath)) { - fs.rmSync(storagePath, { recursive: true, force: true }); - } - fs.mkdirSync(storagePath, { recursive: true }); - - await this.mockChangesAndCopyFiles(workspacePath, storagePath); - - return this.getDiffFiles(workspacePath, storagePath); + return Array.from(diffFiles); } async getChildren(element?: MetadataDiffTreeItem): Promise { @@ -125,7 +91,25 @@ export class MetadataDiffTreeDataProvider implements vscode.TreeDataProvider fs.statSync(path.join(storagePath, file)).isDirectory()); + //check if storage path contains any files + if (folderNames.length === 0) { + return []; + } + const websitePath = path.join(storagePath, folderNames[0]); + + const diffFiles = await this.getDiffFiles(workspacePath, websitePath); if (diffFiles.length === 0) { return []; } @@ -133,10 +117,8 @@ export class MetadataDiffTreeDataProvider implements vscode.TreeDataProvider(); diffFiles.forEach(relativePath => { - if (this._context.storageUri) { - const storedFileUri = vscode.Uri.joinPath(this._context.storageUri, relativePath); + const storedFileUri = vscode.Uri.joinPath(vscode.Uri.file(websitePath), relativePath); filePathMap.set(relativePath, storedFileUri.fsPath); - } }); return this.buildTreeHierarchy(filePathMap); diff --git a/src/common/utilities/PacAuthUtil.ts b/src/common/utilities/PacAuthUtil.ts index 78305a5c4..1c5ad9878 100644 --- a/src/common/utilities/PacAuthUtil.ts +++ b/src/common/utilities/PacAuthUtil.ts @@ -10,22 +10,25 @@ import { AUTH_CREATE_FAILED, AUTH_CREATE_MESSAGE } from "../copilot/constants"; import { showInputBoxAndGetOrgUrl, showProgressWithNotification } from "./Utils"; - export async function createAuthProfileExp(pacWrapper: PacWrapper | undefined) { - const userOrgUrl = await showInputBoxAndGetOrgUrl(); - if (!userOrgUrl) { - return; - } + export async function createAuthProfileExp(pacWrapper: PacWrapper | undefined, userOrgUrl = "") { + if (!userOrgUrl) { + userOrgUrl = await showInputBoxAndGetOrgUrl() ?? ""; + } - if (!pacWrapper) { - vscode.window.showErrorMessage(AUTH_CREATE_FAILED); - return; - } + if (!userOrgUrl) { + return; + } - const pacAuthCreateOutput = await showProgressWithNotification(vscode.l10n.t(AUTH_CREATE_MESSAGE), async () => { return await pacWrapper?.authCreateNewAuthProfileForOrg(userOrgUrl) }); - if (pacAuthCreateOutput && pacAuthCreateOutput.Status !== SUCCESS) { - vscode.window.showErrorMessage(AUTH_CREATE_FAILED); - return; - } + if (!pacWrapper) { + vscode.window.showErrorMessage(AUTH_CREATE_FAILED); + return; + } + + const pacAuthCreateOutput = await showProgressWithNotification(vscode.l10n.t(AUTH_CREATE_MESSAGE), async () => { return await pacWrapper?.authCreateNewAuthProfileForOrg(userOrgUrl) }); + if (pacAuthCreateOutput && pacAuthCreateOutput.Status !== SUCCESS) { + vscode.window.showErrorMessage(AUTH_CREATE_FAILED); + return; + } return pacAuthCreateOutput; } From a7a129ff9f26f092c8b50ef2e1f8739fc0615c14 Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Wed, 5 Mar 2025 14:37:10 +0530 Subject: [PATCH 04/58] enhance diff logic --- .../MetadataDiffTreeDataProvider.ts | 85 ++++++++++--------- .../tree-items/MetadataDiffFileItem.ts | 22 +++-- 2 files changed, 60 insertions(+), 47 deletions(-) diff --git a/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts b/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts index 572f6c1e4..0632c5147 100644 --- a/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts +++ b/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts @@ -49,40 +49,44 @@ export class MetadataDiffTreeDataProvider implements vscode.TreeDataProvider { - const diffFiles: Set = new Set(); + private async getDiffFiles(workspacePath: string, storagePath: string): Promise> { + const diffFiles = new Map(); const workspaceFiles = this.getAllFilesRecursively(workspacePath); const storageFiles = this.getAllFilesRecursively(storagePath); - // Check workspace files against storage files - for (const file of workspaceFiles) { - const relativePath = path.relative(workspacePath, file); + const workspaceFileSet = new Set(workspaceFiles.map(f => path.relative(workspacePath, f))); + const storageFileSet = new Set(storageFiles.map(f => path.relative(storagePath, f))); + + // Files only in workspace or modified files + for (const relativePath of workspaceFileSet) { + const workspaceFile = path.join(workspacePath, relativePath); const storageFile = path.join(storagePath, relativePath); - if (fs.existsSync(storageFile)) { - const workspaceContent = fs.readFileSync(file, "utf8"); + if (!storageFileSet.has(relativePath)) { + diffFiles.set(relativePath, { workspaceFile }); // File only in workspace + } else { + const workspaceContent = fs.readFileSync(workspaceFile, "utf8"); const storageContent = fs.readFileSync(storageFile, "utf8"); - if (workspaceContent !== storageContent) { - diffFiles.add(relativePath); + const normalizedWorkspaceContent = workspaceContent.replace(/\r\n/g, "\n"); + const normalizedStorageContent = storageContent.replace(/\r\n/g, "\n"); + if (normalizedWorkspaceContent !== normalizedStorageContent) { + diffFiles.set(relativePath, { workspaceFile, storageFile }); // Modified file + } } - } else { - diffFiles.add(relativePath); } } - // Check storage files against workspace files (to detect deletions) - for (const file of storageFiles) { - const relativePath = path.relative(storagePath, file); - const workspaceFile = path.join(workspacePath, relativePath); - - if (!fs.existsSync(workspaceFile)) { - diffFiles.add(relativePath); + // Files only in storage (deleted from workspace) + for (const relativePath of storageFileSet) { + if (!workspaceFileSet.has(relativePath)) { + const storageFile = path.join(storagePath, relativePath); + diffFiles.set(relativePath, { storageFile }); // File only in storage } } - return Array.from(diffFiles); + return diffFiles; } async getChildren(element?: MetadataDiffTreeItem): Promise { @@ -103,25 +107,17 @@ export class MetadataDiffTreeDataProvider implements vscode.TreeDataProvider fs.statSync(path.join(storagePath, file)).isDirectory()); - //check if storage path contains any files if (folderNames.length === 0) { return []; } const websitePath = path.join(storagePath, folderNames[0]); const diffFiles = await this.getDiffFiles(workspacePath, websitePath); - if (diffFiles.length === 0) { + if (diffFiles.size === 0) { return []; } - const filePathMap = new Map(); - - diffFiles.forEach(relativePath => { - const storedFileUri = vscode.Uri.joinPath(vscode.Uri.file(websitePath), relativePath); - filePathMap.set(relativePath, storedFileUri.fsPath); - }); - - return this.buildTreeHierarchy(filePathMap); + return this.buildTreeHierarchy(diffFiles); } catch (error) { oneDSLoggerWrapper.getLogger().traceError( Constants.EventNames.METADATA_DIFF_CURRENT_ENV_FETCH_FAILED, @@ -134,15 +130,10 @@ export class MetadataDiffTreeDataProvider implements vscode.TreeDataProvider): MetadataDiffTreeItem[] { + private buildTreeHierarchy(filePathMap: Map): MetadataDiffTreeItem[] { const rootItems: Map = new Map(); - const workspaceFolders = vscode.workspace.workspaceFolders; - if (!workspaceFolders) { - throw new Error("No workspace folders found"); - } - const workspaceRoot = workspaceFolders[0].uri.fsPath; - filePathMap.forEach((storedFilePath, relativePath) => { + filePathMap.forEach(({ workspaceFile, storageFile }, relativePath) => { const parts = relativePath.split(path.sep); let currentLevel = rootItems; let parentItem: MetadataDiffTreeItem | undefined; @@ -155,8 +146,8 @@ export class MetadataDiffTreeDataProvider implements vscode.TreeDataProvider { + return items.filter(item => { + if (item instanceof MetadataDiffFolderItem) { + const childrenArray = Array.from(item.getChildrenMap().values()); + const filteredChildren = filterEmptyFolders(childrenArray); + item.getChildrenMap().clear(); + filteredChildren.forEach(child => item.getChildrenMap().set(child.label, child)); + return filteredChildren.length > 0; + } else if (item instanceof MetadataDiffFileItem) { + return item.hasDiff; + } + return false; + }); + }; + + return filterEmptyFolders(Array.from(rootItems.values())); } } diff --git a/src/common/power-pages/metadata-diff/tree-items/MetadataDiffFileItem.ts b/src/common/power-pages/metadata-diff/tree-items/MetadataDiffFileItem.ts index 5f61e05e1..ad75b5b5a 100644 --- a/src/common/power-pages/metadata-diff/tree-items/MetadataDiffFileItem.ts +++ b/src/common/power-pages/metadata-diff/tree-items/MetadataDiffFileItem.ts @@ -7,18 +7,24 @@ import * as vscode from "vscode"; import { MetadataDiffTreeItem } from "./MetadataDiffTreeItem"; export class MetadataDiffFileItem extends MetadataDiffTreeItem { - constructor(label: string, workspaceFilePath: string, storedFilePath: string) { - super(label, vscode.TreeItemCollapsibleState.None, "file", workspaceFilePath); + public readonly workspaceFile?: string; + public readonly storageFile?: string; + public readonly hasDiff: boolean; - const workspaceUri = vscode.Uri.file(workspaceFilePath); - const storedUri = vscode.Uri.file(storedFilePath); + constructor(label: string, workspaceFile?: string, storageFile?: string, hasDiff = true) { + super(label, vscode.TreeItemCollapsibleState.None, "metadataDiffFileItem"); + this.workspaceFile = workspaceFile; + this.storageFile = storageFile; + this.hasDiff = hasDiff; + + const workspaceUri = workspaceFile ? vscode.Uri.file(workspaceFile) : vscode.Uri.parse(`untitled:${label} (Deleted)`); + const storedUri = storageFile ? vscode.Uri.file(storageFile) : vscode.Uri.parse(`untitled:${label} (Deleted)`); this.resourceUri = workspaceUri; this.command = { - command: "vscode.diff", - title: "Compare Changes", - arguments: [storedUri, workspaceUri, `Diff: ${label}`] + command: 'vscode.diff', + title: 'Show Diff', + arguments: [storedUri, workspaceUri, `${label} (Diff)`] }; - this.iconPath = new vscode.ThemeIcon("file"); } } From bfb1a25a139aa09bbdd2a57dcd51df7094432653 Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Wed, 5 Mar 2025 14:40:15 +0530 Subject: [PATCH 05/58] refactor --- src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts b/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts index 39533bac0..6a258c430 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts @@ -16,7 +16,6 @@ import path from "path"; import { getWebsiteRecordId } from "../../../common/utilities/WorkspaceInfoFinderUtil"; export class MetadataDiffDesktop { - //private readonly _disposables: vscode.Disposable[] = []; private static _isInitialized = false; static isEnabled(): boolean { From 21a681ac03388ffb176f79a6662c31a5cb37e03b Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Wed, 23 Apr 2025 01:40:16 +0530 Subject: [PATCH 06/58] find model version --- src/client/pac/PacTypes.ts | 8 +++ src/client/pac/PacWrapper.ts | 10 +++- .../metadata-diff/MetadataDiffDesktop.ts | 56 +++++++++++++++++-- 3 files changed, 67 insertions(+), 7 deletions(-) diff --git a/src/client/pac/PacTypes.ts b/src/client/pac/PacTypes.ts index de37b0f63..e43b55874 100644 --- a/src/client/pac/PacTypes.ts +++ b/src/client/pac/PacTypes.ts @@ -62,6 +62,14 @@ export type OrgListOutput = { export type PacOrgListOutput = PacOutputWithResultList; +export type PagesList = { + FriendlyName: string, + WebsiteId: string, + ModelVersion: string +} + +export type PacPagesListOutput = PacOutputWithResultList; + export type ActiveOrgOutput = { OrgId: string, UniqueName: string, diff --git a/src/client/pac/PacWrapper.ts b/src/client/pac/PacWrapper.ts index 273e02bf3..be0647b1e 100644 --- a/src/client/pac/PacWrapper.ts +++ b/src/client/pac/PacWrapper.ts @@ -9,7 +9,7 @@ import * as readline from "readline"; import * as fs from "fs-extra"; import { ChildProcessWithoutNullStreams, spawn } from "child_process"; import { BlockingQueue } from "../../common/utilities/BlockingQueue"; -import { PacOutput, PacAdminListOutput, PacAuthListOutput, PacSolutionListOutput, PacOrgListOutput, PacOrgWhoOutput, PacAuthWhoOutput } from "./PacTypes"; +import { PacOutput, PacAdminListOutput, PacAuthListOutput, PacSolutionListOutput, PacOrgListOutput, PacOrgWhoOutput, PacAuthWhoOutput, PacPagesListOutput} from "./PacTypes"; import { v4 } from "uuid"; import { oneDSLoggerWrapper } from "../../common/OneDSLoggerTelemetry/oneDSLoggerWrapper"; @@ -180,8 +180,12 @@ export class PacWrapper { return this.executeCommandAndParseResults(new PacArguments("telemetry", "disable")); } - public async pagesDownload(path: string, websiteId: string): Promise { - return this.pacInterop.executeCommand(new PacArguments("pages", "download", "--path", path, "--webSiteId", websiteId)); + public async pagesDownload(path: string, websiteId: string, modelVersion: string): Promise { + return this.pacInterop.executeCommand(new PacArguments("pages", "download", "--path", path, "--webSiteId", websiteId, "--modelVersion", modelVersion)); + } + + public async pagesList(): Promise { + return this.executeCommandAndParseResults(new PacArguments("pages", "list", "--verbose")); } public exit(): void { diff --git a/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts b/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts index 6a258c430..3ee219de6 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts @@ -14,6 +14,7 @@ import { PacTerminal } from "../../lib/PacTerminal"; import { createAuthProfileExp } from "../../../common/utilities/PacAuthUtil"; import path from "path"; import { getWebsiteRecordId } from "../../../common/utilities/WorkspaceInfoFinderUtil"; +import { PagesList } from "../../pac/PacTypes"; export class MetadataDiffDesktop { private static _isInitialized = false; @@ -81,7 +82,8 @@ export class MetadataDiffDesktop { const workspaceFolders = vscode.workspace.workspaceFolders; if (!workspaceFolders || workspaceFolders.length === 0) { - return []; + vscode.window.showErrorMessage("No folders opened in the current workspace."); + return; } const currentWorkspaceFolder = workspaceFolders[0].uri.fsPath; @@ -104,9 +106,18 @@ export class MetadataDiffDesktop { }; let pacPagesDownload; await vscode.window.withProgress(progressOptions, async (progress) => { - progress.report({ message: "This may take a few minutes..." }); - pacPagesDownload = await pacWrapper.pagesDownload(storagePath, websiteId); - vscode.window.showInformationMessage("Download completed."); + progress.report({ message: "Looking for this website in the connected environment..." }); + const pacPagesList = await this.getPagesList(pacTerminal); + if (pacPagesList && pacPagesList.length > 0) { + const websiteRecord = pacPagesList.find((record) => record.id === websiteId); + if (!websiteRecord) { + vscode.window.showErrorMessage("Website not found in the connected environment."); + return; + } + progress.report({ message: `Downloading "${websiteRecord.name}" as ${websiteRecord.modelVersion === "v2" ? "enhanced" : "standard"} data model. Please wait...` }); + pacPagesDownload = await pacWrapper.pagesDownload(storagePath, websiteId, websiteRecord.modelVersion == "v1" ? "1" : "2"); + vscode.window.showInformationMessage("Download completed."); + } }); if (pacPagesDownload) { const treeDataProvider = MetadataDiffTreeDataProvider.initialize(context); @@ -158,4 +169,41 @@ export class MetadataDiffDesktop { oneDSLoggerWrapper.getLogger().traceError(Constants.EventNames.METADATA_DIFF_INITIALIZATION_FAILED, exceptionError.message, exceptionError); } } + + static async getPagesList(pacTerminal: PacTerminal): Promise<{ name: string, id: string, modelVersion: string }[]> { + const pacWrapper = pacTerminal.getWrapper(); + const pagesListOutput = await pacWrapper.pagesList(); + if (pagesListOutput && pagesListOutput.Status === SUCCESS && pagesListOutput.Information) { + // Parse the list of pages from the string output + const pagesList: PagesList[] = []; + if (Array.isArray(pagesListOutput.Information)) { + // If Information is already an array of strings + pagesListOutput.Information.forEach(line => { + // Skip empty lines or header lines + if (!line.trim() || !line.includes('[')) { + return; + } + + // Extract the relevant parts using regex + const match = line.match(/\[\d+\]\s+([a-f0-9-]+)\s+(.*?)\s+(v[12])\s*$/i); + if (match) { + pagesList.push({ + WebsiteId: match[1].trim(), + FriendlyName: match[2].trim(), + ModelVersion: match[3].trim() + }); + } + }); + } + return pagesList.map((site) => { + return { + name: site.FriendlyName, + id: site.WebsiteId, + modelVersion: site.ModelVersion + } + }); + } + + return []; + } } From fc4a1fd1e47f8dd116d29ac9633370871dc7d215 Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Wed, 23 Apr 2025 04:02:20 +0530 Subject: [PATCH 07/58] Added generate/import/export for diff reports --- package.json | 30 ++ .../metadata-diff/MetadataDiffDesktop.ts | 305 +++++++++++++++++- .../power-pages/metadata-diff/Constants.ts | 1 + .../MetadataDiffTreeDataProvider.ts | 261 ++++++++++----- .../tree-items/MetadataDiffFileItem.ts | 33 +- .../tree-items/MetadataDiffFolderItem.ts | 4 +- .../tree-items/MetadataDiffTreeItem.ts | 15 +- 7 files changed, 548 insertions(+), 101 deletions(-) diff --git a/package.json b/package.json index eef1a5aae..6d6aca95b 100644 --- a/package.json +++ b/package.json @@ -448,6 +448,21 @@ { "command": "microsoft.powerplatform.pages.metadataDiff.triggerFlow", "title": "%microsoft.powerplatform.pages.metadataDiff.login.title%" + }, + { + "command": "microsoft.powerplatform.pages.metadataDiff.generateReport", + "title": "Generate Metadata Diff Report", + "icon": "$(output)" + }, + { + "command": "microsoft.powerplatform.pages.metadataDiff.exportReport", + "title": "Export Metadata Diff Report", + "icon": "$(save-as)" + }, + { + "command": "microsoft.powerplatform.pages.metadataDiff.importReport", + "title": "Import Metadata Diff Report", + "icon": "$(folder-opened)" } ], "configuration": { @@ -959,6 +974,21 @@ "command": "powerpages.copilot.clearConversation", "when": "view == powerpages.copilot", "group": "navigation" + }, + { + "command": "microsoft.powerplatform.pages.metadataDiff.generateReport", + "when": "view == microsoft.powerplatform.pages.metadataDiff", + "group": "navigation" + }, + { + "command": "microsoft.powerplatform.pages.metadataDiff.exportReport", + "when": "view == microsoft.powerplatform.pages.metadataDiff", + "group": "navigation" + }, + { + "command": "microsoft.powerplatform.pages.metadataDiff.importReport", + "when": "view == microsoft.powerplatform.pages.metadataDiff", + "group": "navigation" } ], "view/item/context": [ diff --git a/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts b/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts index 3ee219de6..4f6a1c9e8 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts @@ -16,6 +16,19 @@ import path from "path"; import { getWebsiteRecordId } from "../../../common/utilities/WorkspaceInfoFinderUtil"; import { PagesList } from "../../pac/PacTypes"; +interface DiffFile { + relativePath: string; + changes: string; + type: string; + workspaceContent?: string; + storageContent?: string; +} + +interface MetadataDiffReport { + generatedOn: string; + files: DiffFile[]; +} + export class MetadataDiffDesktop { private static _isInitialized = false; @@ -34,6 +47,28 @@ export class MetadataDiffDesktop { return; } + // Register command for handling file diffs + vscode.commands.registerCommand('metadataDiff.openDiff', async (workspaceFile: string, storedFile: string) => { + try { + const workspaceUri = vscode.Uri.file(workspaceFile); + const storedUri = vscode.Uri.file(storedFile); + const fileName = path.basename(workspaceFile); + + await vscode.commands.executeCommand('vscode.diff', + storedUri, + workspaceUri, + `${fileName} (Metadata Diff)` + ); + } catch (error) { + oneDSLoggerWrapper.getLogger().traceError( + Constants.EventNames.METADATA_DIFF_REPORT_FAILED, + error as string, + error as Error + ); + vscode.window.showErrorMessage("Failed to open diff view"); + } + }); + vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.triggerFlow", async () => { try { const orgUrl = await vscode.window.showInputBox({ @@ -133,7 +168,126 @@ export class MetadataDiffDesktop { catch (error) { oneDSLoggerWrapper.getLogger().traceError(Constants.EventNames.METADATA_DIFF_REFRESH_FAILED, error as string, error as Error, { methodName: null }, {}); } - }) + }); + + vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.generateReport", async () => { + try { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders) { + vscode.window.showErrorMessage("No workspace folder open"); + return; + } + + const storagePath = context.storageUri?.fsPath; + if (!storagePath) { + vscode.window.showErrorMessage("Storage path not found"); + return; + } + + // Generate report content + const reportContent = await generateDiffReport(workspaceFolders[0].uri.fsPath, storagePath); + + // Create and show the report document + const doc = await vscode.workspace.openTextDocument({ + content: reportContent, + language: 'markdown' + }); + await vscode.window.showTextDocument(doc, { + preview: true, + viewColumn: vscode.ViewColumn.Beside + }); + } catch (error) { + oneDSLoggerWrapper.getLogger().traceError( + Constants.EventNames.METADATA_DIFF_REPORT_FAILED, + error as string, + error as Error + ); + vscode.window.showErrorMessage("Failed to generate metadata diff report"); + } + }); + + vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.exportReport", async () => { + try { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders) { + vscode.window.showErrorMessage("No workspace folder open"); + return; + } + + const storagePath = context.storageUri?.fsPath; + if (!storagePath) { + vscode.window.showErrorMessage("Storage path not found"); + return; + } + + // Get diff files + const diffFiles = await getAllDiffFiles(workspaceFolders[0].uri.fsPath, storagePath); + + // Create report object + const report: MetadataDiffReport = { + generatedOn: new Date().toISOString(), + files: diffFiles + }; + + // Save dialog + const saveUri = await vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file('metadata-diff-report.json'), + filters: { + 'JSON files': ['json'] + } + }); + + if (saveUri) { + fs.writeFileSync(saveUri.fsPath, JSON.stringify(report, null, 2)); + vscode.window.showInformationMessage("Report exported successfully"); + } + } catch (error) { + oneDSLoggerWrapper.getLogger().traceError( + Constants.EventNames.METADATA_DIFF_REPORT_FAILED, + error as string, + error as Error + ); + vscode.window.showErrorMessage("Failed to export metadata diff report"); + } + }); + + vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.importReport", async () => { + try { + const fileUri = await vscode.window.showOpenDialog({ + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: false, + filters: { + 'JSON files': ['json'] + } + }); + + if (fileUri && fileUri[0]) { + const reportContent = fs.readFileSync(fileUri[0].fsPath, 'utf8'); + const report = JSON.parse(reportContent) as MetadataDiffReport; + + // Clean up any existing tree data provider + const treeDataProvider = new MetadataDiffTreeDataProvider(context); + + // Update the tree with imported data + await treeDataProvider.setDiffFiles(report.files); + + // Register the new tree data provider + context.subscriptions.push( + vscode.window.registerTreeDataProvider("microsoft.powerplatform.pages.metadataDiff", treeDataProvider) + ); + + vscode.window.showInformationMessage("Report imported successfully"); + } + } catch (error) { + oneDSLoggerWrapper.getLogger().traceError( + Constants.EventNames.METADATA_DIFF_REPORT_FAILED, + error as string, + error as Error + ); + vscode.window.showErrorMessage("Failed to import metadata diff report"); + } + }); try { const isMetadataDiffEnabled = MetadataDiffDesktop.isEnabled(); @@ -207,3 +361,152 @@ export class MetadataDiffDesktop { return []; } } + +async function generateDiffReport(workspacePath: string, storagePath: string): Promise { + let report = '# Power Pages Metadata Diff Report\n\n'; + report += `Generated on: ${new Date().toLocaleString()}\n\n`; + + // Get diff files + const diffFiles = await getAllDiffFiles(workspacePath, storagePath); + + // Group by folders + const folderStructure = new Map(); + diffFiles.forEach(file => { + const dirPath = path.dirname(file.relativePath); + if (!folderStructure.has(dirPath)) { + folderStructure.set(dirPath, []); + } + folderStructure.get(dirPath)!.push(file); + }); + + // Sort folders and generate report + const sortedFolders = Array.from(folderStructure.keys()).sort(); + for (const folder of sortedFolders) { + const files = folderStructure.get(folder)!; + + // Add folder header (skip for root) + if (folder !== '.') { + report += `## ${folder}\n\n`; + } + + // Add files + for (const file of files.sort((a, b) => a.relativePath.localeCompare(b.relativePath))) { + report += `- ${path.basename(file.relativePath)}\n`; + report += ` - Changes: ${file.changes}\n`; + } + report += '\n'; + } + + return report; +} + +async function getAllDiffFiles(workspacePath: string, storagePath: string): Promise { + const diffFiles: DiffFile[] = []; + + // Get website directory from storage path + const folderNames = fs.readdirSync(storagePath).filter(file => + fs.statSync(path.join(storagePath, file)).isDirectory() + ); + if (folderNames.length === 0) { + return diffFiles; + } + const websitePath = path.join(storagePath, folderNames[0]); + + // Get all files + const workspaceFiles = await getAllFiles(workspacePath); + const storageFiles = await getAllFiles(websitePath); + + // Create normalized path maps + const workspaceMap = new Map(workspaceFiles.map(f => { + const normalized = path.relative(workspacePath, f).replace(/\\/g, '/'); + return [normalized, f]; + })); + const storageMap = new Map(storageFiles.map(f => { + const normalized = path.relative(websitePath, f).replace(/\\/g, '/'); + return [normalized, f]; + })); + + // Compare files + for (const [normalized, workspaceFile] of workspaceMap.entries()) { + const storageFile = storageMap.get(normalized); + if (!storageFile) { + diffFiles.push({ + relativePath: normalized, + changes: 'Only in workspace', + type: getFileType(normalized), + workspaceContent: fs.readFileSync(workspaceFile, 'utf8').replace(/\r\n/g, '\n') + }); + continue; + } + + // Compare content + const workspaceContent = fs.readFileSync(workspaceFile, 'utf8').replace(/\r\n/g, '\n'); + const storageContent = fs.readFileSync(storageFile, 'utf8').replace(/\r\n/g, '\n'); + if (workspaceContent !== storageContent) { + diffFiles.push({ + relativePath: normalized, + changes: 'Modified', + type: getFileType(normalized), + workspaceContent, + storageContent + }); + } + } + + // Check for files only in storage + for (const [normalized, storageFile] of storageMap.entries()) { + if (!workspaceMap.has(normalized)) { + diffFiles.push({ + relativePath: normalized, + changes: 'Only in remote', + type: getFileType(normalized), + storageContent: fs.readFileSync(storageFile, 'utf8').replace(/\r\n/g, '\n') + }); + } + } + + return diffFiles; +} + +async function getAllFiles(dirPath: string): Promise { + const files: string[] = []; + + function traverse(currentPath: string) { + const entries = fs.readdirSync(currentPath, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(currentPath, entry.name); + if (entry.isDirectory()) { + traverse(fullPath); + } else { + files.push(fullPath); + } + } + } + + traverse(dirPath); + return files; +} + +function getFileType(filePath: string): string { + const ext = path.extname(filePath).toLowerCase(); + const basename = path.basename(filePath).toLowerCase(); + + if (basename === 'webrole.yml') return 'Roles'; + if (basename === 'websitelanguage.yml') return 'Languages'; + if (ext === '.yml' && filePath.includes('webpages')) return 'Pages'; + if (ext === '.yml' && filePath.includes('webtemplates')) return 'Templates'; + if (ext === '.yml' && filePath.includes('webfiles')) return 'Files'; + + return 'Other'; +} + +function groupDiffsByType(files: DiffFile[]): Record { + return files.reduce((groups: Record, file) => { + const type = file.type || 'Other'; + if (!groups[type]) { + groups[type] = []; + } + groups[type].push(file); + return groups; + }, {}); +} diff --git a/src/common/power-pages/metadata-diff/Constants.ts b/src/common/power-pages/metadata-diff/Constants.ts index 24b18d542..4c19af137 100644 --- a/src/common/power-pages/metadata-diff/Constants.ts +++ b/src/common/power-pages/metadata-diff/Constants.ts @@ -12,6 +12,7 @@ export const Constants = { ORGANIZATION_URL_MISSING: "Organization URL is missing in the results.", EMPTY_RESULTS_ARRAY: "Results array is empty or not an array.", PAC_AUTH_OUTPUT_FAILURE: "pacAuthCreateOutput is missing or unsuccessful.", + METADATA_DIFF_REPORT_FAILED: "metadataDiffReportFailed" } }; export const SUCCESS = "Success"; diff --git a/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts b/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts index 0632c5147..a429d9ec4 100644 --- a/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts +++ b/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts @@ -12,24 +12,33 @@ import { oneDSLoggerWrapper } from "../../OneDSLoggerTelemetry/oneDSLoggerWrappe import { MetadataDiffFileItem } from "./tree-items/MetadataDiffFileItem"; import { MetadataDiffFolderItem } from "./tree-items/MetadataDiffFolderItem"; +interface DiffFile { + relativePath: string; + changes: string; + type: string; + workspaceContent?: string; + storageContent?: string; +} + export class MetadataDiffTreeDataProvider implements vscode.TreeDataProvider { private readonly _disposables: vscode.Disposable[] = []; private readonly _context: vscode.ExtensionContext; private _onDidChangeTreeData: vscode.EventEmitter = new vscode.EventEmitter(); readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event; + private _diffItems: MetadataDiffTreeItem[] = []; - private constructor(context: vscode.ExtensionContext) { + constructor(context: vscode.ExtensionContext) { this._context = context; } - private refresh(): void { - this._onDidChangeTreeData.fire(); - } - public static initialize(context: vscode.ExtensionContext): MetadataDiffTreeDataProvider { return new MetadataDiffTreeDataProvider(context); } + private refresh(): void { + this._onDidChangeTreeData.fire(); + } + getTreeItem(element: MetadataDiffTreeItem): vscode.TreeItem | Thenable { return element; } @@ -52,48 +61,80 @@ export class MetadataDiffTreeDataProvider implements vscode.TreeDataProvider> { const diffFiles = new Map(); - const workspaceFiles = this.getAllFilesRecursively(workspacePath); - const storageFiles = this.getAllFilesRecursively(storagePath); + // Get website directory from storage path + const websitePath = await this.getWebsitePath(storagePath); + if (!websitePath) { + return diffFiles; + } - const workspaceFileSet = new Set(workspaceFiles.map(f => path.relative(workspacePath, f))); - const storageFileSet = new Set(storageFiles.map(f => path.relative(storagePath, f))); + const workspaceFiles = this.getAllFilesRecursively(workspacePath); + const storageFiles = this.getAllFilesRecursively(websitePath); + + // Create normalized path maps for comparison + const workspaceMap = new Map(workspaceFiles.map(f => { + const normalized = path.relative(workspacePath, f).replace(/\\/g, '/'); + return [normalized, f]; + })); + + const storageMap = new Map(storageFiles.map(f => { + const normalized = path.relative(websitePath, f).replace(/\\/g, '/'); + return [normalized, f]; + })); + + // Compare files + for (const [relativePath, workspaceFile] of workspaceMap.entries()) { + const storageFile = storageMap.get(relativePath); + + if (!storageFile) { + // File only exists in workspace + diffFiles.set(relativePath, { workspaceFile }); + continue; + } - // Files only in workspace or modified files - for (const relativePath of workspaceFileSet) { - const workspaceFile = path.join(workspacePath, relativePath); - const storageFile = path.join(storagePath, relativePath); + // Compare content only if both files exist + const workspaceContent = fs.readFileSync(workspaceFile, 'utf8').replace(/\r\n/g, '\n'); + const storageContent = fs.readFileSync(storageFile, 'utf8').replace(/\r\n/g, '\n'); - if (!storageFileSet.has(relativePath)) { - diffFiles.set(relativePath, { workspaceFile }); // File only in workspace - } else { - const workspaceContent = fs.readFileSync(workspaceFile, "utf8"); - const storageContent = fs.readFileSync(storageFile, "utf8"); - if (workspaceContent !== storageContent) { - const normalizedWorkspaceContent = workspaceContent.replace(/\r\n/g, "\n"); - const normalizedStorageContent = storageContent.replace(/\r\n/g, "\n"); - if (normalizedWorkspaceContent !== normalizedStorageContent) { - diffFiles.set(relativePath, { workspaceFile, storageFile }); // Modified file - } - } + if (workspaceContent !== storageContent) { + diffFiles.set(relativePath, { workspaceFile, storageFile }); } } - // Files only in storage (deleted from workspace) - for (const relativePath of storageFileSet) { - if (!workspaceFileSet.has(relativePath)) { - const storageFile = path.join(storagePath, relativePath); - diffFiles.set(relativePath, { storageFile }); // File only in storage + // Check for files only in storage + for (const [relativePath, storageFile] of storageMap.entries()) { + if (!workspaceMap.has(relativePath)) { + diffFiles.set(relativePath, { storageFile }); } } return diffFiles; } + private async getWebsitePath(storagePath: string): Promise { + try { + const folders = fs.readdirSync(storagePath).filter(f => + fs.statSync(path.join(storagePath, f)).isDirectory() + ); + + if (folders.length > 0) { + return path.join(storagePath, folders[0]); + } + } catch (error) { + console.error('Error finding website path:', error); + } + return undefined; + } + async getChildren(element?: MetadataDiffTreeItem): Promise { if (element) { return element.getChildren(); } + // If we have imported diff items, return those + if (this._diffItems && this._diffItems.length > 0) { + return this._diffItems; + } + try { const workspaceFolders = vscode.workspace.workspaceFolders; if (!workspaceFolders) { @@ -106,13 +147,7 @@ export class MetadataDiffTreeDataProvider implements vscode.TreeDataProvider fs.statSync(path.join(storagePath, file)).isDirectory()); - if (folderNames.length === 0) { - return []; - } - const websitePath = path.join(storagePath, folderNames[0]); - - const diffFiles = await this.getDiffFiles(workspacePath, websitePath); + const diffFiles = await this.getDiffFiles(workspacePath, storagePath); if (diffFiles.size === 0) { return []; } @@ -131,57 +166,125 @@ export class MetadataDiffTreeDataProvider implements vscode.TreeDataProvider): MetadataDiffTreeItem[] { - const rootItems: Map = new Map(); + const rootNode = new MetadataDiffFolderItem(''); - filePathMap.forEach(({ workspaceFile, storageFile }, relativePath) => { - const parts = relativePath.split(path.sep); - let currentLevel = rootItems; - let parentItem: MetadataDiffTreeItem | undefined; + for (const [relativePath, { workspaceFile, storageFile }] of filePathMap.entries()) { + const parts = relativePath.split('/'); + let currentNode = rootNode; - for (let i = 0; i < parts.length; i++) { - const isFile = i === parts.length - 1; - const name = parts[i]; + // Create folder hierarchy + for (let i = 0; i < parts.length - 1; i++) { + const folderName = parts[i]; + let folderNode = currentNode.getChildrenMap().get(folderName) as MetadataDiffFolderItem; - if (!currentLevel.has(name)) { - let newItem: MetadataDiffTreeItem; + if (!folderNode) { + folderNode = new MetadataDiffFolderItem(folderName); + currentNode.getChildrenMap().set(folderName, folderNode); + } - if (isFile) { - const hasDiff = workspaceFile === undefined || storageFile === undefined || fs.readFileSync(workspaceFile, "utf8") !== fs.readFileSync(storageFile, "utf8"); - newItem = new MetadataDiffFileItem(name, workspaceFile, storageFile, hasDiff); - } else { - newItem = new MetadataDiffFolderItem(name); - } + currentNode = folderNode; + } - if (parentItem) { - (parentItem as MetadataDiffFolderItem).getChildrenMap().set(name, newItem); - } + // Add file + const fileName = parts[parts.length - 1]; + const fileNode = new MetadataDiffFileItem( + fileName, + workspaceFile, + storageFile, + true + ); + fileNode.description = this.getChangeDescription(workspaceFile, storageFile); + currentNode.getChildrenMap().set(fileName, fileNode); + } - currentLevel.set(name, newItem); - } + // Convert the root's children map to array + return Array.from(rootNode.getChildrenMap().values()); + } + + private getFileType(filePath: string): string { + const ext = path.extname(filePath).toLowerCase(); + const basename = path.basename(filePath).toLowerCase(); + + if (basename === 'webrole.yml') return 'Roles'; + if (basename === 'websitelanguage.yml') return 'Languages'; + if (ext === '.yml' && filePath.includes('webpages')) return 'Pages'; + if (ext === '.yml' && filePath.includes('webtemplates')) return 'Templates'; + if (ext === '.yml' && filePath.includes('webfiles')) return 'Files'; + return 'Other'; + } + + private getChangeDescription(workspaceFile?: string, storageFile?: string): string { + if (!workspaceFile) return 'Only in remote'; + if (!storageFile) return 'Only in workspace'; + return 'Modified'; + } + + async setDiffFiles(files: DiffFile[]): Promise { + const rootNode = new MetadataDiffFolderItem(''); + const sortedFiles = files.sort((a, b) => a.relativePath.localeCompare(b.relativePath)); + + // Get temp directory for storing imported file contents + const tempDir = path.join(this._context.storageUri?.fsPath || '', 'imported_diff'); + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } - parentItem = currentLevel.get(name); - if (!isFile) { - currentLevel = (parentItem as MetadataDiffFolderItem).getChildrenMap(); + for (const file of sortedFiles) { + const parts = file.relativePath.split('/'); + let currentNode = rootNode; + + // Create folder hierarchy + for (let i = 0; i < parts.length - 1; i++) { + const folderName = parts[i]; + let folderNode = currentNode.getChildrenMap().get(folderName) as MetadataDiffFolderItem; + + if (!folderNode) { + // Create folder with expanded state in constructor + folderNode = new MetadataDiffFolderItem(folderName, vscode.TreeItemCollapsibleState.Expanded); + folderNode.iconPath = new vscode.ThemeIcon("folder"); + currentNode.getChildrenMap().set(folderName, folderNode); } + + currentNode = folderNode; + } + + // Rest of the file handling code... + const fileName = parts[parts.length - 1]; + const filePath = path.join(tempDir, file.relativePath); + const dirPath = path.dirname(filePath); + fs.mkdirSync(dirPath, { recursive: true }); + + let workspaceFilePath: string | undefined; + let storageFilePath: string | undefined; + + if (file.workspaceContent) { + workspaceFilePath = filePath + '.workspace'; + fs.writeFileSync(workspaceFilePath, file.workspaceContent); + } + + if (file.storageContent) { + storageFilePath = filePath + '.storage'; + fs.writeFileSync(storageFilePath, file.storageContent); } - }); - - // Filter out folders without changed files - const filterEmptyFolders = (items: MetadataDiffTreeItem[]): MetadataDiffTreeItem[] => { - return items.filter(item => { - if (item instanceof MetadataDiffFolderItem) { - const childrenArray = Array.from(item.getChildrenMap().values()); - const filteredChildren = filterEmptyFolders(childrenArray); - item.getChildrenMap().clear(); - filteredChildren.forEach(child => item.getChildrenMap().set(child.label, child)); - return filteredChildren.length > 0; - } else if (item instanceof MetadataDiffFileItem) { - return item.hasDiff; - } - return false; - }); - }; - return filterEmptyFolders(Array.from(rootItems.values())); + const fileNode = new MetadataDiffFileItem( + fileName, + workspaceFilePath, + storageFilePath, + true + ); + fileNode.description = file.changes; + fileNode.iconPath = new vscode.ThemeIcon("file"); + fileNode.command = { + command: 'metadataDiff.openDiff', + title: 'Show Diff', + arguments: [workspaceFilePath, storageFilePath] + }; + + currentNode.getChildrenMap().set(fileName, fileNode); + } + + this._diffItems = Array.from(rootNode.getChildrenMap().values()); + this._onDidChangeTreeData.fire(); } } diff --git a/src/common/power-pages/metadata-diff/tree-items/MetadataDiffFileItem.ts b/src/common/power-pages/metadata-diff/tree-items/MetadataDiffFileItem.ts index ad75b5b5a..f3fae460f 100644 --- a/src/common/power-pages/metadata-diff/tree-items/MetadataDiffFileItem.ts +++ b/src/common/power-pages/metadata-diff/tree-items/MetadataDiffFileItem.ts @@ -7,24 +7,29 @@ import * as vscode from "vscode"; import { MetadataDiffTreeItem } from "./MetadataDiffTreeItem"; export class MetadataDiffFileItem extends MetadataDiffTreeItem { - public readonly workspaceFile?: string; - public readonly storageFile?: string; - public readonly hasDiff: boolean; - constructor(label: string, workspaceFile?: string, storageFile?: string, hasDiff = true) { - super(label, vscode.TreeItemCollapsibleState.None, "metadataDiffFileItem"); + super( + label, + vscode.TreeItemCollapsibleState.None, + "metadataDiffFileItem", + workspaceFile, + storageFile + ); this.workspaceFile = workspaceFile; this.storageFile = storageFile; this.hasDiff = hasDiff; + this.iconPath = new vscode.ThemeIcon("file"); - const workspaceUri = workspaceFile ? vscode.Uri.file(workspaceFile) : vscode.Uri.parse(`untitled:${label} (Deleted)`); - const storedUri = storageFile ? vscode.Uri.file(storageFile) : vscode.Uri.parse(`untitled:${label} (Deleted)`); - - this.resourceUri = workspaceUri; - this.command = { - command: 'vscode.diff', - title: 'Show Diff', - arguments: [storedUri, workspaceUri, `${label} (Diff)`] - }; + if (hasDiff && (workspaceFile || storageFile)) { + this.command = { + command: 'metadataDiff.openDiff', + title: 'Show Diff', + arguments: [workspaceFile, storageFile] + }; + } } + + public readonly workspaceFile?: string; + public readonly storageFile?: string; + public readonly hasDiff: boolean; } diff --git a/src/common/power-pages/metadata-diff/tree-items/MetadataDiffFolderItem.ts b/src/common/power-pages/metadata-diff/tree-items/MetadataDiffFolderItem.ts index 334da6e3c..c2baeccbb 100644 --- a/src/common/power-pages/metadata-diff/tree-items/MetadataDiffFolderItem.ts +++ b/src/common/power-pages/metadata-diff/tree-items/MetadataDiffFolderItem.ts @@ -7,8 +7,8 @@ import * as vscode from "vscode"; import { MetadataDiffTreeItem } from "./MetadataDiffTreeItem"; export class MetadataDiffFolderItem extends MetadataDiffTreeItem { - constructor(label: string) { - super(label, vscode.TreeItemCollapsibleState.Collapsed, "folder"); + constructor(label: string, collapsibleState: vscode.TreeItemCollapsibleState = vscode.TreeItemCollapsibleState.Expanded) { + super(label, collapsibleState, "folder"); this.iconPath = new vscode.ThemeIcon("folder"); } } diff --git a/src/common/power-pages/metadata-diff/tree-items/MetadataDiffTreeItem.ts b/src/common/power-pages/metadata-diff/tree-items/MetadataDiffTreeItem.ts index 8aa2a9570..6ab0b9442 100644 --- a/src/common/power-pages/metadata-diff/tree-items/MetadataDiffTreeItem.ts +++ b/src/common/power-pages/metadata-diff/tree-items/MetadataDiffTreeItem.ts @@ -5,17 +5,22 @@ import * as vscode from "vscode"; -export abstract class MetadataDiffTreeItem extends vscode.TreeItem { - protected _children: Map = new Map(); +export class MetadataDiffTreeItem extends vscode.TreeItem { + private _childrenMap: Map; constructor( public readonly label: string, public readonly collapsibleState: vscode.TreeItemCollapsibleState, - public readonly contextValue: string, + public readonly contextValue?: string, public readonly filePath?: string, // Workspace file path public readonly storedFilePath?: string // Backup copy path ) { super(label, collapsibleState); + this._childrenMap = new Map(); + this.contextValue = contextValue; + this.iconPath = collapsibleState === vscode.TreeItemCollapsibleState.None ? + new vscode.ThemeIcon("file") : + new vscode.ThemeIcon("folder"); this.tooltip = this.label; if (filePath && storedFilePath) { @@ -28,10 +33,10 @@ export abstract class MetadataDiffTreeItem extends vscode.TreeItem { } public getChildren(): MetadataDiffTreeItem[] { - return Array.from(this._children.values()); + return Array.from(this._childrenMap.values()); } public getChildrenMap(): Map { - return this._children; + return this._childrenMap; } } From c7f6c6e2551f345c85dd12b9b13da296ab96edf2 Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Wed, 23 Apr 2025 05:18:51 +0530 Subject: [PATCH 08/58] Refactor --- .../metadata-diff/MetadataDiffCommands.ts | 260 +++++++++++ .../metadata-diff/MetadataDiffDesktop.ts | 414 +----------------- .../metadata-diff/MetadataDiffUtils.ts | 169 +++++++ 3 files changed, 434 insertions(+), 409 deletions(-) create mode 100644 src/client/power-pages/metadata-diff/MetadataDiffCommands.ts create mode 100644 src/client/power-pages/metadata-diff/MetadataDiffUtils.ts diff --git a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts new file mode 100644 index 000000000..e1031a6a9 --- /dev/null +++ b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts @@ -0,0 +1,260 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import * as vscode from "vscode"; +import * as fs from "fs"; +import * as path from "path"; +import { PacTerminal } from "../../lib/PacTerminal"; +import { Constants, SUCCESS } from "../../../common/power-pages/metadata-diff/Constants"; +import { oneDSLoggerWrapper } from "../../../common/OneDSLoggerTelemetry/oneDSLoggerWrapper"; +import { MetadataDiffTreeDataProvider } from "../../../common/power-pages/metadata-diff/MetadataDiffTreeDataProvider"; +import { createAuthProfileExp } from "../../../common/utilities/PacAuthUtil"; +import { getWebsiteRecordId } from "../../../common/utilities/WorkspaceInfoFinderUtil"; +import { MetadataDiffDesktop } from "./MetadataDiffDesktop"; +import { generateDiffReport, getAllDiffFiles, MetadataDiffReport } from "./MetadataDiffUtils"; + +export async function registerMetadataDiffCommands(context: vscode.ExtensionContext, pacTerminal: PacTerminal): Promise { + // Register command for handling file diffs + vscode.commands.registerCommand('metadataDiff.openDiff', async (workspaceFile: string, storedFile: string) => { + try { + const workspaceUri = vscode.Uri.file(workspaceFile); + const storedUri = vscode.Uri.file(storedFile); + const fileName = path.basename(workspaceFile); + + await vscode.commands.executeCommand('vscode.diff', + storedUri, + workspaceUri, + `${fileName} (Metadata Diff)` + ); + } catch (error) { + oneDSLoggerWrapper.getLogger().traceError( + Constants.EventNames.METADATA_DIFF_REPORT_FAILED, + error as string, + error as Error + ); + vscode.window.showErrorMessage("Failed to open diff view"); + } + }); + + vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.triggerFlow", async () => { + try { + const orgUrl = await vscode.window.showInputBox({ + prompt: "Enter the organization URL", + placeHolder: "https://your-org.crm.dynamics.com" + }); + + if (!orgUrl) { + vscode.window.showErrorMessage("Organization URL is required to trigger the metadata diff flow."); + return; + } + + const urlPattern = /^https:\/\/[a-zA-Z0-9.-]+\d*\.crm\.dynamics\.com\/?$/; + if (!urlPattern.test(orgUrl)) { + vscode.window.showErrorMessage("Invalid organization URL. Please enter a valid URL in the format: https://your-org.crm.dynamics.com", { modal: true }); + return; + } + + const pacWrapper = pacTerminal.getWrapper() + const pacActiveOrg = await pacWrapper.activeOrg(); + if(pacActiveOrg){ + if (pacActiveOrg.Status === SUCCESS) { + if(pacActiveOrg.Results.OrgUrl == orgUrl){ + vscode.window.showInformationMessage("Already connected to the specified environment."); + } + else{ + const pacOrgSelect = await pacWrapper.orgSelect(orgUrl); + if(pacOrgSelect && pacOrgSelect.Status === SUCCESS){ + vscode.window.showInformationMessage("Environment switched successfully."); + } + else{ + vscode.window.showErrorMessage("Failed to switch the environment."); + return; + } + } + } + else{ + await createAuthProfileExp(pacWrapper, orgUrl); + vscode.window.showInformationMessage("Auth profile created successfully."); + } + } + else { + vscode.window.showErrorMessage("Failed to fetch the current environment details."); + return; + } + + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + vscode.window.showErrorMessage("No folders opened in the current workspace."); + return; + } + + const currentWorkspaceFolder = workspaceFolders[0].uri.fsPath; + const websiteId = getWebsiteRecordId(currentWorkspaceFolder); + path.join(websiteId, "metadataDiffStorage"); + const storagePath = context.storageUri?.fsPath; + if (!storagePath) { + throw new Error("Storage path is not defined"); + } + + // Clean out any existing files in storagePath (so we have a "fresh" download) + if (fs.existsSync(storagePath)) { + fs.rmSync(storagePath, { recursive: true, force: true }); + } + fs.mkdirSync(storagePath, { recursive: true }); + const progressOptions: vscode.ProgressOptions = { + location: vscode.ProgressLocation.Notification, + title: "Downloading website metadata", + cancellable: false + }; + let pacPagesDownload; + await vscode.window.withProgress(progressOptions, async (progress) => { + progress.report({ message: "Looking for this website in the connected environment..." }); + const pacPagesList = await MetadataDiffDesktop.getPagesList(pacTerminal); + if (pacPagesList && pacPagesList.length > 0) { + const websiteRecord = pacPagesList.find((record) => record.id === websiteId); + if (!websiteRecord) { + vscode.window.showErrorMessage("Website not found in the connected environment."); + return; + } + progress.report({ message: `Downloading "${websiteRecord.name}" as ${websiteRecord.modelVersion === "v2" ? "enhanced" : "standard"} data model. Please wait...` }); + pacPagesDownload = await pacWrapper.pagesDownload(storagePath, websiteId, websiteRecord.modelVersion == "v1" ? "1" : "2"); + vscode.window.showInformationMessage("Download completed."); + } + }); + if (pacPagesDownload) { + const treeDataProvider = MetadataDiffTreeDataProvider.initialize(context); + context.subscriptions.push( + vscode.window.registerTreeDataProvider("microsoft.powerplatform.pages.metadataDiff", treeDataProvider) + ); + } + else{ + vscode.window.showErrorMessage("Failed to download metadata."); + } + + } + catch (error) { + oneDSLoggerWrapper.getLogger().traceError(Constants.EventNames.METADATA_DIFF_REFRESH_FAILED, error as string, error as Error, { methodName: null }, {}); + } + }); + + vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.generateReport", async () => { + try { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders) { + vscode.window.showErrorMessage("No workspace folder open"); + return; + } + + const storagePath = context.storageUri?.fsPath; + if (!storagePath) { + vscode.window.showErrorMessage("Storage path not found"); + return; + } + + // Generate report content + const reportContent = await generateDiffReport(workspaceFolders[0].uri.fsPath, storagePath); + + // Create and show the report document + const doc = await vscode.workspace.openTextDocument({ + content: reportContent, + language: 'markdown' + }); + await vscode.window.showTextDocument(doc, { + preview: true, + viewColumn: vscode.ViewColumn.Beside + }); + } catch (error) { + oneDSLoggerWrapper.getLogger().traceError( + Constants.EventNames.METADATA_DIFF_REPORT_FAILED, + error as string, + error as Error + ); + vscode.window.showErrorMessage("Failed to generate metadata diff report"); + } + }); + + vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.exportReport", async () => { + try { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders) { + vscode.window.showErrorMessage("No workspace folder open"); + return; + } + + const storagePath = context.storageUri?.fsPath; + if (!storagePath) { + vscode.window.showErrorMessage("Storage path not found"); + return; + } + + // Get diff files + const diffFiles = await getAllDiffFiles(workspaceFolders[0].uri.fsPath, storagePath); + + // Create report object + const report: MetadataDiffReport = { + generatedOn: new Date().toISOString(), + files: diffFiles + }; + + // Save dialog + const saveUri = await vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file('metadata-diff-report.json'), + filters: { + 'JSON files': ['json'] + } + }); + + if (saveUri) { + fs.writeFileSync(saveUri.fsPath, JSON.stringify(report, null, 2)); + vscode.window.showInformationMessage("Report exported successfully"); + } + } catch (error) { + oneDSLoggerWrapper.getLogger().traceError( + Constants.EventNames.METADATA_DIFF_REPORT_FAILED, + error as string, + error as Error + ); + vscode.window.showErrorMessage("Failed to export metadata diff report"); + } + }); + + vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.importReport", async () => { + try { + const fileUri = await vscode.window.showOpenDialog({ + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: false, + filters: { + 'JSON files': ['json'] + } + }); + + if (fileUri && fileUri[0]) { + const reportContent = fs.readFileSync(fileUri[0].fsPath, 'utf8'); + const report = JSON.parse(reportContent) as MetadataDiffReport; + + // Clean up any existing tree data provider + const treeDataProvider = new MetadataDiffTreeDataProvider(context); + + // Update the tree with imported data + await treeDataProvider.setDiffFiles(report.files); + + // Register the new tree data provider + context.subscriptions.push( + vscode.window.registerTreeDataProvider("microsoft.powerplatform.pages.metadataDiff", treeDataProvider) + ); + + vscode.window.showInformationMessage("Report imported successfully"); + } + } catch (error) { + oneDSLoggerWrapper.getLogger().traceError( + Constants.EventNames.METADATA_DIFF_REPORT_FAILED, + error as string, + error as Error + ); + vscode.window.showErrorMessage("Failed to import metadata diff report"); + } + }); +} diff --git a/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts b/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts index 4f6a1c9e8..70fff4d71 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts @@ -8,26 +8,11 @@ import * as fs from "fs"; import { ECSFeaturesClient } from "../../../common/ecs-features/ecsFeatureClient"; import { EnableMetadataDiff } from "../../../common/ecs-features/ecsFeatureGates"; import { oneDSLoggerWrapper } from "../../../common/OneDSLoggerTelemetry/oneDSLoggerWrapper"; -import { Constants, SUCCESS } from "../../../common/power-pages/metadata-diff/Constants"; +import { Constants } from "../../../common/power-pages/metadata-diff/Constants"; import { MetadataDiffTreeDataProvider } from "../../../common/power-pages/metadata-diff/MetadataDiffTreeDataProvider"; import { PacTerminal } from "../../lib/PacTerminal"; -import { createAuthProfileExp } from "../../../common/utilities/PacAuthUtil"; -import path from "path"; -import { getWebsiteRecordId } from "../../../common/utilities/WorkspaceInfoFinderUtil"; import { PagesList } from "../../pac/PacTypes"; - -interface DiffFile { - relativePath: string; - changes: string; - type: string; - workspaceContent?: string; - storageContent?: string; -} - -interface MetadataDiffReport { - generatedOn: string; - files: DiffFile[]; -} +import { registerMetadataDiffCommands } from "./MetadataDiffCommands"; export class MetadataDiffDesktop { private static _isInitialized = false; @@ -47,248 +32,6 @@ export class MetadataDiffDesktop { return; } - // Register command for handling file diffs - vscode.commands.registerCommand('metadataDiff.openDiff', async (workspaceFile: string, storedFile: string) => { - try { - const workspaceUri = vscode.Uri.file(workspaceFile); - const storedUri = vscode.Uri.file(storedFile); - const fileName = path.basename(workspaceFile); - - await vscode.commands.executeCommand('vscode.diff', - storedUri, - workspaceUri, - `${fileName} (Metadata Diff)` - ); - } catch (error) { - oneDSLoggerWrapper.getLogger().traceError( - Constants.EventNames.METADATA_DIFF_REPORT_FAILED, - error as string, - error as Error - ); - vscode.window.showErrorMessage("Failed to open diff view"); - } - }); - - vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.triggerFlow", async () => { - try { - const orgUrl = await vscode.window.showInputBox({ - prompt: "Enter the organization URL", - placeHolder: "https://your-org.crm.dynamics.com" - }); - - if (!orgUrl) { - vscode.window.showErrorMessage("Organization URL is required to trigger the metadata diff flow."); - return; - } - - const urlPattern = /^https:\/\/[a-zA-Z0-9.-]+\d*\.crm\.dynamics\.com\/?$/; - if (!urlPattern.test(orgUrl)) { - vscode.window.showErrorMessage("Invalid organization URL. Please enter a valid URL in the format: https://your-org.crm.dynamics.com", { modal: true }); - return; - } - - const pacWrapper = pacTerminal.getWrapper() - const pacActiveOrg = await pacWrapper.activeOrg(); - if(pacActiveOrg){ - if (pacActiveOrg.Status === SUCCESS) { - if(pacActiveOrg.Results.OrgUrl == orgUrl){ - vscode.window.showInformationMessage("Already connected to the specified environment."); - } - else{ - const pacOrgSelect = await pacWrapper.orgSelect(orgUrl); - if(pacOrgSelect && pacOrgSelect.Status === SUCCESS){ - vscode.window.showInformationMessage("Environment switched successfully."); - } - else{ - vscode.window.showErrorMessage("Failed to switch the environment."); - return; - } - } - } - else{ - await createAuthProfileExp(pacWrapper, orgUrl); - vscode.window.showInformationMessage("Auth profile created successfully."); - } - } - else { - vscode.window.showErrorMessage("Failed to fetch the current environment details."); - return; - } - - const workspaceFolders = vscode.workspace.workspaceFolders; - if (!workspaceFolders || workspaceFolders.length === 0) { - vscode.window.showErrorMessage("No folders opened in the current workspace."); - return; - } - - const currentWorkspaceFolder = workspaceFolders[0].uri.fsPath; - const websiteId = getWebsiteRecordId(currentWorkspaceFolder); - path.join(websiteId, "metadataDiffStorage"); - const storagePath = context.storageUri?.fsPath; - if (!storagePath) { - throw new Error("Storage path is not defined"); - } - - // Clean out any existing files in storagePath (so we have a "fresh" download) - if (fs.existsSync(storagePath)) { - fs.rmSync(storagePath, { recursive: true, force: true }); - } - fs.mkdirSync(storagePath, { recursive: true }); - const progressOptions: vscode.ProgressOptions = { - location: vscode.ProgressLocation.Notification, - title: "Downloading website metadata", - cancellable: false - }; - let pacPagesDownload; - await vscode.window.withProgress(progressOptions, async (progress) => { - progress.report({ message: "Looking for this website in the connected environment..." }); - const pacPagesList = await this.getPagesList(pacTerminal); - if (pacPagesList && pacPagesList.length > 0) { - const websiteRecord = pacPagesList.find((record) => record.id === websiteId); - if (!websiteRecord) { - vscode.window.showErrorMessage("Website not found in the connected environment."); - return; - } - progress.report({ message: `Downloading "${websiteRecord.name}" as ${websiteRecord.modelVersion === "v2" ? "enhanced" : "standard"} data model. Please wait...` }); - pacPagesDownload = await pacWrapper.pagesDownload(storagePath, websiteId, websiteRecord.modelVersion == "v1" ? "1" : "2"); - vscode.window.showInformationMessage("Download completed."); - } - }); - if (pacPagesDownload) { - const treeDataProvider = MetadataDiffTreeDataProvider.initialize(context); - context.subscriptions.push( - vscode.window.registerTreeDataProvider("microsoft.powerplatform.pages.metadataDiff", treeDataProvider) - ); - } - else{ - vscode.window.showErrorMessage("Failed to download metadata."); - } - - } - catch (error) { - oneDSLoggerWrapper.getLogger().traceError(Constants.EventNames.METADATA_DIFF_REFRESH_FAILED, error as string, error as Error, { methodName: null }, {}); - } - }); - - vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.generateReport", async () => { - try { - const workspaceFolders = vscode.workspace.workspaceFolders; - if (!workspaceFolders) { - vscode.window.showErrorMessage("No workspace folder open"); - return; - } - - const storagePath = context.storageUri?.fsPath; - if (!storagePath) { - vscode.window.showErrorMessage("Storage path not found"); - return; - } - - // Generate report content - const reportContent = await generateDiffReport(workspaceFolders[0].uri.fsPath, storagePath); - - // Create and show the report document - const doc = await vscode.workspace.openTextDocument({ - content: reportContent, - language: 'markdown' - }); - await vscode.window.showTextDocument(doc, { - preview: true, - viewColumn: vscode.ViewColumn.Beside - }); - } catch (error) { - oneDSLoggerWrapper.getLogger().traceError( - Constants.EventNames.METADATA_DIFF_REPORT_FAILED, - error as string, - error as Error - ); - vscode.window.showErrorMessage("Failed to generate metadata diff report"); - } - }); - - vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.exportReport", async () => { - try { - const workspaceFolders = vscode.workspace.workspaceFolders; - if (!workspaceFolders) { - vscode.window.showErrorMessage("No workspace folder open"); - return; - } - - const storagePath = context.storageUri?.fsPath; - if (!storagePath) { - vscode.window.showErrorMessage("Storage path not found"); - return; - } - - // Get diff files - const diffFiles = await getAllDiffFiles(workspaceFolders[0].uri.fsPath, storagePath); - - // Create report object - const report: MetadataDiffReport = { - generatedOn: new Date().toISOString(), - files: diffFiles - }; - - // Save dialog - const saveUri = await vscode.window.showSaveDialog({ - defaultUri: vscode.Uri.file('metadata-diff-report.json'), - filters: { - 'JSON files': ['json'] - } - }); - - if (saveUri) { - fs.writeFileSync(saveUri.fsPath, JSON.stringify(report, null, 2)); - vscode.window.showInformationMessage("Report exported successfully"); - } - } catch (error) { - oneDSLoggerWrapper.getLogger().traceError( - Constants.EventNames.METADATA_DIFF_REPORT_FAILED, - error as string, - error as Error - ); - vscode.window.showErrorMessage("Failed to export metadata diff report"); - } - }); - - vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.importReport", async () => { - try { - const fileUri = await vscode.window.showOpenDialog({ - canSelectFiles: true, - canSelectFolders: false, - canSelectMany: false, - filters: { - 'JSON files': ['json'] - } - }); - - if (fileUri && fileUri[0]) { - const reportContent = fs.readFileSync(fileUri[0].fsPath, 'utf8'); - const report = JSON.parse(reportContent) as MetadataDiffReport; - - // Clean up any existing tree data provider - const treeDataProvider = new MetadataDiffTreeDataProvider(context); - - // Update the tree with imported data - await treeDataProvider.setDiffFiles(report.files); - - // Register the new tree data provider - context.subscriptions.push( - vscode.window.registerTreeDataProvider("microsoft.powerplatform.pages.metadataDiff", treeDataProvider) - ); - - vscode.window.showInformationMessage("Report imported successfully"); - } - } catch (error) { - oneDSLoggerWrapper.getLogger().traceError( - Constants.EventNames.METADATA_DIFF_REPORT_FAILED, - error as string, - error as Error - ); - vscode.window.showErrorMessage("Failed to import metadata diff report"); - } - }); - try { const isMetadataDiffEnabled = MetadataDiffDesktop.isEnabled(); @@ -311,6 +54,8 @@ export class MetadataDiffDesktop { } fs.mkdirSync(storagePath, { recursive: true }); + await registerMetadataDiffCommands(context, pacTerminal); + const treeDataProvider = MetadataDiffTreeDataProvider.initialize(context); context.subscriptions.push( vscode.window.registerTreeDataProvider("microsoft.powerplatform.pages.metadataDiff", treeDataProvider) @@ -327,7 +72,7 @@ export class MetadataDiffDesktop { static async getPagesList(pacTerminal: PacTerminal): Promise<{ name: string, id: string, modelVersion: string }[]> { const pacWrapper = pacTerminal.getWrapper(); const pagesListOutput = await pacWrapper.pagesList(); - if (pagesListOutput && pagesListOutput.Status === SUCCESS && pagesListOutput.Information) { + if (pagesListOutput && pagesListOutput.Status === "Success" && pagesListOutput.Information) { // Parse the list of pages from the string output const pagesList: PagesList[] = []; if (Array.isArray(pagesListOutput.Information)) { @@ -361,152 +106,3 @@ export class MetadataDiffDesktop { return []; } } - -async function generateDiffReport(workspacePath: string, storagePath: string): Promise { - let report = '# Power Pages Metadata Diff Report\n\n'; - report += `Generated on: ${new Date().toLocaleString()}\n\n`; - - // Get diff files - const diffFiles = await getAllDiffFiles(workspacePath, storagePath); - - // Group by folders - const folderStructure = new Map(); - diffFiles.forEach(file => { - const dirPath = path.dirname(file.relativePath); - if (!folderStructure.has(dirPath)) { - folderStructure.set(dirPath, []); - } - folderStructure.get(dirPath)!.push(file); - }); - - // Sort folders and generate report - const sortedFolders = Array.from(folderStructure.keys()).sort(); - for (const folder of sortedFolders) { - const files = folderStructure.get(folder)!; - - // Add folder header (skip for root) - if (folder !== '.') { - report += `## ${folder}\n\n`; - } - - // Add files - for (const file of files.sort((a, b) => a.relativePath.localeCompare(b.relativePath))) { - report += `- ${path.basename(file.relativePath)}\n`; - report += ` - Changes: ${file.changes}\n`; - } - report += '\n'; - } - - return report; -} - -async function getAllDiffFiles(workspacePath: string, storagePath: string): Promise { - const diffFiles: DiffFile[] = []; - - // Get website directory from storage path - const folderNames = fs.readdirSync(storagePath).filter(file => - fs.statSync(path.join(storagePath, file)).isDirectory() - ); - if (folderNames.length === 0) { - return diffFiles; - } - const websitePath = path.join(storagePath, folderNames[0]); - - // Get all files - const workspaceFiles = await getAllFiles(workspacePath); - const storageFiles = await getAllFiles(websitePath); - - // Create normalized path maps - const workspaceMap = new Map(workspaceFiles.map(f => { - const normalized = path.relative(workspacePath, f).replace(/\\/g, '/'); - return [normalized, f]; - })); - const storageMap = new Map(storageFiles.map(f => { - const normalized = path.relative(websitePath, f).replace(/\\/g, '/'); - return [normalized, f]; - })); - - // Compare files - for (const [normalized, workspaceFile] of workspaceMap.entries()) { - const storageFile = storageMap.get(normalized); - if (!storageFile) { - diffFiles.push({ - relativePath: normalized, - changes: 'Only in workspace', - type: getFileType(normalized), - workspaceContent: fs.readFileSync(workspaceFile, 'utf8').replace(/\r\n/g, '\n') - }); - continue; - } - - // Compare content - const workspaceContent = fs.readFileSync(workspaceFile, 'utf8').replace(/\r\n/g, '\n'); - const storageContent = fs.readFileSync(storageFile, 'utf8').replace(/\r\n/g, '\n'); - if (workspaceContent !== storageContent) { - diffFiles.push({ - relativePath: normalized, - changes: 'Modified', - type: getFileType(normalized), - workspaceContent, - storageContent - }); - } - } - - // Check for files only in storage - for (const [normalized, storageFile] of storageMap.entries()) { - if (!workspaceMap.has(normalized)) { - diffFiles.push({ - relativePath: normalized, - changes: 'Only in remote', - type: getFileType(normalized), - storageContent: fs.readFileSync(storageFile, 'utf8').replace(/\r\n/g, '\n') - }); - } - } - - return diffFiles; -} - -async function getAllFiles(dirPath: string): Promise { - const files: string[] = []; - - function traverse(currentPath: string) { - const entries = fs.readdirSync(currentPath, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = path.join(currentPath, entry.name); - if (entry.isDirectory()) { - traverse(fullPath); - } else { - files.push(fullPath); - } - } - } - - traverse(dirPath); - return files; -} - -function getFileType(filePath: string): string { - const ext = path.extname(filePath).toLowerCase(); - const basename = path.basename(filePath).toLowerCase(); - - if (basename === 'webrole.yml') return 'Roles'; - if (basename === 'websitelanguage.yml') return 'Languages'; - if (ext === '.yml' && filePath.includes('webpages')) return 'Pages'; - if (ext === '.yml' && filePath.includes('webtemplates')) return 'Templates'; - if (ext === '.yml' && filePath.includes('webfiles')) return 'Files'; - - return 'Other'; -} - -function groupDiffsByType(files: DiffFile[]): Record { - return files.reduce((groups: Record, file) => { - const type = file.type || 'Other'; - if (!groups[type]) { - groups[type] = []; - } - groups[type].push(file); - return groups; - }, {}); -} diff --git a/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts b/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts new file mode 100644 index 000000000..8e2ea4c42 --- /dev/null +++ b/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts @@ -0,0 +1,169 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import * as fs from "fs"; +import * as path from "path"; + +export interface DiffFile { + relativePath: string; + changes: string; + type: string; + workspaceContent?: string; + storageContent?: string; +} + +export interface MetadataDiffReport { + generatedOn: string; + files: DiffFile[]; +} + +export async function generateDiffReport(workspacePath: string, storagePath: string): Promise { + let report = '# Power Pages Metadata Diff Report\n\n'; + report += `Generated on: ${new Date().toLocaleString()}\n\n`; + + // Get diff files + const diffFiles = await getAllDiffFiles(workspacePath, storagePath); + + // Group by folders + const folderStructure = new Map(); + diffFiles.forEach(file => { + const dirPath = path.dirname(file.relativePath); + if (!folderStructure.has(dirPath)) { + folderStructure.set(dirPath, []); + } + folderStructure.get(dirPath)!.push(file); + }); + + // Sort folders and generate report + const sortedFolders = Array.from(folderStructure.keys()).sort(); + for (const folder of sortedFolders) { + const files = folderStructure.get(folder)!; + + // Add folder header (skip for root) + if (folder !== '.') { + report += `## ${folder}\n\n`; + } + + // Add files + for (const file of files.sort((a, b) => a.relativePath.localeCompare(b.relativePath))) { + report += `- ${path.basename(file.relativePath)}\n`; + report += ` - Changes: ${file.changes}\n`; + } + report += '\n'; + } + + return report; +} + +export async function getAllDiffFiles(workspacePath: string, storagePath: string): Promise { + const diffFiles: DiffFile[] = []; + + // Get website directory from storage path + const folderNames = fs.readdirSync(storagePath).filter(file => + fs.statSync(path.join(storagePath, file)).isDirectory() + ); + if (folderNames.length === 0) { + return diffFiles; + } + const websitePath = path.join(storagePath, folderNames[0]); + + // Get all files + const workspaceFiles = await getAllFiles(workspacePath); + const storageFiles = await getAllFiles(websitePath); + + // Create normalized path maps + const workspaceMap = new Map(workspaceFiles.map(f => { + const normalized = path.relative(workspacePath, f).replace(/\\/g, '/'); + return [normalized, f]; + })); + const storageMap = new Map(storageFiles.map(f => { + const normalized = path.relative(websitePath, f).replace(/\\/g, '/'); + return [normalized, f]; + })); + + // Compare files + for (const [normalized, workspaceFile] of workspaceMap.entries()) { + const storageFile = storageMap.get(normalized); + if (!storageFile) { + diffFiles.push({ + relativePath: normalized, + changes: 'Only in workspace', + type: getFileType(normalized), + workspaceContent: fs.readFileSync(workspaceFile, 'utf8').replace(/\r\n/g, '\n') + }); + continue; + } + + // Compare content + const workspaceContent = fs.readFileSync(workspaceFile, 'utf8').replace(/\r\n/g, '\n'); + const storageContent = fs.readFileSync(storageFile, 'utf8').replace(/\r\n/g, '\n'); + if (workspaceContent !== storageContent) { + diffFiles.push({ + relativePath: normalized, + changes: 'Modified', + type: getFileType(normalized), + workspaceContent, + storageContent + }); + } + } + + // Check for files only in storage + for (const [normalized, storageFile] of storageMap.entries()) { + if (!workspaceMap.has(normalized)) { + diffFiles.push({ + relativePath: normalized, + changes: 'Only in remote', + type: getFileType(normalized), + storageContent: fs.readFileSync(storageFile, 'utf8').replace(/\r\n/g, '\n') + }); + } + } + + return diffFiles; +} + +export async function getAllFiles(dirPath: string): Promise { + const files: string[] = []; + + function traverse(currentPath: string) { + const entries = fs.readdirSync(currentPath, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(currentPath, entry.name); + if (entry.isDirectory()) { + traverse(fullPath); + } else { + files.push(fullPath); + } + } + } + + traverse(dirPath); + return files; +} + +export function getFileType(filePath: string): string { + const ext = path.extname(filePath).toLowerCase(); + const basename = path.basename(filePath).toLowerCase(); + + if (basename === 'webrole.yml') return 'Roles'; + if (basename === 'websitelanguage.yml') return 'Languages'; + if (ext === '.yml' && filePath.includes('webpages')) return 'Pages'; + if (ext === '.yml' && filePath.includes('webtemplates')) return 'Templates'; + if (ext === '.yml' && filePath.includes('webfiles')) return 'Files'; + + return 'Other'; +} + +export function groupDiffsByType(files: DiffFile[]): Record { + return files.reduce((groups: Record, file) => { + const type = file.type || 'Other'; + if (!groups[type]) { + groups[type] = []; + } + groups[type].push(file); + return groups; + }, {}); +} From 841dee815185e767e5c8393199f2e1ef831267d4 Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Wed, 23 Apr 2025 12:53:11 +0530 Subject: [PATCH 09/58] removed file type logic --- .../metadata-diff/MetadataDiffUtils.ts | 19 +++---------------- .../MetadataDiffTreeDataProvider.ts | 12 ------------ 2 files changed, 3 insertions(+), 28 deletions(-) diff --git a/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts b/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts index 8e2ea4c42..791a624f0 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts @@ -90,7 +90,7 @@ export async function getAllDiffFiles(workspacePath: string, storagePath: string diffFiles.push({ relativePath: normalized, changes: 'Only in workspace', - type: getFileType(normalized), + type: path.dirname(normalized) || 'Other', workspaceContent: fs.readFileSync(workspaceFile, 'utf8').replace(/\r\n/g, '\n') }); continue; @@ -103,7 +103,7 @@ export async function getAllDiffFiles(workspacePath: string, storagePath: string diffFiles.push({ relativePath: normalized, changes: 'Modified', - type: getFileType(normalized), + type: path.dirname(normalized) || 'Other', workspaceContent, storageContent }); @@ -116,7 +116,7 @@ export async function getAllDiffFiles(workspacePath: string, storagePath: string diffFiles.push({ relativePath: normalized, changes: 'Only in remote', - type: getFileType(normalized), + type: path.dirname(normalized) || 'Other', storageContent: fs.readFileSync(storageFile, 'utf8').replace(/\r\n/g, '\n') }); } @@ -144,19 +144,6 @@ export async function getAllFiles(dirPath: string): Promise { return files; } -export function getFileType(filePath: string): string { - const ext = path.extname(filePath).toLowerCase(); - const basename = path.basename(filePath).toLowerCase(); - - if (basename === 'webrole.yml') return 'Roles'; - if (basename === 'websitelanguage.yml') return 'Languages'; - if (ext === '.yml' && filePath.includes('webpages')) return 'Pages'; - if (ext === '.yml' && filePath.includes('webtemplates')) return 'Templates'; - if (ext === '.yml' && filePath.includes('webfiles')) return 'Files'; - - return 'Other'; -} - export function groupDiffsByType(files: DiffFile[]): Record { return files.reduce((groups: Record, file) => { const type = file.type || 'Other'; diff --git a/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts b/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts index a429d9ec4..78f2a84d9 100644 --- a/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts +++ b/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts @@ -201,18 +201,6 @@ export class MetadataDiffTreeDataProvider implements vscode.TreeDataProvider Date: Wed, 23 Apr 2025 13:33:21 +0530 Subject: [PATCH 10/58] Added clear view functionality --- package.json | 31 ++++++++++++++++--- .../metadata-diff/MetadataDiffCommands.ts | 24 ++++++++++++++ .../metadata-diff/MetadataDiffDesktop.ts | 10 ++++++ .../MetadataDiffTreeDataProvider.ts | 17 ++++++++++ 4 files changed, 77 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 6d6aca95b..9de7b351f 100644 --- a/package.json +++ b/package.json @@ -447,21 +447,32 @@ }, { "command": "microsoft.powerplatform.pages.metadataDiff.triggerFlow", - "title": "%microsoft.powerplatform.pages.metadataDiff.login.title%" + "title": "Connect & Download", + "category": "Power Pages Metadata Diff", + "icon": "$(cloud-download)" }, { "command": "microsoft.powerplatform.pages.metadataDiff.generateReport", - "title": "Generate Metadata Diff Report", - "icon": "$(output)" + "title": "Generate Report", + "category": "Power Pages Metadata Diff", + "icon": "$(markdown)" + }, + { + "command": "microsoft.powerplatform.pages.metadataDiff.clearView", + "title": "Clear View", + "category": "Power Pages Metadata Diff", + "icon": "$(clear-all)" }, { "command": "microsoft.powerplatform.pages.metadataDiff.exportReport", - "title": "Export Metadata Diff Report", + "title": "Export Report", + "category": "Power Pages Metadata Diff", "icon": "$(save-as)" }, { "command": "microsoft.powerplatform.pages.metadataDiff.importReport", - "title": "Import Metadata Diff Report", + "title": "Import Report", + "category": "Power Pages Metadata Diff", "icon": "$(folder-opened)" } ], @@ -980,6 +991,16 @@ "when": "view == microsoft.powerplatform.pages.metadataDiff", "group": "navigation" }, + { + "command": "microsoft.powerplatform.pages.metadataDiff.triggerFlow", + "when": "view == microsoft.powerplatform.pages.metadataDiff", + "group": "navigation" + }, + { + "command": "microsoft.powerplatform.pages.metadataDiff.clearView", + "when": "view == microsoft.powerplatform.pages.metadataDiff", + "group": "navigation" + }, { "command": "microsoft.powerplatform.pages.metadataDiff.exportReport", "when": "view == microsoft.powerplatform.pages.metadataDiff", diff --git a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts index e1031a6a9..628ceaaff 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts @@ -38,6 +38,30 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont } }); + vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.clearView", async () => { + try { + MetadataDiffDesktop.resetTreeView(); + + // Set the context variable to false to show welcome message + await vscode.commands.executeCommand("setContext", "microsoft.powerplatform.pages.metadataDiff.hasData", false); + + // Reload tree data provider to show welcome message + const treeDataProvider = MetadataDiffTreeDataProvider.initialize(context); + context.subscriptions.push( + vscode.window.registerTreeDataProvider("microsoft.powerplatform.pages.metadataDiff", treeDataProvider) + ); + + vscode.window.showInformationMessage("Metadata diff view cleared successfully."); + } catch (error) { + oneDSLoggerWrapper.getLogger().traceError( + Constants.EventNames.METADATA_DIFF_REPORT_FAILED, + error as string, + error as Error + ); + vscode.window.showErrorMessage("Failed to clear metadata diff view"); + } + }); + vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.triggerFlow", async () => { try { const orgUrl = await vscode.window.showInputBox({ diff --git a/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts b/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts index 70fff4d71..df2eb16d7 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts @@ -16,6 +16,7 @@ import { registerMetadataDiffCommands } from "./MetadataDiffCommands"; export class MetadataDiffDesktop { private static _isInitialized = false; + private static _treeDataProvider: MetadataDiffTreeDataProvider | undefined; static isEnabled(): boolean { const enableMetadataDiff = ECSFeaturesClient.getConfig(EnableMetadataDiff).enableMetadataDiff @@ -27,6 +28,14 @@ export class MetadataDiffDesktop { return enableMetadataDiff; } + static resetTreeView(): void { + if (this._treeDataProvider) { + this._treeDataProvider.clearItems(); + // Force reset tree view context to show welcome message + vscode.commands.executeCommand("setContext", "microsoft.powerplatform.pages.metadataDiff.hasData", false); + } + } + static async initialize(context: vscode.ExtensionContext, pacTerminal: PacTerminal): Promise { if (MetadataDiffDesktop._isInitialized) { return; @@ -57,6 +66,7 @@ export class MetadataDiffDesktop { await registerMetadataDiffCommands(context, pacTerminal); const treeDataProvider = MetadataDiffTreeDataProvider.initialize(context); + MetadataDiffDesktop._treeDataProvider = treeDataProvider; context.subscriptions.push( vscode.window.registerTreeDataProvider("microsoft.powerplatform.pages.metadataDiff", treeDataProvider) ); diff --git a/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts b/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts index 78f2a84d9..1c71b9385 100644 --- a/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts +++ b/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts @@ -39,6 +39,23 @@ export class MetadataDiffTreeDataProvider implements vscode.TreeDataProvider { return element; } From 55930d084f49d2dc302eba88d13895ddfa20d2ba Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Wed, 23 Apr 2025 14:36:54 +0530 Subject: [PATCH 11/58] Added dropdown to org picker --- .../metadata-diff/MetadataDiffCommands.ts | 59 +++++++++++++++++-- 1 file changed, 54 insertions(+), 5 deletions(-) diff --git a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts index 628ceaaff..e34a34bd2 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts @@ -64,10 +64,60 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.triggerFlow", async () => { try { - const orgUrl = await vscode.window.showInputBox({ - prompt: "Enter the organization URL", - placeHolder: "https://your-org.crm.dynamics.com" - }); + // Get the PAC wrapper to access org list + const pacWrapper = pacTerminal.getWrapper(); + + let orgUrl: string | undefined; + + // Get list of available organizations + const orgListResult = await pacWrapper.orgList(); + + if (orgListResult && orgListResult.Status === SUCCESS && orgListResult.Results.length > 0) { + // Create items for QuickPick + const items = orgListResult.Results.map(org => { + return { + label: org.FriendlyName, + description: org.EnvironmentUrl, + detail: `${org.OrganizationId} (${org.EnvironmentId})` + }; + }); + + // Add option to enter URL manually + items.push({ + label: "$(plus) Enter organization URL manually", + description: "", + detail: "Enter a custom organization URL" + }); + + // Show QuickPick to select environment + const selected = await vscode.window.showQuickPick(items, { + placeHolder: "Select an environment or enter URL manually", + ignoreFocusOut: true + }); + + if (selected) { + if (selected.description) { + // Use the selected org URL + orgUrl = selected.description; + } else { + // If manual entry option was selected + orgUrl = await vscode.window.showInputBox({ + prompt: "Enter the organization URL", + placeHolder: "https://your-org.crm.dynamics.com", + validateInput: (input) => { + const urlPattern = /^https:\/\/[a-zA-Z0-9.-]+\d*\.crm\.dynamics\.com\/?$/; + return urlPattern.test(input) ? null : "Please enter a valid URL in the format: https://your-org.crm.dynamics.com"; + } + }); + } + } + } else { + // Fallback to manual entry if no orgs are found + orgUrl = await vscode.window.showInputBox({ + prompt: "Enter the organization URL", + placeHolder: "https://your-org.crm.dynamics.com" + }); + } if (!orgUrl) { vscode.window.showErrorMessage("Organization URL is required to trigger the metadata diff flow."); @@ -80,7 +130,6 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont return; } - const pacWrapper = pacTerminal.getWrapper() const pacActiveOrg = await pacWrapper.activeOrg(); if(pacActiveOrg){ if (pacActiveOrg.Status === SUCCESS) { From 404d68b43df7800161b61b0161e446c1e29b9f0b Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Wed, 23 Apr 2025 14:51:44 +0530 Subject: [PATCH 12/58] Fix report import file type parsing --- .../metadata-diff/MetadataDiffTreeDataProvider.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts b/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts index 1c71b9385..f00571da3 100644 --- a/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts +++ b/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts @@ -259,16 +259,21 @@ export class MetadataDiffTreeDataProvider implements vscode.TreeDataProvider Date: Wed, 23 Apr 2025 15:39:20 +0530 Subject: [PATCH 13/58] Improved md report ensuring there is no cx data --- .../metadata-diff/MetadataDiffCommands.ts | 10 ++- .../metadata-diff/MetadataDiffUtils.ts | 85 ++++++++++++++++++- 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts index e34a34bd2..c95f4ac05 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts @@ -234,10 +234,16 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont content: reportContent, language: 'markdown' }); + + // Force the document to be opened in preview mode with explicit encoding await vscode.window.showTextDocument(doc, { - preview: true, - viewColumn: vscode.ViewColumn.Beside + preview: false, // Set to false to ensure it opens properly + viewColumn: vscode.ViewColumn.Beside, + preserveFocus: true }); + + // Force markdown preview to open + await vscode.commands.executeCommand('markdown.showPreview', doc.uri); } catch (error) { oneDSLoggerWrapper.getLogger().traceError( Constants.EventNames.METADATA_DIFF_REPORT_FAILED, diff --git a/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts b/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts index 791a624f0..653e88e3a 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts @@ -38,7 +38,8 @@ export async function generateDiffReport(workspacePath: string, storagePath: str // Sort folders and generate report const sortedFolders = Array.from(folderStructure.keys()).sort(); - for (const folder of sortedFolders) { + for (let i = 0; i < sortedFolders.length; i++) { + const folder = sortedFolders[i]; const files = folderStructure.get(folder)!; // Add folder header (skip for root) @@ -50,13 +51,93 @@ export async function generateDiffReport(workspacePath: string, storagePath: str for (const file of files.sort((a, b) => a.relativePath.localeCompare(b.relativePath))) { report += `- ${path.basename(file.relativePath)}\n`; report += ` - Changes: ${file.changes}\n`; + + // Add detailed YAML property changes for YAML files + if (path.extname(file.relativePath).toLowerCase() === '.yml' || + path.extname(file.relativePath).toLowerCase() === '.yaml') { + try { + if (file.workspaceContent && file.storageContent && file.changes === 'Modified') { + const yaml = require('yaml'); + const workspaceYaml = yaml.parse(file.workspaceContent); + const storageYaml = yaml.parse(file.storageContent); + + const propertyChanges = findYamlPropertyChanges(workspaceYaml, storageYaml); + + if (propertyChanges.length > 0) { + report += ` - Property changes:\n`; + propertyChanges.forEach(change => { + report += ` - ${change}\n`; + }); + } + } + } catch (error) { + report += ` - Failed to parse YAML content: ${error}\n`; + } + } + } + + // Only add a blank line between folders, not after the last folder + if (i < sortedFolders.length - 1) { + report += '\n'; } - report += '\n'; } return report; } +/** + * Finds differences between two YAML objects and returns a list of changes + * @param workspaceObj The workspace YAML object + * @param storageObj The storage YAML object + * @returns An array of change descriptions + */ +function findYamlPropertyChanges(workspaceObj: any, storageObj: any, path = ''): string[] { + if (!workspaceObj || !storageObj) { + return []; + } + + const changes: string[] = []; + + // Check all properties in workspace object + const workspaceKeys = Object.keys(workspaceObj); + for (const key of workspaceKeys) { + const currentPath = path ? `${path}.${key}` : key; + + // Check if key exists in storage + if (!(key in storageObj)) { + changes.push(`Added property: \`${currentPath}\``); + continue; + } + + // Check if both values are objects (for nested properties) + if (typeof workspaceObj[key] === 'object' && workspaceObj[key] !== null && + typeof storageObj[key] === 'object' && storageObj[key] !== null) { + // Recursively check nested objects + const nestedChanges = findYamlPropertyChanges( + workspaceObj[key], + storageObj[key], + currentPath + ); + changes.push(...nestedChanges); + } + // Check primitive values (string, number, boolean, etc.) + else if (JSON.stringify(workspaceObj[key]) !== JSON.stringify(storageObj[key])) { + changes.push(`Modified property: \`${currentPath}\``); + } + } + + // Check for properties that exist in storage but not in workspace + const storageKeys = Object.keys(storageObj); + for (const key of storageKeys) { + const currentPath = path ? `${path}.${key}` : key; + if (!(key in workspaceObj)) { + changes.push(`Removed property: \`${currentPath}\``); + } + } + + return changes; +} + export async function getAllDiffFiles(workspacePath: string, storagePath: string): Promise { const diffFiles: DiffFile[] = []; From ab0f6c794e7737716ec548b9e19ef596de1555ec Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Fri, 25 Apr 2025 13:31:56 +0530 Subject: [PATCH 14/58] refactor --- src/client/power-pages/metadata-diff/MetadataDiffUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts b/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts index 653e88e3a..e452c4b70 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts @@ -5,6 +5,7 @@ import * as fs from "fs"; import * as path from "path"; +import * as yaml from 'yaml'; export interface DiffFile { relativePath: string; @@ -57,7 +58,6 @@ export async function generateDiffReport(workspacePath: string, storagePath: str path.extname(file.relativePath).toLowerCase() === '.yaml') { try { if (file.workspaceContent && file.storageContent && file.changes === 'Modified') { - const yaml = require('yaml'); const workspaceYaml = yaml.parse(file.workspaceContent); const storageYaml = yaml.parse(file.storageContent); From de9ae55779073ef8815c563ab7294abc13cdcb62 Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Fri, 25 Apr 2025 13:48:20 +0530 Subject: [PATCH 15/58] fixed md report preview issue --- .../metadata-diff/MetadataDiffCommands.ts | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts index c95f4ac05..d73d31895 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts @@ -229,21 +229,28 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont // Generate report content const reportContent = await generateDiffReport(workspaceFolders[0].uri.fsPath, storagePath); - // Create and show the report document + // Create the markdown document const doc = await vscode.workspace.openTextDocument({ content: reportContent, language: 'markdown' }); - // Force the document to be opened in preview mode with explicit encoding + // Show the document in column One await vscode.window.showTextDocument(doc, { - preview: false, // Set to false to ensure it opens properly - viewColumn: vscode.ViewColumn.Beside, - preserveFocus: true + preview: false, // Don't use preview mode to ensure stability + viewColumn: vscode.ViewColumn.One, + preserveFocus: false // Ensure focus is on the document }); - // Force markdown preview to open - await vscode.commands.executeCommand('markdown.showPreview', doc.uri); + // Increase delay to ensure document is fully loaded and stable + await new Promise(resolve => setTimeout(resolve, 800)); + + // Close any existing preview first to avoid conflicts + await vscode.commands.executeCommand('markdown.preview.refresh'); + + // Show preview in side-by-side mode + await vscode.commands.executeCommand('markdown.showPreviewToSide'); + } catch (error) { oneDSLoggerWrapper.getLogger().traceError( Constants.EventNames.METADATA_DIFF_REPORT_FAILED, From 1cf95b09b8aa86c05402722d71b6758cdb8b0017 Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Wed, 3 Sep 2025 00:09:36 +0530 Subject: [PATCH 16/58] Add compareWithlocal to context menu --- package.json | 13 ++++ package.nls.json | 1 + .../actions-hub/ActionsHubCommandHandlers.ts | 28 +++++++++ .../actions-hub/ActionsHubTreeDataProvider.ts | 4 +- .../power-pages/actions-hub/Constants.ts | 2 + .../ActionsHubCommandHandlers.test.ts | 62 ++++++++++++++++++- .../ActionsHubTreeDataProvider.test.ts | 16 ++++- 7 files changed, 122 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 66615e41f..24ba7c995 100644 --- a/package.json +++ b/package.json @@ -473,6 +473,10 @@ "command": "microsoft.powerplatform.pages.actionsHub.siteDetails", "title": "%microsoft.powerplatform.pages.actionsHub.siteDetails.title%" }, + { + "command": "microsoft.powerplatform.pages.actionsHub.compareWithLocal", + "title": "%microsoft.powerplatform.pages.actionsHub.compareWithLocal.title%" + }, { "command": "microsoft.powerplatform.pages.actionsHub.activeSite.downloadSite", "title": "%microsoft.powerplatform.pages.actionsHub.activeSite.downloadSite.title%" @@ -1014,6 +1018,10 @@ "command": "microsoft.powerplatform.pages.actionsHub.siteDetails", "when": "never" }, + { + "command": "microsoft.powerplatform.pages.actionsHub.compareWithLocal", + "when": "never" + }, { "command": "microsoft.powerplatform.pages.actionsHub.activeSite.downloadSite", "when": "never" @@ -1227,6 +1235,11 @@ "command": "microsoft.powerplatform.pages.actionsHub.currentActiveSite.runCodeQLScreening", "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == currentActiveSite && microsoft.powerplatform.pages.codeQlScanEnabled", "group": "siteAction@7" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.compareWithLocal", + "when": "view == microsoft.powerplatform.pages.actionsHub && (viewItem == currentActiveSite || viewItem == nonCurrentActiveSite)", + "group": "siteAction@9" } ] }, diff --git a/package.nls.json b/package.nls.json index d592c5fff..58294bc68 100644 --- a/package.nls.json +++ b/package.nls.json @@ -97,6 +97,7 @@ "microsoft.powerplatform.pages.actionsHub.switchEnvironment.title": "Change Environment", "microsoft.powerplatform.pages.actionsHub.showEnvironmentDetails.title": "Show Environment Details", "microsoft.powerplatform.pages.actionsHub.openSitesInStudio.title": "Open in Power Pages Studio", + "microsoft.powerplatform.pages.actionsHub.compareWithLocal.title": "Compare with Local", "microsoft.powerplatform.pages.actionsHub.activeSite.previewSite.title": "Preview", "microsoft.powerplatform.pages.actionsHub.login":{ "message": "Login and connect to a Power Pages environment to use Power Pages actions. [Learn more](https://go.microsoft.com/fwlink/?linkid=2305702).\n[Login](command:microsoft.powerplatform.pages.actionsHub.newAuthProfile)", diff --git a/src/client/power-pages/actions-hub/ActionsHubCommandHandlers.ts b/src/client/power-pages/actions-hub/ActionsHubCommandHandlers.ts index d4116e069..2e1a43fa7 100644 --- a/src/client/power-pages/actions-hub/ActionsHubCommandHandlers.ts +++ b/src/client/power-pages/actions-hub/ActionsHubCommandHandlers.ts @@ -703,6 +703,34 @@ export const showSiteDetails = async (siteTreeItem: SiteTreeItem) => { } } +export const compareWithLocal = async (siteTreeItem: SiteTreeItem) => { + const siteInfo = siteTreeItem.siteInfo; + + traceInfo( + Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_LOCAL_CALLED, + { + methodName: compareWithLocal.name, + siteId: siteInfo.websiteId, + dataModelVersion: siteInfo.dataModelVersion + } + ); + + try { + // Execute the compare with local command + await vscode.commands.executeCommand('microsoft.powerplatform.pages.metadataDiff.triggerFlow'); + } catch (error) { + traceError( + Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_LOCAL_FAILED, + error as Error, + { + methodName: compareWithLocal.name, + siteId: siteInfo.websiteId, + dataModelVersion: siteInfo.dataModelVersion + } + ); + } +} + const getDownloadFolderOptions = () => { const options = [ { diff --git a/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts b/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts index e32080f83..03c5bf80e 100644 --- a/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts +++ b/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts @@ -12,7 +12,7 @@ import { oneDSLoggerWrapper } from "../../../common/OneDSLoggerTelemetry/oneDSLo import { EnvironmentGroupTreeItem } from "./tree-items/EnvironmentGroupTreeItem"; import { IEnvironmentInfo } from "./models/IEnvironmentInfo"; import { PacTerminal } from "../../lib/PacTerminal"; -import { fetchWebsites, openActiveSitesInStudio, openInactiveSitesInStudio, previewSite, createNewAuthProfile, refreshEnvironment, showEnvironmentDetails, switchEnvironment, revealInOS, openSiteManagement, uploadSite, showSiteDetails, downloadSite, openInStudio, reactivateSite, runCodeQLScreening, loginToMatch } from "./ActionsHubCommandHandlers"; +import { fetchWebsites, openActiveSitesInStudio, openInactiveSitesInStudio, previewSite, createNewAuthProfile, refreshEnvironment, showEnvironmentDetails, switchEnvironment, revealInOS, openSiteManagement, uploadSite, showSiteDetails, compareWithLocal, downloadSite, openInStudio, reactivateSite, runCodeQLScreening, loginToMatch } from "./ActionsHubCommandHandlers"; import PacContext from "../../pac/PacContext"; import CurrentSiteContext from "./CurrentSiteContext"; import { IOtherSiteInfo, IWebsiteDetails } from "../../../common/services/Interfaces"; @@ -246,6 +246,8 @@ export class ActionsHubTreeDataProvider implements vscode.TreeDataProvider { }); }); + describe('compareWithLocal', () => { + let mockExecuteCommand: sinon.SinonStub; + let mockSiteTreeItem: SiteTreeItem; + + beforeEach(() => { + mockExecuteCommand = sandbox.stub(vscode.commands, 'executeCommand'); + mockSiteTreeItem = new SiteTreeItem({ + name: "Test Site", + websiteId: "test-id", + dataModelVersion: 1, + status: WebsiteStatus.Active, + websiteUrl: 'https://test-site.com', + isCurrent: false, + siteVisibility: SiteVisibility.Public, + siteManagementUrl: "https://test-site-management.com", + createdOn: "2025-03-20", + creator: "Test Creator", + isCodeSite: false + }); + }); + + it('should execute metadata diff trigger flow command', async () => { + await compareWithLocal(mockSiteTreeItem); + + expect(mockExecuteCommand.calledOnce).to.be.true; + expect(mockExecuteCommand.firstCall.args[0]).to.equal('microsoft.powerplatform.pages.metadataDiff.triggerFlow'); + }); + + it('should handle errors gracefully', async () => { + const error = new Error('Command execution failed'); + mockExecuteCommand.rejects(error); + + await compareWithLocal(mockSiteTreeItem); + + expect(mockExecuteCommand.calledOnce).to.be.true; + expect(traceErrorStub.calledOnce).to.be.true; + expect(traceErrorStub.firstCall.args[0]).to.equal(Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_LOCAL_FAILED); + expect(traceErrorStub.firstCall.args[1]).to.equal(error); + expect(traceErrorStub.firstCall.args[2]).to.deep.equal({ + methodName: 'compareWithLocal', + siteId: 'test-id', + dataModelVersion: 1 + }); + }); + + it('should log telemetry when command is called', async () => { + const traceInfoStub = TelemetryHelper.traceInfo as sinon.SinonStub; + + await compareWithLocal(mockSiteTreeItem); + + expect(traceInfoStub.calledOnce).to.be.true; + expect(traceInfoStub.firstCall.args[0]).to.equal(Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_LOCAL_CALLED); + expect(traceInfoStub.firstCall.args[1]).to.deep.equal({ + methodName: 'compareWithLocal', + siteId: 'test-id', + dataModelVersion: 1 + }); + }); + }); + describe('downloadSite', () => { let dirnameSpy: sinon.SinonSpy; let mockSendText: sinon.SinonStub; diff --git a/src/client/test/Integration/power-pages/actions-hub/ActionsHubTreeDataProvider.test.ts b/src/client/test/Integration/power-pages/actions-hub/ActionsHubTreeDataProvider.test.ts index 43499285f..6fac826ec 100644 --- a/src/client/test/Integration/power-pages/actions-hub/ActionsHubTreeDataProvider.test.ts +++ b/src/client/test/Integration/power-pages/actions-hub/ActionsHubTreeDataProvider.test.ts @@ -218,6 +218,18 @@ describe("ActionsHubTreeDataProvider", () => { expect(mockCommandHandler.calledOnce).to.be.true; }); + it("should register compareWithLocal command", async () => { + const mockCommandHandler = sinon.stub(CommandHandlers, 'compareWithLocal'); + mockCommandHandler.resolves(); + const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false); + actionsHubTreeDataProvider["registerPanel"](pacTerminal); + + expect(registerCommandStub.calledWith("microsoft.powerplatform.pages.actionsHub.compareWithLocal")).to.be.true; + + await registerCommandStub.getCall(13).args[1](); + expect(mockCommandHandler.calledOnce).to.be.true; + }); + it("should register downloadSite command", async () => { const mockCommandHandler = sinon.stub(CommandHandlers, 'downloadSite'); mockCommandHandler.resolves(); @@ -226,7 +238,7 @@ describe("ActionsHubTreeDataProvider", () => { expect(registerCommandStub.calledWith("microsoft.powerplatform.pages.actionsHub.activeSite.downloadSite")).to.be.true; - await registerCommandStub.getCall(13).args[1](); + await registerCommandStub.getCall(14).args[1](); expect(mockCommandHandler.calledOnce).to.be.true; }); @@ -238,7 +250,7 @@ describe("ActionsHubTreeDataProvider", () => { expect(registerCommandStub.calledWith("microsoft.powerplatform.pages.actionsHub.activeSite.openInStudio")).to.be.true; - await registerCommandStub.getCall(14).args[1](); + await registerCommandStub.getCall(15).args[1](); expect(mockCommandHandler.calledOnce).to.be.true; }); From 864d204f249c6cc9aca5f15b8b6a6713f195b6a0 Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Wed, 3 Sep 2025 01:15:29 +0530 Subject: [PATCH 17/58] Add websiteId as a param --- .../actions-hub/ActionsHubCommandHandlers.ts | 4 +- .../metadata-diff/MetadataDiffCommands.ts | 71 +++++++++++++++++++ .../ActionsHubCommandHandlers.test.ts | 5 +- 3 files changed, 76 insertions(+), 4 deletions(-) diff --git a/src/client/power-pages/actions-hub/ActionsHubCommandHandlers.ts b/src/client/power-pages/actions-hub/ActionsHubCommandHandlers.ts index 2e1a43fa7..4bc8f1ce4 100644 --- a/src/client/power-pages/actions-hub/ActionsHubCommandHandlers.ts +++ b/src/client/power-pages/actions-hub/ActionsHubCommandHandlers.ts @@ -716,8 +716,8 @@ export const compareWithLocal = async (siteTreeItem: SiteTreeItem) => { ); try { - // Execute the compare with local command - await vscode.commands.executeCommand('microsoft.powerplatform.pages.metadataDiff.triggerFlow'); + // Execute the compare with local command with specific website ID + await vscode.commands.executeCommand('microsoft.powerplatform.pages.metadataDiff.triggerFlowWithSite', siteInfo.websiteId); } catch (error) { traceError( Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_LOCAL_FAILED, diff --git a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts index d73d31895..19b9e57e5 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts @@ -62,6 +62,77 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont } }); + vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.triggerFlowWithSite", async (websiteId: string) => { + try { + // Get the PAC wrapper to access org list + const pacWrapper = pacTerminal.getWrapper(); + + // Get current org info instead of prompting user + const pacActiveOrg = await pacWrapper.activeOrg(); + if (!pacActiveOrg || pacActiveOrg.Status !== SUCCESS) { + vscode.window.showErrorMessage("No active environment found. Please authenticate first."); + return; + } + + const orgUrl = pacActiveOrg.Results.OrgUrl; + if (!orgUrl) { + vscode.window.showErrorMessage("Current environment URL not found."); + return; + } + + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + vscode.window.showErrorMessage("No folders opened in the current workspace."); + return; + } + + const storagePath = context.storageUri?.fsPath; + if (!storagePath) { + throw new Error("Storage path is not defined"); + } + + // Clean out any existing files in storagePath (so we have a "fresh" download) + if (fs.existsSync(storagePath)) { + fs.rmSync(storagePath, { recursive: true, force: true }); + } + fs.mkdirSync(storagePath, { recursive: true }); + + const progressOptions: vscode.ProgressOptions = { + location: vscode.ProgressLocation.Notification, + title: "Downloading website metadata", + cancellable: false + }; + + let pacPagesDownload; + await vscode.window.withProgress(progressOptions, async (progress) => { + progress.report({ message: "Looking for this website in the connected environment..." }); + const pacPagesList = await MetadataDiffDesktop.getPagesList(pacTerminal); + if (pacPagesList && pacPagesList.length > 0) { + const websiteRecord = pacPagesList.find((record) => record.id === websiteId); + if (!websiteRecord) { + vscode.window.showErrorMessage("Website not found in the connected environment."); + return; + } + progress.report({ message: `Downloading "${websiteRecord.name}" as ${websiteRecord.modelVersion === "v2" ? "enhanced" : "standard"} data model. Please wait...` }); + pacPagesDownload = await pacWrapper.pagesDownload(storagePath, websiteId, websiteRecord.modelVersion == "v1" ? "1" : "2"); + vscode.window.showInformationMessage("Download completed."); + } + }); + + if (pacPagesDownload) { + const treeDataProvider = MetadataDiffTreeDataProvider.initialize(context); + context.subscriptions.push( + vscode.window.registerTreeDataProvider("microsoft.powerplatform.pages.metadataDiff", treeDataProvider) + ); + } else { + vscode.window.showErrorMessage("Failed to download metadata."); + } + + } catch (error) { + oneDSLoggerWrapper.getLogger().traceError(Constants.EventNames.METADATA_DIFF_REFRESH_FAILED, error as string, error as Error, { methodName: null }, {}); + } + }); + vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.triggerFlow", async () => { try { // Get the PAC wrapper to access org list diff --git a/src/client/test/Integration/power-pages/actions-hub/ActionsHubCommandHandlers.test.ts b/src/client/test/Integration/power-pages/actions-hub/ActionsHubCommandHandlers.test.ts index f5c3e3806..e537fd6f1 100644 --- a/src/client/test/Integration/power-pages/actions-hub/ActionsHubCommandHandlers.test.ts +++ b/src/client/test/Integration/power-pages/actions-hub/ActionsHubCommandHandlers.test.ts @@ -1291,11 +1291,12 @@ describe('ActionsHubCommandHandlers', () => { }); }); - it('should execute metadata diff trigger flow command', async () => { + it('should execute metadata diff trigger flow command with site ID', async () => { await compareWithLocal(mockSiteTreeItem); expect(mockExecuteCommand.calledOnce).to.be.true; - expect(mockExecuteCommand.firstCall.args[0]).to.equal('microsoft.powerplatform.pages.metadataDiff.triggerFlow'); + expect(mockExecuteCommand.firstCall.args[0]).to.equal('microsoft.powerplatform.pages.metadataDiff.triggerFlowWithSite'); + expect(mockExecuteCommand.firstCall.args[1]).to.equal('test-id'); }); it('should handle errors gracefully', async () => { From 255081d9a8db74a6935bcf1ad7658add123aa0fa Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Wed, 3 Sep 2025 02:53:48 +0530 Subject: [PATCH 18/58] Handle expanded DM names --- src/client/pac/PacWrapper.ts | 4 ++-- .../power-pages/metadata-diff/MetadataDiffCommands.ts | 6 +++++- .../power-pages/metadata-diff/MetadataDiffDesktop.ts | 9 +++++++-- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/client/pac/PacWrapper.ts b/src/client/pac/PacWrapper.ts index 1fcd68be7..e429d86f4 100644 --- a/src/client/pac/PacWrapper.ts +++ b/src/client/pac/PacWrapper.ts @@ -188,8 +188,8 @@ export class PacWrapper { return this.executeCommandAndParseResults(new PacArguments("pages", "download", "-p", downloadPath, "-id", websiteId, "-mv", dataModelVersion.toString())); } - public async pagesDownload(path: string, websiteId: string, modelVersion: string): Promise { - return this.pacInterop.executeCommand(new PacArguments("pages", "download", "--path", path, "--webSiteId", websiteId, "--modelVersion", modelVersion)); + public async pagesDownload(path: string, websiteId: string, modelVersion: string): Promise { + return this.executeCommandAndParseResults(new PacArguments("pages", "download", "-p", path, "-id", websiteId, "-mv", modelVersion)); } public async pagesList(): Promise { diff --git a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts index 19b9e57e5..ce90c388b 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts @@ -114,7 +114,11 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont return; } progress.report({ message: `Downloading "${websiteRecord.name}" as ${websiteRecord.modelVersion === "v2" ? "enhanced" : "standard"} data model. Please wait...` }); - pacPagesDownload = await pacWrapper.pagesDownload(storagePath, websiteId, websiteRecord.modelVersion == "v1" ? "1" : "2"); + pacPagesDownload = await pacWrapper.pagesDownload( + storagePath, + websiteId, + websiteRecord.modelVersion === "v1" || websiteRecord.modelVersion === "Standard" ? "1" : "2" + ); vscode.window.showInformationMessage("Download completed."); } }); diff --git a/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts b/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts index df2eb16d7..65c67c1bc 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts @@ -94,12 +94,17 @@ export class MetadataDiffDesktop { } // Extract the relevant parts using regex - const match = line.match(/\[\d+\]\s+([a-f0-9-]+)\s+(.*?)\s+(v[12])\s*$/i); + // Example line: ' [2] 8aa65ec4-1578-f011-b4cc-0022480b93b5 Customer Self Service_V1 - customerselfservice-oh1uo Standard ' + const match = line.match(/\[\d+\]\s+([a-f0-9-]+)\s+(.+?)\s+Standard\s*$/i); if (match) { + // Extract WebsiteId, FriendlyName, and ModelVersion from the line + // Example line: ' [2] 8aa65ec4-1578-f011-b4cc-0022480b93b5 Customer Self Service_V1 - customerselfservice-oh1uo Standard ' + const modelVersionMatch = line.match(/\s(Standard|Enhanced)\s*$/i); + const modelVersion = modelVersionMatch ? modelVersionMatch[1].trim() : "Standard"; pagesList.push({ WebsiteId: match[1].trim(), FriendlyName: match[2].trim(), - ModelVersion: match[3].trim() + ModelVersion: modelVersion }); } }); From 0b4af76410fde259c8553e09321fe1d17aef7dbe Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Wed, 3 Sep 2025 03:25:43 +0530 Subject: [PATCH 19/58] Fix for Edm sites --- src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts b/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts index 65c67c1bc..38a20407e 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts @@ -94,8 +94,8 @@ export class MetadataDiffDesktop { } // Extract the relevant parts using regex - // Example line: ' [2] 8aa65ec4-1578-f011-b4cc-0022480b93b5 Customer Self Service_V1 - customerselfservice-oh1uo Standard ' - const match = line.match(/\[\d+\]\s+([a-f0-9-]+)\s+(.+?)\s+Standard\s*$/i); + // Example line: ' [4] e26a79b8-4c5d-f011-bec2-000d3a358057 Test V1_Studio - site-vbdyt Enhanced ' + const match = line.match(/\[\d+\]\s+([a-f0-9-]+)\s+(.+?)\s+(Standard|Enhanced)\s*$/i); if (match) { // Extract WebsiteId, FriendlyName, and ModelVersion from the line // Example line: ' [2] 8aa65ec4-1578-f011-b4cc-0022480b93b5 Customer Self Service_V1 - customerselfservice-oh1uo Standard ' From 3094ee7cc0d56b736612d73185b390a1293f8613 Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Wed, 3 Sep 2025 14:58:16 +0530 Subject: [PATCH 20/58] Show compare with local comand for inactive websites --- loc/translations-export/vscode-powerplatform.xlf | 12 ++++++++++++ package.json | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/loc/translations-export/vscode-powerplatform.xlf b/loc/translations-export/vscode-powerplatform.xlf index fd29403d1..87c22baa2 100644 --- a/loc/translations-export/vscode-powerplatform.xlf +++ b/loc/translations-export/vscode-powerplatform.xlf @@ -1077,6 +1077,15 @@ The fifth line should be '[TRANSLATION HERE](command:powerplatform-walkthrough.s Clear Conversation + + Compare with Local + + + Compare your Power Pages website against a Power Pages environment to view any differences. [Learn more](https://go.microsoft.com/fwlink/?linkid=2305702). +[Get Started](command:microsoft.powerplatform.pages.metadataDiff.triggerFlow) + This is a Markdown formatted string, and the formatting must persist across translations. +The second line should be '[TRANSLATION HERE](command:microsoft.powerplatform.pages.metadataDiff.triggerFlow).', keeping brackets and the text in the parentheses unmodified + Copilot In Power Pages @@ -1210,6 +1219,9 @@ The second line should be '[TRANSLATION HERE](command:powerplatform-walkthrough. POWER PAGES ACTIONS + + POWER PAGES METADATA COMPARATOR + Power Platform diff --git a/package.json b/package.json index 24ba7c995..3335903f3 100644 --- a/package.json +++ b/package.json @@ -1238,7 +1238,7 @@ }, { "command": "microsoft.powerplatform.pages.actionsHub.compareWithLocal", - "when": "view == microsoft.powerplatform.pages.actionsHub && (viewItem == currentActiveSite || viewItem == nonCurrentActiveSite)", + "when": "view == microsoft.powerplatform.pages.actionsHub && (viewItem == currentActiveSite || viewItem == nonCurrentActiveSite || viewItem == inactiveSite)", "group": "siteAction@9" } ] From f6a338ce41c2c54c3321e604971d17e780f9f0d7 Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Fri, 5 Sep 2025 01:33:53 +0530 Subject: [PATCH 21/58] Show diff in actions hub --- package.json | 28 +++---- .../actions-hub/ActionsHubTreeDataProvider.ts | 84 +++++++++++++++++-- .../tree-items/MetadataDiffRootTreeItem.ts | 1 + .../metadata-diff/MetadataDiffCommands.ts | 29 ++----- .../metadata-diff/MetadataDiffDesktop.ts | 10 ++- .../MetadataDiffTreeDataProvider.ts | 21 ++++- 6 files changed, 122 insertions(+), 51 deletions(-) create mode 100644 src/client/power-pages/actions-hub/tree-items/MetadataDiffRootTreeItem.ts diff --git a/package.json b/package.json index 3335903f3..0e618f6bc 100644 --- a/package.json +++ b/package.json @@ -1063,28 +1063,28 @@ }, { "command": "microsoft.powerplatform.pages.metadataDiff.generateReport", - "when": "view == microsoft.powerplatform.pages.metadataDiff", - "group": "navigation" + "when": "view == microsoft.powerplatform.pages.actionsHub && microsoft.powerplatform.pages.metadataDiffEnabled", + "group": "navigation@50" }, { "command": "microsoft.powerplatform.pages.metadataDiff.triggerFlow", - "when": "view == microsoft.powerplatform.pages.metadataDiff", - "group": "navigation" + "when": "view == microsoft.powerplatform.pages.actionsHub && microsoft.powerplatform.pages.metadataDiffEnabled", + "group": "navigation@51" }, { "command": "microsoft.powerplatform.pages.metadataDiff.clearView", - "when": "view == microsoft.powerplatform.pages.metadataDiff", - "group": "navigation" + "when": "view == microsoft.powerplatform.pages.actionsHub && microsoft.powerplatform.pages.metadataDiffEnabled", + "group": "navigation@52" }, { "command": "microsoft.powerplatform.pages.metadataDiff.exportReport", - "when": "view == microsoft.powerplatform.pages.metadataDiff", - "group": "navigation" + "when": "view == microsoft.powerplatform.pages.actionsHub && microsoft.powerplatform.pages.metadataDiffEnabled && microsoft.powerplatform.pages.metadataDiff.hasData", + "group": "navigation@53" }, { "command": "microsoft.powerplatform.pages.metadataDiff.importReport", - "when": "view == microsoft.powerplatform.pages.metadataDiff", - "group": "navigation" + "when": "view == microsoft.powerplatform.pages.actionsHub && microsoft.powerplatform.pages.metadataDiffEnabled", + "group": "navigation@54" } ], "view/item/context": [ @@ -1306,14 +1306,6 @@ "icon": "./src/client/assets/powerPages.svg", "contextualTitle": "%microsoft.powerplatform.pages.actionsHub.title%", "visibility": "visible" - }, - { - "id": "microsoft.powerplatform.pages.metadataDiff", - "name": "%microsoft.powerplatform.pages.metadataDiff.title%", - "when": "microsoft.powerplatform.pages.metadataDiffEnabled", - "icon": "./src/client/assets/powerPages.svg", - "contextualTitle": "%microsoft.powerplatform.pages.metadataDiff.title%", - "visibility": "visible" } ] }, diff --git a/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts b/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts index 03c5bf80e..bc5a702b0 100644 --- a/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts +++ b/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts @@ -21,6 +21,62 @@ import { getBaseEventInfo } from "./TelemetryHelper"; import { PROVIDER_ID } from "../../../common/services/Constants"; import { getOIDFromToken } from "../../../common/services/AuthenticationProvider"; import ArtemisContext from "../../ArtemisContext"; +import { MetadataDiffTreeDataProvider } from "../../../common/power-pages/metadata-diff/MetadataDiffTreeDataProvider"; +import { MetadataDiffTreeItem } from "../../../common/power-pages/metadata-diff/tree-items/MetadataDiffTreeItem"; + +// Wrapper classes to surface Metadata Diff under Actions Hub +class MetadataDiffWrapperTreeItem extends ActionsHubTreeItem { + constructor(private readonly _item: MetadataDiffTreeItem) { + super( + _item.label?.toString(), + _item.collapsibleState ?? vscode.TreeItemCollapsibleState.None, + _item.iconPath || new vscode.ThemeIcon("file"), + _item.contextValue || "metadataDiffItem", + (typeof _item.description === "string" ? _item.description : "") + ); + // Copy over command & tooltip if present + this.command = _item.command; + this.tooltip = _item.tooltip; + } + + public getChildren(): ActionsHubTreeItem[] { + // Map underlying children to wrapper items + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const children: MetadataDiffTreeItem[] = (this._item as any).getChildren ? (this._item as any).getChildren() : []; + if (!children || !Array.isArray(children)) { + return []; + } + return children.map(c => new MetadataDiffWrapperTreeItem(c)); + } +} + +class MetadataDiffGroupTreeItem extends ActionsHubTreeItem { + constructor(private readonly _provider: MetadataDiffTreeDataProvider, hasData: boolean) { + super( + "Metadata Diff", + hasData ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.Collapsed, + new vscode.ThemeIcon("folder"), + "metadataDiffRoot", + hasData ? "" : "No data yet" + ); + this.tooltip = "Power Pages Metadata Comparison"; + } + + public getChildren(): ActionsHubTreeItem[] { + // Force provider to populate its cache if empty (async ignored intentionally) + // eslint-disable-next-line @typescript-eslint/no-floating-promises + // @ts-expect-error Accessing private field for integration wrapper + if (this._provider._diffItems?.length === 0) { + this._provider.getChildren(); + } + // @ts-expect-error Accessing private field for integration wrapper + const items: MetadataDiffTreeItem[] | null | undefined = this._provider._diffItems || []; + if (!items || !Array.isArray(items)) { + return []; + } + return items.map(i => new MetadataDiffWrapperTreeItem(i)); + } +} export class ActionsHubTreeDataProvider implements vscode.TreeDataProvider { private readonly _disposables: vscode.Disposable[] = []; @@ -33,6 +89,7 @@ export class ActionsHubTreeDataProvider implements vscode.TreeDataProvider { return element; } @@ -195,13 +256,24 @@ export class ActionsHubTreeDataProvider implements vscode.TreeDataProvider 0; + rootItems.push(new MetadataDiffGroupTreeItem(mdProvider, hasData)); + } + + rootItems.push(new EnvironmentGroupTreeItem(currentEnvInfo, this._context, this._activeSites, this._inactiveSites)); + if(this._otherSites.length){ + rootItems.push(new OtherSitesGroupTreeItem(this._otherSites)); } - return [ - new EnvironmentGroupTreeItem(currentEnvInfo, this._context, this._activeSites, this._inactiveSites), - new OtherSitesGroupTreeItem(this._otherSites) - ]; + return rootItems; } else { // Login experience scenario return []; diff --git a/src/client/power-pages/actions-hub/tree-items/MetadataDiffRootTreeItem.ts b/src/client/power-pages/actions-hub/tree-items/MetadataDiffRootTreeItem.ts new file mode 100644 index 000000000..349e21a2c --- /dev/null +++ b/src/client/power-pages/actions-hub/tree-items/MetadataDiffRootTreeItem.ts @@ -0,0 +1 @@ +// Intentionally left empty. Previous implementation removed. diff --git a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts index ce90c388b..1ed35ccdb 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts @@ -46,10 +46,9 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont await vscode.commands.executeCommand("setContext", "microsoft.powerplatform.pages.metadataDiff.hasData", false); // Reload tree data provider to show welcome message - const treeDataProvider = MetadataDiffTreeDataProvider.initialize(context); - context.subscriptions.push( - vscode.window.registerTreeDataProvider("microsoft.powerplatform.pages.metadataDiff", treeDataProvider) - ); + // Reinitialize provider and update Actions Hub root + MetadataDiffTreeDataProvider.initialize(context); + vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); vscode.window.showInformationMessage("Metadata diff view cleared successfully."); } catch (error) { @@ -124,10 +123,8 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont }); if (pacPagesDownload) { - const treeDataProvider = MetadataDiffTreeDataProvider.initialize(context); - context.subscriptions.push( - vscode.window.registerTreeDataProvider("microsoft.powerplatform.pages.metadataDiff", treeDataProvider) - ); + MetadataDiffTreeDataProvider.initialize(context); + vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); } else { vscode.window.showErrorMessage("Failed to download metadata."); } @@ -272,10 +269,8 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont } }); if (pacPagesDownload) { - const treeDataProvider = MetadataDiffTreeDataProvider.initialize(context); - context.subscriptions.push( - vscode.window.registerTreeDataProvider("microsoft.powerplatform.pages.metadataDiff", treeDataProvider) - ); + MetadataDiffTreeDataProvider.initialize(context); + vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); } else{ vscode.window.showErrorMessage("Failed to download metadata."); @@ -398,14 +393,8 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont // Clean up any existing tree data provider const treeDataProvider = new MetadataDiffTreeDataProvider(context); - - // Update the tree with imported data - await treeDataProvider.setDiffFiles(report.files); - - // Register the new tree data provider - context.subscriptions.push( - vscode.window.registerTreeDataProvider("microsoft.powerplatform.pages.metadataDiff", treeDataProvider) - ); + await treeDataProvider.setDiffFiles(report.files); // triggers refresh + vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); vscode.window.showInformationMessage("Report imported successfully"); } diff --git a/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts b/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts index 38a20407e..15658f193 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts @@ -13,13 +13,14 @@ import { MetadataDiffTreeDataProvider } from "../../../common/power-pages/metada import { PacTerminal } from "../../lib/PacTerminal"; import { PagesList } from "../../pac/PacTypes"; import { registerMetadataDiffCommands } from "./MetadataDiffCommands"; +import { ActionsHubTreeDataProvider } from "../actions-hub/ActionsHubTreeDataProvider"; export class MetadataDiffDesktop { private static _isInitialized = false; private static _treeDataProvider: MetadataDiffTreeDataProvider | undefined; static isEnabled(): boolean { - const enableMetadataDiff = ECSFeaturesClient.getConfig(EnableMetadataDiff).enableMetadataDiff + const enableMetadataDiff = true; //ECSFeaturesClient.getConfig(EnableMetadataDiff).enableMetadataDiff if (enableMetadataDiff === undefined) { return false; @@ -67,9 +68,10 @@ export class MetadataDiffDesktop { const treeDataProvider = MetadataDiffTreeDataProvider.initialize(context); MetadataDiffDesktop._treeDataProvider = treeDataProvider; - context.subscriptions.push( - vscode.window.registerTreeDataProvider("microsoft.powerplatform.pages.metadataDiff", treeDataProvider) - ); + // Integrate into Actions Hub instead of separate view + ActionsHubTreeDataProvider.setMetadataDiffProvider(treeDataProvider); + // Force refresh of Actions Hub to show new root node + vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); MetadataDiffDesktop._isInitialized = true; oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_INITIALIZED); diff --git a/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts b/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts index f00571da3..075c4613d 100644 --- a/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts +++ b/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts @@ -53,7 +53,9 @@ export class MetadataDiffTreeDataProvider implements vscode.TreeDataProvider { @@ -166,10 +168,20 @@ export class MetadataDiffTreeDataProvider implements vscode.TreeDataProvider 0); + // Refresh Actions Hub so the Metadata Diff group re-renders with data + vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); + return items; } catch (error) { oneDSLoggerWrapper.getLogger().traceError( Constants.EventNames.METADATA_DIFF_CURRENT_ENV_FETCH_FAILED, @@ -295,6 +307,9 @@ export class MetadataDiffTreeDataProvider implements vscode.TreeDataProvider 0); + vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); } } From fbf0d2b8c8ba4ca55ed3484264a25e9af6fb9fa9 Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Fri, 5 Sep 2025 01:47:24 +0530 Subject: [PATCH 22/58] move to bottom of AH --- .../actions-hub/ActionsHubTreeDataProvider.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts b/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts index bc5a702b0..288149556 100644 --- a/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts +++ b/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts @@ -258,6 +258,11 @@ export class ActionsHubTreeDataProvider implements vscode.TreeDataProvider Date: Fri, 5 Sep 2025 02:09:31 +0530 Subject: [PATCH 23/58] Moved icons to context menu --- package.json | 54 +++++++++++++++++++++++++++++++--------------------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/package.json b/package.json index 0e618f6bc..5b80f6247 100644 --- a/package.json +++ b/package.json @@ -503,7 +503,7 @@ }, { "command": "microsoft.powerplatform.pages.metadataDiff.generateReport", - "title": "Generate Report", + "title": "Generate Markdown Report", "category": "Power Pages Metadata Diff", "icon": "$(markdown)" }, @@ -515,13 +515,13 @@ }, { "command": "microsoft.powerplatform.pages.metadataDiff.exportReport", - "title": "Export Report", + "title": "Export Comparison View", "category": "Power Pages Metadata Diff", "icon": "$(save-as)" }, { "command": "microsoft.powerplatform.pages.metadataDiff.importReport", - "title": "Import Report", + "title": "Import Comparison View", "category": "Power Pages Metadata Diff", "icon": "$(folder-opened)" } @@ -1060,43 +1060,53 @@ "command": "powerpages.copilot.clearConversation", "when": "view == powerpages.copilot", "group": "navigation" + } + ], + "view/item/context": [ + { + "command": "pacCLI.authPanel.deleteAuthProfile", + "when": "!virtualWorkspace && view == pacCLI.authPanel", + "group": "inline" + }, + { + "command": "pacCLI.authPanel.selectAuthProfile", + "when": "!virtualWorkspace && view == pacCLI.authPanel", + "group": "inline" }, { "command": "microsoft.powerplatform.pages.metadataDiff.generateReport", - "when": "view == microsoft.powerplatform.pages.actionsHub && microsoft.powerplatform.pages.metadataDiffEnabled", - "group": "navigation@50" + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffRoot && microsoft.powerplatform.pages.metadataDiffEnabled", + "group": "metadataDiff@1" }, { "command": "microsoft.powerplatform.pages.metadataDiff.triggerFlow", - "when": "view == microsoft.powerplatform.pages.actionsHub && microsoft.powerplatform.pages.metadataDiffEnabled", - "group": "navigation@51" + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffRoot && microsoft.powerplatform.pages.metadataDiffEnabled", + "group": "metadataDiff@2" }, { "command": "microsoft.powerplatform.pages.metadataDiff.clearView", - "when": "view == microsoft.powerplatform.pages.actionsHub && microsoft.powerplatform.pages.metadataDiffEnabled", - "group": "navigation@52" + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffRoot && microsoft.powerplatform.pages.metadataDiffEnabled", + "group": "metadataDiff@3" }, { "command": "microsoft.powerplatform.pages.metadataDiff.exportReport", - "when": "view == microsoft.powerplatform.pages.actionsHub && microsoft.powerplatform.pages.metadataDiffEnabled && microsoft.powerplatform.pages.metadataDiff.hasData", - "group": "navigation@53" + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffRoot && microsoft.powerplatform.pages.metadataDiffEnabled && microsoft.powerplatform.pages.metadataDiff.hasData", + "group": "metadataDiff@4" }, { "command": "microsoft.powerplatform.pages.metadataDiff.importReport", - "when": "view == microsoft.powerplatform.pages.actionsHub && microsoft.powerplatform.pages.metadataDiffEnabled", - "group": "navigation@54" - } - ], - "view/item/context": [ + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffRoot && microsoft.powerplatform.pages.metadataDiffEnabled", + "group": "metadataDiff@5" + }, { - "command": "pacCLI.authPanel.deleteAuthProfile", - "when": "!virtualWorkspace && view == pacCLI.authPanel", - "group": "inline" + "command": "microsoft.powerplatform.pages.metadataDiff.exportReport", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffItem && microsoft.powerplatform.pages.metadataDiffEnabled && microsoft.powerplatform.pages.metadataDiff.hasData", + "group": "metadataDiff@4" }, { - "command": "pacCLI.authPanel.selectAuthProfile", - "when": "!virtualWorkspace && view == pacCLI.authPanel", - "group": "inline" + "command": "microsoft.powerplatform.pages.metadataDiff.importReport", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffItem && microsoft.powerplatform.pages.metadataDiffEnabled", + "group": "metadataDiff@5" }, { "command": "pacCLI.authPanel.nameAuthProfile", From 75a749e14165e399d06327b039ba2f505ab47c01 Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Fri, 5 Sep 2025 02:22:04 +0530 Subject: [PATCH 24/58] Hide trigger flow in menu --- package.json | 4 ++-- src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 5b80f6247..09fb06bd0 100644 --- a/package.json +++ b/package.json @@ -1075,12 +1075,12 @@ }, { "command": "microsoft.powerplatform.pages.metadataDiff.generateReport", - "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffRoot && microsoft.powerplatform.pages.metadataDiffEnabled", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffRoot && microsoft.powerplatform.pages.metadataDiffEnabled && microsoft.powerplatform.pages.metadataDiff.hasData", "group": "metadataDiff@1" }, { "command": "microsoft.powerplatform.pages.metadataDiff.triggerFlow", - "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffRoot && microsoft.powerplatform.pages.metadataDiffEnabled", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffRoot && microsoft.powerplatform.pages.metadataDiffEnabled && microsoft.powerplatform.pages.metadataDiff.connectDownloadEnabled", "group": "metadataDiff@2" }, { diff --git a/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts b/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts index 15658f193..d2c392688 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts @@ -18,6 +18,9 @@ import { ActionsHubTreeDataProvider } from "../actions-hub/ActionsHubTreeDataPro export class MetadataDiffDesktop { private static _isInitialized = false; private static _treeDataProvider: MetadataDiffTreeDataProvider | undefined; + // Flag to enable/disable the "Connect & Download" (triggerFlow) command UI + // Toggle this to hide/show the option without modifying package.json again. + private static _connectAndDownloadEnabled: boolean = false; static isEnabled(): boolean { const enableMetadataDiff = true; //ECSFeaturesClient.getConfig(EnableMetadataDiff).enableMetadataDiff @@ -50,6 +53,8 @@ export class MetadataDiffDesktop { }); vscode.commands.executeCommand("setContext", "microsoft.powerplatform.pages.metadataDiffEnabled", isMetadataDiffEnabled); + // Set context for connect & download visibility (independent of overall feature enablement) + vscode.commands.executeCommand("setContext", "microsoft.powerplatform.pages.metadataDiff.connectDownloadEnabled", MetadataDiffDesktop._connectAndDownloadEnabled && isMetadataDiffEnabled); if (!isMetadataDiffEnabled) { return; From 844e638ca1bccb945b9001fc2649be6af6f1edb4 Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Fri, 5 Sep 2025 02:43:19 +0530 Subject: [PATCH 25/58] Added re-sync option --- package.json | 21 +++-- .../metadata-diff/MetadataDiffCommands.ts | 82 +++++++++++++++++++ .../metadata-diff/MetadataDiffDesktop.ts | 8 ++ 3 files changed, 106 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 09fb06bd0..78a2885dc 100644 --- a/package.json +++ b/package.json @@ -507,6 +507,12 @@ "category": "Power Pages Metadata Diff", "icon": "$(markdown)" }, + { + "command": "microsoft.powerplatform.pages.metadataDiff.resync", + "title": "Re-sync", + "category": "Power Pages Metadata Diff", + "icon": "$(refresh)" + }, { "command": "microsoft.powerplatform.pages.metadataDiff.clearView", "title": "Clear View", @@ -1083,30 +1089,35 @@ "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffRoot && microsoft.powerplatform.pages.metadataDiffEnabled && microsoft.powerplatform.pages.metadataDiff.connectDownloadEnabled", "group": "metadataDiff@2" }, + { + "command": "microsoft.powerplatform.pages.metadataDiff.resync", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffRoot && microsoft.powerplatform.pages.metadataDiffEnabled && microsoft.powerplatform.pages.metadataDiff.hasData", + "group": "metadataDiff@3" + }, { "command": "microsoft.powerplatform.pages.metadataDiff.clearView", "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffRoot && microsoft.powerplatform.pages.metadataDiffEnabled", - "group": "metadataDiff@3" + "group": "metadataDiff@4" }, { "command": "microsoft.powerplatform.pages.metadataDiff.exportReport", "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffRoot && microsoft.powerplatform.pages.metadataDiffEnabled && microsoft.powerplatform.pages.metadataDiff.hasData", - "group": "metadataDiff@4" + "group": "metadataDiff@5" }, { "command": "microsoft.powerplatform.pages.metadataDiff.importReport", "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffRoot && microsoft.powerplatform.pages.metadataDiffEnabled", - "group": "metadataDiff@5" + "group": "metadataDiff@6" }, { "command": "microsoft.powerplatform.pages.metadataDiff.exportReport", "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffItem && microsoft.powerplatform.pages.metadataDiffEnabled && microsoft.powerplatform.pages.metadataDiff.hasData", - "group": "metadataDiff@4" + "group": "metadataDiff@5" }, { "command": "microsoft.powerplatform.pages.metadataDiff.importReport", "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffItem && microsoft.powerplatform.pages.metadataDiffEnabled", - "group": "metadataDiff@5" + "group": "metadataDiff@6" }, { "command": "pacCLI.authPanel.nameAuthProfile", diff --git a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts index 1ed35ccdb..b09b8b697 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts @@ -13,6 +13,7 @@ import { MetadataDiffTreeDataProvider } from "../../../common/power-pages/metada import { createAuthProfileExp } from "../../../common/utilities/PacAuthUtil"; import { getWebsiteRecordId } from "../../../common/utilities/WorkspaceInfoFinderUtil"; import { MetadataDiffDesktop } from "./MetadataDiffDesktop"; +import { ActionsHubTreeDataProvider } from "../actions-hub/ActionsHubTreeDataProvider"; import { generateDiffReport, getAllDiffFiles, MetadataDiffReport } from "./MetadataDiffUtils"; export async function registerMetadataDiffCommands(context: vscode.ExtensionContext, pacTerminal: PacTerminal): Promise { @@ -38,6 +39,87 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont } }); + vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.resync", async () => { + try { + // Only proceed if we already have data (context set by menu 'when' clause) + const pacWrapper = pacTerminal.getWrapper(); + const pacActiveOrg = await pacWrapper.activeOrg(); + if (!pacActiveOrg || pacActiveOrg.Status !== SUCCESS) { + vscode.window.showErrorMessage("No active environment found. Please authenticate first."); + return; + } + + // Clear existing diff state so UI returns to initial (welcome) state during re-sync + MetadataDiffDesktop.resetTreeView(); + + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + vscode.window.showErrorMessage("No folders opened in the current workspace."); + return; + } + const currentWorkspaceFolder = workspaceFolders[0].uri.fsPath; + const websiteId = getWebsiteRecordId(currentWorkspaceFolder); + if (!websiteId) { + vscode.window.showErrorMessage("Unable to determine website id from workspace."); + return; + } + + const storagePath = context.storageUri?.fsPath; + if (!storagePath) { + vscode.window.showErrorMessage("Storage path not found"); + return; + } + + // Clean existing downloaded metadata + if (fs.existsSync(storagePath)) { + fs.rmSync(storagePath, { recursive: true, force: true }); + } + fs.mkdirSync(storagePath, { recursive: true }); + + const progressOptions: vscode.ProgressOptions = { + location: vscode.ProgressLocation.Notification, + title: "Re-syncing website metadata", + cancellable: false + }; + let pacPagesDownload; + await vscode.window.withProgress(progressOptions, async (progress) => { + progress.report({ message: "Looking for this website in the connected environment..." }); + const pacPagesList = await MetadataDiffDesktop.getPagesList(pacTerminal); + if (pacPagesList && pacPagesList.length > 0) { + const websiteRecord = pacPagesList.find((record) => record.id === websiteId); + if (!websiteRecord) { + vscode.window.showErrorMessage("Website not found in the connected environment."); + return; + } + progress.report({ message: `Downloading \"${websiteRecord.name}\" as ${websiteRecord.modelVersion === "v2" ? "enhanced" : "standard"} data model. Please wait...` }); + pacPagesDownload = await pacWrapper.pagesDownload( + storagePath, + websiteId, + websiteRecord.modelVersion === "v1" || websiteRecord.modelVersion === "Standard" ? "1" : "2" + ); + } + }); + + if (pacPagesDownload) { + // Create a brand-new provider instance bound to latest storage content + const treeDataProvider = MetadataDiffTreeDataProvider.initialize(context); + // Register with desktop helper & Actions Hub so future resets use this instance + MetadataDiffDesktop.setTreeDataProvider(treeDataProvider); + ActionsHubTreeDataProvider.setMetadataDiffProvider(treeDataProvider); + // Force data population (will also set hasData context + refresh) + // eslint-disable-next-line @typescript-eslint/no-floating-promises + treeDataProvider.getChildren(); + // Explicit refresh to redraw UI while async population runs + vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); + vscode.window.showInformationMessage("Re-sync completed."); + } else { + vscode.window.showErrorMessage("Failed to re-sync metadata."); + } + } catch (error) { + oneDSLoggerWrapper.getLogger().traceError(Constants.EventNames.METADATA_DIFF_REFRESH_FAILED, error as string, error as Error, { methodName: null }, {}); + } + }); + vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.clearView", async () => { try { MetadataDiffDesktop.resetTreeView(); diff --git a/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts b/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts index d2c392688..95f9b070f 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts @@ -40,6 +40,14 @@ export class MetadataDiffDesktop { } } + /** + * Allows external commands (e.g. Re-sync) to replace the active provider + * so subsequent resets operate on the latest instance. + */ + static setTreeDataProvider(provider: MetadataDiffTreeDataProvider): void { + this._treeDataProvider = provider; + } + static async initialize(context: vscode.ExtensionContext, pacTerminal: PacTerminal): Promise { if (MetadataDiffDesktop._isInitialized) { return; From a07189bc7cde31117fcb4bc16d5f8c7fb0d4f55d Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Fri, 5 Sep 2025 02:56:16 +0530 Subject: [PATCH 26/58] Generate HTML report in place of markdown --- package.json | 2 +- .../metadata-diff/MetadataDiffCommands.ts | 34 ++--- .../metadata-diff/MetadataDiffUtils.ts | 132 ++++++++++-------- 3 files changed, 86 insertions(+), 82 deletions(-) diff --git a/package.json b/package.json index 78a2885dc..cdea42ad1 100644 --- a/package.json +++ b/package.json @@ -503,7 +503,7 @@ }, { "command": "microsoft.powerplatform.pages.metadataDiff.generateReport", - "title": "Generate Markdown Report", + "title": "Generate HTML Report", "category": "Power Pages Metadata Diff", "icon": "$(markdown)" }, diff --git a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts index b09b8b697..f3347c3fe 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts @@ -371,38 +371,34 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont vscode.window.showErrorMessage("No workspace folder open"); return; } - const storagePath = context.storageUri?.fsPath; if (!storagePath) { vscode.window.showErrorMessage("Storage path not found"); return; } - // Generate report content - const reportContent = await generateDiffReport(workspaceFolders[0].uri.fsPath, storagePath); + // Generate HTML report (was Markdown previously) + const htmlReport = await generateDiffReport(workspaceFolders[0].uri.fsPath, storagePath); - // Create the markdown document + // Open source view (HTML) – helpful if user wants to copy/export. const doc = await vscode.workspace.openTextDocument({ - content: reportContent, - language: 'markdown' + content: htmlReport, + language: 'html' }); - - // Show the document in column One await vscode.window.showTextDocument(doc, { - preview: false, // Don't use preview mode to ensure stability + preview: false, viewColumn: vscode.ViewColumn.One, - preserveFocus: false // Ensure focus is on the document + preserveFocus: false }); - // Increase delay to ensure document is fully loaded and stable - await new Promise(resolve => setTimeout(resolve, 800)); - - // Close any existing preview first to avoid conflicts - await vscode.commands.executeCommand('markdown.preview.refresh'); - - // Show preview in side-by-side mode - await vscode.commands.executeCommand('markdown.showPreviewToSide'); - + // Create / reveal webview preview side-by-side + const panel = vscode.window.createWebviewPanel( + 'metadataDiffReportPreview', + 'Power Pages Metadata Diff Report', + { viewColumn: vscode.ViewColumn.Two, preserveFocus: true }, + { enableScripts: true } // no scripts currently, but allow future enhancements + ); + panel.webview.html = htmlReport; } catch (error) { oneDSLoggerWrapper.getLogger().traceError( Constants.EventNames.METADATA_DIFF_REPORT_FAILED, diff --git a/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts b/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts index e452c4b70..f2e68e595 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts @@ -21,68 +21,91 @@ export interface MetadataDiffReport { } export async function generateDiffReport(workspacePath: string, storagePath: string): Promise { - let report = '# Power Pages Metadata Diff Report\n\n'; - report += `Generated on: ${new Date().toLocaleString()}\n\n`; + // Build a self‑contained HTML report (previously Markdown) so we can render it directly in a webview. + const generatedOn = new Date().toLocaleString(); - // Get diff files const diffFiles = await getAllDiffFiles(workspacePath, storagePath); - // Group by folders + // Group by folder const folderStructure = new Map(); diffFiles.forEach(file => { - const dirPath = path.dirname(file.relativePath); + const dirPath = path.dirname(file.relativePath) || '.'; if (!folderStructure.has(dirPath)) { folderStructure.set(dirPath, []); } folderStructure.get(dirPath)!.push(file); }); - // Sort folders and generate report const sortedFolders = Array.from(folderStructure.keys()).sort(); - for (let i = 0; i < sortedFolders.length; i++) { - const folder = sortedFolders[i]; - const files = folderStructure.get(folder)!; - // Add folder header (skip for root) + const escapeHtml = (value: string | undefined) => (value || '').replace(/&/g, '&').replace(//g, '>'); + + let body = `

Power Pages Metadata Diff Report

`; + body += `

Generated on: ${escapeHtml(generatedOn)}

`; + + if (sortedFolders.length === 0) { + body += '

No differences found.

'; + } + + for (const folder of sortedFolders) { + const files = folderStructure.get(folder)!; + const folderId = folder === '.' ? 'root' : folder; if (folder !== '.') { - report += `## ${folder}\n\n`; + body += `

${escapeHtml(folder)}

`; + } else { + body += `

Root

`; } - - // Add files + body += `
    `; for (const file of files.sort((a, b) => a.relativePath.localeCompare(b.relativePath))) { - report += `- ${path.basename(file.relativePath)}\n`; - report += ` - Changes: ${file.changes}\n`; + body += `
  • `; + body += `
    ${escapeHtml(path.basename(file.relativePath))}`; + body += `${escapeHtml(file.changes)}
    `; - // Add detailed YAML property changes for YAML files - if (path.extname(file.relativePath).toLowerCase() === '.yml' || - path.extname(file.relativePath).toLowerCase() === '.yaml') { + // YAML property level differences + if ((file.relativePath.toLowerCase().endsWith('.yml') || file.relativePath.toLowerCase().endsWith('.yaml')) && file.workspaceContent && file.storageContent && file.changes === 'Modified') { try { - if (file.workspaceContent && file.storageContent && file.changes === 'Modified') { - const workspaceYaml = yaml.parse(file.workspaceContent); - const storageYaml = yaml.parse(file.storageContent); - - const propertyChanges = findYamlPropertyChanges(workspaceYaml, storageYaml); - - if (propertyChanges.length > 0) { - report += ` - Property changes:\n`; - propertyChanges.forEach(change => { - report += ` - ${change}\n`; - }); - } + const workspaceYaml = yaml.parse(file.workspaceContent); + const storageYaml = yaml.parse(file.storageContent); + const propertyChanges = findYamlPropertyChanges(workspaceYaml, storageYaml); + if (propertyChanges.length > 0) { + body += '
    Property changes
      '; + propertyChanges.forEach(change => { + body += `
    • ${escapeHtml(change)}
    • `; + }); + body += '
    '; } - } catch (error) { - report += ` - Failed to parse YAML content: ${error}\n`; + } catch (error: unknown) { + body += `
    Failed to parse YAML content: ${escapeHtml(error instanceof Error ? error.message : String(error))}
    `; } } + body += '
  • '; } - - // Only add a blank line between folders, not after the last folder - if (i < sortedFolders.length - 1) { - report += '\n'; - } + body += '
'; } - return report; + // Lightweight styling – no external dependencies. + const html = ` +${body}`; + + return html; } /** @@ -91,50 +114,35 @@ export async function generateDiffReport(workspacePath: string, storagePath: str * @param storageObj The storage YAML object * @returns An array of change descriptions */ -function findYamlPropertyChanges(workspaceObj: any, storageObj: any, path = ''): string[] { - if (!workspaceObj || !storageObj) { +function findYamlPropertyChanges(workspaceObj: unknown, storageObj: unknown, path = ''): string[] { + const isObj = (o: unknown): o is Record => typeof o === 'object' && o !== null && !Array.isArray(o); + if (!isObj(workspaceObj) || !isObj(storageObj)) { return []; } const changes: string[] = []; - - // Check all properties in workspace object const workspaceKeys = Object.keys(workspaceObj); for (const key of workspaceKeys) { const currentPath = path ? `${path}.${key}` : key; - - // Check if key exists in storage if (!(key in storageObj)) { changes.push(`Added property: \`${currentPath}\``); continue; } - - // Check if both values are objects (for nested properties) - if (typeof workspaceObj[key] === 'object' && workspaceObj[key] !== null && - typeof storageObj[key] === 'object' && storageObj[key] !== null) { - // Recursively check nested objects - const nestedChanges = findYamlPropertyChanges( - workspaceObj[key], - storageObj[key], - currentPath - ); - changes.push(...nestedChanges); - } - // Check primitive values (string, number, boolean, etc.) - else if (JSON.stringify(workspaceObj[key]) !== JSON.stringify(storageObj[key])) { + const wVal = workspaceObj[key]; + const sVal = (storageObj as Record)[key]; + if (isObj(wVal) && isObj(sVal)) { + changes.push(...findYamlPropertyChanges(wVal, sVal, currentPath)); + } else if (JSON.stringify(wVal) !== JSON.stringify(sVal)) { changes.push(`Modified property: \`${currentPath}\``); } } - - // Check for properties that exist in storage but not in workspace const storageKeys = Object.keys(storageObj); for (const key of storageKeys) { - const currentPath = path ? `${path}.${key}` : key; if (!(key in workspaceObj)) { + const currentPath = path ? `${path}.${key}` : key; changes.push(`Removed property: \`${currentPath}\``); } } - return changes; } From 6a87d472284af72f6ba7bf152029197fef729b86 Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Fri, 5 Sep 2025 03:24:58 +0530 Subject: [PATCH 27/58] Updated messages --- .../metadata-diff/MetadataDiffCommands.ts | 73 ++++++++++++------- .../metadata-diff/MetadataDiffUtils.ts | 2 +- .../MetadataDiffTreeDataProvider.ts | 13 ++++ 3 files changed, 59 insertions(+), 29 deletions(-) diff --git a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts index f3347c3fe..3fb93ebd9 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts @@ -10,10 +10,11 @@ import { PacTerminal } from "../../lib/PacTerminal"; import { Constants, SUCCESS } from "../../../common/power-pages/metadata-diff/Constants"; import { oneDSLoggerWrapper } from "../../../common/OneDSLoggerTelemetry/oneDSLoggerWrapper"; import { MetadataDiffTreeDataProvider } from "../../../common/power-pages/metadata-diff/MetadataDiffTreeDataProvider"; -import { createAuthProfileExp } from "../../../common/utilities/PacAuthUtil"; -import { getWebsiteRecordId } from "../../../common/utilities/WorkspaceInfoFinderUtil"; import { MetadataDiffDesktop } from "./MetadataDiffDesktop"; import { ActionsHubTreeDataProvider } from "../actions-hub/ActionsHubTreeDataProvider"; +import { createAuthProfileExp } from "../../../common/utilities/PacAuthUtil"; +import { getWebsiteRecordId } from "../../../common/utilities/WorkspaceInfoFinderUtil"; +// Duplicate imports removed import { generateDiffReport, getAllDiffFiles, MetadataDiffReport } from "./MetadataDiffUtils"; export async function registerMetadataDiffCommands(context: vscode.ExtensionContext, pacTerminal: PacTerminal): Promise { @@ -78,10 +79,11 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont const progressOptions: vscode.ProgressOptions = { location: vscode.ProgressLocation.Notification, - title: "Re-syncing website metadata", + title: vscode.l10n.t("Re-syncing website metadata"), cancellable: false }; let pacPagesDownload; + let comparisonBuilt = false; await vscode.window.withProgress(progressOptions, async (progress) => { progress.report({ message: "Looking for this website in the connected environment..." }); const pacPagesList = await MetadataDiffDesktop.getPagesList(pacTerminal); @@ -91,27 +93,26 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont vscode.window.showErrorMessage("Website not found in the connected environment."); return; } - progress.report({ message: `Downloading \"${websiteRecord.name}\" as ${websiteRecord.modelVersion === "v2" ? "enhanced" : "standard"} data model. Please wait...` }); + progress.report({ message: vscode.l10n.t('Retrieving "{0}" as {1} data model. Please wait...', websiteRecord.name, websiteRecord.modelVersion === 'v2' ? 'enhanced' : 'standard') }); pacPagesDownload = await pacWrapper.pagesDownload( storagePath, websiteId, websiteRecord.modelVersion === "v1" || websiteRecord.modelVersion === "Standard" ? "1" : "2" ); + if (pacPagesDownload) { + progress.report({ message: vscode.l10n.t('Comparing metadata of "{0}"...', websiteRecord.name) }); + const provider = MetadataDiffTreeDataProvider.initialize(context); + MetadataDiffDesktop.setTreeDataProvider(provider); + ActionsHubTreeDataProvider.setMetadataDiffProvider(provider); + await provider.getChildren(); + vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); + comparisonBuilt = true; + } } }); - if (pacPagesDownload) { - // Create a brand-new provider instance bound to latest storage content - const treeDataProvider = MetadataDiffTreeDataProvider.initialize(context); - // Register with desktop helper & Actions Hub so future resets use this instance - MetadataDiffDesktop.setTreeDataProvider(treeDataProvider); - ActionsHubTreeDataProvider.setMetadataDiffProvider(treeDataProvider); - // Force data population (will also set hasData context + refresh) - // eslint-disable-next-line @typescript-eslint/no-floating-promises - treeDataProvider.getChildren(); - // Explicit refresh to redraw UI while async population runs - vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); - vscode.window.showInformationMessage("Re-sync completed."); + if (pacPagesDownload && comparisonBuilt) { + vscode.window.showInformationMessage(vscode.l10n.t("You can now view the comparison")); } else { vscode.window.showErrorMessage("Failed to re-sync metadata."); } @@ -180,11 +181,12 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont const progressOptions: vscode.ProgressOptions = { location: vscode.ProgressLocation.Notification, - title: "Downloading website metadata", + title: vscode.l10n.t("Downloading website metadata"), cancellable: false }; let pacPagesDownload; + let comparisonBuilt = false; await vscode.window.withProgress(progressOptions, async (progress) => { progress.report({ message: "Looking for this website in the connected environment..." }); const pacPagesList = await MetadataDiffDesktop.getPagesList(pacTerminal); @@ -194,19 +196,26 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont vscode.window.showErrorMessage("Website not found in the connected environment."); return; } - progress.report({ message: `Downloading "${websiteRecord.name}" as ${websiteRecord.modelVersion === "v2" ? "enhanced" : "standard"} data model. Please wait...` }); + progress.report({ message: vscode.l10n.t('Retrieving "{0}" as {1} data model. Please wait...', websiteRecord.name, websiteRecord.modelVersion === 'v2' ? 'enhanced' : 'standard') }); pacPagesDownload = await pacWrapper.pagesDownload( storagePath, websiteId, websiteRecord.modelVersion === "v1" || websiteRecord.modelVersion === "Standard" ? "1" : "2" ); - vscode.window.showInformationMessage("Download completed."); + if (pacPagesDownload) { + progress.report({ message: vscode.l10n.t('Comparing metadata of "{0}"...', websiteRecord.name) }); + const provider = MetadataDiffTreeDataProvider.initialize(context); + MetadataDiffDesktop.setTreeDataProvider(provider); + ActionsHubTreeDataProvider.setMetadataDiffProvider(provider); + await provider.getChildren(); + vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); + comparisonBuilt = true; + } } }); - if (pacPagesDownload) { - MetadataDiffTreeDataProvider.initialize(context); - vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); + if (pacPagesDownload && comparisonBuilt) { + vscode.window.showInformationMessage(vscode.l10n.t("You can now view the comparison")); } else { vscode.window.showErrorMessage("Failed to download metadata."); } @@ -332,10 +341,11 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont fs.mkdirSync(storagePath, { recursive: true }); const progressOptions: vscode.ProgressOptions = { location: vscode.ProgressLocation.Notification, - title: "Downloading website metadata", + title: vscode.l10n.t("Downloading website metadata"), cancellable: false }; let pacPagesDownload; + let comparisonBuilt = false; await vscode.window.withProgress(progressOptions, async (progress) => { progress.report({ message: "Looking for this website in the connected environment..." }); const pacPagesList = await MetadataDiffDesktop.getPagesList(pacTerminal); @@ -345,14 +355,21 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont vscode.window.showErrorMessage("Website not found in the connected environment."); return; } - progress.report({ message: `Downloading "${websiteRecord.name}" as ${websiteRecord.modelVersion === "v2" ? "enhanced" : "standard"} data model. Please wait...` }); + progress.report({ message: vscode.l10n.t('Retrieving "{0}" as {1} data model. Please wait...', websiteRecord.name, websiteRecord.modelVersion === 'v2' ? 'enhanced' : 'standard') }); pacPagesDownload = await pacWrapper.pagesDownload(storagePath, websiteId, websiteRecord.modelVersion == "v1" ? "1" : "2"); - vscode.window.showInformationMessage("Download completed."); + if (pacPagesDownload) { + progress.report({ message: vscode.l10n.t('Comparing metadata of "{0}"...', websiteRecord.name) }); + const provider = MetadataDiffTreeDataProvider.initialize(context); + MetadataDiffDesktop.setTreeDataProvider(provider); + ActionsHubTreeDataProvider.setMetadataDiffProvider(provider); + await provider.getChildren(); + vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); + comparisonBuilt = true; + } } }); - if (pacPagesDownload) { - MetadataDiffTreeDataProvider.initialize(context); - vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); + if (pacPagesDownload && comparisonBuilt) { + vscode.window.showInformationMessage(vscode.l10n.t("You can now view the comparison")); } else{ vscode.window.showErrorMessage("Failed to download metadata."); diff --git a/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts b/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts index f2e68e595..fcb07bf47 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts @@ -91,7 +91,7 @@ h1{margin-top:0;font-size:20px;} h2{margin-top:24px;font-size:16px;border-bottom:1px solid #ccc;padding-bottom:4px;} .meta{color:#666;font-size:12px;margin-top:-4px;} ul.file-list{list-style:none;margin:0;padding:0;} -li.file-item{margin:6px 0;padding:8px;border:1px solid #dcdcdc;border-radius:4px;background:var(--vscode-editor-background,#fff);} +li.file-item{margin:6px 0;padding:8px;border:1px solid #dcdcdc;border-radius:4px;background:var(--vscode-editor-background,#fff);} .file-header{display:flex;justify-content:space-between;align-items:center;} .file-name{font-weight:600;} .change-tag{font-size:11px;padding:2px 6px;border-radius:12px;background:#e0e0e0;color:#333;text-transform:uppercase;letter-spacing:.5px;} diff --git a/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts b/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts index 075c4613d..7e1a3e148 100644 --- a/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts +++ b/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts @@ -26,6 +26,10 @@ export class MetadataDiffTreeDataProvider implements vscode.TreeDataProvider = new vscode.EventEmitter(); readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event; private _diffItems: MetadataDiffTreeItem[] = []; + // Emits when the diff data has been fully populated for the first time + private _onDataLoaded: vscode.EventEmitter = new vscode.EventEmitter(); + public readonly onDataLoaded: vscode.Event = this._onDataLoaded.event; + private _dataLoadedNotified = false; constructor(context: vscode.ExtensionContext) { this._context = context; @@ -41,6 +45,7 @@ export class MetadataDiffTreeDataProvider implements vscode.TreeDataProvider 0); // Refresh Actions Hub so the Metadata Diff group re-renders with data vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); + if (!this._dataLoadedNotified && this._diffItems.length > 0) { + this._dataLoadedNotified = true; + this._onDataLoaded.fire(); + } return items; } catch (error) { oneDSLoggerWrapper.getLogger().traceError( @@ -311,5 +320,9 @@ export class MetadataDiffTreeDataProvider implements vscode.TreeDataProvider 0); vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); + if (!this._dataLoadedNotified && this._diffItems.length > 0) { + this._dataLoadedNotified = true; + this._onDataLoaded.fire(); + } } } From 45d70853c96e63d2e99db1319c95f6d0184d8a3b Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Fri, 5 Sep 2025 03:44:43 +0530 Subject: [PATCH 28/58] Tweaks to metadata diff header --- .../actions-hub/ActionsHubTreeDataProvider.ts | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts b/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts index 288149556..ff07e152b 100644 --- a/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts +++ b/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts @@ -51,15 +51,18 @@ class MetadataDiffWrapperTreeItem extends ActionsHubTreeItem { } class MetadataDiffGroupTreeItem extends ActionsHubTreeItem { - constructor(private readonly _provider: MetadataDiffTreeDataProvider, hasData: boolean) { + constructor(private readonly _provider: MetadataDiffTreeDataProvider, hasData: boolean, label: string, websiteName?: string, envName?: string) { super( - "Metadata Diff", + label, hasData ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.Collapsed, - new vscode.ThemeIcon("folder"), + // Use Split Editor icon (codicon split-horizontal) as requested + new vscode.ThemeIcon("split-horizontal"), "metadataDiffRoot", - hasData ? "" : "No data yet" + hasData ? "" : vscode.l10n.t("No data yet") ); - this.tooltip = "Power Pages Metadata Comparison"; + this.tooltip = hasData && websiteName && envName + ? vscode.l10n.t("Power Pages metadata comparison: {0} local vs {1}", websiteName, envName) + : vscode.l10n.t("Power Pages metadata comparison"); } public getChildren(): ActionsHubTreeItem[] { @@ -271,7 +274,17 @@ export class ActionsHubTreeDataProvider implements vscode.TreeDataProvider 0; - rootItems.push(new MetadataDiffGroupTreeItem(mdProvider, hasData)); + + let label = vscode.l10n.t("Metadata Diff"); + let websiteName: string | undefined; + let envName: string | undefined; + if (hasData) { + const workspaceFolders = vscode.workspace.workspaceFolders; + websiteName = workspaceFolders && workspaceFolders.length > 0 ? workspaceFolders[0].name : vscode.l10n.t("Local"); + envName = authInfo?.OrganizationFriendlyName || vscode.l10n.t("Current Environment"); + label = vscode.l10n.t("Metadata Diff ({0} (Local <-> {1}))", websiteName, envName); + } + rootItems.push(new MetadataDiffGroupTreeItem(mdProvider, hasData, label, websiteName, envName)); } return rootItems; From 848f52f6e70658316249e3291c1c4bb8260f913c Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Fri, 5 Sep 2025 04:03:01 +0530 Subject: [PATCH 29/58] Added view and discard local changes --- package.json | 24 +++++- .../actions-hub/ActionsHubTreeDataProvider.ts | 10 +++ .../metadata-diff/MetadataDiffCommands.ts | 74 +++++++++++++++++++ .../tree-items/MetadataDiffFileItem.ts | 19 ++++- 4 files changed, 123 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index cdea42ad1..e2b3f2744 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "extends": "@istanbuljs/nyc-config-typescript", "all": "true", "include": [ - "**/*.ts" + "**/*.ts" ], "exclude": [ "**/*.test.ts" @@ -530,6 +530,18 @@ "title": "Import Comparison View", "category": "Power Pages Metadata Diff", "icon": "$(folder-opened)" + }, + { + "command": "metadataDiff.openComparison", + "title": "Open Comparison", + "category": "Power Pages Metadata Diff", + "icon": "$(diff)" + }, + { + "command": "metadataDiff.discardLocalChanges", + "title": "Discard Local Changes", + "category": "Power Pages Metadata Diff", + "icon": "$(discard)" } ], "configuration": { @@ -1119,6 +1131,16 @@ "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffItem && microsoft.powerplatform.pages.metadataDiffEnabled", "group": "metadataDiff@6" }, + { + "command": "metadataDiff.openComparison", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffFileModified && microsoft.powerplatform.pages.metadataDiffEnabled", + "group": "metadataDiffFile@1" + }, + { + "command": "metadataDiff.discardLocalChanges", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffFileModified && microsoft.powerplatform.pages.metadataDiffEnabled", + "group": "metadataDiffFile@2" + }, { "command": "pacCLI.authPanel.nameAuthProfile", "when": "!virtualWorkspace && view == pacCLI.authPanel" diff --git a/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts b/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts index ff07e152b..411c4c310 100644 --- a/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts +++ b/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts @@ -39,6 +39,16 @@ class MetadataDiffWrapperTreeItem extends ActionsHubTreeItem { this.tooltip = _item.tooltip; } + // Expose underlying file paths for command handlers invoked via context menu + public get filePath(): string | undefined { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (this._item as any).filePath; + } + public get storedFilePath(): string | undefined { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (this._item as any).storedFilePath; + } + public getChildren(): ActionsHubTreeItem[] { // Map underlying children to wrapper items // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts index 3fb93ebd9..3bdac29d4 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts @@ -40,6 +40,80 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont } }); + // Explicit alias command shown in context menu (kept separate for clearer telemetry / labeling) + vscode.commands.registerCommand('metadataDiff.openComparison', async (itemOrWorkspace?: unknown, maybeStored?: unknown) => { + // Support invocation with either (workspace, stored) or a single tree item + let workspaceFile: string | undefined; + let storedFile: string | undefined; + if (typeof itemOrWorkspace === 'string') { + workspaceFile = itemOrWorkspace; + storedFile = typeof maybeStored === 'string' ? maybeStored : undefined; + } else if (itemOrWorkspace && typeof itemOrWorkspace === 'object') { + // Attempt to read wrapper properties + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const obj: any = itemOrWorkspace; + workspaceFile = obj.filePath || obj.workspaceFile; + storedFile = obj.storedFilePath || obj.storageFile; + } + if (workspaceFile && storedFile) { + await vscode.commands.executeCommand('metadataDiff.openDiff', workspaceFile, storedFile); + } else { + vscode.window.showWarningMessage('Unable to open comparison for this item.'); + } + }); + + // Discard local changes => overwrite workspace file with stored (remote) version + vscode.commands.registerCommand('metadataDiff.discardLocalChanges', async (itemOrWorkspace?: unknown, maybeStored?: unknown) => { + try { + let workspaceFile: string | undefined; + let storedFile: string | undefined; + if (typeof itemOrWorkspace === 'string') { + workspaceFile = itemOrWorkspace; + storedFile = typeof maybeStored === 'string' ? maybeStored : undefined; + } else if (itemOrWorkspace && typeof itemOrWorkspace === 'object') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const obj: any = itemOrWorkspace; + workspaceFile = obj.filePath || obj.workspaceFile; + storedFile = obj.storedFilePath || obj.storageFile; + } + if (!workspaceFile || !storedFile) { + vscode.window.showWarningMessage('Unable to discard changes for this item.'); + return; + } + const confirm = await vscode.window.showWarningMessage( + vscode.l10n.t('Discard local changes to "{0}"? This will overwrite the local file with the server copy.', path.basename(workspaceFile)), + { modal: true }, + vscode.l10n.t('Discard') + ); + if (confirm !== vscode.l10n.t('Discard')) { + return; + } + const remoteContent = fs.readFileSync(storedFile, 'utf8'); + fs.writeFileSync(workspaceFile, remoteContent, 'utf8'); + // Show a diff after discard for confirmation (optional) or simply info message + vscode.window.showInformationMessage(vscode.l10n.t('Local changes discarded for "{0}".', path.basename(workspaceFile))); + // Re-run diff provider to update statuses (file should now be identical and removed from diff view) + const provider = MetadataDiffDesktop['_treeDataProvider'] as MetadataDiffTreeDataProvider | undefined; // best-effort access + if (provider) { + // Invalidate cached diff items without wiping remote storage directory + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const providerAny: any = provider; + if (providerAny._diffItems) { + providerAny._diffItems = []; + } + await provider.getChildren(); + } + vscode.commands.executeCommand('microsoft.powerplatform.pages.actionsHub.refresh'); + } catch (error) { + oneDSLoggerWrapper.getLogger().traceError( + Constants.EventNames.METADATA_DIFF_REPORT_FAILED, + error as string, + error as Error + ); + vscode.window.showErrorMessage('Failed to discard local changes'); + } + }); + vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.resync", async () => { try { // Only proceed if we already have data (context set by menu 'when' clause) diff --git a/src/common/power-pages/metadata-diff/tree-items/MetadataDiffFileItem.ts b/src/common/power-pages/metadata-diff/tree-items/MetadataDiffFileItem.ts index f3fae460f..d0a69f572 100644 --- a/src/common/power-pages/metadata-diff/tree-items/MetadataDiffFileItem.ts +++ b/src/common/power-pages/metadata-diff/tree-items/MetadataDiffFileItem.ts @@ -8,15 +8,28 @@ import { MetadataDiffTreeItem } from "./MetadataDiffTreeItem"; export class MetadataDiffFileItem extends MetadataDiffTreeItem { constructor(label: string, workspaceFile?: string, storageFile?: string, hasDiff = true) { + // Derive a more specific context value so that modified items can have dedicated menu actions + let contextValue = "metadataDiffFileItem"; // default + if (workspaceFile && storageFile) { + contextValue = "metadataDiffFileModified"; // both sides exist and differ + } else if (workspaceFile && !storageFile) { + contextValue = "metadataDiffFileOnlyLocal"; + } else if (!workspaceFile && storageFile) { + contextValue = "metadataDiffFileOnlyRemote"; + } + super( label, vscode.TreeItemCollapsibleState.None, - "metadataDiffFileItem", + contextValue, workspaceFile, storageFile ); - this.workspaceFile = workspaceFile; - this.storageFile = storageFile; + // Mirror to base properties so wrapper can access via reflection + this.workspaceFile = workspaceFile; + this.storageFile = storageFile; + (this as unknown as { filePath?: string }).filePath = workspaceFile; + (this as unknown as { storedFilePath?: string }).storedFilePath = storageFile; this.hasDiff = hasDiff; this.iconPath = new vscode.ThemeIcon("file"); From c4031a7857b39990b1f0806bffb1cc673ff30a5c Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Fri, 5 Sep 2025 04:12:27 +0530 Subject: [PATCH 30/58] reset diff view on env change --- .../actions-hub/ActionsHubTreeDataProvider.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts b/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts index 411c4c310..77c67600c 100644 --- a/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts +++ b/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts @@ -103,13 +103,35 @@ export class ActionsHubTreeDataProvider implements vscode.TreeDataProvider { + const currentEnvId = PacContext.AuthInfo?.EnvironmentId; + const envChanged = currentEnvId !== this._lastEnvironmentId; + if (envChanged) { + // Clear Metadata Diff data when environment switches so user doesn't see stale comparison + if (ActionsHubTreeDataProvider._metadataDiffProvider) { + try { + ActionsHubTreeDataProvider._metadataDiffProvider.clearItems(); + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.ACTIONS_HUB_REFRESH, { + methodName: 'environmentChangedMetadataDiffReset', + previousEnvironmentId: this._lastEnvironmentId || 'undefined', + newEnvironmentId: currentEnvId || 'undefined' + }); + } catch (e) { + oneDSLoggerWrapper.getLogger().traceError(Constants.EventNames.ACTIONS_HUB_REFRESH, e as string, e as Error, { methodName: 'environmentChangedMetadataDiffReset' }, {}); + } + } + this._lastEnvironmentId = currentEnvId; + } this._loadWebsites = true; this.refresh(); }), From c8be02156ac1efefefa705f575b18ab87a8dd454 Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Fri, 5 Sep 2025 04:23:47 +0530 Subject: [PATCH 31/58] Added compare with env to power pages context menu --- package.json | 8 ++++++-- .../power-pages/metadata-diff/MetadataDiffDesktop.ts | 5 ----- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index e2b3f2744..16abed35a 100644 --- a/package.json +++ b/package.json @@ -497,7 +497,7 @@ }, { "command": "microsoft.powerplatform.pages.metadataDiff.triggerFlow", - "title": "Connect & Download", + "title": "Compare with Environment", "category": "Power Pages Metadata Diff", "icon": "$(cloud-download)" }, @@ -818,6 +818,10 @@ "command": "microsoft-powerapps-portals.webfile", "group": "1_powerpages@5" }, + { + "command": "microsoft.powerplatform.pages.metadataDiff.triggerFlow", + "group": "1_powerpages@6" + }, { "command": "microsoft-powerapps-portals.webtemplate", "group": "1_powerpages@3" @@ -1098,7 +1102,7 @@ }, { "command": "microsoft.powerplatform.pages.metadataDiff.triggerFlow", - "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffRoot && microsoft.powerplatform.pages.metadataDiffEnabled && microsoft.powerplatform.pages.metadataDiff.connectDownloadEnabled", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffRoot && microsoft.powerplatform.pages.metadataDiffEnabled", "group": "metadataDiff@2" }, { diff --git a/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts b/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts index 95f9b070f..40b842812 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts @@ -18,9 +18,6 @@ import { ActionsHubTreeDataProvider } from "../actions-hub/ActionsHubTreeDataPro export class MetadataDiffDesktop { private static _isInitialized = false; private static _treeDataProvider: MetadataDiffTreeDataProvider | undefined; - // Flag to enable/disable the "Connect & Download" (triggerFlow) command UI - // Toggle this to hide/show the option without modifying package.json again. - private static _connectAndDownloadEnabled: boolean = false; static isEnabled(): boolean { const enableMetadataDiff = true; //ECSFeaturesClient.getConfig(EnableMetadataDiff).enableMetadataDiff @@ -61,8 +58,6 @@ export class MetadataDiffDesktop { }); vscode.commands.executeCommand("setContext", "microsoft.powerplatform.pages.metadataDiffEnabled", isMetadataDiffEnabled); - // Set context for connect & download visibility (independent of overall feature enablement) - vscode.commands.executeCommand("setContext", "microsoft.powerplatform.pages.metadataDiff.connectDownloadEnabled", MetadataDiffDesktop._connectAndDownloadEnabled && isMetadataDiffEnabled); if (!isMetadataDiffEnabled) { return; From be611a6b8f9c1b27a34ae9fbba7176eb8ad0a17d Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Wed, 8 Oct 2025 11:55:41 +0530 Subject: [PATCH 32/58] Changed label and error handling --- .../metadata-diff/MetadataDiffCommands.ts | 61 +++++++++++++++---- .../metadata-diff/MetadataDiffUtils.ts | 8 +-- .../MetadataDiffTreeDataProvider.ts | 5 +- 3 files changed, 56 insertions(+), 18 deletions(-) diff --git a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts index 3bdac29d4..d88f01828 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts @@ -19,24 +19,60 @@ import { generateDiffReport, getAllDiffFiles, MetadataDiffReport } from "./Metad export async function registerMetadataDiffCommands(context: vscode.ExtensionContext, pacTerminal: PacTerminal): Promise { // Register command for handling file diffs - vscode.commands.registerCommand('metadataDiff.openDiff', async (workspaceFile: string, storedFile: string) => { + vscode.commands.registerCommand('metadataDiff.openDiff', async (workspaceFile?: string, storedFile?: string) => { try { - const workspaceUri = vscode.Uri.file(workspaceFile); - const storedUri = vscode.Uri.file(storedFile); - const fileName = path.basename(workspaceFile); - - await vscode.commands.executeCommand('vscode.diff', - storedUri, - workspaceUri, - `${fileName} (Metadata Diff)` - ); + if (!workspaceFile && !storedFile) { + vscode.window.showWarningMessage('No file paths provided for diff.'); + return; + } + + // Ensure storage directory for temp placeholders exists + const tempRoot = path.join(context.storageUri?.fsPath || '', 'tempDiff'); + if (!fs.existsSync(tempRoot)) { + fs.mkdirSync(tempRoot, { recursive: true }); + } + + const makeEmptySide = (basename: string, suffix: string) => { + const emptyPath = path.join(tempRoot, `${basename}.${suffix}.empty`); + if (!fs.existsSync(emptyPath)) { + fs.writeFileSync(emptyPath, ''); + } + return emptyPath; + }; + + let leftUri: vscode.Uri; // environment/original + let rightUri: vscode.Uri; // local/modified + let title: string; + + if (workspaceFile && storedFile) { + // Standard modified diff: stored (Environment) vs workspace (Local) + leftUri = vscode.Uri.file(storedFile); + rightUri = vscode.Uri.file(workspaceFile); + title = `${path.basename(workspaceFile)} (Modified)`; + } else if (workspaceFile && !storedFile) { + // Added locally: empty (Environment) -> workspace file (Local) + const base = path.basename(workspaceFile); + const emptyPath = makeEmptySide(base, 'env'); + leftUri = vscode.Uri.file(emptyPath); + rightUri = vscode.Uri.file(workspaceFile); + title = `${base} (Only in Local)`; + } else { + // Only in Environment: stored file content -> empty local + const base = path.basename(storedFile!); + const emptyPath = makeEmptySide(base, 'local'); + leftUri = vscode.Uri.file(storedFile!); + rightUri = vscode.Uri.file(emptyPath); + title = `${base} (Only in Environment)`; + } + + await vscode.commands.executeCommand('vscode.diff', leftUri, rightUri, title); } catch (error) { oneDSLoggerWrapper.getLogger().traceError( Constants.EventNames.METADATA_DIFF_REPORT_FAILED, error as string, error as Error ); - vscode.window.showErrorMessage("Failed to open diff view"); + vscode.window.showErrorMessage('Failed to open diff view'); } }); @@ -55,7 +91,8 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont workspaceFile = obj.filePath || obj.workspaceFile; storedFile = obj.storedFilePath || obj.storageFile; } - if (workspaceFile && storedFile) { + // Allow opening for added / removed as well (one-sided) + if (workspaceFile || storedFile) { await vscode.commands.executeCommand('metadataDiff.openDiff', workspaceFile, storedFile); } else { vscode.window.showWarningMessage('Unable to open comparison for this item.'); diff --git a/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts b/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts index fcb07bf47..5b77bce7f 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts @@ -96,8 +96,8 @@ li.file-item{margin:6px 0;padding:8px;border:1px solid #dcdcdc;border-radius:4px .file-name{font-weight:600;} .change-tag{font-size:11px;padding:2px 6px;border-radius:12px;background:#e0e0e0;color:#333;text-transform:uppercase;letter-spacing:.5px;} li.file-item.modified .change-tag{background:#fff4ce;color:#8a6d00;} -li.file-item['only-in-workspace'] .change-tag, li.file-item.only-in-workspace .change-tag{background:#dff6dd;color:#0e700e;} -li.file-item['only-in-remote'] .change-tag, li.file-item.only-in-remote .change-tag{background:#fde7e9;color:#a4262c;} +li.file-item['only-in-local'] .change-tag, li.file-item.only-in-local .change-tag{background:#dff6dd;color:#0e700e;} +li.file-item['only-in-environment'] .change-tag, li.file-item.only-in-environment .change-tag{background:#fde7e9;color:#a4262c;} details.prop-changes{margin-top:4px;} details.prop-changes summary{cursor:pointer;font-weight:600;} details.prop-changes ul{margin:4px 0 0 16px;padding:0;} @@ -178,7 +178,7 @@ export async function getAllDiffFiles(workspacePath: string, storagePath: string if (!storageFile) { diffFiles.push({ relativePath: normalized, - changes: 'Only in workspace', + changes: 'Only in Local', type: path.dirname(normalized) || 'Other', workspaceContent: fs.readFileSync(workspaceFile, 'utf8').replace(/\r\n/g, '\n') }); @@ -204,7 +204,7 @@ export async function getAllDiffFiles(workspacePath: string, storagePath: string if (!workspaceMap.has(normalized)) { diffFiles.push({ relativePath: normalized, - changes: 'Only in remote', + changes: 'Only in Environment', type: path.dirname(normalized) || 'Other', storageContent: fs.readFileSync(storageFile, 'utf8').replace(/\r\n/g, '\n') }); diff --git a/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts b/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts index 7e1a3e148..5cfac0128 100644 --- a/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts +++ b/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts @@ -240,8 +240,9 @@ export class MetadataDiffTreeDataProvider implements vscode.TreeDataProvider Date: Wed, 8 Oct 2025 12:22:48 +0530 Subject: [PATCH 33/58] add ecs flag back --- src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts b/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts index 40b842812..13d22cf54 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts @@ -20,7 +20,7 @@ export class MetadataDiffDesktop { private static _treeDataProvider: MetadataDiffTreeDataProvider | undefined; static isEnabled(): boolean { - const enableMetadataDiff = true; //ECSFeaturesClient.getConfig(EnableMetadataDiff).enableMetadataDiff + const enableMetadataDiff = ECSFeaturesClient.getConfig(EnableMetadataDiff).enableMetadataDiff if (enableMetadataDiff === undefined) { return false; From 27114dbd1b672cedbebe6283eaf7c2447907d1ae Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Wed, 8 Oct 2025 12:26:35 +0530 Subject: [PATCH 34/58] Potential fix for code scanning alert no. 54: Incomplete HTML attribute sanitization Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- src/client/power-pages/metadata-diff/MetadataDiffUtils.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts b/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts index 5b77bce7f..05cccbafa 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts @@ -38,7 +38,12 @@ export async function generateDiffReport(workspacePath: string, storagePath: str const sortedFolders = Array.from(folderStructure.keys()).sort(); - const escapeHtml = (value: string | undefined) => (value || '').replace(/&/g, '&').replace(//g, '>'); + const escapeHtml = (value: string | undefined) => + (value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); let body = `

Power Pages Metadata Diff Report

`; body += `

Generated on: ${escapeHtml(generatedOn)}

`; From 3ed14372fd8e42af06ade365d8fe4a614d2c4ac2 Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Wed, 8 Oct 2025 12:39:06 +0530 Subject: [PATCH 35/58] indents --- package.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 047f52b7f..bca4abd9e 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "extends": "@istanbuljs/nyc-config-typescript", "all": "true", "include": [ - "**/*.ts" + "**/*.ts" ], "exclude": [ "**/*.test.ts" @@ -503,7 +503,7 @@ }, { "command": "microsoft.powerplatform.pages.metadataDiff.triggerFlow", - "title": "Compare with Environment", + "title": "Compare with Environment", "category": "Power Pages Metadata Diff", "icon": "$(cloud-download)" }, @@ -527,13 +527,13 @@ }, { "command": "microsoft.powerplatform.pages.metadataDiff.exportReport", - "title": "Export Comparison View", + "title": "Export Comparison View", "category": "Power Pages Metadata Diff", "icon": "$(save-as)" }, { "command": "microsoft.powerplatform.pages.metadataDiff.importReport", - "title": "Import Comparison View", + "title": "Import Comparison View", "category": "Power Pages Metadata Diff", "icon": "$(folder-opened)" }, @@ -1088,7 +1088,7 @@ "command": "powerpages.copilot.clearConversation", "when": "view == powerpages.copilot", "group": "navigation" - } + } ], "view/item/context": [ { From 6769e3ea681e6a87ec3f62dd8d2bd29468a943f4 Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Wed, 8 Oct 2025 13:47:10 +0530 Subject: [PATCH 36/58] remove empty file --- .../actions-hub/tree-items/MetadataDiffRootTreeItem.ts | 1 - 1 file changed, 1 deletion(-) delete mode 100644 src/client/power-pages/actions-hub/tree-items/MetadataDiffRootTreeItem.ts diff --git a/src/client/power-pages/actions-hub/tree-items/MetadataDiffRootTreeItem.ts b/src/client/power-pages/actions-hub/tree-items/MetadataDiffRootTreeItem.ts deleted file mode 100644 index 349e21a2c..000000000 --- a/src/client/power-pages/actions-hub/tree-items/MetadataDiffRootTreeItem.ts +++ /dev/null @@ -1 +0,0 @@ -// Intentionally left empty. Previous implementation removed. From 8eba4a79d145abf52a1d7875c3c9f7aea2c9a0d4 Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Wed, 8 Oct 2025 13:53:35 +0530 Subject: [PATCH 37/58] Update src/client/power-pages/metadata-diff/MetadataDiffCommands.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/client/power-pages/metadata-diff/MetadataDiffCommands.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts index d88f01828..d9cb185e0 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts @@ -439,7 +439,6 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont const currentWorkspaceFolder = workspaceFolders[0].uri.fsPath; const websiteId = getWebsiteRecordId(currentWorkspaceFolder); - path.join(websiteId, "metadataDiffStorage"); const storagePath = context.storageUri?.fsPath; if (!storagePath) { throw new Error("Storage path is not defined"); From 3b358763bdd620d64c52f3b8ac6d46a400a1f315 Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Wed, 8 Oct 2025 19:28:00 +0530 Subject: [PATCH 38/58] fixed merge issue --- src/common/ecs-features/ecsFeatureGates.ts | 23 +++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/common/ecs-features/ecsFeatureGates.ts b/src/common/ecs-features/ecsFeatureGates.ts index 3bbd2c591..dddf2d57e 100644 --- a/src/common/ecs-features/ecsFeatureGates.ts +++ b/src/common/ecs-features/ecsFeatureGates.ts @@ -88,7 +88,28 @@ export const { fallback: { enableCodeQlScan: false, } -}) +}); + +export const { + feature: EnableOpenInDesktop +} = getFeatureConfigs({ + teamName: PowerPagesClientName, + description: 'Enable Open in Desktop functionality in VSCode Web', + fallback: { + enableOpenInDesktop: false, + } +}); + +export const { + feature: EnableDuplicateFileHandling +} = getFeatureConfigs({ + teamName: PowerPagesClientName, + description: 'Enable duplicate file handling for webpage folders', + fallback: { + enableDuplicateFileHandling: true, + disallowedDuplicateFileHandlingOrgs: "", + } +}); export const { feature: EnableMetadataDiff From 52dfca95cdec7d8930bca272ac78e74eb2b760ee Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Thu, 9 Oct 2025 14:56:19 +0530 Subject: [PATCH 39/58] updated regex --- src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts b/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts index 13d22cf54..b24ae1464 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts @@ -104,8 +104,8 @@ export class MetadataDiffDesktop { } // Extract the relevant parts using regex - // Example line: ' [4] e26a79b8-4c5d-f011-bec2-000d3a358057 Test V1_Studio - site-vbdyt Enhanced ' - const match = line.match(/\[\d+\]\s+([a-f0-9-]+)\s+(.+?)\s+(Standard|Enhanced)\s*$/i); + // Example line: ' [1] 4b47eaeb-5f5d-f011-bec1-000d3a5b8fd6 test_portal Standard No ' + const match = line.match(/\s*\[\d+\]\s+([a-f0-9-]+)\s+(.+?)\s+(Standard|Enhanced)(?:\s{2,}.*)?$/i); if (match) { // Extract WebsiteId, FriendlyName, and ModelVersion from the line // Example line: ' [2] 8aa65ec4-1578-f011-b4cc-0022480b93b5 Customer Self Service_V1 - customerselfservice-oh1uo Standard ' From bf324689ced40ba25f919c8ca7a613c16842bebb Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Mon, 13 Oct 2025 02:23:06 +0530 Subject: [PATCH 40/58] fix bug in regex --- .../metadata-diff/MetadataDiffTreeDataProvider.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts b/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts index 5cfac0128..60af2ab57 100644 --- a/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts +++ b/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts @@ -136,9 +136,12 @@ export class MetadataDiffTreeDataProvider implements vscode.TreeDataProvider { try { - const folders = fs.readdirSync(storagePath).filter(f => - fs.statSync(path.join(storagePath, f)).isDirectory() - ); + const folders = fs.readdirSync(storagePath).filter(f => { + if (f.startsWith('.')) { + return false; + } + return fs.statSync(path.join(storagePath, f)).isDirectory(); + }); if (folders.length > 0) { return path.join(storagePath, folders[0]); From 28f2c9d05d94a019629e1a7cc2454a1cdf90e409 Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Mon, 13 Oct 2025 11:45:28 +0530 Subject: [PATCH 41/58] using existing pages download --- src/client/pac/PacWrapper.ts | 4 ---- .../power-pages/metadata-diff/MetadataDiffCommands.ts | 10 +++++----- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/client/pac/PacWrapper.ts b/src/client/pac/PacWrapper.ts index 58c7e9f0c..772128e23 100644 --- a/src/client/pac/PacWrapper.ts +++ b/src/client/pac/PacWrapper.ts @@ -229,10 +229,6 @@ export class PacWrapper { // The next operation will create a new process } - public async pagesDownload(path: string, websiteId: string, modelVersion: string): Promise { - return this.executeCommandAndParseResults(new PacArguments("pages", "download", "-p", path, "-id", websiteId, "-mv", modelVersion)); - } - public async pagesList(): Promise { return this.executeCommandAndParseResults(new PacArguments("pages", "list", "--verbose")); } diff --git a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts index d9cb185e0..d4529fe17 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts @@ -205,10 +205,10 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont return; } progress.report({ message: vscode.l10n.t('Retrieving "{0}" as {1} data model. Please wait...', websiteRecord.name, websiteRecord.modelVersion === 'v2' ? 'enhanced' : 'standard') }); - pacPagesDownload = await pacWrapper.pagesDownload( + pacPagesDownload = await pacWrapper.downloadSite( storagePath, websiteId, - websiteRecord.modelVersion === "v1" || websiteRecord.modelVersion === "Standard" ? "1" : "2" + websiteRecord.modelVersion === "v1" || websiteRecord.modelVersion === "Standard" ? 1 : 2 ); if (pacPagesDownload) { progress.report({ message: vscode.l10n.t('Comparing metadata of "{0}"...', websiteRecord.name) }); @@ -308,10 +308,10 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont return; } progress.report({ message: vscode.l10n.t('Retrieving "{0}" as {1} data model. Please wait...', websiteRecord.name, websiteRecord.modelVersion === 'v2' ? 'enhanced' : 'standard') }); - pacPagesDownload = await pacWrapper.pagesDownload( + pacPagesDownload = await pacWrapper.downloadSite( storagePath, websiteId, - websiteRecord.modelVersion === "v1" || websiteRecord.modelVersion === "Standard" ? "1" : "2" + websiteRecord.modelVersion === "v1" || websiteRecord.modelVersion === "Standard" ? 1 : 2 ); if (pacPagesDownload) { progress.report({ message: vscode.l10n.t('Comparing metadata of "{0}"...', websiteRecord.name) }); @@ -466,7 +466,7 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont return; } progress.report({ message: vscode.l10n.t('Retrieving "{0}" as {1} data model. Please wait...', websiteRecord.name, websiteRecord.modelVersion === 'v2' ? 'enhanced' : 'standard') }); - pacPagesDownload = await pacWrapper.pagesDownload(storagePath, websiteId, websiteRecord.modelVersion == "v1" ? "1" : "2"); + pacPagesDownload = await pacWrapper.downloadSite(storagePath, websiteId, websiteRecord.modelVersion == "v1" ? 1 : 2); if (pacPagesDownload) { progress.report({ message: vscode.l10n.t('Comparing metadata of "{0}"...', websiteRecord.name) }); const provider = MetadataDiffTreeDataProvider.initialize(context); From 0e473ddbaae66c44063087c18a06a7ed4e028824 Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Mon, 13 Oct 2025 15:12:25 +0530 Subject: [PATCH 42/58] Add metadata comparison and localization strings for website management --- l10n/bundle.l10n.json | 15 +++++++ .../vscode-powerplatform.xlf | 45 +++++++++++++++++++ .../actions-hub/ActionsHubCommandHandlers.ts | 5 ++- .../actions-hub/ActionsHubTreeDataProvider.ts | 10 +---- 4 files changed, 64 insertions(+), 11 deletions(-) diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index 2fc15d114..a31c449cf 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -182,6 +182,14 @@ "Upload changes": "Upload changes", "The preview shown is for published changes. Please publish any pending changes to see them in the preview.": "The preview shown is for published changes. Please publish any pending changes to see them in the preview.", "Please login to preview the site.": "Please login to preview the site.", + "Discard local changes to \"{0}\"? This will overwrite the local file with the server copy.": "Discard local changes to \"{0}\"? This will overwrite the local file with the server copy.", + "Discard": "Discard", + "Local changes discarded for \"{0}\".": "Local changes discarded for \"{0}\".", + "Re-syncing website metadata": "Re-syncing website metadata", + "Retrieving \"{0}\" as {1} data model. Please wait...": "Retrieving \"{0}\" as {1} data model. Please wait...", + "Comparing metadata of \"{0}\"...": "Comparing metadata of \"{0}\"...", + "You can now view the comparison": "You can now view the comparison", + "Downloading website metadata": "Downloading website metadata", "Enter the name of the web template": "Enter the name of the web template", "Please enter a name for the web template.": "Please enter a name for the web template.", "A webtemplate with the same name already exists. Please enter a different name.": "A webtemplate with the same name already exists. Please enter a different name.", @@ -338,6 +346,13 @@ "Error creating config file: {0}": "Error creating config file: {0}", "PowerPages config file updated successfully: {0}": "PowerPages config file updated successfully: {0}", "Error updating config file: {0}": "Error updating config file: {0}", + "No data yet": "No data yet", + "Power Pages metadata comparison: {0} local vs {1}": "Power Pages metadata comparison: {0} local vs {1}", + "Power Pages metadata comparison": "Power Pages metadata comparison", + "Metadata Diff": "Metadata Diff", + "Local": "Local", + "Current Environment": "Current Environment", + "Metadata Diff ({0} (Local <-> {1}))": "Metadata Diff ({0} (Local <-> {1}))", "Timestamp: {0}/{0} is the timestamp": { "message": "Timestamp: {0}", "comment": [ diff --git a/loc/translations-export/vscode-powerplatform.xlf b/loc/translations-export/vscode-powerplatform.xlf index 2de235348..6d78ebf94 100644 --- a/loc/translations-export/vscode-powerplatform.xlf +++ b/loc/translations-export/vscode-powerplatform.xlf @@ -215,6 +215,9 @@ Check the CodeQL extension panel for available queries. Command failed with exit code: {0} + + Comparing metadata of "{0}"... + Confirm @@ -260,6 +263,9 @@ Check the CodeQL extension panel for available queries. Current + + Current Environment + Current site path not found. @@ -277,6 +283,12 @@ Check the CodeQL extension panel for available queries. Default Environment: {0} The {0} represents profile's resource/environment URL + + Discard + + + Discard local changes to "{0}"? This will overwrite the local file with the server copy. + Dislike something? Tell us more. @@ -303,6 +315,9 @@ The {3} represents Solution's Type (Managed or Unmanaged), but that test is loca Download failed: {0} + + Downloading website metadata + Edit the site @@ -543,6 +558,12 @@ Return to this chat and @powerpages can help you write and edit your website cod Line: {0} + + Local + + + Local changes discarded for "{0}". + Login @@ -565,6 +586,12 @@ Return to this chat and @powerpages can help you write and edit your website cod Maximum 30 characters allowed + + Metadata Diff + + + Metadata Diff ({0} (Local <-> {1})) + Microsoft wants your feedback @@ -610,6 +637,9 @@ The {3} represents Dataverse Environment's Organization ID (GUID) No analysis results found. + + No data yet + No environments found @@ -733,6 +763,12 @@ The {3} represents Dataverse Environment's Organization ID (GUID) Power Pages Copilot is now connected to the environment: {0} : {1} {0} represents the environment name + + Power Pages metadata comparison + + + Power Pages metadata comparison: {0} local vs {1} + Power Pages site download completed successfully. Would you like to open the downloaded site folder? @@ -768,6 +804,9 @@ The {3} represents Dataverse Environment's Organization ID (GUID) Profile Kind: {0} The {0} represents the profile type (Admin vs Dataverse) + + Re-syncing website metadata + Ready to select download folder @@ -777,6 +816,9 @@ The {3} represents Dataverse Environment's Organization ID (GUID) Results opened in SARIF viewer successfully. + + Retrieving "{0}" as {1} data model. Please wait... + Running CodeQL analysis... @@ -1060,6 +1102,9 @@ The {3} represents Dataverse Environment's Organization ID (GUID) You are editing a live, public site + + You can now view the comparison + You can use this in <a href="#" id="github-copilot-link">GitHub Copilot with @powerpages</a> and leverage best of both world. diff --git a/src/client/power-pages/actions-hub/ActionsHubCommandHandlers.ts b/src/client/power-pages/actions-hub/ActionsHubCommandHandlers.ts index ae169c2c4..bc912fd73 100644 --- a/src/client/power-pages/actions-hub/ActionsHubCommandHandlers.ts +++ b/src/client/power-pages/actions-hub/ActionsHubCommandHandlers.ts @@ -716,8 +716,9 @@ export const compareWithLocal = async (siteTreeItem: SiteTreeItem) => { ); try { - // Execute the compare with local command with specific website ID - await vscode.commands.executeCommand('microsoft.powerplatform.pages.metadataDiff.triggerFlowWithSite', siteInfo.websiteId); + await vscode.commands.executeCommand( + 'microsoft.powerplatform.pages.metadataDiff.triggerFlowWithSite', + siteInfo.websiteId); } catch (error) { traceError( Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_LOCAL_FAILED, diff --git a/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts b/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts index 77c67600c..a9dbcb521 100644 --- a/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts +++ b/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts @@ -12,7 +12,7 @@ import { oneDSLoggerWrapper } from "../../../common/OneDSLoggerTelemetry/oneDSLo import { EnvironmentGroupTreeItem } from "./tree-items/EnvironmentGroupTreeItem"; import { IEnvironmentInfo } from "./models/IEnvironmentInfo"; import { PacTerminal } from "../../lib/PacTerminal"; -import { fetchWebsites, openActiveSitesInStudio, openInactiveSitesInStudio, previewSite, createNewAuthProfile, refreshEnvironment, showEnvironmentDetails, switchEnvironment, revealInOS, openSiteManagement, uploadSite, showSiteDetails, compareWithLocal, downloadSite, openInStudio, reactivateSite, runCodeQLScreening, loginToMatch } from "./ActionsHubCommandHandlers"; +import { fetchWebsites, openActiveSitesInStudio, openInactiveSitesInStudio, previewSite, createNewAuthProfile, refreshEnvironment, showEnvironmentDetails, switchEnvironment, revealInOS, openSiteManagement, uploadSite, showSiteDetails, downloadSite, openInStudio, reactivateSite, runCodeQLScreening, loginToMatch, compareWithLocal } from "./ActionsHubCommandHandlers"; import PacContext from "../../pac/PacContext"; import CurrentSiteContext from "./CurrentSiteContext"; import { IOtherSiteInfo, IWebsiteDetails } from "../../../common/services/Interfaces"; @@ -24,7 +24,6 @@ import ArtemisContext from "../../ArtemisContext"; import { MetadataDiffTreeDataProvider } from "../../../common/power-pages/metadata-diff/MetadataDiffTreeDataProvider"; import { MetadataDiffTreeItem } from "../../../common/power-pages/metadata-diff/tree-items/MetadataDiffTreeItem"; -// Wrapper classes to surface Metadata Diff under Actions Hub class MetadataDiffWrapperTreeItem extends ActionsHubTreeItem { constructor(private readonly _item: MetadataDiffTreeItem) { super( @@ -34,12 +33,10 @@ class MetadataDiffWrapperTreeItem extends ActionsHubTreeItem { _item.contextValue || "metadataDiffItem", (typeof _item.description === "string" ? _item.description : "") ); - // Copy over command & tooltip if present this.command = _item.command; this.tooltip = _item.tooltip; } - // Expose underlying file paths for command handlers invoked via context menu public get filePath(): string | undefined { // eslint-disable-next-line @typescript-eslint/no-explicit-any return (this._item as any).filePath; @@ -50,7 +47,6 @@ class MetadataDiffWrapperTreeItem extends ActionsHubTreeItem { } public getChildren(): ActionsHubTreeItem[] { - // Map underlying children to wrapper items // eslint-disable-next-line @typescript-eslint/no-explicit-any const children: MetadataDiffTreeItem[] = (this._item as any).getChildren ? (this._item as any).getChildren() : []; if (!children || !Array.isArray(children)) { @@ -65,7 +61,6 @@ class MetadataDiffGroupTreeItem extends ActionsHubTreeItem { super( label, hasData ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.Collapsed, - // Use Split Editor icon (codicon split-horizontal) as requested new vscode.ThemeIcon("split-horizontal"), "metadataDiffRoot", hasData ? "" : vscode.l10n.t("No data yet") @@ -76,7 +71,6 @@ class MetadataDiffGroupTreeItem extends ActionsHubTreeItem { } public getChildren(): ActionsHubTreeItem[] { - // Force provider to populate its cache if empty (async ignored intentionally) // eslint-disable-next-line @typescript-eslint/no-floating-promises // @ts-expect-error Accessing private field for integration wrapper if (this._provider._diffItems?.length === 0) { @@ -103,7 +97,6 @@ export class ActionsHubTreeDataProvider implements vscode.TreeDataProvider Date: Mon, 13 Oct 2025 15:52:52 +0530 Subject: [PATCH 43/58] Refactor metadata diff handling by creating MetadataDiffGroupTreeItem and MetadataDiffWrapperTreeItem classes --- .../actions-hub/ActionsHubTreeDataProvider.ts | 63 +------------------ .../tree-items/MetadataDiffGroupTreeItem.ts | 42 +++++++++++++ .../tree-items/MetadataDiffWrapperTreeItem.ts | 44 +++++++++++++ 3 files changed, 87 insertions(+), 62 deletions(-) create mode 100644 src/client/power-pages/actions-hub/tree-items/MetadataDiffGroupTreeItem.ts create mode 100644 src/client/power-pages/actions-hub/tree-items/MetadataDiffWrapperTreeItem.ts diff --git a/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts b/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts index a9dbcb521..f32f16bdc 100644 --- a/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts +++ b/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts @@ -22,68 +22,7 @@ import { PROVIDER_ID } from "../../../common/services/Constants"; import { getOIDFromToken } from "../../../common/services/AuthenticationProvider"; import ArtemisContext from "../../ArtemisContext"; import { MetadataDiffTreeDataProvider } from "../../../common/power-pages/metadata-diff/MetadataDiffTreeDataProvider"; -import { MetadataDiffTreeItem } from "../../../common/power-pages/metadata-diff/tree-items/MetadataDiffTreeItem"; - -class MetadataDiffWrapperTreeItem extends ActionsHubTreeItem { - constructor(private readonly _item: MetadataDiffTreeItem) { - super( - _item.label?.toString(), - _item.collapsibleState ?? vscode.TreeItemCollapsibleState.None, - _item.iconPath || new vscode.ThemeIcon("file"), - _item.contextValue || "metadataDiffItem", - (typeof _item.description === "string" ? _item.description : "") - ); - this.command = _item.command; - this.tooltip = _item.tooltip; - } - - public get filePath(): string | undefined { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return (this._item as any).filePath; - } - public get storedFilePath(): string | undefined { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return (this._item as any).storedFilePath; - } - - public getChildren(): ActionsHubTreeItem[] { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const children: MetadataDiffTreeItem[] = (this._item as any).getChildren ? (this._item as any).getChildren() : []; - if (!children || !Array.isArray(children)) { - return []; - } - return children.map(c => new MetadataDiffWrapperTreeItem(c)); - } -} - -class MetadataDiffGroupTreeItem extends ActionsHubTreeItem { - constructor(private readonly _provider: MetadataDiffTreeDataProvider, hasData: boolean, label: string, websiteName?: string, envName?: string) { - super( - label, - hasData ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.Collapsed, - new vscode.ThemeIcon("split-horizontal"), - "metadataDiffRoot", - hasData ? "" : vscode.l10n.t("No data yet") - ); - this.tooltip = hasData && websiteName && envName - ? vscode.l10n.t("Power Pages metadata comparison: {0} local vs {1}", websiteName, envName) - : vscode.l10n.t("Power Pages metadata comparison"); - } - - public getChildren(): ActionsHubTreeItem[] { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - // @ts-expect-error Accessing private field for integration wrapper - if (this._provider._diffItems?.length === 0) { - this._provider.getChildren(); - } - // @ts-expect-error Accessing private field for integration wrapper - const items: MetadataDiffTreeItem[] | null | undefined = this._provider._diffItems || []; - if (!items || !Array.isArray(items)) { - return []; - } - return items.map(i => new MetadataDiffWrapperTreeItem(i)); - } -} +import { MetadataDiffGroupTreeItem } from "./tree-items/MetadataDiffGroupTreeItem"; export class ActionsHubTreeDataProvider implements vscode.TreeDataProvider { private readonly _disposables: vscode.Disposable[] = []; diff --git a/src/client/power-pages/actions-hub/tree-items/MetadataDiffGroupTreeItem.ts b/src/client/power-pages/actions-hub/tree-items/MetadataDiffGroupTreeItem.ts new file mode 100644 index 000000000..5f5c9a5fa --- /dev/null +++ b/src/client/power-pages/actions-hub/tree-items/MetadataDiffGroupTreeItem.ts @@ -0,0 +1,42 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import * as vscode from "vscode"; +import { ActionsHubTreeItem } from "./ActionsHubTreeItem"; +import { MetadataDiffTreeDataProvider } from "../../../../common/power-pages/metadata-diff/MetadataDiffTreeDataProvider"; +import { MetadataDiffTreeItem } from "../../../../common/power-pages/metadata-diff/tree-items/MetadataDiffTreeItem"; +import { MetadataDiffWrapperTreeItem } from "./MetadataDiffWrapperTreeItem"; + +/** + * Root group node for Metadata Diff items within the Actions Hub tree. + */ +export class MetadataDiffGroupTreeItem extends ActionsHubTreeItem { + constructor(private readonly _provider: MetadataDiffTreeDataProvider, hasData: boolean, label: string, websiteName?: string, envName?: string) { + super( + label, + hasData ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.Collapsed, + new vscode.ThemeIcon("split-horizontal"), + "metadataDiffRoot", + hasData ? "" : vscode.l10n.t("No data yet") + ); + this.tooltip = hasData && websiteName && envName + ? vscode.l10n.t("Power Pages metadata comparison: {0} local vs {1}", websiteName, envName) + : vscode.l10n.t("Power Pages metadata comparison"); + } + + public getChildren(): ActionsHubTreeItem[] { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + // @ts-expect-error Accessing private field for integration wrapper + if (this._provider._diffItems?.length === 0) { + this._provider.getChildren(); + } + // @ts-expect-error Accessing private field for integration wrapper + const items: MetadataDiffTreeItem[] | null | undefined = this._provider._diffItems || []; + if (!items || !Array.isArray(items)) { + return []; + } + return items.map(i => new MetadataDiffWrapperTreeItem(i)); + } +} diff --git a/src/client/power-pages/actions-hub/tree-items/MetadataDiffWrapperTreeItem.ts b/src/client/power-pages/actions-hub/tree-items/MetadataDiffWrapperTreeItem.ts new file mode 100644 index 000000000..a228e8bc2 --- /dev/null +++ b/src/client/power-pages/actions-hub/tree-items/MetadataDiffWrapperTreeItem.ts @@ -0,0 +1,44 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import * as vscode from "vscode"; +import { ActionsHubTreeItem } from "./ActionsHubTreeItem"; +import { MetadataDiffTreeItem } from "../../../../common/power-pages/metadata-diff/tree-items/MetadataDiffTreeItem"; + +/** + * Wrapper tree item for metadata diff entries to integrate with existing Actions Hub tree. + */ +export class MetadataDiffWrapperTreeItem extends ActionsHubTreeItem { + constructor(private readonly _item: MetadataDiffTreeItem) { + super( + _item.label?.toString(), + _item.collapsibleState ?? vscode.TreeItemCollapsibleState.None, + _item.iconPath || new vscode.ThemeIcon("file"), + _item.contextValue || "metadataDiffItem", + (typeof _item.description === "string" ? _item.description : "") + ); + this.command = _item.command; + this.tooltip = _item.tooltip; + } + + public get filePath(): string | undefined { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (this._item as any).filePath; + } + + public get storedFilePath(): string | undefined { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (this._item as any).storedFilePath; + } + + public getChildren(): ActionsHubTreeItem[] { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const children: MetadataDiffTreeItem[] = (this._item as any).getChildren ? (this._item as any).getChildren() : []; + if (!children || !Array.isArray(children)) { + return []; + } + return children.map(c => new MetadataDiffWrapperTreeItem(c)); + } +} From c4a4498497cc46043674da49661f054a8495bb53 Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Tue, 14 Oct 2025 12:56:15 +0530 Subject: [PATCH 44/58] Update regex examples and model version extraction logic in MetadataDiffDesktop --- .../power-pages/metadata-diff/MetadataDiffDesktop.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts b/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts index b24ae1464..47536aeeb 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts @@ -104,13 +104,14 @@ export class MetadataDiffDesktop { } // Extract the relevant parts using regex - // Example line: ' [1] 4b47eaeb-5f5d-f011-bec1-000d3a5b8fd6 test_portal Standard No ' + // Example lines: + // ' [1] 4b47eaeb-5f5d-f011-bec1-000d3a5b8fd6 test_portal Standard No ' + // ' [7] e26a79b8-4c5d-f011-bec2-000d3a358057 Test V1_Studio - site-vbdyt Enhanced No ' const match = line.match(/\s*\[\d+\]\s+([a-f0-9-]+)\s+(.+?)\s+(Standard|Enhanced)(?:\s{2,}.*)?$/i); if (match) { // Extract WebsiteId, FriendlyName, and ModelVersion from the line - // Example line: ' [2] 8aa65ec4-1578-f011-b4cc-0022480b93b5 Customer Self Service_V1 - customerselfservice-oh1uo Standard ' - const modelVersionMatch = line.match(/\s(Standard|Enhanced)\s*$/i); - const modelVersion = modelVersionMatch ? modelVersionMatch[1].trim() : "Standard"; + // Example line: ' [2] 8aa65ec4-1578-f011-b4cc-0022480b93b5 Customer Self Service_V1 - customerselfservice-oh1uo Standard No ' + const modelVersion = match[3].trim().toLowerCase() === "enhanced" ? "2" : "1"; pagesList.push({ WebsiteId: match[1].trim(), FriendlyName: match[2].trim(), From 02bcb088db3bd53f91b05a508d76bd629e2c58ac Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Tue, 14 Oct 2025 13:23:40 +0530 Subject: [PATCH 45/58] Remove unused lastEnvironmentId tracking and related environment change logic in ActionsHubTreeDataProvider --- .../actions-hub/ActionsHubTreeDataProvider.ts | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts b/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts index f32f16bdc..d0c13308b 100644 --- a/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts +++ b/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts @@ -36,33 +36,13 @@ export class ActionsHubTreeDataProvider implements vscode.TreeDataProvider { - const currentEnvId = PacContext.AuthInfo?.EnvironmentId; - const envChanged = currentEnvId !== this._lastEnvironmentId; - if (envChanged) { - if (ActionsHubTreeDataProvider._metadataDiffProvider) { - try { - ActionsHubTreeDataProvider._metadataDiffProvider.clearItems(); - oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.ACTIONS_HUB_REFRESH, { - methodName: 'environmentChangedMetadataDiffReset', - previousEnvironmentId: this._lastEnvironmentId || 'undefined', - newEnvironmentId: currentEnvId || 'undefined' - }); - } catch (e) { - oneDSLoggerWrapper.getLogger().traceError(Constants.EventNames.ACTIONS_HUB_REFRESH, e as string, e as Error, { methodName: 'environmentChangedMetadataDiffReset' }, {}); - } - } - this._lastEnvironmentId = currentEnvId; - } this._loadWebsites = true; this.refresh(); }), From c7db9d4c3a8b888c48d65fa2ed02ca3fa6e61c7d Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Tue, 14 Oct 2025 13:36:02 +0530 Subject: [PATCH 46/58] Refactor metadata diff labels to use constants for improved localization support --- .../power-pages/actions-hub/ActionsHubTreeDataProvider.ts | 8 ++++---- src/client/power-pages/actions-hub/Constants.ts | 4 ++++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts b/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts index d0c13308b..c92e43607 100644 --- a/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts +++ b/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts @@ -218,14 +218,14 @@ export class ActionsHubTreeDataProvider implements vscode.TreeDataProvider 0; - let label = vscode.l10n.t("Metadata Diff"); + let label = Constants.Strings.METADATA_DIFF; let websiteName: string | undefined; let envName: string | undefined; if (hasData) { const workspaceFolders = vscode.workspace.workspaceFolders; - websiteName = workspaceFolders && workspaceFolders.length > 0 ? workspaceFolders[0].name : vscode.l10n.t("Local"); - envName = authInfo?.OrganizationFriendlyName || vscode.l10n.t("Current Environment"); - label = vscode.l10n.t("Metadata Diff ({0} (Local <-> {1}))", websiteName, envName); + websiteName = workspaceFolders && workspaceFolders.length > 0 ? workspaceFolders[0].name : Constants.Strings.LOCAL; + envName = authInfo?.OrganizationFriendlyName || Constants.Strings.CURRENT_ENVIRONMENT; + label = vscode.l10n.t(Constants.Strings.METADATA_DIFF_FORMAT, websiteName, envName); } rootItems.push(new MetadataDiffGroupTreeItem(mdProvider, hasData, label, websiteName, envName)); } diff --git a/src/client/power-pages/actions-hub/Constants.ts b/src/client/power-pages/actions-hub/Constants.ts index 4f51f5b6c..15b8b75d3 100644 --- a/src/client/power-pages/actions-hub/Constants.ts +++ b/src/client/power-pages/actions-hub/Constants.ts @@ -28,6 +28,10 @@ export const Constants = { OTHER_SITES: vscode.l10n.t("Other Sites"), ACTIVE_SITES: vscode.l10n.t("Active Sites"), INACTIVE_SITES: vscode.l10n.t("Inactive Sites"), + METADATA_DIFF: vscode.l10n.t("Metadata Diff"), + METADATA_DIFF_FORMAT: vscode.l10n.t("Metadata Diff ({0} (Local <-> {1}))"), + LOCAL: vscode.l10n.t("Local"), + CURRENT_ENVIRONMENT: vscode.l10n.t("Current Environment"), NO_SITES_FOUND: vscode.l10n.t("No sites found"), NO_ENVIRONMENTS_FOUND: vscode.l10n.t("No environments found"), SELECT_ENVIRONMENT: vscode.l10n.t("Select an environment"), From 4dfb407edea0fd43852d40bce1db0914a4ecde8b Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Tue, 14 Oct 2025 13:55:12 +0530 Subject: [PATCH 47/58] Ensure metadata diff provider builds diff list before evaluating data presence --- .../power-pages/actions-hub/ActionsHubTreeDataProvider.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts b/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts index c92e43607..bbe5b32f9 100644 --- a/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts +++ b/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts @@ -212,9 +212,7 @@ export class ActionsHubTreeDataProvider implements vscode.TreeDataProvider 0; From 5133a47a0ba0b54624a1eb1f1efe5679b541ee0d Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Tue, 14 Oct 2025 13:59:31 +0530 Subject: [PATCH 48/58] Refactor metadata diff command registrations to use fully qualified command names --- .../power-pages/metadata-diff/MetadataDiffCommands.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts index d4529fe17..812e73d96 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts @@ -14,12 +14,11 @@ import { MetadataDiffDesktop } from "./MetadataDiffDesktop"; import { ActionsHubTreeDataProvider } from "../actions-hub/ActionsHubTreeDataProvider"; import { createAuthProfileExp } from "../../../common/utilities/PacAuthUtil"; import { getWebsiteRecordId } from "../../../common/utilities/WorkspaceInfoFinderUtil"; -// Duplicate imports removed import { generateDiffReport, getAllDiffFiles, MetadataDiffReport } from "./MetadataDiffUtils"; export async function registerMetadataDiffCommands(context: vscode.ExtensionContext, pacTerminal: PacTerminal): Promise { // Register command for handling file diffs - vscode.commands.registerCommand('metadataDiff.openDiff', async (workspaceFile?: string, storedFile?: string) => { + vscode.commands.registerCommand('microsoft.powerplatform.pages.metadataDiff.openDiff', async (workspaceFile?: string, storedFile?: string) => { try { if (!workspaceFile && !storedFile) { vscode.window.showWarningMessage('No file paths provided for diff.'); @@ -77,7 +76,7 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont }); // Explicit alias command shown in context menu (kept separate for clearer telemetry / labeling) - vscode.commands.registerCommand('metadataDiff.openComparison', async (itemOrWorkspace?: unknown, maybeStored?: unknown) => { + vscode.commands.registerCommand('microsoft.powerplatform.pages.metadataDiff.openComparison', async (itemOrWorkspace?: unknown, maybeStored?: unknown) => { // Support invocation with either (workspace, stored) or a single tree item let workspaceFile: string | undefined; let storedFile: string | undefined; @@ -93,14 +92,14 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont } // Allow opening for added / removed as well (one-sided) if (workspaceFile || storedFile) { - await vscode.commands.executeCommand('metadataDiff.openDiff', workspaceFile, storedFile); + await vscode.commands.executeCommand('microsoft.powerplatform.pages.metadataDiff.openDiff', workspaceFile, storedFile); } else { vscode.window.showWarningMessage('Unable to open comparison for this item.'); } }); // Discard local changes => overwrite workspace file with stored (remote) version - vscode.commands.registerCommand('metadataDiff.discardLocalChanges', async (itemOrWorkspace?: unknown, maybeStored?: unknown) => { + vscode.commands.registerCommand('microsoft.powerplatform.pages.metadataDiff.discardLocalChanges', async (itemOrWorkspace?: unknown, maybeStored?: unknown) => { try { let workspaceFile: string | undefined; let storedFile: string | undefined; From 18dabea6ca3495a294fb2462b5eced3db2244efd Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Tue, 14 Oct 2025 17:04:36 +0530 Subject: [PATCH 49/58] Improve localization support by using l10n for warning messages in metadata diff commands --- .../metadata-diff/MetadataDiffCommands.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts index 812e73d96..ae2b92dfe 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts @@ -17,15 +17,13 @@ import { getWebsiteRecordId } from "../../../common/utilities/WorkspaceInfoFinde import { generateDiffReport, getAllDiffFiles, MetadataDiffReport } from "./MetadataDiffUtils"; export async function registerMetadataDiffCommands(context: vscode.ExtensionContext, pacTerminal: PacTerminal): Promise { - // Register command for handling file diffs vscode.commands.registerCommand('microsoft.powerplatform.pages.metadataDiff.openDiff', async (workspaceFile?: string, storedFile?: string) => { try { if (!workspaceFile && !storedFile) { - vscode.window.showWarningMessage('No file paths provided for diff.'); + vscode.window.showWarningMessage(vscode.l10n.t("No file paths provided for diff.")); return; } - // Ensure storage directory for temp placeholders exists const tempRoot = path.join(context.storageUri?.fsPath || '', 'tempDiff'); if (!fs.existsSync(tempRoot)) { fs.mkdirSync(tempRoot, { recursive: true }); @@ -39,15 +37,14 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont return emptyPath; }; - let leftUri: vscode.Uri; // environment/original - let rightUri: vscode.Uri; // local/modified + let leftUri: vscode.Uri; + let rightUri: vscode.Uri; let title: string; if (workspaceFile && storedFile) { - // Standard modified diff: stored (Environment) vs workspace (Local) leftUri = vscode.Uri.file(storedFile); rightUri = vscode.Uri.file(workspaceFile); - title = `${path.basename(workspaceFile)} (Modified)`; + title = vscode.l10n.t('{0} (Modified)', path.basename(workspaceFile)); } else if (workspaceFile && !storedFile) { // Added locally: empty (Environment) -> workspace file (Local) const base = path.basename(workspaceFile); @@ -94,7 +91,7 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont if (workspaceFile || storedFile) { await vscode.commands.executeCommand('microsoft.powerplatform.pages.metadataDiff.openDiff', workspaceFile, storedFile); } else { - vscode.window.showWarningMessage('Unable to open comparison for this item.'); + vscode.window.showWarningMessage(vscode.l10n.t("Unable to open comparison for this item.")); } }); @@ -113,7 +110,7 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont storedFile = obj.storedFilePath || obj.storageFile; } if (!workspaceFile || !storedFile) { - vscode.window.showWarningMessage('Unable to discard changes for this item.'); + vscode.window.showWarningMessage(vscode.l10n.t("Unable to discard changes for this item.")); return; } const confirm = await vscode.window.showWarningMessage( From 29ffd198f974a886ec2771f79cd7fd07a2bbcb9b Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Wed, 15 Oct 2025 01:32:11 +0530 Subject: [PATCH 50/58] Refactor MetadataDiffDesktop methods for improved readability and maintainability --- .../metadata-diff/MetadataDiffDesktop.ts | 24 ++++++------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts b/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts index 47536aeeb..b67eaa34d 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts @@ -32,14 +32,14 @@ export class MetadataDiffDesktop { static resetTreeView(): void { if (this._treeDataProvider) { this._treeDataProvider.clearItems(); - // Force reset tree view context to show welcome message - vscode.commands.executeCommand("setContext", "microsoft.powerplatform.pages.metadataDiff.hasData", false); + vscode.commands.executeCommand("setContext", + "microsoft.powerplatform.pages.metadataDiff.hasData", false); } } /** - * Allows external commands (e.g. Re-sync) to replace the active provider - * so subsequent resets operate on the latest instance. + * Allows external commands to replace the active provider + * allowing subsequent resets to use the latest instance. */ static setTreeDataProvider(provider: MetadataDiffTreeDataProvider): void { this._treeDataProvider = provider; @@ -76,13 +76,11 @@ export class MetadataDiffDesktop { const treeDataProvider = MetadataDiffTreeDataProvider.initialize(context); MetadataDiffDesktop._treeDataProvider = treeDataProvider; - // Integrate into Actions Hub instead of separate view ActionsHubTreeDataProvider.setMetadataDiffProvider(treeDataProvider); - // Force refresh of Actions Hub to show new root node vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); - MetadataDiffDesktop._isInitialized = true; oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_INITIALIZED); + } catch (exception) { const exceptionError = exception as Error; oneDSLoggerWrapper.getLogger().traceError(Constants.EventNames.METADATA_DIFF_INITIALIZATION_FAILED, exceptionError.message, exceptionError); @@ -93,24 +91,16 @@ export class MetadataDiffDesktop { const pacWrapper = pacTerminal.getWrapper(); const pagesListOutput = await pacWrapper.pagesList(); if (pagesListOutput && pagesListOutput.Status === "Success" && pagesListOutput.Information) { - // Parse the list of pages from the string output const pagesList: PagesList[] = []; if (Array.isArray(pagesListOutput.Information)) { - // If Information is already an array of strings pagesListOutput.Information.forEach(line => { - // Skip empty lines or header lines if (!line.trim() || !line.includes('[')) { return; } - // Extract the relevant parts using regex - // Example lines: - // ' [1] 4b47eaeb-5f5d-f011-bec1-000d3a5b8fd6 test_portal Standard No ' - // ' [7] e26a79b8-4c5d-f011-bec2-000d3a358057 Test V1_Studio - site-vbdyt Enhanced No ' - const match = line.match(/\s*\[\d+\]\s+([a-f0-9-]+)\s+(.+?)\s+(Standard|Enhanced)(?:\s{2,}.*)?$/i); + const regex = /\s*\[\d+\]\s+([a-f0-9-]+)\s+(.+?)\s+(Standard|Enhanced)(?:\s{2,}.*)?$/i; + const match = line.match(regex); if (match) { - // Extract WebsiteId, FriendlyName, and ModelVersion from the line - // Example line: ' [2] 8aa65ec4-1578-f011-b4cc-0022480b93b5 Customer Self Service_V1 - customerselfservice-oh1uo Standard No ' const modelVersion = match[3].trim().toLowerCase() === "enhanced" ? "2" : "1"; pagesList.push({ WebsiteId: match[1].trim(), From 157a62fdca9bd6d0e7b594d025594cf2c171af1f Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Wed, 15 Oct 2025 11:44:44 +0530 Subject: [PATCH 51/58] Enhance telemetry logging in MetadataDiffDesktop and improve localization for diff titles --- .../power-pages/metadata-diff/MetadataDiffCommands.ts | 9 ++------- .../power-pages/metadata-diff/MetadataDiffDesktop.ts | 10 ++++++---- src/common/power-pages/metadata-diff/Constants.ts | 1 + 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts index ae2b92dfe..11dd1f5b3 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts @@ -46,19 +46,17 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont rightUri = vscode.Uri.file(workspaceFile); title = vscode.l10n.t('{0} (Modified)', path.basename(workspaceFile)); } else if (workspaceFile && !storedFile) { - // Added locally: empty (Environment) -> workspace file (Local) const base = path.basename(workspaceFile); const emptyPath = makeEmptySide(base, 'env'); leftUri = vscode.Uri.file(emptyPath); rightUri = vscode.Uri.file(workspaceFile); - title = `${base} (Only in Local)`; + title = vscode.l10n.t('{0} (Only in Local)', base); } else { - // Only in Environment: stored file content -> empty local const base = path.basename(storedFile!); const emptyPath = makeEmptySide(base, 'local'); leftUri = vscode.Uri.file(storedFile!); rightUri = vscode.Uri.file(emptyPath); - title = `${base} (Only in Environment)`; + title = vscode.l10n.t('{0} (Only in Environment)', base); } await vscode.commands.executeCommand('vscode.diff', leftUri, rightUri, title); @@ -72,16 +70,13 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont } }); - // Explicit alias command shown in context menu (kept separate for clearer telemetry / labeling) vscode.commands.registerCommand('microsoft.powerplatform.pages.metadataDiff.openComparison', async (itemOrWorkspace?: unknown, maybeStored?: unknown) => { - // Support invocation with either (workspace, stored) or a single tree item let workspaceFile: string | undefined; let storedFile: string | undefined; if (typeof itemOrWorkspace === 'string') { workspaceFile = itemOrWorkspace; storedFile = typeof maybeStored === 'string' ? maybeStored : undefined; } else if (itemOrWorkspace && typeof itemOrWorkspace === 'object') { - // Attempt to read wrapper properties // eslint-disable-next-line @typescript-eslint/no-explicit-any const obj: any = itemOrWorkspace; workspaceFile = obj.filePath || obj.workspaceFile; diff --git a/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts b/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts index b67eaa34d..a7564df10 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts @@ -9,6 +9,7 @@ import { ECSFeaturesClient } from "../../../common/ecs-features/ecsFeatureClient import { EnableMetadataDiff } from "../../../common/ecs-features/ecsFeatureGates"; import { oneDSLoggerWrapper } from "../../../common/OneDSLoggerTelemetry/oneDSLoggerWrapper"; import { Constants } from "../../../common/power-pages/metadata-diff/Constants"; +import { getBaseEventInfo } from "../actions-hub/TelemetryHelper"; import { MetadataDiffTreeDataProvider } from "../../../common/power-pages/metadata-diff/MetadataDiffTreeDataProvider"; import { PacTerminal } from "../../lib/PacTerminal"; import { PagesList } from "../../pac/PacTypes"; @@ -53,8 +54,9 @@ export class MetadataDiffDesktop { try { const isMetadataDiffEnabled = MetadataDiffDesktop.isEnabled(); - oneDSLoggerWrapper.getLogger().traceInfo("EnableMetadataDiff", { - isEnabled: isMetadataDiffEnabled.toString() + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_ENABLED, { + isEnabled: isMetadataDiffEnabled.toString(), + ...getBaseEventInfo() }); vscode.commands.executeCommand("setContext", "microsoft.powerplatform.pages.metadataDiffEnabled", isMetadataDiffEnabled); @@ -79,11 +81,11 @@ export class MetadataDiffDesktop { ActionsHubTreeDataProvider.setMetadataDiffProvider(treeDataProvider); vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); MetadataDiffDesktop._isInitialized = true; - oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_INITIALIZED); + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_INITIALIZED, getBaseEventInfo()); } catch (exception) { const exceptionError = exception as Error; - oneDSLoggerWrapper.getLogger().traceError(Constants.EventNames.METADATA_DIFF_INITIALIZATION_FAILED, exceptionError.message, exceptionError); + oneDSLoggerWrapper.getLogger().traceError(Constants.EventNames.METADATA_DIFF_INITIALIZATION_FAILED, exceptionError.message, exceptionError, getBaseEventInfo()); } } diff --git a/src/common/power-pages/metadata-diff/Constants.ts b/src/common/power-pages/metadata-diff/Constants.ts index 4c19af137..f51218f13 100644 --- a/src/common/power-pages/metadata-diff/Constants.ts +++ b/src/common/power-pages/metadata-diff/Constants.ts @@ -5,6 +5,7 @@ export const Constants = { EventNames: { + METADATA_DIFF_ENABLED: "metadataDiffEnabled", METADATA_DIFF_INITIALIZED: "metadataDiffInitialized", METADATA_DIFF_REFRESH_FAILED: "metadataDiffRefreshFailed", METADATA_DIFF_INITIALIZATION_FAILED: "metadataDiffInitializationFailed", From d66244d9b5576f1bd41394d9c98af2cfed04e4de Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Wed, 15 Oct 2025 11:51:04 +0530 Subject: [PATCH 52/58] Add telemetry logging for metadata diff command executions --- .../metadata-diff/MetadataDiffCommands.ts | 41 +++++++++++++++++++ .../power-pages/metadata-diff/Constants.ts | 1 + 2 files changed, 42 insertions(+) diff --git a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts index 11dd1f5b3..b9974e272 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts @@ -15,9 +15,14 @@ import { ActionsHubTreeDataProvider } from "../actions-hub/ActionsHubTreeDataPro import { createAuthProfileExp } from "../../../common/utilities/PacAuthUtil"; import { getWebsiteRecordId } from "../../../common/utilities/WorkspaceInfoFinderUtil"; import { generateDiffReport, getAllDiffFiles, MetadataDiffReport } from "./MetadataDiffUtils"; +import { getBaseEventInfo } from "../actions-hub/TelemetryHelper"; export async function registerMetadataDiffCommands(context: vscode.ExtensionContext, pacTerminal: PacTerminal): Promise { vscode.commands.registerCommand('microsoft.powerplatform.pages.metadataDiff.openDiff', async (workspaceFile?: string, storedFile?: string) => { + oneDSLoggerWrapper.getLogger().traceInfo( + Constants.EventNames.METADATA_DIFF_COMMAND_EXECUTED, + { command: 'openDiff', hasWorkspaceFile: !!workspaceFile, hasStoredFile: !!storedFile, ...getBaseEventInfo() } + ); try { if (!workspaceFile && !storedFile) { vscode.window.showWarningMessage(vscode.l10n.t("No file paths provided for diff.")); @@ -71,6 +76,10 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont }); vscode.commands.registerCommand('microsoft.powerplatform.pages.metadataDiff.openComparison', async (itemOrWorkspace?: unknown, maybeStored?: unknown) => { + oneDSLoggerWrapper.getLogger().traceInfo( + Constants.EventNames.METADATA_DIFF_COMMAND_EXECUTED, + { command: 'openComparison', argType: typeof itemOrWorkspace, ...getBaseEventInfo() } + ); let workspaceFile: string | undefined; let storedFile: string | undefined; if (typeof itemOrWorkspace === 'string') { @@ -92,6 +101,10 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont // Discard local changes => overwrite workspace file with stored (remote) version vscode.commands.registerCommand('microsoft.powerplatform.pages.metadataDiff.discardLocalChanges', async (itemOrWorkspace?: unknown, maybeStored?: unknown) => { + oneDSLoggerWrapper.getLogger().traceInfo( + Constants.EventNames.METADATA_DIFF_COMMAND_EXECUTED, + { command: 'discardLocalChanges', ...getBaseEventInfo() } + ); try { let workspaceFile: string | undefined; let storedFile: string | undefined; @@ -143,6 +156,10 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont }); vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.resync", async () => { + oneDSLoggerWrapper.getLogger().traceInfo( + Constants.EventNames.METADATA_DIFF_COMMAND_EXECUTED, + { command: 'resync', ...getBaseEventInfo() } + ); try { // Only proceed if we already have data (context set by menu 'when' clause) const pacWrapper = pacTerminal.getWrapper(); @@ -224,6 +241,10 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont }); vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.clearView", async () => { + oneDSLoggerWrapper.getLogger().traceInfo( + Constants.EventNames.METADATA_DIFF_COMMAND_EXECUTED, + { command: 'clearView', ...getBaseEventInfo() } + ); try { MetadataDiffDesktop.resetTreeView(); @@ -247,6 +268,10 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont }); vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.triggerFlowWithSite", async (websiteId: string) => { + oneDSLoggerWrapper.getLogger().traceInfo( + Constants.EventNames.METADATA_DIFF_COMMAND_EXECUTED, + { command: 'triggerFlowWithSite', hasWebsiteId: !!websiteId, ...getBaseEventInfo() } + ); try { // Get the PAC wrapper to access org list const pacWrapper = pacTerminal.getWrapper(); @@ -328,6 +353,10 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont }); vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.triggerFlow", async () => { + oneDSLoggerWrapper.getLogger().traceInfo( + Constants.EventNames.METADATA_DIFF_COMMAND_EXECUTED, + { command: 'triggerFlow', ...getBaseEventInfo() } + ); try { // Get the PAC wrapper to access org list const pacWrapper = pacTerminal.getWrapper(); @@ -483,6 +512,10 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont }); vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.generateReport", async () => { + oneDSLoggerWrapper.getLogger().traceInfo( + Constants.EventNames.METADATA_DIFF_COMMAND_EXECUTED, + { command: 'generateReport', ...getBaseEventInfo() } + ); try { const workspaceFolders = vscode.workspace.workspaceFolders; if (!workspaceFolders) { @@ -528,6 +561,10 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont }); vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.exportReport", async () => { + oneDSLoggerWrapper.getLogger().traceInfo( + Constants.EventNames.METADATA_DIFF_COMMAND_EXECUTED, + { command: 'exportReport', ...getBaseEventInfo() } + ); try { const workspaceFolders = vscode.workspace.workspaceFolders; if (!workspaceFolders) { @@ -573,6 +610,10 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont }); vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.importReport", async () => { + oneDSLoggerWrapper.getLogger().traceInfo( + Constants.EventNames.METADATA_DIFF_COMMAND_EXECUTED, + { command: 'importReport', ...getBaseEventInfo() } + ); try { const fileUri = await vscode.window.showOpenDialog({ canSelectFiles: true, diff --git a/src/common/power-pages/metadata-diff/Constants.ts b/src/common/power-pages/metadata-diff/Constants.ts index f51218f13..4862cfece 100644 --- a/src/common/power-pages/metadata-diff/Constants.ts +++ b/src/common/power-pages/metadata-diff/Constants.ts @@ -7,6 +7,7 @@ export const Constants = { EventNames: { METADATA_DIFF_ENABLED: "metadataDiffEnabled", METADATA_DIFF_INITIALIZED: "metadataDiffInitialized", + METADATA_DIFF_COMMAND_EXECUTED: "metadataDiffCommandExecuted", METADATA_DIFF_REFRESH_FAILED: "metadataDiffRefreshFailed", METADATA_DIFF_INITIALIZATION_FAILED: "metadataDiffInitializationFailed", METADATA_DIFF_CURRENT_ENV_FETCH_FAILED: "metadataDiffCurrentEnvFetchFailed", From 15a9f863924d21fde238d07e48e44451b4966113 Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Wed, 15 Oct 2025 15:05:44 +0530 Subject: [PATCH 53/58] Fixed discard changes logic --- package.json | 36 +- .../metadata-diff/MetadataDiffCommands.ts | 565 ++++++++++-------- .../metadata-diff/MetadataDiffDesktop.ts | 36 +- .../unit/metadata-diff/DiscardLogic.test.ts | 79 +++ .../MetadataDiffTreeDataProvider.ts | 83 ++- .../tree-items/MetadataDiffFileItem.ts | 8 +- .../tree-items/MetadataDiffTreeItem.ts | 2 +- 7 files changed, 507 insertions(+), 302 deletions(-) create mode 100644 src/client/test/unit/metadata-diff/DiscardLogic.test.ts diff --git a/package.json b/package.json index bca4abd9e..b415856d7 100644 --- a/package.json +++ b/package.json @@ -538,16 +538,34 @@ "icon": "$(folder-opened)" }, { - "command": "metadataDiff.openComparison", + "command": "microsoft.powerplatform.pages.metadataDiff.openComparison", "title": "Open Comparison", "category": "Power Pages Metadata Diff", "icon": "$(diff)" }, { - "command": "metadataDiff.discardLocalChanges", + "command": "microsoft.powerplatform.pages.metadataDiff.discardLocalChanges", "title": "Discard Local Changes", "category": "Power Pages Metadata Diff", "icon": "$(discard)" + }, + { + "command": "microsoft.powerplatform.pages.metadataDiff.discardLocalChanges.modified", + "title": "Revert to Environment Version", + "category": "Power Pages Metadata Diff", + "icon": "$(discard)" + }, + { + "command": "microsoft.powerplatform.pages.metadataDiff.discardLocalChanges.deleteLocal", + "title": "Delete Local File", + "category": "Power Pages Metadata Diff", + "icon": "$(trash)" + }, + { + "command": "microsoft.powerplatform.pages.metadataDiff.discardLocalChanges.materialize", + "title": "Add Local Copy", + "category": "Power Pages Metadata Diff", + "icon": "$(cloud-download)" } ], "configuration": { @@ -1142,15 +1160,25 @@ "group": "metadataDiff@6" }, { - "command": "metadataDiff.openComparison", + "command": "microsoft.powerplatform.pages.metadataDiff.openComparison", "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffFileModified && microsoft.powerplatform.pages.metadataDiffEnabled", "group": "metadataDiffFile@1" }, { - "command": "metadataDiff.discardLocalChanges", + "command": "microsoft.powerplatform.pages.metadataDiff.discardLocalChanges.modified", "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffFileModified && microsoft.powerplatform.pages.metadataDiffEnabled", "group": "metadataDiffFile@2" }, + { + "command": "microsoft.powerplatform.pages.metadataDiff.discardLocalChanges.deleteLocal", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffFileOnlyLocal && microsoft.powerplatform.pages.metadataDiffEnabled", + "group": "metadataDiffFile@2" + }, + { + "command": "microsoft.powerplatform.pages.metadataDiff.discardLocalChanges.materialize", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffFileOnlyRemote && microsoft.powerplatform.pages.metadataDiffEnabled", + "group": "metadataDiffFile@2" + }, { "command": "pacCLI.authPanel.nameAuthProfile", "when": "!virtualWorkspace && view == pacCLI.authPanel" diff --git a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts index b9974e272..203393ee9 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts @@ -12,11 +12,183 @@ import { oneDSLoggerWrapper } from "../../../common/OneDSLoggerTelemetry/oneDSLo import { MetadataDiffTreeDataProvider } from "../../../common/power-pages/metadata-diff/MetadataDiffTreeDataProvider"; import { MetadataDiffDesktop } from "./MetadataDiffDesktop"; import { ActionsHubTreeDataProvider } from "../actions-hub/ActionsHubTreeDataProvider"; +import { fetchWebsites } from "../actions-hub/ActionsHubCommandHandlers"; +import { WebsiteDataModel } from "../../../common/services/Constants"; +import { IWebsiteDetails } from "../../../common/services/Interfaces"; import { createAuthProfileExp } from "../../../common/utilities/PacAuthUtil"; import { getWebsiteRecordId } from "../../../common/utilities/WorkspaceInfoFinderUtil"; import { generateDiffReport, getAllDiffFiles, MetadataDiffReport } from "./MetadataDiffUtils"; import { getBaseEventInfo } from "../actions-hub/TelemetryHelper"; +// ---------------- Helper Functions (internal) ---------------- +function getWorkspaceAndWebsiteId(): { workspacePath: string, websiteId: string } | undefined { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + vscode.window.showErrorMessage("No folders opened in the current workspace."); + return undefined; + } + const workspacePath = workspaceFolders[0].uri.fsPath; + const websiteId = getWebsiteRecordId(workspacePath); + if (!websiteId) { + vscode.window.showErrorMessage("Unable to determine website id from workspace."); + return undefined; + } + return { workspacePath, websiteId }; +} + +function recreateStorageDirectory(storagePath: string | undefined): string | undefined { + if (!storagePath) { + vscode.window.showErrorMessage("Storage path not found"); + return undefined; + } + try { + if (fs.existsSync(storagePath)) { + fs.rmSync(storagePath, { recursive: true, force: true }); + } + fs.mkdirSync(storagePath, { recursive: true }); + return storagePath; + } catch (error) { + oneDSLoggerWrapper.getLogger().traceError("MetadataDiffStoragePrepFailed", (error as Error).message, error as Error); + vscode.window.showErrorMessage("Failed to prepare storage directory"); + return undefined; + } +} + +function findSite(websiteId: string, active: IWebsiteDetails[], inactive: IWebsiteDetails[]): IWebsiteDetails | undefined { + const lower = websiteId.toLowerCase(); + return [...active, ...inactive].find(s => s.websiteRecordId.toLowerCase() === lower); +} + +async function initProviderAndRefresh(context: vscode.ExtensionContext): Promise { + const provider = MetadataDiffTreeDataProvider.initialize(context); + MetadataDiffDesktop.setTreeDataProvider(provider); + ActionsHubTreeDataProvider.setMetadataDiffProvider(provider); + await provider.getChildren(); + vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); +} + +async function executeDiffWorkflow( + context: vscode.ExtensionContext, + pacTerminal: PacTerminal, + websiteId: string, + progressTitle: string +): Promise { + const storagePath = recreateStorageDirectory(context.storageUri?.fsPath); + if (!storagePath) { return false; } + const pacWrapper = pacTerminal.getWrapper(); + let success = false; + const progressOptions: vscode.ProgressOptions = { location: vscode.ProgressLocation.Notification, title: progressTitle, cancellable: false }; + await vscode.window.withProgress(progressOptions, async (progress) => { + progress.report({ message: "Looking for this website in the connected environment..." }); + const websites = await fetchWebsites(); + const site = findSite(websiteId, websites.activeSites, websites.inactiveSites); + if (!site) { + vscode.window.showErrorMessage("Website not found in the connected environment."); + return; + } + const modelVersion: 1 | 2 = site.dataModel === WebsiteDataModel.Standard ? 1 : 2; + progress.report({ message: vscode.l10n.t('Retrieving "{0}" as {1} data model. Please wait...', site.name, modelVersion === 2 ? 'enhanced' : 'standard') }); + const download = await pacWrapper.downloadSite(storagePath, websiteId, modelVersion); + if (!download) { return; } + progress.report({ message: vscode.l10n.t('Comparing metadata of "{0}"...', site.name) }); + await initProviderAndRefresh(context); + success = true; + }); + return success; +} + +// Exported for tests to exercise discard logic without needing command registration +export async function performDiscard( + context: vscode.ExtensionContext, + itemOrWorkspace?: unknown, + maybeStored?: unknown +): Promise<{ actionType?: 'overwrite' | 'delete' | 'materialize' } | undefined> { + let workspaceFile: string | undefined; + let storedFile: string | undefined; + let relativePath: string | undefined; + if (typeof itemOrWorkspace === 'string') { + workspaceFile = itemOrWorkspace; + storedFile = typeof maybeStored === 'string' ? maybeStored : undefined; + } else if (itemOrWorkspace && typeof itemOrWorkspace === 'object') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const obj: any = itemOrWorkspace; + workspaceFile = obj.filePath || obj.workspaceFile; + storedFile = obj.storedFilePath || obj.storageFile; + relativePath = obj.relativePath; + } + const workspaceFolders = vscode.workspace.workspaceFolders; + const workspaceRoot = workspaceFolders && workspaceFolders.length ? workspaceFolders[0].uri.fsPath : undefined; + let actionType: 'overwrite' | 'delete' | 'materialize' | undefined; + + if (workspaceFile && storedFile) { + const confirm = await vscode.window.showWarningMessage( + vscode.l10n.t('Discard local changes to "{0}"? This will overwrite the local file with the environment copy.', path.basename(workspaceFile)), + { modal: true }, + vscode.l10n.t('Discard') + ); + if (confirm !== vscode.l10n.t('Discard')) { return; } + const remoteContent = fs.readFileSync(storedFile, 'utf8'); + fs.writeFileSync(workspaceFile, remoteContent, 'utf8'); + vscode.window.showInformationMessage(vscode.l10n.t('Local changes discarded for "{0}".', path.basename(workspaceFile))); + actionType = 'overwrite'; + } else if (workspaceFile && !storedFile) { + const confirm = await vscode.window.showWarningMessage( + vscode.l10n.t('Delete local file "{0}"? (It does not exist in the environment)', path.basename(workspaceFile)), + { modal: true }, + vscode.l10n.t('Delete') + ); + if (confirm !== vscode.l10n.t('Delete')) { return; } + if (fs.existsSync(workspaceFile)) { + fs.unlinkSync(workspaceFile); + } + vscode.window.showInformationMessage(vscode.l10n.t('Local file "{0}" deleted.', path.basename(workspaceFile))); + actionType = 'delete'; + } else if (!workspaceFile && storedFile && workspaceRoot) { + let targetRelative = relativePath; + if (!targetRelative) { + const storageRoot = context.storageUri?.fsPath; + if (storageRoot) { + try { + if (storedFile.startsWith(storageRoot)) { + const remainder = storedFile.substring(storageRoot.length).replace(/^\\|\//, ''); + const parts = remainder.split(/\\|\//).filter(p => !!p); + if (parts.length > 1) { + targetRelative = parts.slice(1).join('/'); + } + } + } catch { /* ignore */ } + } + } + if (!targetRelative) { + vscode.window.showWarningMessage(vscode.l10n.t('Unable to materialize environment file locally (path resolution failed).')); + return; + } + const targetPath = path.join(workspaceRoot, targetRelative); + const confirm = await vscode.window.showWarningMessage( + vscode.l10n.t('Create local copy of environment file "{0}"?', targetRelative), + { modal: true }, + vscode.l10n.t('Create') + ); + if (confirm !== vscode.l10n.t('Create')) { return; } + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + const remoteContent = fs.readFileSync(storedFile, 'utf8'); + fs.writeFileSync(targetPath, remoteContent, 'utf8'); + vscode.window.showInformationMessage(vscode.l10n.t('Environment file "{0}" created locally.', targetRelative)); + actionType = 'materialize'; + } else { + vscode.window.showWarningMessage(vscode.l10n.t('Unable to process discard action for this item.')); + return; + } + + const provider = MetadataDiffDesktop['_treeDataProvider'] as MetadataDiffTreeDataProvider | undefined; + if (provider) { + await provider.recomputeDiff(); + } else { + vscode.commands.executeCommand('microsoft.powerplatform.pages.actionsHub.refresh'); + } + return { actionType }; +} + export async function registerMetadataDiffCommands(context: vscode.ExtensionContext, pacTerminal: PacTerminal): Promise { vscode.commands.registerCommand('microsoft.powerplatform.pages.metadataDiff.openDiff', async (workspaceFile?: string, storedFile?: string) => { oneDSLoggerWrapper.getLogger().traceInfo( @@ -99,61 +271,37 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont } }); - // Discard local changes => overwrite workspace file with stored (remote) version - vscode.commands.registerCommand('microsoft.powerplatform.pages.metadataDiff.discardLocalChanges', async (itemOrWorkspace?: unknown, maybeStored?: unknown) => { - oneDSLoggerWrapper.getLogger().traceInfo( - Constants.EventNames.METADATA_DIFF_COMMAND_EXECUTED, - { command: 'discardLocalChanges', ...getBaseEventInfo() } - ); - try { - let workspaceFile: string | undefined; - let storedFile: string | undefined; - if (typeof itemOrWorkspace === 'string') { - workspaceFile = itemOrWorkspace; - storedFile = typeof maybeStored === 'string' ? maybeStored : undefined; - } else if (itemOrWorkspace && typeof itemOrWorkspace === 'object') { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const obj: any = itemOrWorkspace; - workspaceFile = obj.filePath || obj.workspaceFile; - storedFile = obj.storedFilePath || obj.storageFile; - } - if (!workspaceFile || !storedFile) { - vscode.window.showWarningMessage(vscode.l10n.t("Unable to discard changes for this item.")); - return; - } - const confirm = await vscode.window.showWarningMessage( - vscode.l10n.t('Discard local changes to "{0}"? This will overwrite the local file with the server copy.', path.basename(workspaceFile)), - { modal: true }, - vscode.l10n.t('Discard') + // Helper that performs discard logic and returns actionType for telemetry. + function registerDiscardCommand(commandId: string) { + vscode.commands.registerCommand(commandId, async (itemOrWorkspace?: unknown, maybeStored?: unknown) => { + oneDSLoggerWrapper.getLogger().traceInfo( + Constants.EventNames.METADATA_DIFF_COMMAND_EXECUTED, + { command: commandId.split('.').slice(-1)[0], variantCommandId: commandId, phase: 'start', ...getBaseEventInfo() } ); - if (confirm !== vscode.l10n.t('Discard')) { - return; - } - const remoteContent = fs.readFileSync(storedFile, 'utf8'); - fs.writeFileSync(workspaceFile, remoteContent, 'utf8'); - // Show a diff after discard for confirmation (optional) or simply info message - vscode.window.showInformationMessage(vscode.l10n.t('Local changes discarded for "{0}".', path.basename(workspaceFile))); - // Re-run diff provider to update statuses (file should now be identical and removed from diff view) - const provider = MetadataDiffDesktop['_treeDataProvider'] as MetadataDiffTreeDataProvider | undefined; // best-effort access - if (provider) { - // Invalidate cached diff items without wiping remote storage directory - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const providerAny: any = provider; - if (providerAny._diffItems) { - providerAny._diffItems = []; + try { + const result = await performDiscard(context, itemOrWorkspace, maybeStored); + if (result?.actionType) { + oneDSLoggerWrapper.getLogger().traceInfo( + Constants.EventNames.METADATA_DIFF_COMMAND_EXECUTED, + { command: 'discardLocalChanges', variantCommandId: commandId, actionType: result.actionType, phase: 'completed', ...getBaseEventInfo() } + ); } - await provider.getChildren(); - } - vscode.commands.executeCommand('microsoft.powerplatform.pages.actionsHub.refresh'); - } catch (error) { - oneDSLoggerWrapper.getLogger().traceError( - Constants.EventNames.METADATA_DIFF_REPORT_FAILED, - error as string, - error as Error - ); - vscode.window.showErrorMessage('Failed to discard local changes'); - } - }); + } catch (error) { + oneDSLoggerWrapper.getLogger().traceError( + Constants.EventNames.METADATA_DIFF_REPORT_FAILED, + error as string, + error as Error + ); + vscode.window.showErrorMessage('Failed to discard local changes'); + } + }); + } + + // Base (legacy) command id retained for compatibility, plus contextual variants. + registerDiscardCommand('microsoft.powerplatform.pages.metadataDiff.discardLocalChanges'); + registerDiscardCommand('microsoft.powerplatform.pages.metadataDiff.discardLocalChanges.modified'); + registerDiscardCommand('microsoft.powerplatform.pages.metadataDiff.discardLocalChanges.deleteLocal'); + registerDiscardCommand('microsoft.powerplatform.pages.metadataDiff.discardLocalChanges.materialize'); vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.resync", async () => { oneDSLoggerWrapper.getLogger().traceInfo( @@ -168,69 +316,11 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont vscode.window.showErrorMessage("No active environment found. Please authenticate first."); return; } - - // Clear existing diff state so UI returns to initial (welcome) state during re-sync MetadataDiffDesktop.resetTreeView(); - - const workspaceFolders = vscode.workspace.workspaceFolders; - if (!workspaceFolders || workspaceFolders.length === 0) { - vscode.window.showErrorMessage("No folders opened in the current workspace."); - return; - } - const currentWorkspaceFolder = workspaceFolders[0].uri.fsPath; - const websiteId = getWebsiteRecordId(currentWorkspaceFolder); - if (!websiteId) { - vscode.window.showErrorMessage("Unable to determine website id from workspace."); - return; - } - - const storagePath = context.storageUri?.fsPath; - if (!storagePath) { - vscode.window.showErrorMessage("Storage path not found"); - return; - } - - // Clean existing downloaded metadata - if (fs.existsSync(storagePath)) { - fs.rmSync(storagePath, { recursive: true, force: true }); - } - fs.mkdirSync(storagePath, { recursive: true }); - - const progressOptions: vscode.ProgressOptions = { - location: vscode.ProgressLocation.Notification, - title: vscode.l10n.t("Re-syncing website metadata"), - cancellable: false - }; - let pacPagesDownload; - let comparisonBuilt = false; - await vscode.window.withProgress(progressOptions, async (progress) => { - progress.report({ message: "Looking for this website in the connected environment..." }); - const pacPagesList = await MetadataDiffDesktop.getPagesList(pacTerminal); - if (pacPagesList && pacPagesList.length > 0) { - const websiteRecord = pacPagesList.find((record) => record.id === websiteId); - if (!websiteRecord) { - vscode.window.showErrorMessage("Website not found in the connected environment."); - return; - } - progress.report({ message: vscode.l10n.t('Retrieving "{0}" as {1} data model. Please wait...', websiteRecord.name, websiteRecord.modelVersion === 'v2' ? 'enhanced' : 'standard') }); - pacPagesDownload = await pacWrapper.downloadSite( - storagePath, - websiteId, - websiteRecord.modelVersion === "v1" || websiteRecord.modelVersion === "Standard" ? 1 : 2 - ); - if (pacPagesDownload) { - progress.report({ message: vscode.l10n.t('Comparing metadata of "{0}"...', websiteRecord.name) }); - const provider = MetadataDiffTreeDataProvider.initialize(context); - MetadataDiffDesktop.setTreeDataProvider(provider); - ActionsHubTreeDataProvider.setMetadataDiffProvider(provider); - await provider.getChildren(); - vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); - comparisonBuilt = true; - } - } - }); - - if (pacPagesDownload && comparisonBuilt) { + const ws = getWorkspaceAndWebsiteId(); + if (!ws) { return; } + const success = await executeDiffWorkflow(context, pacTerminal, ws.websiteId, vscode.l10n.t("Re-syncing website metadata")); + if (success) { vscode.window.showInformationMessage(vscode.l10n.t("You can now view the comparison")); } else { vscode.window.showErrorMessage("Failed to re-sync metadata."); @@ -267,12 +357,27 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont } }); - vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.triggerFlowWithSite", async (websiteId: string) => { + // Accept either a raw websiteId (string) or a SiteTreeItem / object carrying siteInfo.websiteId + vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.triggerFlowWithSite", async (arg?: unknown) => { oneDSLoggerWrapper.getLogger().traceInfo( Constants.EventNames.METADATA_DIFF_COMMAND_EXECUTED, - { command: 'triggerFlowWithSite', hasWebsiteId: !!websiteId, ...getBaseEventInfo() } + { command: 'triggerFlowWithSite', argType: typeof arg, ...getBaseEventInfo() } ); try { + // Normalize input to websiteId + let websiteId: string | undefined; + if (typeof arg === 'string') { + websiteId = arg; + } else if (arg && typeof arg === 'object') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const anyArg: any = arg; + websiteId = anyArg?.siteInfo?.websiteId || anyArg?.websiteId || anyArg?.id || undefined; + } + + if (!websiteId) { + vscode.window.showErrorMessage("Website id not provided from context."); + return; + } // Get the PAC wrapper to access org list const pacWrapper = pacTerminal.getWrapper(); @@ -289,59 +394,10 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont return; } - const workspaceFolders = vscode.workspace.workspaceFolders; - if (!workspaceFolders || workspaceFolders.length === 0) { - vscode.window.showErrorMessage("No folders opened in the current workspace."); - return; - } - - const storagePath = context.storageUri?.fsPath; - if (!storagePath) { - throw new Error("Storage path is not defined"); - } - - // Clean out any existing files in storagePath (so we have a "fresh" download) - if (fs.existsSync(storagePath)) { - fs.rmSync(storagePath, { recursive: true, force: true }); - } - fs.mkdirSync(storagePath, { recursive: true }); - - const progressOptions: vscode.ProgressOptions = { - location: vscode.ProgressLocation.Notification, - title: vscode.l10n.t("Downloading website metadata"), - cancellable: false - }; - - let pacPagesDownload; - let comparisonBuilt = false; - await vscode.window.withProgress(progressOptions, async (progress) => { - progress.report({ message: "Looking for this website in the connected environment..." }); - const pacPagesList = await MetadataDiffDesktop.getPagesList(pacTerminal); - if (pacPagesList && pacPagesList.length > 0) { - const websiteRecord = pacPagesList.find((record) => record.id === websiteId); - if (!websiteRecord) { - vscode.window.showErrorMessage("Website not found in the connected environment."); - return; - } - progress.report({ message: vscode.l10n.t('Retrieving "{0}" as {1} data model. Please wait...', websiteRecord.name, websiteRecord.modelVersion === 'v2' ? 'enhanced' : 'standard') }); - pacPagesDownload = await pacWrapper.downloadSite( - storagePath, - websiteId, - websiteRecord.modelVersion === "v1" || websiteRecord.modelVersion === "Standard" ? 1 : 2 - ); - if (pacPagesDownload) { - progress.report({ message: vscode.l10n.t('Comparing metadata of "{0}"...', websiteRecord.name) }); - const provider = MetadataDiffTreeDataProvider.initialize(context); - MetadataDiffDesktop.setTreeDataProvider(provider); - ActionsHubTreeDataProvider.setMetadataDiffProvider(provider); - await provider.getChildren(); - vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); - comparisonBuilt = true; - } - } - }); - - if (pacPagesDownload && comparisonBuilt) { + const ws = getWorkspaceAndWebsiteId(); + if (!ws) { return; } + const success = await executeDiffWorkflow(context, pacTerminal, websiteId, vscode.l10n.t("Downloading website metadata")); + if (success) { vscode.window.showInformationMessage(vscode.l10n.t("You can now view the comparison")); } else { vscode.window.showErrorMessage("Failed to download metadata."); @@ -363,38 +419,40 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont let orgUrl: string | undefined; + // Get current active org first so we can mark it in the picker + const currentActiveOrg = await pacWrapper.activeOrg(); + const currentOrgUrl = currentActiveOrg && currentActiveOrg.Status === SUCCESS ? currentActiveOrg.Results.OrgUrl : undefined; + // Get list of available organizations const orgListResult = await pacWrapper.orgList(); if (orgListResult && orgListResult.Status === SUCCESS && orgListResult.Results.length > 0) { - // Create items for QuickPick + // Align item shape with Actions Hub switchEnvironment (detail holds URL, description shows CURRENT) const items = orgListResult.Results.map(org => { return { label: org.FriendlyName, - description: org.EnvironmentUrl, - detail: `${org.OrganizationId} (${org.EnvironmentId})` + description: currentOrgUrl && currentOrgUrl === org.EnvironmentUrl ? vscode.l10n.t("Current") : "", + detail: org.EnvironmentUrl }; }); - // Add option to enter URL manually + // Add option to enter URL manually (detail intentionally blank) items.push({ label: "$(plus) Enter organization URL manually", description: "", - detail: "Enter a custom organization URL" + detail: "" }); - // Show QuickPick to select environment const selected = await vscode.window.showQuickPick(items, { placeHolder: "Select an environment or enter URL manually", ignoreFocusOut: true }); if (selected) { - if (selected.description) { - // Use the selected org URL - orgUrl = selected.description; + if (selected.detail) { + orgUrl = selected.detail; // environment URL from list } else { - // If manual entry option was selected + // Manual entry orgUrl = await vscode.window.showInputBox({ prompt: "Enter the organization URL", placeHolder: "https://your-org.crm.dynamics.com", @@ -424,84 +482,92 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont return; } - const pacActiveOrg = await pacWrapper.activeOrg(); - if(pacActiveOrg){ - if (pacActiveOrg.Status === SUCCESS) { - if(pacActiveOrg.Results.OrgUrl == orgUrl){ - vscode.window.showInformationMessage("Already connected to the specified environment."); - } - else{ - const pacOrgSelect = await pacWrapper.orgSelect(orgUrl); - if(pacOrgSelect && pacOrgSelect.Status === SUCCESS){ - vscode.window.showInformationMessage("Environment switched successfully."); - } - else{ - vscode.window.showErrorMessage("Failed to switch the environment."); - return; - } + // Helper utilities for normalization & verification + const normalizeUrl = (u?: string) => u ? u.replace(/\/+$/,'').toLowerCase() : u; + const targetUrlNormalized = normalizeUrl(orgUrl); + + const verifySwitch = async (attempts = 5, delayMs = 500): Promise => { + for (let i = 0; i < attempts; i++) { + const who = await pacWrapper.activeOrg(); + if (who && who.Status === SUCCESS && normalizeUrl(who.Results.OrgUrl) === targetUrlNormalized) { + return true; } + await new Promise(r => setTimeout(r, delayMs)); } - else{ - await createAuthProfileExp(pacWrapper, orgUrl); - vscode.window.showInformationMessage("Auth profile created successfully."); - } - } - else { - vscode.window.showErrorMessage("Failed to fetch the current environment details."); - return; - } + return false; + }; - const workspaceFolders = vscode.workspace.workspaceFolders; - if (!workspaceFolders || workspaceFolders.length === 0) { - vscode.window.showErrorMessage("No folders opened in the current workspace."); - return; - } + const switchEnvironmentIfNeeded = async (): Promise => { + const active = await pacWrapper.activeOrg(); + if (!active || active.Status !== SUCCESS) { + // No active org context -> create auth profile (will also set context) + await createAuthProfileExp(pacWrapper, orgUrl!); + const verified = await verifySwitch(); + if (!verified) { + oneDSLoggerWrapper.getLogger().traceError("MetadataDiffEnvSwitchFailed", "Post-auth verification failed", new Error("Active org mismatch")); + return false; + } + vscode.window.showInformationMessage("Auth profile created and environment selected."); + return true; + } - const currentWorkspaceFolder = workspaceFolders[0].uri.fsPath; - const websiteId = getWebsiteRecordId(currentWorkspaceFolder); - const storagePath = context.storageUri?.fsPath; - if (!storagePath) { - throw new Error("Storage path is not defined"); - } + if (normalizeUrl(active.Results.OrgUrl) === targetUrlNormalized) { + vscode.window.showInformationMessage("Already connected to the specified environment."); + return true; + } - // Clean out any existing files in storagePath (so we have a "fresh" download) - if (fs.existsSync(storagePath)) { - fs.rmSync(storagePath, { recursive: true, force: true }); - } - fs.mkdirSync(storagePath, { recursive: true }); - const progressOptions: vscode.ProgressOptions = { - location: vscode.ProgressLocation.Notification, - title: vscode.l10n.t("Downloading website metadata"), - cancellable: false - }; - let pacPagesDownload; - let comparisonBuilt = false; - await vscode.window.withProgress(progressOptions, async (progress) => { - progress.report({ message: "Looking for this website in the connected environment..." }); - const pacPagesList = await MetadataDiffDesktop.getPagesList(pacTerminal); - if (pacPagesList && pacPagesList.length > 0) { - const websiteRecord = pacPagesList.find((record) => record.id === websiteId); - if (!websiteRecord) { - vscode.window.showErrorMessage("Website not found in the connected environment."); - return; + // Attempt switch with progress UI + const switchResult = await vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: vscode.l10n.t("Switching environment"), + cancellable: false + }, async (progress) => { + progress.report({ message: vscode.l10n.t("Invoking pac org select...") }); + const selectOutput = await pacWrapper.orgSelect(orgUrl!); + if (!selectOutput || selectOutput.Status !== SUCCESS) { + const errMsg = selectOutput?.Errors && selectOutput.Errors.length ? selectOutput.Errors.join("; ") : "Unknown error"; + oneDSLoggerWrapper.getLogger().traceError("MetadataDiffEnvSwitchFailed", errMsg, new Error(errMsg)); + return false; } - progress.report({ message: vscode.l10n.t('Retrieving "{0}" as {1} data model. Please wait...', websiteRecord.name, websiteRecord.modelVersion === 'v2' ? 'enhanced' : 'standard') }); - pacPagesDownload = await pacWrapper.downloadSite(storagePath, websiteId, websiteRecord.modelVersion == "v1" ? 1 : 2); - if (pacPagesDownload) { - progress.report({ message: vscode.l10n.t('Comparing metadata of "{0}"...', websiteRecord.name) }); - const provider = MetadataDiffTreeDataProvider.initialize(context); - MetadataDiffDesktop.setTreeDataProvider(provider); - ActionsHubTreeDataProvider.setMetadataDiffProvider(provider); - await provider.getChildren(); - vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); - comparisonBuilt = true; + progress.report({ message: vscode.l10n.t("Verifying environment switch...") }); + const verified = await verifySwitch(); + if (!verified) { + // One retry attempt + progress.report({ message: vscode.l10n.t("Retrying environment switch...") }); + const retryOutput = await pacWrapper.orgSelect(orgUrl!); + if (!retryOutput || retryOutput.Status !== SUCCESS) { + const retryErr = retryOutput?.Errors && retryOutput.Errors.length ? retryOutput.Errors.join("; ") : "Unknown retry error"; + oneDSLoggerWrapper.getLogger().traceError("MetadataDiffEnvSwitchRetryFailed", retryErr, new Error(retryErr)); + return false; + } + const verifiedRetry = await verifySwitch(); + if (!verifiedRetry) { + oneDSLoggerWrapper.getLogger().traceError("MetadataDiffEnvSwitchVerificationFailed", "Verification failed after retry", new Error("verification failed")); + return false; + } } + return true; + }); + + if (switchResult) { + vscode.window.showInformationMessage("Environment switched successfully."); + return true; } - }); - if (pacPagesDownload && comparisonBuilt) { - vscode.window.showInformationMessage(vscode.l10n.t("You can now view the comparison")); + vscode.window.showErrorMessage("Failed to switch the environment."); + return false; + }; + + const switched = await switchEnvironmentIfNeeded(); + if (!switched) { + return; // Abort flow if environment not ready } - else{ + + const ws = getWorkspaceAndWebsiteId(); + if (!ws) { return; } + const success = await executeDiffWorkflow(context, pacTerminal, ws.websiteId, vscode.l10n.t("Downloading website metadata")); + if (success) { + vscode.window.showInformationMessage(vscode.l10n.t("You can now view the comparison")); + } else { vscode.window.showErrorMessage("Failed to download metadata."); } @@ -644,4 +710,5 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont vscode.window.showErrorMessage("Failed to import metadata diff report"); } }); + } diff --git a/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts b/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts index a7564df10..2d00433b2 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts @@ -12,7 +12,6 @@ import { Constants } from "../../../common/power-pages/metadata-diff/Constants"; import { getBaseEventInfo } from "../actions-hub/TelemetryHelper"; import { MetadataDiffTreeDataProvider } from "../../../common/power-pages/metadata-diff/MetadataDiffTreeDataProvider"; import { PacTerminal } from "../../lib/PacTerminal"; -import { PagesList } from "../../pac/PacTypes"; import { registerMetadataDiffCommands } from "./MetadataDiffCommands"; import { ActionsHubTreeDataProvider } from "../actions-hub/ActionsHubTreeDataProvider"; @@ -89,38 +88,5 @@ export class MetadataDiffDesktop { } } - static async getPagesList(pacTerminal: PacTerminal): Promise<{ name: string, id: string, modelVersion: string }[]> { - const pacWrapper = pacTerminal.getWrapper(); - const pagesListOutput = await pacWrapper.pagesList(); - if (pagesListOutput && pagesListOutput.Status === "Success" && pagesListOutput.Information) { - const pagesList: PagesList[] = []; - if (Array.isArray(pagesListOutput.Information)) { - pagesListOutput.Information.forEach(line => { - if (!line.trim() || !line.includes('[')) { - return; - } - - const regex = /\s*\[\d+\]\s+([a-f0-9-]+)\s+(.+?)\s+(Standard|Enhanced)(?:\s{2,}.*)?$/i; - const match = line.match(regex); - if (match) { - const modelVersion = match[3].trim().toLowerCase() === "enhanced" ? "2" : "1"; - pagesList.push({ - WebsiteId: match[1].trim(), - FriendlyName: match[2].trim(), - ModelVersion: modelVersion - }); - } - }); - } - return pagesList.map((site) => { - return { - name: site.FriendlyName, - id: site.WebsiteId, - modelVersion: site.ModelVersion - } - }); - } - - return []; - } + // getPagesList removed: model version & site metadata now sourced from Actions Hub website cache (fetchWebsites) } diff --git a/src/client/test/unit/metadata-diff/DiscardLogic.test.ts b/src/client/test/unit/metadata-diff/DiscardLogic.test.ts new file mode 100644 index 000000000..fa13ee7f9 --- /dev/null +++ b/src/client/test/unit/metadata-diff/DiscardLogic.test.ts @@ -0,0 +1,79 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { performDiscard } from '../../../../client/power-pages/metadata-diff/MetadataDiffCommands'; + +// Minimal mock ExtensionContext for performDiscard (only storageUri is required for materialize flow) +interface MinimalContext extends Partial { storageUri: vscode.Uri; } + +describe('metadata-diff performDiscard', () => { + const sandbox = sinon.createSandbox(); + let workspaceRoot: string; + let storageRoot: string; + let context: MinimalContext; + + beforeEach(() => { + workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mdiff-workspace-')); + storageRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mdiff-storage-')); + context = { storageUri: vscode.Uri.file(storageRoot) } as MinimalContext; + // Patch workspaceFolders (read-only in normal runtime) for test purposes + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (vscode.workspace as any).workspaceFolders = [{ uri: vscode.Uri.file(workspaceRoot) }]; + // Stub dialogs to auto-confirm using first action item + sandbox.stub(vscode.window, 'showWarningMessage').callsFake((...args: unknown[]) => { + // action items start after the (possibly) options object + const items = args.filter(a => typeof a === 'string').slice(1); // crude but effective for this controlled usage + const first = items[0]; + return Promise.resolve(first as never); + }); + sandbox.stub(vscode.window, 'showInformationMessage').resolves(undefined as never); + }); + + afterEach(() => { + sandbox.restore(); + // Cleanup temp dirs + try { fs.rmSync(workspaceRoot, { recursive: true, force: true }); } catch { /* ignore */ } + try { fs.rmSync(storageRoot, { recursive: true, force: true }); } catch { /* ignore */ } + }); + + it('overwrites modified local file with environment copy (actionType overwrite)', async () => { + const localFile = path.join(workspaceRoot, 'folder', 'file.txt'); + fs.mkdirSync(path.dirname(localFile), { recursive: true }); + fs.writeFileSync(localFile, 'LOCAL', 'utf8'); + const storedFile = path.join(storageRoot, 'guid-site', 'folder', 'file.txt'); + fs.mkdirSync(path.dirname(storedFile), { recursive: true }); + fs.writeFileSync(storedFile, 'REMOTE', 'utf8'); + + const result = await performDiscard(context as unknown as vscode.ExtensionContext, { workspaceFile: localFile, storedFilePath: storedFile }); + expect(result?.actionType).to.equal('overwrite'); + expect(fs.readFileSync(localFile, 'utf8')).to.equal('REMOTE'); + }); + + it('deletes local-only file (actionType delete)', async () => { + const localFile = path.join(workspaceRoot, 'onlyLocal.txt'); + fs.writeFileSync(localFile, 'ONLY_LOCAL', 'utf8'); + const result = await performDiscard(context as unknown as vscode.ExtensionContext, { workspaceFile: localFile }); + expect(result?.actionType).to.equal('delete'); + expect(fs.existsSync(localFile)).to.be.false; + }); + + it('materializes environment-only file (actionType materialize)', async () => { + const storedFile = path.join(storageRoot, 'site-guid', 'nested', 'envOnly.txt'); + fs.mkdirSync(path.dirname(storedFile), { recursive: true }); + fs.writeFileSync(storedFile, 'ENV_ONLY', 'utf8'); + const relativePath = 'nested/envOnly.txt'; + const result = await performDiscard(context as unknown as vscode.ExtensionContext, { storedFilePath: storedFile, relativePath }); + expect(result?.actionType).to.equal('materialize'); + const localPath = path.join(workspaceRoot, relativePath); + expect(fs.existsSync(localPath)).to.be.true; + expect(fs.readFileSync(localPath, 'utf8')).to.equal('ENV_ONLY'); + }); +}); diff --git a/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts b/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts index 60af2ab57..94506ed07 100644 --- a/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts +++ b/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts @@ -12,6 +12,13 @@ import { oneDSLoggerWrapper } from "../../OneDSLoggerTelemetry/oneDSLoggerWrappe import { MetadataDiffFileItem } from "./tree-items/MetadataDiffFileItem"; import { MetadataDiffFolderItem } from "./tree-items/MetadataDiffFolderItem"; +// Internal utility directories created under storage that should not be +// mistaken for the downloaded website folder when recomputing diffs. +const IGNORE_STORAGE_DIRS = new Set([ + 'tempDiff', // created for one‑sided / placeholder diff views + 'imported_diff' // created when importing a saved report +]); + interface DiffFile { relativePath: string; changes: string; @@ -43,6 +50,40 @@ export class MetadataDiffTreeDataProvider implements vscode.TreeDataProvider { + try { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { return; } + const workspacePath = workspaceFolders[0].uri.fsPath; + const storagePath = this._context.storageUri?.fsPath; + if (!storagePath) { return; } + + const diffFiles = await this.getDiffFiles(workspacePath, storagePath); + const items = this.buildTreeHierarchy(diffFiles); + this._diffItems = items; + vscode.commands.executeCommand("setContext", "microsoft.powerplatform.pages.metadataDiff.hasData", this._diffItems.length > 0); + this.refresh(); + vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); + if (!this._dataLoadedNotified && this._diffItems.length > 0) { + this._dataLoadedNotified = true; + this._onDataLoaded.fire(); + } + } catch (error) { + oneDSLoggerWrapper.getLogger().traceError( + Constants.EventNames.METADATA_DIFF_CURRENT_ENV_FETCH_FAILED, + error as string, + error as Error, + { methodName: this.recomputeDiff }, + {} + ); + } + } + clearItems(): void { this._diffItems = []; this._dataLoadedNotified = false; // allow message again after reset @@ -136,16 +177,34 @@ export class MetadataDiffTreeDataProvider implements vscode.TreeDataProvider { try { - const folders = fs.readdirSync(storagePath).filter(f => { - if (f.startsWith('.')) { - return false; - } - return fs.statSync(path.join(storagePath, f)).isDirectory(); - }); + const folders = fs.readdirSync(storagePath) + .filter(f => { + if (f.startsWith('.')) { return false; } + if (IGNORE_STORAGE_DIRS.has(f)) { return false; } + const full = path.join(storagePath, f); + return fs.statSync(full).isDirectory(); + }); + + if (folders.length === 0) { + return undefined; + } - if (folders.length > 0) { - return path.join(storagePath, folders[0]); + // If multiple candidate folders exist (e.g. multiple downloads), + // pick the most recently modified directory instead of relying on + // filesystem enumeration order, which is not guaranteed. + let chosen = folders[0]; + if (folders.length > 1) { + let latestMTime = -1; + for (const f of folders) { + const stat = fs.statSync(path.join(storagePath, f)); + const mtime = stat.mtimeMs; + if (mtime > latestMTime) { + latestMTime = mtime; + chosen = f; + } + } } + return path.join(storagePath, chosen); } catch (error) { console.error('Error finding website path:', error); } @@ -232,7 +291,8 @@ export class MetadataDiffTreeDataProvider implements vscode.TreeDataProvider Date: Wed, 5 Nov 2025 17:40:12 +0530 Subject: [PATCH 54/58] Remove DiscardLogic test file --- .../unit/metadata-diff/DiscardLogic.test.ts | 79 ------------------- 1 file changed, 79 deletions(-) delete mode 100644 src/client/test/unit/metadata-diff/DiscardLogic.test.ts diff --git a/src/client/test/unit/metadata-diff/DiscardLogic.test.ts b/src/client/test/unit/metadata-diff/DiscardLogic.test.ts deleted file mode 100644 index fa13ee7f9..000000000 --- a/src/client/test/unit/metadata-diff/DiscardLogic.test.ts +++ /dev/null @@ -1,79 +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 { expect } from 'chai'; -import * as sinon from 'sinon'; -import * as vscode from 'vscode'; -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { performDiscard } from '../../../../client/power-pages/metadata-diff/MetadataDiffCommands'; - -// Minimal mock ExtensionContext for performDiscard (only storageUri is required for materialize flow) -interface MinimalContext extends Partial { storageUri: vscode.Uri; } - -describe('metadata-diff performDiscard', () => { - const sandbox = sinon.createSandbox(); - let workspaceRoot: string; - let storageRoot: string; - let context: MinimalContext; - - beforeEach(() => { - workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mdiff-workspace-')); - storageRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mdiff-storage-')); - context = { storageUri: vscode.Uri.file(storageRoot) } as MinimalContext; - // Patch workspaceFolders (read-only in normal runtime) for test purposes - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (vscode.workspace as any).workspaceFolders = [{ uri: vscode.Uri.file(workspaceRoot) }]; - // Stub dialogs to auto-confirm using first action item - sandbox.stub(vscode.window, 'showWarningMessage').callsFake((...args: unknown[]) => { - // action items start after the (possibly) options object - const items = args.filter(a => typeof a === 'string').slice(1); // crude but effective for this controlled usage - const first = items[0]; - return Promise.resolve(first as never); - }); - sandbox.stub(vscode.window, 'showInformationMessage').resolves(undefined as never); - }); - - afterEach(() => { - sandbox.restore(); - // Cleanup temp dirs - try { fs.rmSync(workspaceRoot, { recursive: true, force: true }); } catch { /* ignore */ } - try { fs.rmSync(storageRoot, { recursive: true, force: true }); } catch { /* ignore */ } - }); - - it('overwrites modified local file with environment copy (actionType overwrite)', async () => { - const localFile = path.join(workspaceRoot, 'folder', 'file.txt'); - fs.mkdirSync(path.dirname(localFile), { recursive: true }); - fs.writeFileSync(localFile, 'LOCAL', 'utf8'); - const storedFile = path.join(storageRoot, 'guid-site', 'folder', 'file.txt'); - fs.mkdirSync(path.dirname(storedFile), { recursive: true }); - fs.writeFileSync(storedFile, 'REMOTE', 'utf8'); - - const result = await performDiscard(context as unknown as vscode.ExtensionContext, { workspaceFile: localFile, storedFilePath: storedFile }); - expect(result?.actionType).to.equal('overwrite'); - expect(fs.readFileSync(localFile, 'utf8')).to.equal('REMOTE'); - }); - - it('deletes local-only file (actionType delete)', async () => { - const localFile = path.join(workspaceRoot, 'onlyLocal.txt'); - fs.writeFileSync(localFile, 'ONLY_LOCAL', 'utf8'); - const result = await performDiscard(context as unknown as vscode.ExtensionContext, { workspaceFile: localFile }); - expect(result?.actionType).to.equal('delete'); - expect(fs.existsSync(localFile)).to.be.false; - }); - - it('materializes environment-only file (actionType materialize)', async () => { - const storedFile = path.join(storageRoot, 'site-guid', 'nested', 'envOnly.txt'); - fs.mkdirSync(path.dirname(storedFile), { recursive: true }); - fs.writeFileSync(storedFile, 'ENV_ONLY', 'utf8'); - const relativePath = 'nested/envOnly.txt'; - const result = await performDiscard(context as unknown as vscode.ExtensionContext, { storedFilePath: storedFile, relativePath }); - expect(result?.actionType).to.equal('materialize'); - const localPath = path.join(workspaceRoot, relativePath); - expect(fs.existsSync(localPath)).to.be.true; - expect(fs.readFileSync(localPath, 'utf8')).to.equal('ENV_ONLY'); - }); -}); From 218ae63b1d21357a26f9534de0abe924947c6b89 Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Thu, 13 Nov 2025 13:25:11 +0530 Subject: [PATCH 55/58] Fix in import report command --- .../metadata-diff/MetadataDiffCommands.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts index 203393ee9..843f27e76 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts @@ -694,9 +694,16 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont const reportContent = fs.readFileSync(fileUri[0].fsPath, 'utf8'); const report = JSON.parse(reportContent) as MetadataDiffReport; - // Clean up any existing tree data provider - const treeDataProvider = new MetadataDiffTreeDataProvider(context); - await treeDataProvider.setDiffFiles(report.files); // triggers refresh + // Use existing provider if present so Actions Hub keeps reference; otherwise create & register new provider. + // Accessing static private for backward compatibility without altering class surface area. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const existingProvider = (MetadataDiffDesktop as any)._treeDataProvider as MetadataDiffTreeDataProvider | undefined; + const treeDataProvider = existingProvider || new MetadataDiffTreeDataProvider(context); + if (!existingProvider) { + MetadataDiffDesktop.setTreeDataProvider(treeDataProvider); + ActionsHubTreeDataProvider.setMetadataDiffProvider(treeDataProvider); + } + await treeDataProvider.setDiffFiles(report.files); // triggers refresh + context updates vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); vscode.window.showInformationMessage("Report imported successfully"); From 7838c3f5288c1e7347ae675c9dfeeb9ba6c6561e Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Mon, 17 Nov 2025 17:57:24 +0530 Subject: [PATCH 56/58] Utilizing pac context for fetching current org --- .../power-pages/metadata-diff/MetadataDiffCommands.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts index 843f27e76..38f933532 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts @@ -19,6 +19,7 @@ import { createAuthProfileExp } from "../../../common/utilities/PacAuthUtil"; import { getWebsiteRecordId } from "../../../common/utilities/WorkspaceInfoFinderUtil"; import { generateDiffReport, getAllDiffFiles, MetadataDiffReport } from "./MetadataDiffUtils"; import { getBaseEventInfo } from "../actions-hub/TelemetryHelper"; +import PacContext from "../../pac/PacContext"; // ---------------- Helper Functions (internal) ---------------- function getWorkspaceAndWebsiteId(): { workspacePath: string, websiteId: string } | undefined { @@ -378,17 +379,15 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont vscode.window.showErrorMessage("Website id not provided from context."); return; } - // Get the PAC wrapper to access org list - const pacWrapper = pacTerminal.getWrapper(); // Get current org info instead of prompting user - const pacActiveOrg = await pacWrapper.activeOrg(); - if (!pacActiveOrg || pacActiveOrg.Status !== SUCCESS) { + const pacActiveOrg = PacContext.OrgInfo; + if (!pacActiveOrg) { vscode.window.showErrorMessage("No active environment found. Please authenticate first."); return; } - const orgUrl = pacActiveOrg.Results.OrgUrl; + const orgUrl = pacActiveOrg?.OrgUrl; if (!orgUrl) { vscode.window.showErrorMessage("Current environment URL not found."); return; From 11390e9970257498def8a9a2d47a2f3b3a819cfc Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Mon, 17 Nov 2025 18:22:12 +0530 Subject: [PATCH 57/58] Ported other refrences to pac context too. --- .../metadata-diff/MetadataDiffCommands.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts index 38f933532..a8351dc13 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts @@ -311,9 +311,8 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont ); try { // Only proceed if we already have data (context set by menu 'when' clause) - const pacWrapper = pacTerminal.getWrapper(); - const pacActiveOrg = await pacWrapper.activeOrg(); - if (!pacActiveOrg || pacActiveOrg.Status !== SUCCESS) { + const pacActiveOrg = PacContext.OrgInfo; + if (!pacActiveOrg) { vscode.window.showErrorMessage("No active environment found. Please authenticate first."); return; } @@ -419,8 +418,8 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont let orgUrl: string | undefined; // Get current active org first so we can mark it in the picker - const currentActiveOrg = await pacWrapper.activeOrg(); - const currentOrgUrl = currentActiveOrg && currentActiveOrg.Status === SUCCESS ? currentActiveOrg.Results.OrgUrl : undefined; + const currentActiveOrg = PacContext.OrgInfo; + const currentOrgUrl = currentActiveOrg ? currentActiveOrg.OrgUrl : undefined; // Get list of available organizations const orgListResult = await pacWrapper.orgList(); @@ -487,8 +486,8 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont const verifySwitch = async (attempts = 5, delayMs = 500): Promise => { for (let i = 0; i < attempts; i++) { - const who = await pacWrapper.activeOrg(); - if (who && who.Status === SUCCESS && normalizeUrl(who.Results.OrgUrl) === targetUrlNormalized) { + const currentActiveOrg = PacContext.OrgInfo; + if (currentActiveOrg && normalizeUrl(currentActiveOrg.OrgUrl) === targetUrlNormalized) { return true; } await new Promise(r => setTimeout(r, delayMs)); @@ -497,8 +496,8 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont }; const switchEnvironmentIfNeeded = async (): Promise => { - const active = await pacWrapper.activeOrg(); - if (!active || active.Status !== SUCCESS) { + const currentActiveOrg = PacContext.OrgInfo; + if (!currentActiveOrg) { // No active org context -> create auth profile (will also set context) await createAuthProfileExp(pacWrapper, orgUrl!); const verified = await verifySwitch(); From 8bea88cdc1f946e96dee008a3bc3d19e12999db5 Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Mon, 17 Nov 2025 18:23:30 +0530 Subject: [PATCH 58/58] Fix: Update organization URL reference in metadata diff command registration --- src/client/power-pages/metadata-diff/MetadataDiffCommands.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts index a8351dc13..aadddb18b 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts @@ -509,7 +509,7 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont return true; } - if (normalizeUrl(active.Results.OrgUrl) === targetUrlNormalized) { + if (normalizeUrl(currentActiveOrg.OrgUrl) === targetUrlNormalized) { vscode.window.showInformationMessage("Already connected to the specified environment."); return true; }