From de6ac4639c71b707de39387a55ff1601a03acb25 Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Sat, 1 Mar 2025 04:01:22 +0530 Subject: [PATCH 01/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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 f7847432c9ef3608407fbe1b6d0b38c68f45d022 Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Fri, 5 Sep 2025 04:58:25 +0530 Subject: [PATCH 32/37] Telemetry and tests --- .../metadata-diff/MetadataDiffCommands.ts | 70 +++++++++-- .../metadata-diff/MetadataDiffUtils.ts | 4 +- .../power-pages/metadata-diff/Constants.ts | 7 ++ .../MetadataDiffTreeDataProvider.ts | 116 ++++++++++++------ .../MetadataDiffTreeDataProvider.test.ts | 90 ++++++++++++++ 5 files changed, 236 insertions(+), 51 deletions(-) create mode 100644 src/common/test/unit/metadata-diff/MetadataDiffTreeDataProvider.test.ts diff --git a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts index 3bdac29d4..388064bb2 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts @@ -19,17 +19,63 @@ 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) { + return; + } + + // Determine scenario: + // Added locally: workspaceFile only + // Removed locally: storedFile only + // Modified: both files + const addedLocally = !!workspaceFile && !storedFile; + const removedLocally = !!storedFile && !workspaceFile; + + const leftIsEnv = !workspaceFile || removedLocally; // left = environment side + // Build URIs (use virtual empty doc for missing side) + const emptyDocContent = ''; // can be extended if needed + const makeEmptyUri = (label: string) => vscode.Uri.parse(`untitled:__metadata_diff__/${label}`); + + let leftUri: vscode.Uri; + let rightUri: vscode.Uri; + let titleBase: string; + + if (addedLocally) { + // Show empty (environment) on left, local on right + leftUri = makeEmptyUri('environment'); + rightUri = vscode.Uri.file(workspaceFile!); + titleBase = path.basename(workspaceFile!); + } else if (removedLocally) { + // Show environment file on left, empty on right + leftUri = vscode.Uri.file(storedFile!); + rightUri = makeEmptyUri('local'); + titleBase = path.basename(storedFile!); + } else { + leftUri = vscode.Uri.file(storedFile!); + rightUri = vscode.Uri.file(workspaceFile!); + titleBase = path.basename(workspaceFile!); + } + + // If an empty side, ensure a document is opened (untitled) so diff API works consistently + if (addedLocally) { + await vscode.workspace.openTextDocument(leftUri).then(doc => { + if (doc.getText().length === 0) { + // no edit needed, blank + } + }); + } else if (removedLocally) { + await vscode.workspace.openTextDocument(rightUri).then(doc => { + if (doc.getText().length === 0) { + // blank + } + }); + } + + const title = `${titleBase} (Local ↔ Environment)`; + await vscode.commands.executeCommand('vscode.diff', leftUri, rightUri, title, { + preview: true + }); } catch (error) { oneDSLoggerWrapper.getLogger().traceError( Constants.EventNames.METADATA_DIFF_REPORT_FAILED, @@ -55,10 +101,10 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont workspaceFile = obj.filePath || obj.workspaceFile; storedFile = obj.storedFilePath || obj.storageFile; } - if (workspaceFile && storedFile) { + if (workspaceFile || storedFile) { await vscode.commands.executeCommand('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.')); } }); diff --git a/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts b/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts index fcb07bf47..ffde57c8c 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts @@ -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/Constants.ts b/src/common/power-pages/metadata-diff/Constants.ts index 4c19af137..f17217d65 100644 --- a/src/common/power-pages/metadata-diff/Constants.ts +++ b/src/common/power-pages/metadata-diff/Constants.ts @@ -9,6 +9,13 @@ export const Constants = { METADATA_DIFF_REFRESH_FAILED: "metadataDiffRefreshFailed", METADATA_DIFF_INITIALIZATION_FAILED: "metadataDiffInitializationFailed", METADATA_DIFF_CURRENT_ENV_FETCH_FAILED: "metadataDiffCurrentEnvFetchFailed", + METADATA_DIFF_CLEAR_CALLED: "metadataDiffClearCalled", + METADATA_DIFF_CLEAR_FAILED: "metadataDiffClearFailed", + METADATA_DIFF_GET_CHILDREN_CALLED: "metadataDiffGetChildrenCalled", + METADATA_DIFF_GET_CHILDREN_COMPLETED: "metadataDiffGetChildrenCompleted", + METADATA_DIFF_SET_FILES_CALLED: "metadataDiffSetFilesCalled", + METADATA_DIFF_SET_FILES_COMPLETED: "metadataDiffSetFilesCompleted", + METADATA_DIFF_SET_FILES_FAILED: "metadataDiffSetFilesFailed", 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.", diff --git a/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts b/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts index 7e1a3e148..12f9a185f 100644 --- a/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts +++ b/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts @@ -25,6 +25,9 @@ export class MetadataDiffTreeDataProvider implements vscode.TreeDataProvider = new vscode.EventEmitter(); readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event; + // Stored diff tree (root level items) – Actions Hub wrapper accesses this through a private field + // Keep name for backward compatibility with wrapper reflection access. + // eslint-disable-next-line @typescript-eslint/naming-convention private _diffItems: MetadataDiffTreeItem[] = []; // Emits when the diff data has been fully populated for the first time private _onDataLoaded: vscode.EventEmitter = new vscode.EventEmitter(); @@ -43,24 +46,49 @@ export class MetadataDiffTreeDataProvider implements vscode.TreeDataProvider { + return this._diffItems; + } + + /** + * Clears existing diff items and resets provider state & contexts. + * Designed to be resilient – errors are logged via telemetry but not thrown. + */ clearItems(): void { - this._diffItems = []; - this._dataLoadedNotified = false; // allow message again after reset - // Reset any stored data - const storagePath = this._context.storageUri?.fsPath; - if (storagePath && fs.existsSync(storagePath)) { - try { - fs.rmSync(storagePath, { recursive: true, force: true }); - fs.mkdirSync(storagePath, { recursive: true }); - } catch (error) { - console.error('Error cleaning storage path:', error); + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_CLEAR_CALLED, { methodName: 'clearItems' }); + try { + this._diffItems = []; + this._dataLoadedNotified = false; // allow message again after reset + // Reset any stored diff import artifacts + const storagePath = this._context.storageUri?.fsPath; + if (storagePath && fs.existsSync(storagePath)) { + try { + fs.rmSync(storagePath, { recursive: true, force: true }); + fs.mkdirSync(storagePath, { recursive: true }); + } catch (innerError) { + oneDSLoggerWrapper.getLogger().traceError( + Constants.EventNames.METADATA_DIFF_CLEAR_FAILED, + innerError as string, + innerError as Error, + { methodName: 'clearItems.storageCleanup' }, + {} + ); + } } + vscode.commands.executeCommand("setContext", "microsoft.powerplatform.pages.metadataDiff.hasData", false); + this._onDidChangeTreeData.fire(); + // Refresh Actions Hub so the integrated root node updates + vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); + } catch (error) { + oneDSLoggerWrapper.getLogger().traceError( + Constants.EventNames.METADATA_DIFF_CLEAR_FAILED, + error as string, + error as Error, + { methodName: 'clearItems' }, + {} + ); } - // Set context to show welcome message again - vscode.commands.executeCommand("setContext", "microsoft.powerplatform.pages.metadataDiff.hasData", false); - this._onDidChangeTreeData.fire(); - // Also refresh Actions Hub so the integrated root node updates - vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); } getTreeItem(element: MetadataDiffTreeItem): vscode.TreeItem | Thenable { @@ -154,49 +182,48 @@ export class MetadataDiffTreeDataProvider implements vscode.TreeDataProvider 0) { return this._diffItems; } + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_GET_CHILDREN_CALLED, { methodName: 'getChildren' }); try { const workspaceFolders = vscode.workspace.workspaceFolders; if (!workspaceFolders) { + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_GET_CHILDREN_COMPLETED, { methodName: 'getChildren', result: 'noWorkspace' }); return []; } const workspacePath = workspaceFolders[0].uri.fsPath; const storagePath = this._context.storageUri?.fsPath; if (!storagePath) { - throw new Error("Storage path is not defined"); + throw new Error("Metadata Diff storage path is not defined"); } const diffFiles = await this.getDiffFiles(workspacePath, storagePath); if (diffFiles.size === 0) { - // Explicitly clear cache & contexts when no differences this._diffItems = []; vscode.commands.executeCommand("setContext", "microsoft.powerplatform.pages.metadataDiff.hasData", false); + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_GET_CHILDREN_COMPLETED, { methodName: 'getChildren', result: 'noDifferences' }); return []; } const items = this.buildTreeHierarchy(diffFiles); - // Cache for Actions Hub wrapper which reads the private field directly this._diffItems = items; - // Update contexts so welcome content & root node state update vscode.commands.executeCommand("setContext", "microsoft.powerplatform.pages.metadataDiff.hasData", this._diffItems.length > 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(); } + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_GET_CHILDREN_COMPLETED, { methodName: 'getChildren', result: 'success', itemCount: this._diffItems.length }); return items; } catch (error) { oneDSLoggerWrapper.getLogger().traceError( Constants.EventNames.METADATA_DIFF_CURRENT_ENV_FETCH_FAILED, error as string, error as Error, - { methodName: this.getChildren }, + { methodName: 'getChildren' }, {} ); return null; @@ -240,12 +267,16 @@ export class MetadataDiffTreeDataProvider implements vscode.TreeDataProvider { + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_SET_FILES_CALLED, { methodName: 'setDiffFiles', fileCount: files?.length || 0 }); const rootNode = new MetadataDiffFolderItem(''); const sortedFiles = files.sort((a, b) => a.relativePath.localeCompare(b.relativePath)); @@ -255,7 +286,8 @@ 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(); - } + this._diffItems = Array.from(rootNode.getChildrenMap().values()); + this._onDidChangeTreeData.fire(); + vscode.commands.executeCommand("setContext", "microsoft.powerplatform.pages.metadataDiff.hasData", this._diffItems.length > 0); + vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); + if (!this._dataLoadedNotified && this._diffItems.length > 0) { + this._dataLoadedNotified = true; + this._onDataLoaded.fire(); + } + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_SET_FILES_COMPLETED, { methodName: 'setDiffFiles', itemCount: this._diffItems.length }); + } catch (error) { + oneDSLoggerWrapper.getLogger().traceError( + Constants.EventNames.METADATA_DIFF_SET_FILES_FAILED, + error as string, + error as Error, + { methodName: 'setDiffFiles' }, + {} + ); + throw error; + } } } diff --git a/src/common/test/unit/metadata-diff/MetadataDiffTreeDataProvider.test.ts b/src/common/test/unit/metadata-diff/MetadataDiffTreeDataProvider.test.ts new file mode 100644 index 000000000..3379eb76b --- /dev/null +++ b/src/common/test/unit/metadata-diff/MetadataDiffTreeDataProvider.test.ts @@ -0,0 +1,90 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +// Tests for MetadataDiffTreeDataProvider +import * as sinon from "sinon"; +import { expect } from "chai"; +import * as vscode from "vscode"; +import * as fs from "fs"; +import * as path from "path"; +import { MetadataDiffTreeDataProvider } from "../../../../common/power-pages/metadata-diff/MetadataDiffTreeDataProvider"; +import { oneDSLoggerWrapper } from "../../../../common/OneDSLoggerTelemetry/oneDSLoggerWrapper"; + +// Helper to create a mock extension context with in-memory storage path +function createMockContext(tmpDir: string): vscode.ExtensionContext { + const ctx = { + extensionUri: vscode.Uri.file(tmpDir), + storageUri: vscode.Uri.file(path.join(tmpDir, 'storage')), + globalStorageUri: vscode.Uri.file(path.join(tmpDir, 'gstorage')), + logUri: vscode.Uri.file(path.join(tmpDir, 'log')), + extensionPath: tmpDir, + globalState: { } as unknown, + workspaceState: { } as unknown, + subscriptions: [], + asAbsolutePath: (p: string) => path.join(tmpDir, p), + environmentVariableCollection: { } as unknown, + extensionMode: vscode.ExtensionMode.Test, + storagePath: path.join(tmpDir, 'storage'), + globalStoragePath: path.join(tmpDir, 'gstorage'), + logPath: path.join(tmpDir, 'log'), + secrets: { } as unknown, + extension: { } as unknown, + languageModelAccessInformation: { } as unknown + } as unknown; + return ctx as vscode.ExtensionContext; +} + +describe("MetadataDiffTreeDataProvider", () => { + let sandbox: sinon.SinonSandbox; + let tmpDir: string; + let context: vscode.ExtensionContext; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + tmpDir = fs.mkdtempSync(path.join(process.cwd(), 'mdiff-test-')); + context = createMockContext(tmpDir); + fs.mkdirSync(context.storageUri!.fsPath, { recursive: true }); + sandbox.stub(oneDSLoggerWrapper, 'getLogger').returns({ + traceInfo: () => {}, + traceError: () => {} + } as any); + }); + + afterEach(() => { + sandbox.restore(); + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } + }); + + it("returns empty when no workspace", async () => { + sandbox.stub(vscode.workspace, 'workspaceFolders').value(undefined); + const provider = new MetadataDiffTreeDataProvider(context); + const children = await provider.getChildren(); + expect(children).to.deep.equal([]); + }); + + it("setDiffFiles populates items and contexts", async () => { + sandbox.stub(vscode.workspace, 'workspaceFolders').value([ { uri: vscode.Uri.file(tmpDir), name: 'ws', index: 0 } as any ]); + const execSpy = sandbox.stub(vscode.commands, 'executeCommand').resolves(); + const provider = new MetadataDiffTreeDataProvider(context); + await provider.setDiffFiles([ + { relativePath: 'a/b/file.txt', changes: 'Modified', type: 'modified', workspaceContent: 'one', storageContent: 'two' } + ] as any); + expect(provider.items.length).to.equal(1); + expect(execSpy.calledWith('setContext', 'microsoft.powerplatform.pages.metadataDiff.hasData', true)).to.be.true; + }); + + it("clearItems resets state and context", async () => { + const execSpy = sandbox.stub(vscode.commands, 'executeCommand').resolves(); + const provider = new MetadataDiffTreeDataProvider(context); + await provider.setDiffFiles([ + { relativePath: 'file.txt', changes: 'Modified', type: 'modified', workspaceContent: 'one', storageContent: 'two' } + ] as any); + expect(provider.items.length).to.equal(1); + provider.clearItems(); + expect(provider.items.length).to.equal(0); + expect(execSpy.calledWith('setContext', 'microsoft.powerplatform.pages.metadataDiff.hasData', false)).to.be.true; + }); +}); From e3f2c84d88ac60d831a34def4ea99eccaa0137a7 Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Fri, 5 Sep 2025 05:06:04 +0530 Subject: [PATCH 33/37] Telemetry enhancements --- .../actions-hub/ActionsHubTreeDataProvider.ts | 12 ++- .../metadata-diff/MetadataDiffCommands.ts | 79 +++++++++++++------ .../metadata-diff/MetadataDiffTelemetry.ts | 52 ++++++++++++ .../power-pages/metadata-diff/Constants.ts | 40 +++++++++- .../MetadataDiffTreeDataProvider.ts | 43 ++++++++-- 5 files changed, 193 insertions(+), 33 deletions(-) create mode 100644 src/client/power-pages/metadata-diff/MetadataDiffTelemetry.ts diff --git a/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts b/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts index 77c67600c..48be4107c 100644 --- a/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts +++ b/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts @@ -23,6 +23,7 @@ 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"; +import { Constants as MDiffConstants } from "../../../common/power-pages/metadata-diff/Constants"; // Wrapper classes to surface Metadata Diff under Actions Hub class MetadataDiffWrapperTreeItem extends ActionsHubTreeItem { @@ -316,7 +317,16 @@ export class ActionsHubTreeDataProvider implements vscode.TreeDataProvider {1}))", websiteName, envName); } - rootItems.push(new MetadataDiffGroupTreeItem(mdProvider, hasData, label, websiteName, envName)); + const groupItem = new MetadataDiffGroupTreeItem(mdProvider, hasData, label, websiteName, envName); + rootItems.push(groupItem); + try { + oneDSLoggerWrapper.getLogger().traceInfo(MDiffConstants.EventNames.METADATA_DIFF_ROOT_RENDERED, { + hasData, + itemCount: hasData ? (mdProvider as any)._diffItems.length : 0, + websiteName, + environmentName: envName + }); + } catch { /* non-blocking */ } } return rootItems; diff --git a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts index 388064bb2..8da2402fb 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts @@ -16,11 +16,13 @@ import { createAuthProfileExp } from "../../../common/utilities/PacAuthUtil"; import { getWebsiteRecordId } from "../../../common/utilities/WorkspaceInfoFinderUtil"; // Duplicate imports removed import { generateDiffReport, getAllDiffFiles, MetadataDiffReport } from "./MetadataDiffUtils"; +import { getMetadataDiffBaseEventInfo } from "./MetadataDiffTelemetry"; 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 { + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_OPEN_DIFF_CALLED, { ...getMetadataDiffBaseEventInfo(), hasWorkspace: !!workspaceFile, hasStored: !!storedFile }); if (!workspaceFile && !storedFile) { return; } @@ -32,9 +34,7 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont const addedLocally = !!workspaceFile && !storedFile; const removedLocally = !!storedFile && !workspaceFile; - const leftIsEnv = !workspaceFile || removedLocally; // left = environment side // Build URIs (use virtual empty doc for missing side) - const emptyDocContent = ''; // can be extended if needed const makeEmptyUri = (label: string) => vscode.Uri.parse(`untitled:__metadata_diff__/${label}`); let leftUri: vscode.Uri; @@ -76,9 +76,10 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont await vscode.commands.executeCommand('vscode.diff', leftUri, rightUri, title, { preview: true }); + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_OPEN_DIFF_SUCCEEDED, { ...getMetadataDiffBaseEventInfo(), diffType: addedLocally ? 'onlyLocal' : removedLocally ? 'onlyEnvironment' : 'modified', fileName: titleBase }); } catch (error) { oneDSLoggerWrapper.getLogger().traceError( - Constants.EventNames.METADATA_DIFF_REPORT_FAILED, + Constants.EventNames.METADATA_DIFF_OPEN_DIFF_FAILED, error as string, error as Error ); @@ -102,15 +103,19 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont storedFile = obj.storedFilePath || obj.storageFile; } if (workspaceFile || storedFile) { + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_OPEN_COMPARISON_CALLED, { ...getMetadataDiffBaseEventInfo(), hasWorkspace: !!workspaceFile, hasStored: !!storedFile }); await vscode.commands.executeCommand('metadataDiff.openDiff', workspaceFile, storedFile); + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_OPEN_COMPARISON_SUCCEEDED, { ...getMetadataDiffBaseEventInfo() }); } else { vscode.window.showWarningMessage(vscode.l10n.t('Unable to open comparison for this item.')); + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_OPEN_COMPARISON_FAILED, { ...getMetadataDiffBaseEventInfo(), reason: 'noWorkspaceOrStored' }); } }); // Discard local changes => overwrite workspace file with stored (remote) version vscode.commands.registerCommand('metadataDiff.discardLocalChanges', async (itemOrWorkspace?: unknown, maybeStored?: unknown) => { try { + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_DISCARD_LOCAL_CALLED, { ...getMetadataDiffBaseEventInfo() }); let workspaceFile: string | undefined; let storedFile: string | undefined; if (typeof itemOrWorkspace === 'string') { @@ -138,6 +143,7 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont 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))); + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_DISCARD_LOCAL_SUCCEEDED, { ...getMetadataDiffBaseEventInfo(), fileName: 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) { @@ -151,17 +157,14 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont } 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 - ); + oneDSLoggerWrapper.getLogger().traceError(Constants.EventNames.METADATA_DIFF_DISCARD_LOCAL_FAILED, error as string, error as Error, { ...getMetadataDiffBaseEventInfo() }, {}); vscode.window.showErrorMessage('Failed to discard local changes'); } }); vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.resync", async () => { try { + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_RESYNC_CALLED, { ...getMetadataDiffBaseEventInfo() }); // Only proceed if we already have data (context set by menu 'when' clause) const pacWrapper = pacTerminal.getWrapper(); const pacActiveOrg = await pacWrapper.activeOrg(); @@ -202,8 +205,7 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont title: vscode.l10n.t("Re-syncing website metadata"), cancellable: false }; - let pacPagesDownload; - let comparisonBuilt = false; + let pacPagesDownload; let comparisonBuilt = false; const start = Date.now(); let downloadStart = 0; let downloadEnd = 0; let buildStart = 0; let buildEnd = 0; await vscode.window.withProgress(progressOptions, async (progress) => { progress.report({ message: "Looking for this website in the connected environment..." }); const pacPagesList = await MetadataDiffDesktop.getPagesList(pacTerminal); @@ -214,18 +216,22 @@ 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') }); + downloadStart = Date.now(); pacPagesDownload = await pacWrapper.pagesDownload( storagePath, websiteId, websiteRecord.modelVersion === "v1" || websiteRecord.modelVersion === "Standard" ? "1" : "2" ); + downloadEnd = Date.now(); if (pacPagesDownload) { progress.report({ message: vscode.l10n.t('Comparing metadata of "{0}"...', websiteRecord.name) }); + buildStart = Date.now(); const provider = MetadataDiffTreeDataProvider.initialize(context); MetadataDiffDesktop.setTreeDataProvider(provider); ActionsHubTreeDataProvider.setMetadataDiffProvider(provider); await provider.getChildren(); vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); + buildEnd = Date.now(); comparisonBuilt = true; } } @@ -233,11 +239,14 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont if (pacPagesDownload && comparisonBuilt) { vscode.window.showInformationMessage(vscode.l10n.t("You can now view the comparison")); + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_RESYNC_SUCCEEDED, { ...getMetadataDiffBaseEventInfo() }); + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_PERF_SUMMARY, { ...getMetadataDiffBaseEventInfo(), scenario: 'resync', totalMs: Date.now()-start, downloadMs: downloadEnd-downloadStart, diffBuildMs: buildEnd-buildStart }); } else { vscode.window.showErrorMessage("Failed to re-sync metadata."); + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_RESYNC_FAILED, { ...getMetadataDiffBaseEventInfo(), comparisonBuilt }); } } catch (error) { - oneDSLoggerWrapper.getLogger().traceError(Constants.EventNames.METADATA_DIFF_REFRESH_FAILED, error as string, error as Error, { methodName: null }, {}); + oneDSLoggerWrapper.getLogger().traceError(Constants.EventNames.METADATA_DIFF_RESYNC_FAILED, error as string, error as Error, { ...getMetadataDiffBaseEventInfo() }, {}); } }); @@ -254,6 +263,7 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); vscode.window.showInformationMessage("Metadata diff view cleared successfully."); + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_CLEAR_SUCCEEDED, { ...getMetadataDiffBaseEventInfo() }); } catch (error) { oneDSLoggerWrapper.getLogger().traceError( Constants.EventNames.METADATA_DIFF_REPORT_FAILED, @@ -266,6 +276,7 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.triggerFlowWithSite", async (websiteId: string) => { try { + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_TRIGGER_FLOW_WITH_SITE_CALLED, { ...getMetadataDiffBaseEventInfo(), websiteId }); // Get the PAC wrapper to access org list const pacWrapper = pacTerminal.getWrapper(); @@ -305,8 +316,7 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont cancellable: false }; - let pacPagesDownload; - let comparisonBuilt = false; + let pacPagesDownload; let comparisonBuilt = false; const start = Date.now(); let downloadStart=0; let downloadEnd=0; let buildStart=0; let buildEnd=0; await vscode.window.withProgress(progressOptions, async (progress) => { progress.report({ message: "Looking for this website in the connected environment..." }); const pacPagesList = await MetadataDiffDesktop.getPagesList(pacTerminal); @@ -317,18 +327,22 @@ 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') }); + downloadStart = Date.now(); pacPagesDownload = await pacWrapper.pagesDownload( storagePath, websiteId, websiteRecord.modelVersion === "v1" || websiteRecord.modelVersion === "Standard" ? "1" : "2" ); + downloadEnd = Date.now(); if (pacPagesDownload) { progress.report({ message: vscode.l10n.t('Comparing metadata of "{0}"...', websiteRecord.name) }); + buildStart = Date.now(); const provider = MetadataDiffTreeDataProvider.initialize(context); MetadataDiffDesktop.setTreeDataProvider(provider); ActionsHubTreeDataProvider.setMetadataDiffProvider(provider); await provider.getChildren(); vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); + buildEnd = Date.now(); comparisonBuilt = true; } } @@ -336,17 +350,21 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont if (pacPagesDownload && comparisonBuilt) { vscode.window.showInformationMessage(vscode.l10n.t("You can now view the comparison")); + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_TRIGGER_FLOW_WITH_SITE_SUCCEEDED, { ...getMetadataDiffBaseEventInfo(), websiteId }); + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_PERF_SUMMARY, { ...getMetadataDiffBaseEventInfo(), scenario: 'triggerFlowWithSite', websiteId, totalMs: Date.now()-start, downloadMs: downloadEnd-downloadStart, diffBuildMs: buildEnd-buildStart }); } else { vscode.window.showErrorMessage("Failed to download metadata."); + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_TRIGGER_FLOW_WITH_SITE_FAILED, { ...getMetadataDiffBaseEventInfo(), websiteId, comparisonBuilt }); } } catch (error) { - oneDSLoggerWrapper.getLogger().traceError(Constants.EventNames.METADATA_DIFF_REFRESH_FAILED, error as string, error as Error, { methodName: null }, {}); + oneDSLoggerWrapper.getLogger().traceError(Constants.EventNames.METADATA_DIFF_TRIGGER_FLOW_WITH_SITE_FAILED, error as string, error as Error, { ...getMetadataDiffBaseEventInfo(), websiteId }, {}); } }); vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.triggerFlow", async () => { try { + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_TRIGGER_FLOW_CALLED, { ...getMetadataDiffBaseEventInfo() }); // Get the PAC wrapper to access org list const pacWrapper = pacTerminal.getWrapper(); @@ -464,8 +482,7 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont title: vscode.l10n.t("Downloading website metadata"), cancellable: false }; - let pacPagesDownload; - let comparisonBuilt = false; + let pacPagesDownload; let comparisonBuilt = false; const start= Date.now(); let downloadStart=0; let downloadEnd=0; let buildStart=0; let buildEnd=0; await vscode.window.withProgress(progressOptions, async (progress) => { progress.report({ message: "Looking for this website in the connected environment..." }); const pacPagesList = await MetadataDiffDesktop.getPagesList(pacTerminal); @@ -476,33 +493,41 @@ 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"); + downloadStart = Date.now(); + pacPagesDownload = await pacWrapper.pagesDownload(storagePath, websiteId, websiteRecord.modelVersion == "v1" ? "1" : "2"); + downloadEnd = Date.now(); if (pacPagesDownload) { progress.report({ message: vscode.l10n.t('Comparing metadata of "{0}"...', websiteRecord.name) }); + buildStart = Date.now(); const provider = MetadataDiffTreeDataProvider.initialize(context); MetadataDiffDesktop.setTreeDataProvider(provider); ActionsHubTreeDataProvider.setMetadataDiffProvider(provider); await provider.getChildren(); vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); + buildEnd = Date.now(); comparisonBuilt = true; } } }); if (pacPagesDownload && comparisonBuilt) { - vscode.window.showInformationMessage(vscode.l10n.t("You can now view the comparison")); + vscode.window.showInformationMessage(vscode.l10n.t("You can now view the comparison")); + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_TRIGGER_FLOW_SUCCEEDED, { ...getMetadataDiffBaseEventInfo() }); + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_PERF_SUMMARY, { ...getMetadataDiffBaseEventInfo(), scenario: 'triggerFlow', totalMs: Date.now()-start, downloadMs: downloadEnd-downloadStart, diffBuildMs: buildEnd-buildStart }); } else{ - vscode.window.showErrorMessage("Failed to download metadata."); + vscode.window.showErrorMessage("Failed to download metadata."); + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_TRIGGER_FLOW_FAILED, { ...getMetadataDiffBaseEventInfo(), comparisonBuilt }); } } catch (error) { - oneDSLoggerWrapper.getLogger().traceError(Constants.EventNames.METADATA_DIFF_REFRESH_FAILED, error as string, error as Error, { methodName: null }, {}); + oneDSLoggerWrapper.getLogger().traceError(Constants.EventNames.METADATA_DIFF_TRIGGER_FLOW_FAILED, error as string, error as Error, { ...getMetadataDiffBaseEventInfo() }, {}); } }); vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.generateReport", async () => { try { + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_REPORT_GENERATE_CALLED, { ...getMetadataDiffBaseEventInfo() }); const workspaceFolders = vscode.workspace.workspaceFolders; if (!workspaceFolders) { vscode.window.showErrorMessage("No workspace folder open"); @@ -536,9 +561,10 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont { enableScripts: true } // no scripts currently, but allow future enhancements ); panel.webview.html = htmlReport; + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_REPORT_GENERATE_SUCCEEDED, { ...getMetadataDiffBaseEventInfo(), htmlLength: htmlReport.length }); } catch (error) { oneDSLoggerWrapper.getLogger().traceError( - Constants.EventNames.METADATA_DIFF_REPORT_FAILED, + Constants.EventNames.METADATA_DIFF_REPORT_GENERATE_FAILED, error as string, error as Error ); @@ -548,6 +574,7 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.exportReport", async () => { try { + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_REPORT_EXPORT_CALLED, { ...getMetadataDiffBaseEventInfo() }); const workspaceFolders = vscode.workspace.workspaceFolders; if (!workspaceFolders) { vscode.window.showErrorMessage("No workspace folder open"); @@ -578,12 +605,14 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont }); if (saveUri) { - fs.writeFileSync(saveUri.fsPath, JSON.stringify(report, null, 2)); + const json = JSON.stringify(report, null, 2); + fs.writeFileSync(saveUri.fsPath, json); vscode.window.showInformationMessage("Report exported successfully"); + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_REPORT_EXPORT_SUCCEEDED, { ...getMetadataDiffBaseEventInfo(), fileCount: diffFiles.length, exportSizeBytes: json.length }); } } catch (error) { oneDSLoggerWrapper.getLogger().traceError( - Constants.EventNames.METADATA_DIFF_REPORT_FAILED, + Constants.EventNames.METADATA_DIFF_REPORT_EXPORT_FAILED, error as string, error as Error ); @@ -593,6 +622,7 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.importReport", async () => { try { + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_REPORT_IMPORT_CALLED, { ...getMetadataDiffBaseEventInfo() }); const fileUri = await vscode.window.showOpenDialog({ canSelectFiles: true, canSelectFolders: false, @@ -612,10 +642,11 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); vscode.window.showInformationMessage("Report imported successfully"); + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_REPORT_IMPORT_SUCCEEDED, { ...getMetadataDiffBaseEventInfo(), fileCount: report.files.length }); } } catch (error) { oneDSLoggerWrapper.getLogger().traceError( - Constants.EventNames.METADATA_DIFF_REPORT_FAILED, + Constants.EventNames.METADATA_DIFF_REPORT_IMPORT_FAILED, error as string, error as Error ); diff --git a/src/client/power-pages/metadata-diff/MetadataDiffTelemetry.ts b/src/client/power-pages/metadata-diff/MetadataDiffTelemetry.ts new file mode 100644 index 000000000..52ef96387 --- /dev/null +++ b/src/client/power-pages/metadata-diff/MetadataDiffTelemetry.ts @@ -0,0 +1,52 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import PacContext from "../../pac/PacContext"; +import CurrentSiteContext from "../actions-hub/CurrentSiteContext"; +import ArtemisContext from "../../ArtemisContext"; +import { oneDSLoggerWrapper } from "../../../common/OneDSLoggerTelemetry/oneDSLoggerWrapper"; + +// Narrow helper (mirrors Actions Hub base enrichment) kept local to avoid coupling common layer. +export const getMetadataDiffBaseEventInfo = () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const eventInfo: any = {}; + + if (ArtemisContext.ServiceResponse) { + eventInfo.stamp = ArtemisContext.ServiceResponse?.stamp ?? ""; + eventInfo.geo = ArtemisContext.ServiceResponse?.response?.geoName ?? ""; + } + + if (PacContext.OrgInfo?.OrgId) { + eventInfo.orgId = PacContext.OrgInfo.OrgId; + } + if (PacContext.OrgInfo?.OrgUrl) { + eventInfo.orgUrl = PacContext.OrgInfo.OrgUrl; + } + if (PacContext.AuthInfo?.TenantId) { + eventInfo.tenantId = PacContext.AuthInfo.TenantId; + } + if (PacContext.AuthInfo?.EnvironmentId) { + eventInfo.environmentId = PacContext.AuthInfo.EnvironmentId; + eventInfo.environmentName = PacContext.AuthInfo.OrganizationFriendlyName; + } + if (CurrentSiteContext.currentSiteId) { + eventInfo.currentSiteId = CurrentSiteContext.currentSiteId; + } + return eventInfo; +}; + +export const mdTraceInfo = (eventName: string, eventInfo?: object) => { + const base = getMetadataDiffBaseEventInfo(); + oneDSLoggerWrapper.getLogger().traceInfo(eventName, { ...base, ...eventInfo }); +}; + +export const mdTraceError = (eventName: string, error: Error | string, eventInfo?: object) => { + const base = getMetadataDiffBaseEventInfo(); + if (typeof error === 'string') { + oneDSLoggerWrapper.getLogger().traceError(eventName, error, new Error(error), { ...base, ...eventInfo }); + } else { + oneDSLoggerWrapper.getLogger().traceError(eventName, error.message, error, { ...base, ...eventInfo }); + } +}; diff --git a/src/common/power-pages/metadata-diff/Constants.ts b/src/common/power-pages/metadata-diff/Constants.ts index f17217d65..fac84bd5c 100644 --- a/src/common/power-pages/metadata-diff/Constants.ts +++ b/src/common/power-pages/metadata-diff/Constants.ts @@ -19,7 +19,45 @@ 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" + METADATA_DIFF_REPORT_FAILED: "metadataDiffReportFailed", + // Enhanced telemetry events (added v1.1) + METADATA_DIFF_TRIGGER_FLOW_CALLED: "metadataDiffTriggerFlowCalled", + METADATA_DIFF_TRIGGER_FLOW_SUCCEEDED: "metadataDiffTriggerFlowSucceeded", + METADATA_DIFF_TRIGGER_FLOW_FAILED: "metadataDiffTriggerFlowFailed", + METADATA_DIFF_TRIGGER_FLOW_WITH_SITE_CALLED: "metadataDiffTriggerFlowWithSiteCalled", + METADATA_DIFF_TRIGGER_FLOW_WITH_SITE_SUCCEEDED: "metadataDiffTriggerFlowWithSiteSucceeded", + METADATA_DIFF_TRIGGER_FLOW_WITH_SITE_FAILED: "metadataDiffTriggerFlowWithSiteFailed", + METADATA_DIFF_RESYNC_CALLED: "metadataDiffResyncCalled", + METADATA_DIFF_RESYNC_SUCCEEDED: "metadataDiffResyncSucceeded", + METADATA_DIFF_RESYNC_FAILED: "metadataDiffResyncFailed", + METADATA_DIFF_DOWNLOAD_STARTED: "metadataDiffDownloadStarted", + METADATA_DIFF_DOWNLOAD_COMPLETED: "metadataDiffDownloadCompleted", + METADATA_DIFF_DOWNLOAD_FAILED: "metadataDiffDownloadFailed", + METADATA_DIFF_DIFF_BUILD_STARTED: "metadataDiffDiffBuildStarted", + METADATA_DIFF_DIFF_BUILD_COMPLETED: "metadataDiffDiffBuildCompleted", + METADATA_DIFF_FILE_COUNTS: "metadataDiffFileCounts", + METADATA_DIFF_NO_DIFFERENCES: "metadataDiffNoDifferences", + METADATA_DIFF_OPEN_DIFF_CALLED: "metadataDiffOpenDiffCalled", + METADATA_DIFF_OPEN_DIFF_SUCCEEDED: "metadataDiffOpenDiffSucceeded", + METADATA_DIFF_OPEN_DIFF_FAILED: "metadataDiffOpenDiffFailed", + METADATA_DIFF_DISCARD_LOCAL_CALLED: "metadataDiffDiscardLocalCalled", + METADATA_DIFF_DISCARD_LOCAL_SUCCEEDED: "metadataDiffDiscardLocalSucceeded", + METADATA_DIFF_DISCARD_LOCAL_FAILED: "metadataDiffDiscardLocalFailed", + METADATA_DIFF_REPORT_GENERATE_CALLED: "metadataDiffReportGenerateCalled", + METADATA_DIFF_REPORT_GENERATE_SUCCEEDED: "metadataDiffReportGenerateSucceeded", + METADATA_DIFF_REPORT_GENERATE_FAILED: "metadataDiffReportGenerateFailed", + METADATA_DIFF_REPORT_EXPORT_CALLED: "metadataDiffReportExportCalled", + METADATA_DIFF_REPORT_EXPORT_SUCCEEDED: "metadataDiffReportExportSucceeded", + METADATA_DIFF_REPORT_EXPORT_FAILED: "metadataDiffReportExportFailed", + METADATA_DIFF_REPORT_IMPORT_CALLED: "metadataDiffReportImportCalled", + METADATA_DIFF_REPORT_IMPORT_SUCCEEDED: "metadataDiffReportImportSucceeded", + METADATA_DIFF_REPORT_IMPORT_FAILED: "metadataDiffReportImportFailed", + METADATA_DIFF_PERF_SUMMARY: "metadataDiffPerfSummary", + METADATA_DIFF_CLEAR_SUCCEEDED: "metadataDiffClearSucceeded", + METADATA_DIFF_ROOT_RENDERED: "metadataDiffRootRendered", + METADATA_DIFF_OPEN_COMPARISON_CALLED: "metadataDiffOpenComparisonCalled", + METADATA_DIFF_OPEN_COMPARISON_FAILED: "metadataDiffOpenComparisonFailed", + METADATA_DIFF_OPEN_COMPARISON_SUCCEEDED: "metadataDiffOpenComparisonSucceeded" } }; 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 12f9a185f..65c5e3c6d 100644 --- a/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts +++ b/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts @@ -9,6 +9,7 @@ import * as path from "path"; import { MetadataDiffTreeItem } from "./tree-items/MetadataDiffTreeItem"; import { Constants } from "./Constants"; import { oneDSLoggerWrapper } from "../../OneDSLoggerTelemetry/oneDSLoggerWrapper"; +import { getMetadataDiffBaseEventInfo } from "../../../client/power-pages/metadata-diff/MetadataDiffTelemetry"; import { MetadataDiffFileItem } from "./tree-items/MetadataDiffFileItem"; import { MetadataDiffFolderItem } from "./tree-items/MetadataDiffFolderItem"; @@ -33,6 +34,8 @@ export class MetadataDiffTreeDataProvider implements vscode.TreeDataProvider = new vscode.EventEmitter(); public readonly onDataLoaded: vscode.Event = this._onDataLoaded.event; private _dataLoadedNotified = false; + // Guard so we only emit file composition event once per build cycle + private _emittedFileCounts = false; constructor(context: vscode.ExtensionContext) { this._context = context; @@ -186,7 +189,7 @@ export class MetadataDiffTreeDataProvider implements vscode.TreeDataProvider { - oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_SET_FILES_CALLED, { methodName: 'setDiffFiles', fileCount: files?.length || 0 }); + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_SET_FILES_CALLED, { methodName: 'setDiffFiles', fileCount: files?.length || 0, ...getMetadataDiffBaseEventInfo() }); const rootNode = new MetadataDiffFolderItem(''); const sortedFiles = files.sort((a, b) => a.relativePath.localeCompare(b.relativePath)); @@ -355,16 +365,35 @@ export class MetadataDiffTreeDataProvider implements vscode.TreeDataProvider) { + let modified = 0; let onlyLocal = 0; let onlyEnvironment = 0; + const extensionCounts: Record = {}; + for (const [relative, pair] of diffFiles.entries()) { + if (pair.workspaceFile && pair.storageFile) modified++; else if (pair.workspaceFile) onlyLocal++; else if (pair.storageFile) onlyEnvironment++; + const ext = (relative.includes('.') ? '.' + relative.split('.').pop() : ''); + extensionCounts[ext] = (extensionCounts[ext] || 0) + 1; + } + const total = diffFiles.size; + // Limit histogram to top 10 extensions + other + const sorted = Object.entries(extensionCounts).sort((a,b) => b[1]-a[1]); + const top = sorted.slice(0,10); + const otherCount = sorted.slice(10).reduce((acc,cur)=>acc+cur[1],0); + const histogram: Record = {}; + for (const [k,v] of top) histogram[k||''] = v; + if (otherCount>0) histogram['__other'] = otherCount; + return { total, modified, onlyLocal, onlyEnvironment, extensionHistogram: JSON.stringify(histogram) }; + } } From 7e0c90a94fb9abdb7e6e8abcee4a394161b78e49 Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Fri, 5 Sep 2025 05:17:19 +0530 Subject: [PATCH 34/37] Cleanup --- .../metadata-diff/MetadataDiffCommands.ts | 236 ++---------------- .../metadata-diff/MetadataDiffHelpers.ts | 135 ++++++++++ .../metadata-diff/MetadataDiffUtils.ts | 8 +- 3 files changed, 168 insertions(+), 211 deletions(-) create mode 100644 src/client/power-pages/metadata-diff/MetadataDiffHelpers.ts diff --git a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts index 8da2402fb..a86d34e53 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts @@ -17,6 +17,7 @@ import { getWebsiteRecordId } from "../../../common/utilities/WorkspaceInfoFinde // Duplicate imports removed import { generateDiffReport, getAllDiffFiles, MetadataDiffReport } from "./MetadataDiffUtils"; import { getMetadataDiffBaseEventInfo } from "./MetadataDiffTelemetry"; +import { buildComparison, ensureActiveOrg, resetDirectory, toModelVersion } from "./MetadataDiffHelpers"; export async function registerMetadataDiffCommands(context: vscode.ExtensionContext, pacTerminal: PacTerminal): Promise { // Register command for handling file diffs @@ -165,85 +166,19 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.resync", async () => { try { oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_RESYNC_CALLED, { ...getMetadataDiffBaseEventInfo() }); - // 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 + if (!(await ensureActiveOrg(pacTerminal))) return; 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; const start = Date.now(); let downloadStart = 0; let downloadEnd = 0; let buildStart = 0; let buildEnd = 0; - 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') }); - downloadStart = Date.now(); - pacPagesDownload = await pacWrapper.pagesDownload( - storagePath, - websiteId, - websiteRecord.modelVersion === "v1" || websiteRecord.modelVersion === "Standard" ? "1" : "2" - ); - downloadEnd = Date.now(); - if (pacPagesDownload) { - progress.report({ message: vscode.l10n.t('Comparing metadata of "{0}"...', websiteRecord.name) }); - buildStart = Date.now(); - const provider = MetadataDiffTreeDataProvider.initialize(context); - MetadataDiffDesktop.setTreeDataProvider(provider); - ActionsHubTreeDataProvider.setMetadataDiffProvider(provider); - await provider.getChildren(); - vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); - buildEnd = Date.now(); - comparisonBuilt = true; - } - } - }); - - if (pacPagesDownload && comparisonBuilt) { + const folders = vscode.workspace.workspaceFolders; + if (!folders) return; + const websiteId = getWebsiteRecordId(folders[0].uri.fsPath); + if (!websiteId) { vscode.window.showErrorMessage(vscode.l10n.t("Unable to determine website id from workspace.")); return; } + const result = await buildComparison(context, pacTerminal, websiteId, vscode.l10n.t("Re-syncing website metadata"), "resync"); + if (result.success) { vscode.window.showInformationMessage(vscode.l10n.t("You can now view the comparison")); oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_RESYNC_SUCCEEDED, { ...getMetadataDiffBaseEventInfo() }); - oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_PERF_SUMMARY, { ...getMetadataDiffBaseEventInfo(), scenario: 'resync', totalMs: Date.now()-start, downloadMs: downloadEnd-downloadStart, diffBuildMs: buildEnd-buildStart }); } else { - vscode.window.showErrorMessage("Failed to re-sync metadata."); - oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_RESYNC_FAILED, { ...getMetadataDiffBaseEventInfo(), comparisonBuilt }); + vscode.window.showErrorMessage(vscode.l10n.t("Failed to re-sync metadata.")); + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_RESYNC_FAILED, { ...getMetadataDiffBaseEventInfo() }); } } catch (error) { oneDSLoggerWrapper.getLogger().traceError(Constants.EventNames.METADATA_DIFF_RESYNC_FAILED, error as string, error as Error, { ...getMetadataDiffBaseEventInfo() }, {}); @@ -277,86 +212,15 @@ export async function registerMetadataDiffCommands(context: vscode.ExtensionCont vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.triggerFlowWithSite", async (websiteId: string) => { try { oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_TRIGGER_FLOW_WITH_SITE_CALLED, { ...getMetadataDiffBaseEventInfo(), websiteId }); - // 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: vscode.l10n.t("Downloading website metadata"), - cancellable: false - }; - - let pacPagesDownload; let comparisonBuilt = false; const start = Date.now(); let downloadStart=0; let downloadEnd=0; let buildStart=0; let buildEnd=0; - 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') }); - downloadStart = Date.now(); - pacPagesDownload = await pacWrapper.pagesDownload( - storagePath, - websiteId, - websiteRecord.modelVersion === "v1" || websiteRecord.modelVersion === "Standard" ? "1" : "2" - ); - downloadEnd = Date.now(); - if (pacPagesDownload) { - progress.report({ message: vscode.l10n.t('Comparing metadata of "{0}"...', websiteRecord.name) }); - buildStart = Date.now(); - const provider = MetadataDiffTreeDataProvider.initialize(context); - MetadataDiffDesktop.setTreeDataProvider(provider); - ActionsHubTreeDataProvider.setMetadataDiffProvider(provider); - await provider.getChildren(); - vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); - buildEnd = Date.now(); - comparisonBuilt = true; - } - } - }); - - if (pacPagesDownload && comparisonBuilt) { + if (!(await ensureActiveOrg(pacTerminal))) return; + const result = await buildComparison(context, pacTerminal, websiteId, vscode.l10n.t("Downloading website metadata"), "triggerFlowWithSite"); + if (result.success) { vscode.window.showInformationMessage(vscode.l10n.t("You can now view the comparison")); oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_TRIGGER_FLOW_WITH_SITE_SUCCEEDED, { ...getMetadataDiffBaseEventInfo(), websiteId }); - oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_PERF_SUMMARY, { ...getMetadataDiffBaseEventInfo(), scenario: 'triggerFlowWithSite', websiteId, totalMs: Date.now()-start, downloadMs: downloadEnd-downloadStart, diffBuildMs: buildEnd-buildStart }); } else { - vscode.window.showErrorMessage("Failed to download metadata."); - oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_TRIGGER_FLOW_WITH_SITE_FAILED, { ...getMetadataDiffBaseEventInfo(), websiteId, comparisonBuilt }); + vscode.window.showErrorMessage(vscode.l10n.t("Failed to download metadata.")); + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_TRIGGER_FLOW_WITH_SITE_FAILED, { ...getMetadataDiffBaseEventInfo(), websiteId }); } - } catch (error) { oneDSLoggerWrapper.getLogger().traceError(Constants.EventNames.METADATA_DIFF_TRIGGER_FLOW_WITH_SITE_FAILED, error as string, error as Error, { ...getMetadataDiffBaseEventInfo(), websiteId }, {}); } @@ -458,65 +322,17 @@ 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 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: vscode.l10n.t("Downloading website metadata"), - cancellable: false - }; - let pacPagesDownload; let comparisonBuilt = false; const start= Date.now(); let downloadStart=0; let downloadEnd=0; let buildStart=0; let buildEnd=0; - 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') }); - downloadStart = Date.now(); - pacPagesDownload = await pacWrapper.pagesDownload(storagePath, websiteId, websiteRecord.modelVersion == "v1" ? "1" : "2"); - downloadEnd = Date.now(); - if (pacPagesDownload) { - progress.report({ message: vscode.l10n.t('Comparing metadata of "{0}"...', websiteRecord.name) }); - buildStart = Date.now(); - const provider = MetadataDiffTreeDataProvider.initialize(context); - MetadataDiffDesktop.setTreeDataProvider(provider); - ActionsHubTreeDataProvider.setMetadataDiffProvider(provider); - await provider.getChildren(); - vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); - buildEnd = Date.now(); - comparisonBuilt = true; - } - } - }); - if (pacPagesDownload && comparisonBuilt) { - vscode.window.showInformationMessage(vscode.l10n.t("You can now view the comparison")); - oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_TRIGGER_FLOW_SUCCEEDED, { ...getMetadataDiffBaseEventInfo() }); - oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_PERF_SUMMARY, { ...getMetadataDiffBaseEventInfo(), scenario: 'triggerFlow', totalMs: Date.now()-start, downloadMs: downloadEnd-downloadStart, diffBuildMs: buildEnd-buildStart }); - } - else{ - vscode.window.showErrorMessage("Failed to download metadata."); - oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_TRIGGER_FLOW_FAILED, { ...getMetadataDiffBaseEventInfo(), comparisonBuilt }); + const folders = vscode.workspace.workspaceFolders; + if (!folders) { vscode.window.showErrorMessage(vscode.l10n.t("No folders opened in the current workspace.")); return; } + const websiteId = getWebsiteRecordId(folders[0].uri.fsPath); + if (!websiteId) { vscode.window.showErrorMessage(vscode.l10n.t("Unable to determine website id from workspace.")); return; } + const result = await buildComparison(context, pacTerminal, websiteId, vscode.l10n.t("Downloading website metadata"), "triggerFlow"); + if (result.success) { + vscode.window.showInformationMessage(vscode.l10n.t("You can now view the comparison")); + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_TRIGGER_FLOW_SUCCEEDED, { ...getMetadataDiffBaseEventInfo() }); + } else { + vscode.window.showErrorMessage(vscode.l10n.t("Failed to download metadata.")); + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_TRIGGER_FLOW_FAILED, { ...getMetadataDiffBaseEventInfo() }); } } diff --git a/src/client/power-pages/metadata-diff/MetadataDiffHelpers.ts b/src/client/power-pages/metadata-diff/MetadataDiffHelpers.ts new file mode 100644 index 000000000..26ba6ecb1 --- /dev/null +++ b/src/client/power-pages/metadata-diff/MetadataDiffHelpers.ts @@ -0,0 +1,135 @@ +/* + * 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 { PacTerminal } from "../../lib/PacTerminal"; +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 { MetadataDiffDesktop } from "./MetadataDiffDesktop"; +import { ActionsHubTreeDataProvider } from "../actions-hub/ActionsHubTreeDataProvider"; +import { getMetadataDiffBaseEventInfo } from "./MetadataDiffTelemetry"; + +// Small data container for timings +export interface ComparisonTimings { + totalMs: number; + downloadMs: number; + diffBuildMs: number; +} + +export interface ComparisonResult { + success: boolean; + timings: ComparisonTimings; +} + +/** + * Ensures a single folder workspace & returns paths + website id. + */ +export function resolveWorkspaceAndWebsiteId(getWebsiteId: (workspaceFolderPath: string) => string | undefined): { workspacePath: string, storagePath: string, websiteId: string } | undefined { + const folders = vscode.workspace.workspaceFolders; + if (!folders || folders.length === 0) { + vscode.window.showErrorMessage(vscode.l10n.t("No folders opened in the current workspace.")); + return undefined; + } + const workspacePath = folders[0].uri.fsPath; + const storagePath = vscode.workspace.getWorkspaceFolder(folders[0].uri)?.uri.fsPath; // fallback, usually extension context handles storage + // Storage path for Metadata Diff is provided by extension context.storageUri; caller validates existence. + const websiteId = getWebsiteId(workspacePath) || ""; + if (!websiteId) { + vscode.window.showErrorMessage(vscode.l10n.t("Unable to determine website id from workspace.")); + return undefined; + } + return { workspacePath, storagePath: storagePath || workspacePath, websiteId }; +} + +/** + * Removes & recreates the given storage directory. + */ +export function resetDirectory(dir: string): void { + if (fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + fs.mkdirSync(dir, { recursive: true }); +} + +/** + * Returns model version accepted by pac pages download ("1" | "2"). + */ +export function toModelVersion(modelVersion: string): string { + return (modelVersion === "v1" || modelVersion === "Standard") ? "1" : "2"; +} + +/** + * Shared routine used by triggerFlow / resync / triggerFlowWithSite. + * Handles: listing sites, locating site, calling pages download, building diff provider. + */ +export async function buildComparison( + context: vscode.ExtensionContext, + pacTerminal: PacTerminal, + websiteId: string, + progressTitle: string, + scenario: string +): Promise { + const pacWrapper = pacTerminal.getWrapper(); + const start = Date.now(); + let downloadStart = 0; let downloadEnd = 0; let buildStart = 0; let buildEnd = 0; + let success = false; + + await vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: progressTitle, + cancellable: false + }, async (progress) => { + progress.report({ message: vscode.l10n.t("Locating website in environment...") }); + const pages = await MetadataDiffDesktop.getPagesList(pacTerminal); + const record = pages.find(p => p.id === websiteId); + if (!record) { + vscode.window.showErrorMessage(vscode.l10n.t("Website not found in the connected environment.")); + return; + } + progress.report({ message: vscode.l10n.t('Retrieving "{0}" as {1} data model. Please wait...', record.name, record.modelVersion === 'v2' ? 'enhanced' : 'standard') }); + const storagePath = context.storageUri?.fsPath; + if (!storagePath) { + vscode.window.showErrorMessage(vscode.l10n.t("Storage path not found")); + return; + } + resetDirectory(storagePath); + downloadStart = Date.now(); + const download = await pacWrapper.pagesDownload(storagePath, websiteId, toModelVersion(record.modelVersion)); + downloadEnd = Date.now(); + if (download && download.Status === SUCCESS) { + progress.report({ message: vscode.l10n.t('Comparing metadata of "{0}"...', record.name) }); + buildStart = Date.now(); + const provider = MetadataDiffTreeDataProvider.initialize(context); + MetadataDiffDesktop.setTreeDataProvider(provider); + ActionsHubTreeDataProvider.setMetadataDiffProvider(provider); + await provider.getChildren(); + vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); + buildEnd = Date.now(); + success = true; + } + }); + + const timings: ComparisonTimings = { + totalMs: Date.now() - start, + downloadMs: downloadEnd - downloadStart, + diffBuildMs: buildEnd - buildStart + }; + + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_PERF_SUMMARY, { ...getMetadataDiffBaseEventInfo(), scenario, ...timings }); + return { success, timings }; +} + +/** Ensures an active org before executing comparison logic. */ +export async function ensureActiveOrg(pacTerminal: PacTerminal): Promise { + const pacWrapper = pacTerminal.getWrapper(); + const active = await pacWrapper.activeOrg(); + if (!active || active.Status !== SUCCESS) { + vscode.window.showErrorMessage(vscode.l10n.t("No active environment found. Please authenticate first.")); + return false; + } + return true; +} diff --git a/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts b/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts index ffde57c8c..627b5f2c8 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts @@ -38,7 +38,13 @@ 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, '>'); + // Escape for both element text and attribute contexts. Adds quotes to address CodeQL finding. + const escapeHtml = (value: string | undefined) => (value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); let body = `

