();
+ diffFiles.forEach(file => {
+ const dirPath = path.dirname(file.relativePath) || '.';
+ if (!folderStructure.has(dirPath)) {
+ folderStructure.set(dirPath, []);
+ }
+ folderStructure.get(dirPath)!.push(file);
+ });
+
+ const sortedFolders = Array.from(folderStructure.keys()).sort();
+
+ const escapeHtml = (value: string | undefined) =>
+ (value || '')
+ .replace(/&/g, '&')
+ .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 !== '.') {
+ body += `${escapeHtml(folder)}
`;
+ } else {
+ body += `Root
`;
+ }
+ body += `';
+ }
+
+ // Lightweight styling – no external dependencies.
+ const html = `
+${body}`;
+
+ return html;
+}
+
+/**
+ * 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: 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[] = [];
+ const workspaceKeys = Object.keys(workspaceObj);
+ for (const key of workspaceKeys) {
+ const currentPath = path ? `${path}.${key}` : key;
+ if (!(key in storageObj)) {
+ changes.push(`Added property: \`${currentPath}\``);
+ continue;
+ }
+ 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}\``);
+ }
+ }
+ const storageKeys = Object.keys(storageObj);
+ for (const key of storageKeys) {
+ if (!(key in workspaceObj)) {
+ const currentPath = path ? `${path}.${key}` : key;
+ changes.push(`Removed property: \`${currentPath}\``);
+ }
+ }
+ return changes;
+}
+
+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 Local',
+ type: path.dirname(normalized) || 'Other',
+ 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: path.dirname(normalized) || 'Other',
+ 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 Environment',
+ type: path.dirname(normalized) || 'Other',
+ 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 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/test/Integration/power-pages/actions-hub/ActionsHubCommandHandlers.test.ts b/src/client/test/Integration/power-pages/actions-hub/ActionsHubCommandHandlers.test.ts
index 199134cac..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
@@ -6,7 +6,7 @@
import { expect } from 'chai';
import * as sinon from 'sinon';
import * as vscode from 'vscode';
-import { showEnvironmentDetails, refreshEnvironment, switchEnvironment, openActiveSitesInStudio, openInactiveSitesInStudio, createNewAuthProfile, previewSite, fetchWebsites, revealInOS, uploadSite, createKnownSiteIdsSet, findOtherSites, showSiteDetails, openSiteManagement, downloadSite, openInStudio, runCodeQLScreening, loginToMatch } from '../../../../power-pages/actions-hub/ActionsHubCommandHandlers';
+import { showEnvironmentDetails, refreshEnvironment, switchEnvironment, openActiveSitesInStudio, openInactiveSitesInStudio, createNewAuthProfile, previewSite, fetchWebsites, revealInOS, uploadSite, createKnownSiteIdsSet, findOtherSites, showSiteDetails, compareWithLocal, openSiteManagement, downloadSite, openInStudio, runCodeQLScreening, loginToMatch } from '../../../../power-pages/actions-hub/ActionsHubCommandHandlers';
import { Constants } from '../../../../power-pages/actions-hub/Constants';
import * as CommonUtils from '../../../../power-pages/commonUtility';
import { AuthInfo, CloudInstance, EnvironmentType, OrgInfo } from '../../../../pac/PacTypes';
@@ -1270,6 +1270,67 @@ describe('ActionsHubCommandHandlers', () => {
});
});
+ 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 with site ID', async () => {
+ await compareWithLocal(mockSiteTreeItem);
+
+ expect(mockExecuteCommand.calledOnce).to.be.true;
+ 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 () => {
+ 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;
});
diff --git a/src/common/ecs-features/ecsFeatureGates.ts b/src/common/ecs-features/ecsFeatureGates.ts
index bec19a58b..c52285123 100644
--- a/src/common/ecs-features/ecsFeatureGates.ts
+++ b/src/common/ecs-features/ecsFeatureGates.ts
@@ -98,7 +98,7 @@ export const {
fallback: {
enableCodeQlScan: false,
}
-})
+});
export const {
feature: EnableOpenInDesktop
@@ -120,3 +120,13 @@ export const {
disallowedDuplicateFileHandlingOrgs: "",
}
});
+
+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..4862cfece
--- /dev/null
+++ b/src/common/power-pages/metadata-diff/Constants.ts
@@ -0,0 +1,20 @@
+/*
+ * 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_ENABLED: "metadataDiffEnabled",
+ METADATA_DIFF_INITIALIZED: "metadataDiffInitialized",
+ METADATA_DIFF_COMMAND_EXECUTED: "metadataDiffCommandExecuted",
+ METADATA_DIFF_REFRESH_FAILED: "metadataDiffRefreshFailed",
+ METADATA_DIFF_INITIALIZATION_FAILED: "metadataDiffInitializationFailed",
+ METADATA_DIFF_CURRENT_ENV_FETCH_FAILED: "metadataDiffCurrentEnvFetchFailed",
+ 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
new file mode 100644
index 000000000..94506ed07
--- /dev/null
+++ b/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts
@@ -0,0 +1,393 @@
+/*
+ * 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";
+
+// Internal utility directories created under storage that should not be
+// mistaken for the downloaded website folder when recomputing diffs.
+const IGNORE_STORAGE_DIRS = new Set([
+ 'tempDiff', // created for one‑sided / placeholder diff views
+ 'imported_diff' // created when importing a saved report
+]);
+
+interface DiffFile {
+ relativePath: string;
+ changes: string;
+ 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[] = [];
+ // 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;
+ }
+
+ public static initialize(context: vscode.ExtensionContext): MetadataDiffTreeDataProvider {
+ return new MetadataDiffTreeDataProvider(context);
+ }
+
+ private refresh(): void {
+ this._onDidChangeTreeData.fire();
+ }
+
+ /**
+ * Recompute the diff tree without clearing the downloaded site storage.
+ * Used after in-place edits (e.g. discard local changes) so that we don't
+ * wipe the remote snapshot but still update the UI & contexts.
+ */
+ public async recomputeDiff(): Promise {
+ try {
+ const workspaceFolders = vscode.workspace.workspaceFolders;
+ if (!workspaceFolders || workspaceFolders.length === 0) { return; }
+ const workspacePath = workspaceFolders[0].uri.fsPath;
+ const storagePath = this._context.storageUri?.fsPath;
+ if (!storagePath) { return; }
+
+ const diffFiles = await this.getDiffFiles(workspacePath, storagePath);
+ const items = this.buildTreeHierarchy(diffFiles);
+ this._diffItems = items;
+ vscode.commands.executeCommand("setContext", "microsoft.powerplatform.pages.metadataDiff.hasData", this._diffItems.length > 0);
+ this.refresh();
+ vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh");
+ if (!this._dataLoadedNotified && this._diffItems.length > 0) {
+ this._dataLoadedNotified = true;
+ this._onDataLoaded.fire();
+ }
+ } catch (error) {
+ oneDSLoggerWrapper.getLogger().traceError(
+ Constants.EventNames.METADATA_DIFF_CURRENT_ENV_FETCH_FAILED,
+ error as string,
+ error as Error,
+ { methodName: this.recomputeDiff },
+ {}
+ );
+ }
+ }
+
+ clearItems(): void {
+ this._diffItems = [];
+ this._dataLoadedNotified = false; // allow message again after reset
+ // 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);
+ }
+ }
+ // 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 {
+ 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 getDiffFiles(workspacePath: string, storagePath: string): Promise