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)}
    `; + + // YAML property level differences + if ((file.relativePath.toLowerCase().endsWith('.yml') || file.relativePath.toLowerCase().endsWith('.yaml')) && file.workspaceContent && file.storageContent && file.changes === 'Modified') { + try { + 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: unknown) { + body += `
    Failed to parse YAML content: ${escapeHtml(error instanceof Error ? error.message : String(error))}
    `; + } + } + body += '
  • '; + } + 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/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""'); + }); +}); diff --git a/src/common/ecs-features/ecsFeatureGates.ts b/src/common/ecs-features/ecsFeatureGates.ts index f1b2d7da2..ce7fdb158 100644 --- a/src/common/ecs-features/ecsFeatureGates.ts +++ b/src/common/ecs-features/ecsFeatureGates.ts @@ -99,3 +99,13 @@ export const { enableCodeQlScan: 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..fac84bd5c --- /dev/null +++ b/src/common/power-pages/metadata-diff/Constants.ts @@ -0,0 +1,63 @@ +/* + * 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_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.", + 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 new file mode 100644 index 000000000..65c5e3c6d --- /dev/null +++ b/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts @@ -0,0 +1,399 @@ +/* + * 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 { getMetadataDiffBaseEventInfo } from "../../../client/power-pages/metadata-diff/MetadataDiffTelemetry"; +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; + // 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(); + 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; + } + + public static initialize(context: vscode.ExtensionContext): MetadataDiffTreeDataProvider { + return new MetadataDiffTreeDataProvider(context); + } + + private refresh(): void { + this._onDidChangeTreeData.fire(); + } + + /** Public accessor used by tests (avoids reflection) */ + public get items(): ReadonlyArray { + 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 { + 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' }, + {} + ); + } + } + + 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> { + const diffFiles = new Map(); + + // Get website directory from storage path + const websitePath = await this.getWebsitePath(storagePath); + if (!websitePath) { + return diffFiles; + } + + 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; + } + + // 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 (workspaceContent !== storageContent) { + diffFiles.set(relativePath, { workspaceFile, storageFile }); + } + } + + // 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 (this._diffItems && this._diffItems.length > 0) { + return this._diffItems; + } + + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_GET_CHILDREN_CALLED, { methodName: 'getChildren', ...getMetadataDiffBaseEventInfo() }); + 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("Metadata Diff storage path is not defined"); + } + + const diffFiles = await this.getDiffFiles(workspacePath, storagePath); + if (diffFiles.size === 0) { + this._diffItems = []; + vscode.commands.executeCommand("setContext", "microsoft.powerplatform.pages.metadataDiff.hasData", false); + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_NO_DIFFERENCES, { methodName: 'getChildren', ...getMetadataDiffBaseEventInfo() }); + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_GET_CHILDREN_COMPLETED, { methodName: 'getChildren', result: 'noDifferences', ...getMetadataDiffBaseEventInfo() }); + return []; + } + + const items = this.buildTreeHierarchy(diffFiles); + this._diffItems = items; + 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(); + } + // Emit file composition metrics once + if (!this._emittedFileCounts) { + this._emittedFileCounts = true; + const counts = this.calculateFileCounts(diffFiles); + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_FILE_COUNTS, { methodName: 'getChildren', ...counts, ...getMetadataDiffBaseEventInfo() }); + } + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_GET_CHILDREN_COMPLETED, { methodName: 'getChildren', result: 'success', itemCount: this._diffItems.length, ...getMetadataDiffBaseEventInfo() }); + return items; + } catch (error) { + oneDSLoggerWrapper.getLogger().traceError( + Constants.EventNames.METADATA_DIFF_CURRENT_ENV_FETCH_FAILED, + error as string, + error as Error, + { methodName: 'getChildren', ...getMetadataDiffBaseEventInfo() }, + {} + ); + return null; + } + } + + private buildTreeHierarchy(filePathMap: Map): MetadataDiffTreeItem[] { + const rootNode = new MetadataDiffFolderItem(''); + + for (const [relativePath, { workspaceFile, storageFile }] of filePathMap.entries()) { + const parts = 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) { + folderNode = new MetadataDiffFolderItem(folderName); + currentNode.getChildrenMap().set(folderName, folderNode); + } + + currentNode = folderNode; + } + + // 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); + } + + // Convert the root's children map to array + return Array.from(rootNode.getChildrenMap().values()); + } + + private getChangeDescription(workspaceFile?: string, storageFile?: string): string { + if (!workspaceFile) return vscode.l10n.t("Only in Environment"); + if (!storageFile) return vscode.l10n.t("Only in Local"); + return vscode.l10n.t("Modified"); + } + + /** + * Injects a pre-computed diff set (e.g., from imported report) replacing current state. + */ + async setDiffFiles(files: DiffFile[]): Promise { + 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)); + + // 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 }); + } + + try { + 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 }); + + // Extract original file extension to preserve it + const fileExt = path.extname(fileName); + + let workspaceFilePath: string | undefined; + let storageFilePath: string | undefined; + + if (file.workspaceContent) { + // Use the original file extension in the temp file to preserve language identification + workspaceFilePath = `${filePath}.workspace${fileExt}`; + fs.writeFileSync(workspaceFilePath, file.workspaceContent); + } + + if (file.storageContent) { + // Use the original file extension in the temp file to preserve language identification + storageFilePath = `${filePath}.storage${fileExt}`; + fs.writeFileSync(storageFilePath, file.storageContent); + } + + 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(); + 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, ...getMetadataDiffBaseEventInfo() }); + } catch (error) { + oneDSLoggerWrapper.getLogger().traceError( + Constants.EventNames.METADATA_DIFF_SET_FILES_FAILED, + error as string, + error as Error, + { methodName: 'setDiffFiles', ...getMetadataDiffBaseEventInfo() }, + {} + ); + throw error; + } + } + + private calculateFileCounts(diffFiles: Map) { + 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) }; + } +} 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..d0a69f572 --- /dev/null +++ b/src/common/power-pages/metadata-diff/tree-items/MetadataDiffFileItem.ts @@ -0,0 +1,48 @@ +/* + * 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, 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, + contextValue, + workspaceFile, + 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"); + + 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 new file mode 100644 index 000000000..c2baeccbb --- /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, 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 new file mode 100644 index 000000000..6ab0b9442 --- /dev/null +++ b/src/common/power-pages/metadata-diff/tree-items/MetadataDiffTreeItem.ts @@ -0,0 +1,42 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import * as vscode from "vscode"; + +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 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) { + this.command = { + command: "metadataDiff.openDiff", + title: "Open Diff", + arguments: [filePath, storedFilePath] // Pass file paths to the command + }; + } + } + + public getChildren(): MetadataDiffTreeItem[] { + return Array.from(this._childrenMap.values()); + } + + public getChildrenMap(): Map { + return this._childrenMap; + } +} 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; + }); +}); 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; }