Power Pages Metadata Diff Report

`; body += `

Generated on: ${escapeHtml(generatedOn)}

`; From 7f5ea1b90a1ec5c9ed69318302711e1cdc5c4145 Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Fri, 5 Sep 2025 05:31:53 +0530 Subject: [PATCH 35/37] Tests --- mdiff-util-test-GZVN8H/mod.html | 1 + mdiff-util-test-GZVN8H/onlyLocal.txt | 1 + mdiff-util-test-SNEPoI/siteA/mod.html | 1 + mdiff-util-test-SNEPoI/siteA/onlyRemote.txt | 1 + mdiff-util-test-grYYYK/siteA/mod.html | 1 + mdiff-util-test-grYYYK/siteA/onlyRemote.txt | 1 + mdiff-util-test-pjKmkH/mod.html | 1 + mdiff-util-test-pjKmkH/onlyLocal.txt | 1 + src/client/extension.ts | 17 ++++++- .../metadata-diff/MetadataDiffUtils.ts | 16 ++++++- .../metadata-diff/MetadataDiffUtils.test.ts | 44 +++++++++++++++++++ 11 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 mdiff-util-test-GZVN8H/mod.html create mode 100644 mdiff-util-test-GZVN8H/onlyLocal.txt create mode 100644 mdiff-util-test-SNEPoI/siteA/mod.html create mode 100644 mdiff-util-test-SNEPoI/siteA/onlyRemote.txt create mode 100644 mdiff-util-test-grYYYK/siteA/mod.html create mode 100644 mdiff-util-test-grYYYK/siteA/onlyRemote.txt create mode 100644 mdiff-util-test-pjKmkH/mod.html create mode 100644 mdiff-util-test-pjKmkH/onlyLocal.txt create mode 100644 src/client/test/unit/power-pages/metadata-diff/MetadataDiffUtils.test.ts diff --git a/mdiff-util-test-GZVN8H/mod.html b/mdiff-util-test-GZVN8H/mod.html new file mode 100644 index 000000000..bd197d218 --- /dev/null +++ b/mdiff-util-test-GZVN8H/mod.html @@ -0,0 +1 @@ +
local & update
\ No newline at end of file diff --git a/mdiff-util-test-GZVN8H/onlyLocal.txt b/mdiff-util-test-GZVN8H/onlyLocal.txt new file mode 100644 index 000000000..c2c027fec --- /dev/null +++ b/mdiff-util-test-GZVN8H/onlyLocal.txt @@ -0,0 +1 @@ +local \ No newline at end of file diff --git a/mdiff-util-test-SNEPoI/siteA/mod.html b/mdiff-util-test-SNEPoI/siteA/mod.html new file mode 100644 index 000000000..5cdc38599 --- /dev/null +++ b/mdiff-util-test-SNEPoI/siteA/mod.html @@ -0,0 +1 @@ +
remote & update
\ No newline at end of file diff --git a/mdiff-util-test-SNEPoI/siteA/onlyRemote.txt b/mdiff-util-test-SNEPoI/siteA/onlyRemote.txt new file mode 100644 index 000000000..b3da01866 --- /dev/null +++ b/mdiff-util-test-SNEPoI/siteA/onlyRemote.txt @@ -0,0 +1 @@ +remote \ No newline at end of file diff --git a/mdiff-util-test-grYYYK/siteA/mod.html b/mdiff-util-test-grYYYK/siteA/mod.html new file mode 100644 index 000000000..5cdc38599 --- /dev/null +++ b/mdiff-util-test-grYYYK/siteA/mod.html @@ -0,0 +1 @@ +
remote & update
\ No newline at end of file diff --git a/mdiff-util-test-grYYYK/siteA/onlyRemote.txt b/mdiff-util-test-grYYYK/siteA/onlyRemote.txt new file mode 100644 index 000000000..b3da01866 --- /dev/null +++ b/mdiff-util-test-grYYYK/siteA/onlyRemote.txt @@ -0,0 +1 @@ +remote \ No newline at end of file diff --git a/mdiff-util-test-pjKmkH/mod.html b/mdiff-util-test-pjKmkH/mod.html new file mode 100644 index 000000000..bd197d218 --- /dev/null +++ b/mdiff-util-test-pjKmkH/mod.html @@ -0,0 +1 @@ +
local & update
\ No newline at end of file diff --git a/mdiff-util-test-pjKmkH/onlyLocal.txt b/mdiff-util-test-pjKmkH/onlyLocal.txt new file mode 100644 index 000000000..c2c027fec --- /dev/null +++ b/mdiff-util-test-pjKmkH/onlyLocal.txt @@ -0,0 +1 @@ +local \ No newline at end of file diff --git a/src/client/extension.ts b/src/client/extension.ts index 1069b733c..6dbfe7fbf 100644 --- a/src/client/extension.ts +++ b/src/client/extension.ts @@ -282,6 +282,21 @@ export async function activate( ActionsHub.initialize(context, pacTerminal) ]); + // Initialize Metadata Diff after ActionsHub so that its root node can attach; gated by ECS + try { + // Dynamic import to avoid circular reference at top-level during tests + const { EnableMetadataDiff } = await import("../common/ecs-features/ecsFeatureGates"); + const { enableMetadataDiff } = ECSFeaturesClient.getConfig(EnableMetadataDiff); + if (enableMetadataDiff) { + await MetadataDiffDesktop.initialize(context, pacTerminal); + } else { + vscode.commands.executeCommand("setContext", "microsoft.powerplatform.pages.metadataDiffEnabled", false); + } + } catch (e) { + // Non-blocking – log minimal telemetry; avoid failing activation + oneDSLoggerWrapper.getLogger().traceError("MetadataDiffInitSkipped", (e as Error).message, e as Error); + } + vscode.commands.executeCommand('setContext', 'microsoft.powerplatform.environment.initialized', true); }), @@ -312,8 +327,6 @@ export async function activate( const workspaceFolderWatcher = vscode.workspace.onDidChangeWorkspaceFolders(handleWorkspaceFolderChange); _context.subscriptions.push(workspaceFolderWatcher); - MetadataDiffDesktop.initialize(context, pacTerminal) - if (shouldEnableDebugger()) { activateDebugger(context); } diff --git a/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts b/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts index 627b5f2c8..f8664f10d 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts @@ -62,8 +62,22 @@ export async function generateDiffReport(workspacePath: string, storagePath: str body += `

