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/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/package.json b/package.json
index a69f33932..16abed35a 100644
--- a/package.json
+++ b/package.json
@@ -32,7 +32,7 @@
"extends": "@istanbuljs/nyc-config-typescript",
"all": "true",
"include": [
- "**/*.ts"
+ "**/*.ts"
],
"exclude": [
"**/*.test.ts"
@@ -194,6 +194,11 @@
"contents": "%microsoft.powerplatform.pages.actionsHub.login%",
"when": "!virtualWorkspace && pacCLI.authPanel.interactiveLoginSupported",
"enablement": "microsoft.powerplatform.environment.initialized && !microsoft.powerplatform.pages.actionsHub.loadingWebsites"
+ },
+ {
+ "view": "microsoft.powerplatform.pages.metadataDiff",
+ "contents": "%microsoft.powerplatform.pages.metadataDiff.login%",
+ "when": "!virtualWorkspace && pacCLI.authPanel.interactiveLoginSupported"
}
],
"commands": [
@@ -468,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%"
@@ -485,6 +494,54 @@
"category": "Power Pages",
"title": "%microsoft.powerplatform.pages.actionsHub.currentActiveSite.runCodeQLScreening.title%",
"when": "microsoft.powerplatform.pages.codeQlScanEnabled"
+ },
+ {
+ "command": "microsoft.powerplatform.pages.metadataDiff.triggerFlow",
+ "title": "Compare with Environment",
+ "category": "Power Pages Metadata Diff",
+ "icon": "$(cloud-download)"
+ },
+ {
+ "command": "microsoft.powerplatform.pages.metadataDiff.generateReport",
+ "title": "Generate HTML Report",
+ "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",
+ "category": "Power Pages Metadata Diff",
+ "icon": "$(clear-all)"
+ },
+ {
+ "command": "microsoft.powerplatform.pages.metadataDiff.exportReport",
+ "title": "Export Comparison View",
+ "category": "Power Pages Metadata Diff",
+ "icon": "$(save-as)"
+ },
+ {
+ "command": "microsoft.powerplatform.pages.metadataDiff.importReport",
+ "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": {
@@ -761,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"
@@ -979,6 +1040,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"
@@ -1017,7 +1082,7 @@
"command": "powerpages.copilot.clearConversation",
"when": "view == powerpages.copilot",
"group": "navigation"
- }
+ }
],
"view/item/context": [
{
@@ -1030,6 +1095,56 @@
"when": "!virtualWorkspace && view == pacCLI.authPanel",
"group": "inline"
},
+ {
+ "command": "microsoft.powerplatform.pages.metadataDiff.generateReport",
+ "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",
+ "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@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@5"
+ },
+ {
+ "command": "microsoft.powerplatform.pages.metadataDiff.importReport",
+ "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffRoot && microsoft.powerplatform.pages.metadataDiffEnabled",
+ "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@5"
+ },
+ {
+ "command": "microsoft.powerplatform.pages.metadataDiff.importReport",
+ "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"
@@ -1167,6 +1282,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 || viewItem == inactiveSite)",
+ "group": "siteAction@9"
}
]
},
diff --git a/package.nls.json b/package.nls.json
index 7b98ca430..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)",
@@ -116,5 +117,13 @@
"microsoft.powerplatform.pages.actionsHub.activeSite.openInStudio.title": "Open in Power Pages Studio",
"microsoft.powerplatform.pages.actionsHub.inactiveSite.reactivateSite.title": "Reactivate Site",
"microsoft.powerplatform.pages.actionsHub.currentActiveSite.runCodeQLScreening.title": "Run CodeQL Screening",
- "microsoft.powerplatform.pages.actionsHub.configuration.downloadSite.description": "The folder where site will be downloaded when using Power Pages Actions. Leave this empty to ask for a folder every time you download a site."
+ "microsoft.powerplatform.pages.actionsHub.configuration.downloadSite.description": "The folder where site will be downloaded when using Power Pages Actions. Leave this empty to ask for a folder every time you download a 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 5a1c6fe39..6dbfe7fbf 100644
--- a/src/client/extension.ts
+++ b/src/client/extension.ts
@@ -54,6 +54,7 @@ import { PROVIDER_ID } from "../common/services/Constants";
import { activateServerApiAutocomplete } from "../common/intellisense";
import { EnableServerLogicChanges } from "../common/ecs-features/ecsFeatureGates";
import { setServerApiTelemetryContext } from "../common/intellisense/ServerApiTelemetryContext";
+import { MetadataDiffDesktop } from "./power-pages/metadata-diff/MetadataDiffDesktop";
let client: LanguageClient;
let _context: vscode.ExtensionContext;
@@ -281,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);
}),
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 8ded00abf..e429d86f4 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";
@@ -188,6 +188,14 @@ 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.executeCommandAndParseResults(new PacArguments("pages", "download", "-p", path, "-id", websiteId, "-mv", modelVersion));
+ }
+
+ public async pagesList(): Promise {
+ return this.executeCommandAndParseResults(new PacArguments("pages", "list", "--verbose"));
+ }
+
public exit(): void {
this.pacInterop.exit();
}
diff --git a/src/client/power-pages/actions-hub/ActionsHubCommandHandlers.ts b/src/client/power-pages/actions-hub/ActionsHubCommandHandlers.ts
index d4116e069..4bc8f1ce4 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 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,
+ 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..48be4107c 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";
@@ -21,6 +21,76 @@ 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";
+import { Constants as MDiffConstants } from "../../../common/power-pages/metadata-diff/Constants";
+
+// 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;
+ }
+
+ // 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
+ const children: MetadataDiffTreeItem[] = (this._item as any).getChildren ? (this._item as any).getChildren() : [];
+ if (!children || !Array.isArray(children)) {
+ return [];
+ }
+ return children.map(c => new MetadataDiffWrapperTreeItem(c));
+ }
+}
+
+class MetadataDiffGroupTreeItem extends ActionsHubTreeItem {
+ constructor(private readonly _provider: MetadataDiffTreeDataProvider, hasData: boolean, label: string, websiteName?: string, envName?: string) {
+ super(
+ label,
+ hasData ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.Collapsed,
+ // Use Split Editor icon (codicon split-horizontal) as requested
+ new vscode.ThemeIcon("split-horizontal"),
+ "metadataDiffRoot",
+ hasData ? "" : vscode.l10n.t("No data yet")
+ );
+ this.tooltip = hasData && websiteName && envName
+ ? vscode.l10n.t("Power Pages metadata comparison: {0} local vs {1}", websiteName, envName)
+ : vscode.l10n.t("Power Pages metadata comparison");
+ }
+
+ public getChildren(): ActionsHubTreeItem[] {
+ // 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,13 +103,36 @@ 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();
}),
@@ -162,6 +255,10 @@ export class ActionsHubTreeDataProvider implements vscode.TreeDataProvider {
return element;
}
@@ -195,13 +292,44 @@ export class ActionsHubTreeDataProvider implements vscode.TreeDataProvider 0;
+
+ 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);
+ }
+ 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 [
- new EnvironmentGroupTreeItem(currentEnvInfo, this._context, this._activeSites, this._inactiveSites),
- new OtherSitesGroupTreeItem(this._otherSites)
- ];
+
+ return rootItems;
} else {
// Login experience scenario
return [];
@@ -246,6 +374,8 @@ export class ActionsHubTreeDataProvider implements vscode.TreeDataProvider {
+ // 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;
+ }
+
+ // Determine scenario:
+ // Added locally: workspaceFile only
+ // Removed locally: storedFile only
+ // Modified: both files
+ const addedLocally = !!workspaceFile && !storedFile;
+ const removedLocally = !!storedFile && !workspaceFile;
+
+ // Build URIs (use virtual empty doc for missing side)
+ 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
+ });
+ 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_OPEN_DIFF_FAILED,
+ error as string,
+ error as Error
+ );
+ vscode.window.showErrorMessage("Failed to open diff view");
+ }
+ });
+
+ // 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) {
+ 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') {
+ 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)));
+ 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) {
+ // 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_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() });
+ if (!(await ensureActiveOrg(pacTerminal))) return;
+ MetadataDiffDesktop.resetTreeView();
+ 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() });
+ } else {
+ 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() }, {});
+ }
+ });
+
+ 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
+ // 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.");
+ oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_CLEAR_SUCCEEDED, { ...getMetadataDiffBaseEventInfo() });
+ } 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.triggerFlowWithSite", async (websiteId: string) => {
+ try {
+ oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_TRIGGER_FLOW_WITH_SITE_CALLED, { ...getMetadataDiffBaseEventInfo(), websiteId });
+ 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 });
+ } else {
+ 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 }, {});
+ }
+ });
+
+ 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();
+
+ 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.");
+ 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 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 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() });
+ }
+
+ }
+ catch (error) {
+ 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");
+ return;
+ }
+ const storagePath = context.storageUri?.fsPath;
+ if (!storagePath) {
+ vscode.window.showErrorMessage("Storage path not found");
+ return;
+ }
+
+ // Generate HTML report (was Markdown previously)
+ const htmlReport = await generateDiffReport(workspaceFolders[0].uri.fsPath, storagePath);
+
+ // Open source view (HTML) – helpful if user wants to copy/export.
+ const doc = await vscode.workspace.openTextDocument({
+ content: htmlReport,
+ language: 'html'
+ });
+ await vscode.window.showTextDocument(doc, {
+ preview: false,
+ viewColumn: vscode.ViewColumn.One,
+ preserveFocus: false
+ });
+
+ // 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;
+ oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_REPORT_GENERATE_SUCCEEDED, { ...getMetadataDiffBaseEventInfo(), htmlLength: htmlReport.length });
+ } catch (error) {
+ oneDSLoggerWrapper.getLogger().traceError(
+ Constants.EventNames.METADATA_DIFF_REPORT_GENERATE_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 {
+ 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");
+ 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) {
+ 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_EXPORT_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 {
+ oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_REPORT_IMPORT_CALLED, { ...getMetadataDiffBaseEventInfo() });
+ 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);
+ await treeDataProvider.setDiffFiles(report.files); // triggers refresh
+ 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_IMPORT_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
new file mode 100644
index 000000000..13d22cf54
--- /dev/null
+++ b/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts
@@ -0,0 +1,133 @@
+/*
+ * 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 } from "../../../common/power-pages/metadata-diff/Constants";
+import { MetadataDiffTreeDataProvider } from "../../../common/power-pages/metadata-diff/MetadataDiffTreeDataProvider";
+import { PacTerminal } from "../../lib/PacTerminal";
+import { PagesList } from "../../pac/PacTypes";
+import { registerMetadataDiffCommands } from "./MetadataDiffCommands";
+import { ActionsHubTreeDataProvider } from "../actions-hub/ActionsHubTreeDataProvider";
+
+export class MetadataDiffDesktop {
+ private static _isInitialized = false;
+ private static _treeDataProvider: MetadataDiffTreeDataProvider | undefined;
+
+ static isEnabled(): boolean {
+ const enableMetadataDiff = ECSFeaturesClient.getConfig(EnableMetadataDiff).enableMetadataDiff
+
+ if (enableMetadataDiff === undefined) {
+ return false;
+ }
+
+ 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);
+ }
+ }
+
+ /**
+ * 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;
+ }
+
+ 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 });
+
+ await registerMetadataDiffCommands(context, pacTerminal);
+
+ const treeDataProvider = MetadataDiffTreeDataProvider.initialize(context);
+ MetadataDiffDesktop._treeDataProvider = treeDataProvider;
+ // Integrate into Actions Hub instead of separate view
+ ActionsHubTreeDataProvider.setMetadataDiffProvider(treeDataProvider);
+ // Force refresh of Actions Hub to show new root node
+ vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh");
+
+ MetadataDiffDesktop._isInitialized = true;
+ oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_INITIALIZED);
+ } catch (exception) {
+ const exceptionError = exception as Error;
+ oneDSLoggerWrapper.getLogger().traceError(Constants.EventNames.METADATA_DIFF_INITIALIZATION_FAILED, exceptionError.message, exceptionError);
+ }
+ }
+
+ 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
+ // 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 '
+ 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: modelVersion
+ });
+ }
+ });
+ }
+ return pagesList.map((site) => {
+ return {
+ name: site.FriendlyName,
+ id: site.WebsiteId,
+ modelVersion: site.ModelVersion
+ }
+ });
+ }
+
+ return [];
+ }
+}
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/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/client/power-pages/metadata-diff/MetadataDiffUtils.ts b/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts
new file mode 100644
index 000000000..f8664f10d
--- /dev/null
+++ b/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts
@@ -0,0 +1,265 @@
+/*
+ * 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";
+import * as yaml from 'yaml';
+
+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 {
+ // Build a self‑contained HTML report (previously Markdown) so we can render it directly in a webview.
+ const generatedOn = new Date().toLocaleString();
+
+ const diffFiles = await getAllDiffFiles(workspacePath, storagePath);
+
+ // Group by folder
+ 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);
+ });
+
+ const sortedFolders = Array.from(folderStructure.keys()).sort();
+
+ // 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)}
`;
+
+ 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 += `
`;
+ const allowedChangeClasses = new Set(["modified","only-in-local","only-in-environment"]);
+ for (const file of files.sort((a, b) => a.relativePath.localeCompare(b.relativePath))) {
+ 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)}