Root

`; } body += `
    `; + const allowedChangeClasses = new Set(["modified","only-in-local","only-in-environment"]); for (const file of files.sort((a, b) => a.relativePath.localeCompare(b.relativePath))) { - body += `
  • `; + const rawChange = file.changes.toLowerCase().replace(/\s+/g,'-'); + // Map known values to canonical class names; fallback to 'modified' to avoid arbitrary injection + let changeClass = "modified"; + if (rawChange.includes("only") && rawChange.includes("local")) { + changeClass = "only-in-local"; + } else if (rawChange.includes("only") && (rawChange.includes("environment") || rawChange.includes("remote"))) { + changeClass = "only-in-environment"; + } else if (rawChange === "modified") { + changeClass = "modified"; + } + if (!allowedChangeClasses.has(changeClass)) { + changeClass = "modified"; + } + body += `
  • `; body += `
    ${escapeHtml(path.basename(file.relativePath))}`; body += `${escapeHtml(file.changes)}
    `; diff --git a/src/client/test/unit/power-pages/metadata-diff/MetadataDiffUtils.test.ts b/src/client/test/unit/power-pages/metadata-diff/MetadataDiffUtils.test.ts new file mode 100644 index 000000000..835e038d7 --- /dev/null +++ b/src/client/test/unit/power-pages/metadata-diff/MetadataDiffUtils.test.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 { expect } from "chai"; +import * as fs from "fs"; +import * as path from "path"; +import { generateDiffReport } from "../../../../power-pages/metadata-diff/MetadataDiffUtils"; + +// Simple helper to create temp folder structure +function makeTempDir(): string { + const dir = fs.mkdtempSync(path.join(process.cwd(), "mdiff-util-test-")); + return dir; +} + +describe("MetadataDiffUtils.generateDiffReport", () => { + it("escapes HTML special chars and sanitizes class names", async () => { + const workspace = makeTempDir(); + const storageRoot = makeTempDir(); + // Simulate downloaded folder structure: storageRoot/siteA/ + const siteDir = path.join(storageRoot, "siteA"); + fs.mkdirSync(siteDir, { recursive: true }); + + // File present in workspace only (attempt injection via filename / contents is not used in class) + fs.writeFileSync(path.join(workspace, 'onlyLocal.txt'), 'local'); + // File present in storage only + fs.writeFileSync(path.join(siteDir, 'onlyRemote.txt'), 'remote'); + // Modified file with differing content + fs.writeFileSync(path.join(workspace, 'mod.html'), '
    local & update
    '); + fs.writeFileSync(path.join(siteDir, 'mod.html'), '
    remote & update
    '); + + const html = await generateDiffReport(workspace, storageRoot); + // Ensure potentially unsafe characters from content are escaped (should not appear raw) + expect(html).to.not.contain('
    local & update
    '); + expect(html).to.not.contain('
    remote & update
    '); + // Class names should be canonical; no raw 'only-in-local' transformation injection beyond whitelist + expect(html).to.match(/file-item only-in-local/); + expect(html).to.match(/file-item only-in-environment/); + expect(html).to.match(/file-item modified/); + // Should not contain unescaped quotes in attributes leading to potential breakouts + expect(html).to.not.contain('class="file-item modified""'); + }); +}); From 21895966dcae0b1c6c6fb0b17d1937e52acf5937 Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Wed, 8 Oct 2025 11:28:05 +0530 Subject: [PATCH 36/37] Added 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 f67c6a874fdf9b75f6e0e9925bda94352765cb46 Mon Sep 17 00:00:00 2001 From: Rishabh Jain Date: Wed, 8 Oct 2025 11:30:24 +0530 Subject: [PATCH 37/37] Removed unused imports --- src/client/power-pages/metadata-diff/MetadataDiffCommands.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts index a86d34e53..a84194c0c 100644 --- a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts +++ b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts @@ -11,13 +11,12 @@ import { Constants, SUCCESS } from "../../../common/power-pages/metadata-diff/Co import { oneDSLoggerWrapper } from "../../../common/OneDSLoggerTelemetry/oneDSLoggerWrapper"; import { MetadataDiffTreeDataProvider } from "../../../common/power-pages/metadata-diff/MetadataDiffTreeDataProvider"; 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"; import { getMetadataDiffBaseEventInfo } from "./MetadataDiffTelemetry"; -import { buildComparison, ensureActiveOrg, resetDirectory, toModelVersion } from "./MetadataDiffHelpers"; +import { buildComparison, ensureActiveOrg } from "./MetadataDiffHelpers"; export async function registerMetadataDiffCommands(context: vscode.ExtensionContext, pacTerminal: PacTerminal): Promise { // Register command for handling file diffs