diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index 709fbf898..3637c2e13 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -178,6 +178,14 @@ "Upload changes": "Upload changes", "The preview shown is for published changes. Please publish any pending changes to see them in the preview.": "The preview shown is for published changes. Please publish any pending changes to see them in the preview.", "Please login to preview the site.": "Please login to preview the site.", + "Discard local changes to \"{0}\"? This will overwrite the local file with the server copy.": "Discard local changes to \"{0}\"? This will overwrite the local file with the server copy.", + "Discard": "Discard", + "Local changes discarded for \"{0}\".": "Local changes discarded for \"{0}\".", + "Re-syncing website metadata": "Re-syncing website metadata", + "Retrieving \"{0}\" as {1} data model. Please wait...": "Retrieving \"{0}\" as {1} data model. Please wait...", + "Comparing metadata of \"{0}\"...": "Comparing metadata of \"{0}\"...", + "You can now view the comparison": "You can now view the comparison", + "Downloading website metadata": "Downloading website metadata", "Enter the name of the web template": "Enter the name of the web template", "Please enter a name for the web template.": "Please enter a name for the web template.", "A webtemplate with the same name already exists. Please enter a different name.": "A webtemplate with the same name already exists. Please enter a different name.", @@ -334,6 +342,13 @@ "Error creating config file: {0}": "Error creating config file: {0}", "PowerPages config file updated successfully: {0}": "PowerPages config file updated successfully: {0}", "Error updating config file: {0}": "Error updating config file: {0}", + "No data yet": "No data yet", + "Power Pages metadata comparison: {0} local vs {1}": "Power Pages metadata comparison: {0} local vs {1}", + "Power Pages metadata comparison": "Power Pages metadata comparison", + "Metadata Diff": "Metadata Diff", + "Local": "Local", + "Current Environment": "Current Environment", + "Metadata Diff ({0} (Local <-> {1}))": "Metadata Diff ({0} (Local <-> {1}))", "Timestamp: {0}/{0} is the timestamp": { "message": "Timestamp: {0}", "comment": [ diff --git a/loc/translations-export/vscode-powerplatform.xlf b/loc/translations-export/vscode-powerplatform.xlf index 5238c4521..4abd99a34 100644 --- a/loc/translations-export/vscode-powerplatform.xlf +++ b/loc/translations-export/vscode-powerplatform.xlf @@ -215,6 +215,9 @@ Check the CodeQL extension panel for available queries. Command failed with exit code: {0} + + Comparing metadata of "{0}"... + Confirm @@ -260,6 +263,9 @@ Check the CodeQL extension panel for available queries. Current + + Current Environment + Current site path not found. @@ -277,6 +283,12 @@ Check the CodeQL extension panel for available queries. Default Environment: {0} The {0} represents profile's resource/environment URL + + Discard + + + Discard local changes to "{0}"? This will overwrite the local file with the server copy. + Dislike something? Tell us more. @@ -303,6 +315,9 @@ The {3} represents Solution's Type (Managed or Unmanaged), but that test is loca Download failed: {0} + + Downloading website metadata + Edit the site @@ -537,6 +552,12 @@ Return to this chat and @powerpages can help you write and edit your website cod Line: {0} + + Local + + + Local changes discarded for "{0}". + Login @@ -559,6 +580,12 @@ Return to this chat and @powerpages can help you write and edit your website cod Maximum 30 characters allowed + + Metadata Diff + + + Metadata Diff ({0} (Local <-> {1})) + Microsoft wants your feedback @@ -604,6 +631,9 @@ The {3} represents Dataverse Environment's Organization ID (GUID) No analysis results found. + + No data yet + No environments found @@ -727,6 +757,12 @@ The {3} represents Dataverse Environment's Organization ID (GUID) Power Pages Copilot is now connected to the environment: {0} : {1} {0} represents the environment name + + Power Pages metadata comparison + + + Power Pages metadata comparison: {0} local vs {1} + Power Pages site download completed successfully. Would you like to open the downloaded site folder? @@ -762,6 +798,9 @@ The {3} represents Dataverse Environment's Organization ID (GUID) Profile Kind: {0} The {0} represents the profile type (Admin vs Dataverse) + + Re-syncing website metadata + Ready to select download folder @@ -771,6 +810,9 @@ The {3} represents Dataverse Environment's Organization ID (GUID) Results opened in SARIF viewer successfully. + + Retrieving "{0}" as {1} data model. Please wait... + Running CodeQL analysis... @@ -1048,6 +1090,9 @@ The {3} represents Dataverse Environment's Organization ID (GUID) You are editing a live, public site + + You can now view the comparison + You can use this in <a href="#" id="github-copilot-link">GitHub Copilot with @powerpages</a> and leverage best of both world. @@ -1095,6 +1140,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 @@ -1228,6 +1282,9 @@ The second line should be '[TRANSLATION HERE](command:powerplatform-walkthrough. POWER PAGES ACTIONS + + POWER PAGES METADATA COMPARATOR + Power Platform diff --git a/package.json b/package.json index 2b5874d87..f0b3daef8 100644 --- a/package.json +++ b/package.json @@ -199,6 +199,11 @@ "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" + }, { "view": "microsoft.powerplatform.pages.actionsHub", "contents": "%pacCLI.authPanel.welcome.whenInteractiveNotSupported%", @@ -478,6 +483,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%" @@ -495,6 +504,72 @@ "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": "microsoft.powerplatform.pages.metadataDiff.openComparison", + "title": "Open Comparison", + "category": "Power Pages Metadata Diff", + "icon": "$(diff)" + }, + { + "command": "microsoft.powerplatform.pages.metadataDiff.discardLocalChanges", + "title": "Discard Local Changes", + "category": "Power Pages Metadata Diff", + "icon": "$(discard)" + }, + { + "command": "microsoft.powerplatform.pages.metadataDiff.discardLocalChanges.modified", + "title": "Revert to Environment Version", + "category": "Power Pages Metadata Diff", + "icon": "$(discard)" + }, + { + "command": "microsoft.powerplatform.pages.metadataDiff.discardLocalChanges.deleteLocal", + "title": "Delete Local File", + "category": "Power Pages Metadata Diff", + "icon": "$(trash)" + }, + { + "command": "microsoft.powerplatform.pages.metadataDiff.discardLocalChanges.materialize", + "title": "Add Local Copy", + "category": "Power Pages Metadata Diff", + "icon": "$(cloud-download)" } ], "configuration": { @@ -771,6 +846,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" @@ -989,6 +1068,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" @@ -1040,6 +1123,66 @@ "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": "microsoft.powerplatform.pages.metadataDiff.openComparison", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffFileModified && microsoft.powerplatform.pages.metadataDiffEnabled", + "group": "metadataDiffFile@1" + }, + { + "command": "microsoft.powerplatform.pages.metadataDiff.discardLocalChanges.modified", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffFileModified && microsoft.powerplatform.pages.metadataDiffEnabled", + "group": "metadataDiffFile@2" + }, + { + "command": "microsoft.powerplatform.pages.metadataDiff.discardLocalChanges.deleteLocal", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffFileOnlyLocal && microsoft.powerplatform.pages.metadataDiffEnabled", + "group": "metadataDiffFile@2" + }, + { + "command": "microsoft.powerplatform.pages.metadataDiff.discardLocalChanges.materialize", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffFileOnlyRemote && microsoft.powerplatform.pages.metadataDiffEnabled", + "group": "metadataDiffFile@2" + }, { "command": "pacCLI.authPanel.nameAuthProfile", "when": "!virtualWorkspace && view == pacCLI.authPanel" @@ -1177,6 +1320,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..1069b733c 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; @@ -311,6 +312,8 @@ export async function activate( const workspaceFolderWatcher = vscode.workspace.onDidChangeWorkspaceFolders(handleWorkspaceFolderChange); _context.subscriptions.push(workspaceFolderWatcher); + MetadataDiffDesktop.initialize(context, pacTerminal) + if (shouldEnableDebugger()) { activateDebugger(context); } diff --git a/src/client/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 022abd704..772128e23 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"; @@ -229,6 +229,10 @@ export class PacWrapper { // The next operation will create a new process } + 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 c0f6bcbb4..bc912fd73 100644 --- a/src/client/power-pages/actions-hub/ActionsHubCommandHandlers.ts +++ b/src/client/power-pages/actions-hub/ActionsHubCommandHandlers.ts @@ -703,6 +703,35 @@ 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 { + 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..bbe5b32f9 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, downloadSite, openInStudio, reactivateSite, runCodeQLScreening, loginToMatch, compareWithLocal } from "./ActionsHubCommandHandlers"; import PacContext from "../../pac/PacContext"; import CurrentSiteContext from "./CurrentSiteContext"; import { IOtherSiteInfo, IWebsiteDetails } from "../../../common/services/Interfaces"; @@ -21,6 +21,8 @@ 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 { MetadataDiffGroupTreeItem } from "./tree-items/MetadataDiffGroupTreeItem"; export class ActionsHubTreeDataProvider implements vscode.TreeDataProvider { private readonly _disposables: vscode.Disposable[] = []; @@ -33,6 +35,7 @@ export class ActionsHubTreeDataProvider implements vscode.TreeDataProvider { return element; } @@ -195,13 +202,33 @@ export class ActionsHubTreeDataProvider implements vscode.TreeDataProvider 0; + + let label = Constants.Strings.METADATA_DIFF; + let websiteName: string | undefined; + let envName: string | undefined; + if (hasData) { + const workspaceFolders = vscode.workspace.workspaceFolders; + websiteName = workspaceFolders && workspaceFolders.length > 0 ? workspaceFolders[0].name : Constants.Strings.LOCAL; + envName = authInfo?.OrganizationFriendlyName || Constants.Strings.CURRENT_ENVIRONMENT; + label = vscode.l10n.t(Constants.Strings.METADATA_DIFF_FORMAT, websiteName, envName); + } + rootItems.push(new MetadataDiffGroupTreeItem(mdProvider, hasData, label, websiteName, envName)); + } + + return rootItems; } else { // Login experience scenario return []; @@ -246,6 +273,8 @@ export class ActionsHubTreeDataProvider implements vscode.TreeDataProvider {1}))"), + LOCAL: vscode.l10n.t("Local"), + CURRENT_ENVIRONMENT: vscode.l10n.t("Current Environment"), NO_SITES_FOUND: vscode.l10n.t("No sites found"), NO_ENVIRONMENTS_FOUND: vscode.l10n.t("No environments found"), SELECT_ENVIRONMENT: vscode.l10n.t("Select an environment"), @@ -187,6 +191,8 @@ export const Constants = { ACTIONS_HUB_SHOW_SITE_DETAILS_CALLED: "ActionsHubShowSiteDetailsCalled", ACTIONS_HUB_SHOW_SITE_DETAILS_FAILED: "ActionsHubShowSiteDetailsFailed", ACTIONS_HUB_SHOW_SITE_DETAILS_COPY_TO_CLIPBOARD: "ActionsHubShowSiteDetailsCopyToClipboard", + ACTIONS_HUB_COMPARE_WITH_LOCAL_CALLED: "ActionsHubCompareWithLocalCalled", + ACTIONS_HUB_COMPARE_WITH_LOCAL_FAILED: "ActionsHubCompareWithLocalFailed", ACTIONS_HUB_DOWNLOAD_SITE_CALLED: "ActionsHubDownloadSiteCalled", ACTIONS_HUB_DOWNLOAD_SITE_FAILED: "ActionsHubDownloadSiteFailed", ACTIONS_HUB_DOWNLOAD_SITE_PAC_TRIGGERED: "ActionsHubDownloadSitePacTriggered", diff --git a/src/client/power-pages/actions-hub/tree-items/MetadataDiffGroupTreeItem.ts b/src/client/power-pages/actions-hub/tree-items/MetadataDiffGroupTreeItem.ts new file mode 100644 index 000000000..5f5c9a5fa --- /dev/null +++ b/src/client/power-pages/actions-hub/tree-items/MetadataDiffGroupTreeItem.ts @@ -0,0 +1,42 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import * as vscode from "vscode"; +import { ActionsHubTreeItem } from "./ActionsHubTreeItem"; +import { MetadataDiffTreeDataProvider } from "../../../../common/power-pages/metadata-diff/MetadataDiffTreeDataProvider"; +import { MetadataDiffTreeItem } from "../../../../common/power-pages/metadata-diff/tree-items/MetadataDiffTreeItem"; +import { MetadataDiffWrapperTreeItem } from "./MetadataDiffWrapperTreeItem"; + +/** + * Root group node for Metadata Diff items within the Actions Hub tree. + */ +export class MetadataDiffGroupTreeItem extends ActionsHubTreeItem { + constructor(private readonly _provider: MetadataDiffTreeDataProvider, hasData: boolean, label: string, websiteName?: string, envName?: string) { + super( + label, + hasData ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.Collapsed, + new vscode.ThemeIcon("split-horizontal"), + "metadataDiffRoot", + hasData ? "" : vscode.l10n.t("No data yet") + ); + this.tooltip = hasData && websiteName && envName + ? vscode.l10n.t("Power Pages metadata comparison: {0} local vs {1}", websiteName, envName) + : vscode.l10n.t("Power Pages metadata comparison"); + } + + public getChildren(): ActionsHubTreeItem[] { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + // @ts-expect-error Accessing private field for integration wrapper + if (this._provider._diffItems?.length === 0) { + this._provider.getChildren(); + } + // @ts-expect-error Accessing private field for integration wrapper + const items: MetadataDiffTreeItem[] | null | undefined = this._provider._diffItems || []; + if (!items || !Array.isArray(items)) { + return []; + } + return items.map(i => new MetadataDiffWrapperTreeItem(i)); + } +} diff --git a/src/client/power-pages/actions-hub/tree-items/MetadataDiffWrapperTreeItem.ts b/src/client/power-pages/actions-hub/tree-items/MetadataDiffWrapperTreeItem.ts new file mode 100644 index 000000000..a228e8bc2 --- /dev/null +++ b/src/client/power-pages/actions-hub/tree-items/MetadataDiffWrapperTreeItem.ts @@ -0,0 +1,44 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import * as vscode from "vscode"; +import { ActionsHubTreeItem } from "./ActionsHubTreeItem"; +import { MetadataDiffTreeItem } from "../../../../common/power-pages/metadata-diff/tree-items/MetadataDiffTreeItem"; + +/** + * Wrapper tree item for metadata diff entries to integrate with existing Actions Hub tree. + */ +export class MetadataDiffWrapperTreeItem extends ActionsHubTreeItem { + constructor(private readonly _item: MetadataDiffTreeItem) { + super( + _item.label?.toString(), + _item.collapsibleState ?? vscode.TreeItemCollapsibleState.None, + _item.iconPath || new vscode.ThemeIcon("file"), + _item.contextValue || "metadataDiffItem", + (typeof _item.description === "string" ? _item.description : "") + ); + this.command = _item.command; + this.tooltip = _item.tooltip; + } + + public get filePath(): string | undefined { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (this._item as any).filePath; + } + + public get storedFilePath(): string | undefined { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (this._item as any).storedFilePath; + } + + public getChildren(): ActionsHubTreeItem[] { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const children: MetadataDiffTreeItem[] = (this._item as any).getChildren ? (this._item as any).getChildren() : []; + if (!children || !Array.isArray(children)) { + return []; + } + return children.map(c => new MetadataDiffWrapperTreeItem(c)); + } +} diff --git a/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts new file mode 100644 index 000000000..aadddb18b --- /dev/null +++ b/src/client/power-pages/metadata-diff/MetadataDiffCommands.ts @@ -0,0 +1,719 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import * as vscode from "vscode"; +import * as fs from "fs"; +import * as path from "path"; +import { PacTerminal } from "../../lib/PacTerminal"; +import { Constants, SUCCESS } from "../../../common/power-pages/metadata-diff/Constants"; +import { oneDSLoggerWrapper } from "../../../common/OneDSLoggerTelemetry/oneDSLoggerWrapper"; +import { MetadataDiffTreeDataProvider } from "../../../common/power-pages/metadata-diff/MetadataDiffTreeDataProvider"; +import { MetadataDiffDesktop } from "./MetadataDiffDesktop"; +import { ActionsHubTreeDataProvider } from "../actions-hub/ActionsHubTreeDataProvider"; +import { fetchWebsites } from "../actions-hub/ActionsHubCommandHandlers"; +import { WebsiteDataModel } from "../../../common/services/Constants"; +import { IWebsiteDetails } from "../../../common/services/Interfaces"; +import { createAuthProfileExp } from "../../../common/utilities/PacAuthUtil"; +import { getWebsiteRecordId } from "../../../common/utilities/WorkspaceInfoFinderUtil"; +import { generateDiffReport, getAllDiffFiles, MetadataDiffReport } from "./MetadataDiffUtils"; +import { getBaseEventInfo } from "../actions-hub/TelemetryHelper"; +import PacContext from "../../pac/PacContext"; + +// ---------------- Helper Functions (internal) ---------------- +function getWorkspaceAndWebsiteId(): { workspacePath: string, websiteId: string } | undefined { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + vscode.window.showErrorMessage("No folders opened in the current workspace."); + return undefined; + } + const workspacePath = workspaceFolders[0].uri.fsPath; + const websiteId = getWebsiteRecordId(workspacePath); + if (!websiteId) { + vscode.window.showErrorMessage("Unable to determine website id from workspace."); + return undefined; + } + return { workspacePath, websiteId }; +} + +function recreateStorageDirectory(storagePath: string | undefined): string | undefined { + if (!storagePath) { + vscode.window.showErrorMessage("Storage path not found"); + return undefined; + } + try { + if (fs.existsSync(storagePath)) { + fs.rmSync(storagePath, { recursive: true, force: true }); + } + fs.mkdirSync(storagePath, { recursive: true }); + return storagePath; + } catch (error) { + oneDSLoggerWrapper.getLogger().traceError("MetadataDiffStoragePrepFailed", (error as Error).message, error as Error); + vscode.window.showErrorMessage("Failed to prepare storage directory"); + return undefined; + } +} + +function findSite(websiteId: string, active: IWebsiteDetails[], inactive: IWebsiteDetails[]): IWebsiteDetails | undefined { + const lower = websiteId.toLowerCase(); + return [...active, ...inactive].find(s => s.websiteRecordId.toLowerCase() === lower); +} + +async function initProviderAndRefresh(context: vscode.ExtensionContext): Promise { + const provider = MetadataDiffTreeDataProvider.initialize(context); + MetadataDiffDesktop.setTreeDataProvider(provider); + ActionsHubTreeDataProvider.setMetadataDiffProvider(provider); + await provider.getChildren(); + vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); +} + +async function executeDiffWorkflow( + context: vscode.ExtensionContext, + pacTerminal: PacTerminal, + websiteId: string, + progressTitle: string +): Promise { + const storagePath = recreateStorageDirectory(context.storageUri?.fsPath); + if (!storagePath) { return false; } + const pacWrapper = pacTerminal.getWrapper(); + let success = false; + const progressOptions: vscode.ProgressOptions = { location: vscode.ProgressLocation.Notification, title: progressTitle, cancellable: false }; + await vscode.window.withProgress(progressOptions, async (progress) => { + progress.report({ message: "Looking for this website in the connected environment..." }); + const websites = await fetchWebsites(); + const site = findSite(websiteId, websites.activeSites, websites.inactiveSites); + if (!site) { + vscode.window.showErrorMessage("Website not found in the connected environment."); + return; + } + const modelVersion: 1 | 2 = site.dataModel === WebsiteDataModel.Standard ? 1 : 2; + progress.report({ message: vscode.l10n.t('Retrieving "{0}" as {1} data model. Please wait...', site.name, modelVersion === 2 ? 'enhanced' : 'standard') }); + const download = await pacWrapper.downloadSite(storagePath, websiteId, modelVersion); + if (!download) { return; } + progress.report({ message: vscode.l10n.t('Comparing metadata of "{0}"...', site.name) }); + await initProviderAndRefresh(context); + success = true; + }); + return success; +} + +// Exported for tests to exercise discard logic without needing command registration +export async function performDiscard( + context: vscode.ExtensionContext, + itemOrWorkspace?: unknown, + maybeStored?: unknown +): Promise<{ actionType?: 'overwrite' | 'delete' | 'materialize' } | undefined> { + let workspaceFile: string | undefined; + let storedFile: string | undefined; + let relativePath: string | undefined; + if (typeof itemOrWorkspace === 'string') { + workspaceFile = itemOrWorkspace; + storedFile = typeof maybeStored === 'string' ? maybeStored : undefined; + } else if (itemOrWorkspace && typeof itemOrWorkspace === 'object') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const obj: any = itemOrWorkspace; + workspaceFile = obj.filePath || obj.workspaceFile; + storedFile = obj.storedFilePath || obj.storageFile; + relativePath = obj.relativePath; + } + const workspaceFolders = vscode.workspace.workspaceFolders; + const workspaceRoot = workspaceFolders && workspaceFolders.length ? workspaceFolders[0].uri.fsPath : undefined; + let actionType: 'overwrite' | 'delete' | 'materialize' | undefined; + + if (workspaceFile && storedFile) { + const confirm = await vscode.window.showWarningMessage( + vscode.l10n.t('Discard local changes to "{0}"? This will overwrite the local file with the environment copy.', path.basename(workspaceFile)), + { modal: true }, + vscode.l10n.t('Discard') + ); + if (confirm !== vscode.l10n.t('Discard')) { return; } + const remoteContent = fs.readFileSync(storedFile, 'utf8'); + fs.writeFileSync(workspaceFile, remoteContent, 'utf8'); + vscode.window.showInformationMessage(vscode.l10n.t('Local changes discarded for "{0}".', path.basename(workspaceFile))); + actionType = 'overwrite'; + } else if (workspaceFile && !storedFile) { + const confirm = await vscode.window.showWarningMessage( + vscode.l10n.t('Delete local file "{0}"? (It does not exist in the environment)', path.basename(workspaceFile)), + { modal: true }, + vscode.l10n.t('Delete') + ); + if (confirm !== vscode.l10n.t('Delete')) { return; } + if (fs.existsSync(workspaceFile)) { + fs.unlinkSync(workspaceFile); + } + vscode.window.showInformationMessage(vscode.l10n.t('Local file "{0}" deleted.', path.basename(workspaceFile))); + actionType = 'delete'; + } else if (!workspaceFile && storedFile && workspaceRoot) { + let targetRelative = relativePath; + if (!targetRelative) { + const storageRoot = context.storageUri?.fsPath; + if (storageRoot) { + try { + if (storedFile.startsWith(storageRoot)) { + const remainder = storedFile.substring(storageRoot.length).replace(/^\\|\//, ''); + const parts = remainder.split(/\\|\//).filter(p => !!p); + if (parts.length > 1) { + targetRelative = parts.slice(1).join('/'); + } + } + } catch { /* ignore */ } + } + } + if (!targetRelative) { + vscode.window.showWarningMessage(vscode.l10n.t('Unable to materialize environment file locally (path resolution failed).')); + return; + } + const targetPath = path.join(workspaceRoot, targetRelative); + const confirm = await vscode.window.showWarningMessage( + vscode.l10n.t('Create local copy of environment file "{0}"?', targetRelative), + { modal: true }, + vscode.l10n.t('Create') + ); + if (confirm !== vscode.l10n.t('Create')) { return; } + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + const remoteContent = fs.readFileSync(storedFile, 'utf8'); + fs.writeFileSync(targetPath, remoteContent, 'utf8'); + vscode.window.showInformationMessage(vscode.l10n.t('Environment file "{0}" created locally.', targetRelative)); + actionType = 'materialize'; + } else { + vscode.window.showWarningMessage(vscode.l10n.t('Unable to process discard action for this item.')); + return; + } + + const provider = MetadataDiffDesktop['_treeDataProvider'] as MetadataDiffTreeDataProvider | undefined; + if (provider) { + await provider.recomputeDiff(); + } else { + vscode.commands.executeCommand('microsoft.powerplatform.pages.actionsHub.refresh'); + } + return { actionType }; +} + +export async function registerMetadataDiffCommands(context: vscode.ExtensionContext, pacTerminal: PacTerminal): Promise { + vscode.commands.registerCommand('microsoft.powerplatform.pages.metadataDiff.openDiff', async (workspaceFile?: string, storedFile?: string) => { + oneDSLoggerWrapper.getLogger().traceInfo( + Constants.EventNames.METADATA_DIFF_COMMAND_EXECUTED, + { command: 'openDiff', hasWorkspaceFile: !!workspaceFile, hasStoredFile: !!storedFile, ...getBaseEventInfo() } + ); + try { + if (!workspaceFile && !storedFile) { + vscode.window.showWarningMessage(vscode.l10n.t("No file paths provided for diff.")); + return; + } + + const tempRoot = path.join(context.storageUri?.fsPath || '', 'tempDiff'); + if (!fs.existsSync(tempRoot)) { + fs.mkdirSync(tempRoot, { recursive: true }); + } + + const makeEmptySide = (basename: string, suffix: string) => { + const emptyPath = path.join(tempRoot, `${basename}.${suffix}.empty`); + if (!fs.existsSync(emptyPath)) { + fs.writeFileSync(emptyPath, ''); + } + return emptyPath; + }; + + let leftUri: vscode.Uri; + let rightUri: vscode.Uri; + let title: string; + + if (workspaceFile && storedFile) { + leftUri = vscode.Uri.file(storedFile); + rightUri = vscode.Uri.file(workspaceFile); + title = vscode.l10n.t('{0} (Modified)', path.basename(workspaceFile)); + } else if (workspaceFile && !storedFile) { + const base = path.basename(workspaceFile); + const emptyPath = makeEmptySide(base, 'env'); + leftUri = vscode.Uri.file(emptyPath); + rightUri = vscode.Uri.file(workspaceFile); + title = vscode.l10n.t('{0} (Only in Local)', base); + } else { + const base = path.basename(storedFile!); + const emptyPath = makeEmptySide(base, 'local'); + leftUri = vscode.Uri.file(storedFile!); + rightUri = vscode.Uri.file(emptyPath); + title = vscode.l10n.t('{0} (Only in Environment)', base); + } + + await vscode.commands.executeCommand('vscode.diff', leftUri, rightUri, title); + } catch (error) { + oneDSLoggerWrapper.getLogger().traceError( + Constants.EventNames.METADATA_DIFF_REPORT_FAILED, + error as string, + error as Error + ); + vscode.window.showErrorMessage('Failed to open diff view'); + } + }); + + vscode.commands.registerCommand('microsoft.powerplatform.pages.metadataDiff.openComparison', async (itemOrWorkspace?: unknown, maybeStored?: unknown) => { + oneDSLoggerWrapper.getLogger().traceInfo( + Constants.EventNames.METADATA_DIFF_COMMAND_EXECUTED, + { command: 'openComparison', argType: typeof itemOrWorkspace, ...getBaseEventInfo() } + ); + let workspaceFile: string | undefined; + let storedFile: string | undefined; + if (typeof itemOrWorkspace === 'string') { + 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; + } + // Allow opening for added / removed as well (one-sided) + if (workspaceFile || storedFile) { + await vscode.commands.executeCommand('microsoft.powerplatform.pages.metadataDiff.openDiff', workspaceFile, storedFile); + } else { + vscode.window.showWarningMessage(vscode.l10n.t("Unable to open comparison for this item.")); + } + }); + + // Helper that performs discard logic and returns actionType for telemetry. + function registerDiscardCommand(commandId: string) { + vscode.commands.registerCommand(commandId, async (itemOrWorkspace?: unknown, maybeStored?: unknown) => { + oneDSLoggerWrapper.getLogger().traceInfo( + Constants.EventNames.METADATA_DIFF_COMMAND_EXECUTED, + { command: commandId.split('.').slice(-1)[0], variantCommandId: commandId, phase: 'start', ...getBaseEventInfo() } + ); + try { + const result = await performDiscard(context, itemOrWorkspace, maybeStored); + if (result?.actionType) { + oneDSLoggerWrapper.getLogger().traceInfo( + Constants.EventNames.METADATA_DIFF_COMMAND_EXECUTED, + { command: 'discardLocalChanges', variantCommandId: commandId, actionType: result.actionType, phase: 'completed', ...getBaseEventInfo() } + ); + } + } catch (error) { + oneDSLoggerWrapper.getLogger().traceError( + Constants.EventNames.METADATA_DIFF_REPORT_FAILED, + error as string, + error as Error + ); + vscode.window.showErrorMessage('Failed to discard local changes'); + } + }); + } + + // Base (legacy) command id retained for compatibility, plus contextual variants. + registerDiscardCommand('microsoft.powerplatform.pages.metadataDiff.discardLocalChanges'); + registerDiscardCommand('microsoft.powerplatform.pages.metadataDiff.discardLocalChanges.modified'); + registerDiscardCommand('microsoft.powerplatform.pages.metadataDiff.discardLocalChanges.deleteLocal'); + registerDiscardCommand('microsoft.powerplatform.pages.metadataDiff.discardLocalChanges.materialize'); + + vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.resync", async () => { + oneDSLoggerWrapper.getLogger().traceInfo( + Constants.EventNames.METADATA_DIFF_COMMAND_EXECUTED, + { command: 'resync', ...getBaseEventInfo() } + ); + try { + // Only proceed if we already have data (context set by menu 'when' clause) + const pacActiveOrg = PacContext.OrgInfo; + if (!pacActiveOrg) { + vscode.window.showErrorMessage("No active environment found. Please authenticate first."); + return; + } + MetadataDiffDesktop.resetTreeView(); + const ws = getWorkspaceAndWebsiteId(); + if (!ws) { return; } + const success = await executeDiffWorkflow(context, pacTerminal, ws.websiteId, vscode.l10n.t("Re-syncing website metadata")); + if (success) { + vscode.window.showInformationMessage(vscode.l10n.t("You can now view the comparison")); + } else { + vscode.window.showErrorMessage("Failed to re-sync metadata."); + } + } catch (error) { + oneDSLoggerWrapper.getLogger().traceError(Constants.EventNames.METADATA_DIFF_REFRESH_FAILED, error as string, error as Error, { methodName: null }, {}); + } + }); + + vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.clearView", async () => { + oneDSLoggerWrapper.getLogger().traceInfo( + Constants.EventNames.METADATA_DIFF_COMMAND_EXECUTED, + { command: 'clearView', ...getBaseEventInfo() } + ); + 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."); + } 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"); + } + }); + + // Accept either a raw websiteId (string) or a SiteTreeItem / object carrying siteInfo.websiteId + vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.triggerFlowWithSite", async (arg?: unknown) => { + oneDSLoggerWrapper.getLogger().traceInfo( + Constants.EventNames.METADATA_DIFF_COMMAND_EXECUTED, + { command: 'triggerFlowWithSite', argType: typeof arg, ...getBaseEventInfo() } + ); + try { + // Normalize input to websiteId + let websiteId: string | undefined; + if (typeof arg === 'string') { + websiteId = arg; + } else if (arg && typeof arg === 'object') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const anyArg: any = arg; + websiteId = anyArg?.siteInfo?.websiteId || anyArg?.websiteId || anyArg?.id || undefined; + } + + if (!websiteId) { + vscode.window.showErrorMessage("Website id not provided from context."); + return; + } + + // Get current org info instead of prompting user + const pacActiveOrg = PacContext.OrgInfo; + if (!pacActiveOrg) { + vscode.window.showErrorMessage("No active environment found. Please authenticate first."); + return; + } + + const orgUrl = pacActiveOrg?.OrgUrl; + if (!orgUrl) { + vscode.window.showErrorMessage("Current environment URL not found."); + return; + } + + const ws = getWorkspaceAndWebsiteId(); + if (!ws) { return; } + const success = await executeDiffWorkflow(context, pacTerminal, websiteId, vscode.l10n.t("Downloading website metadata")); + if (success) { + vscode.window.showInformationMessage(vscode.l10n.t("You can now view the comparison")); + } else { + vscode.window.showErrorMessage("Failed to download metadata."); + } + + } catch (error) { + oneDSLoggerWrapper.getLogger().traceError(Constants.EventNames.METADATA_DIFF_REFRESH_FAILED, error as string, error as Error, { methodName: null }, {}); + } + }); + + vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.triggerFlow", async () => { + oneDSLoggerWrapper.getLogger().traceInfo( + Constants.EventNames.METADATA_DIFF_COMMAND_EXECUTED, + { command: 'triggerFlow', ...getBaseEventInfo() } + ); + try { + // Get the PAC wrapper to access org list + const pacWrapper = pacTerminal.getWrapper(); + + let orgUrl: string | undefined; + + // Get current active org first so we can mark it in the picker + const currentActiveOrg = PacContext.OrgInfo; + const currentOrgUrl = currentActiveOrg ? currentActiveOrg.OrgUrl : undefined; + + // Get list of available organizations + const orgListResult = await pacWrapper.orgList(); + + if (orgListResult && orgListResult.Status === SUCCESS && orgListResult.Results.length > 0) { + // Align item shape with Actions Hub switchEnvironment (detail holds URL, description shows CURRENT) + const items = orgListResult.Results.map(org => { + return { + label: org.FriendlyName, + description: currentOrgUrl && currentOrgUrl === org.EnvironmentUrl ? vscode.l10n.t("Current") : "", + detail: org.EnvironmentUrl + }; + }); + + // Add option to enter URL manually (detail intentionally blank) + items.push({ + label: "$(plus) Enter organization URL manually", + description: "", + detail: "" + }); + + const selected = await vscode.window.showQuickPick(items, { + placeHolder: "Select an environment or enter URL manually", + ignoreFocusOut: true + }); + + if (selected) { + if (selected.detail) { + orgUrl = selected.detail; // environment URL from list + } else { + // Manual entry + 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; + } + + // Helper utilities for normalization & verification + const normalizeUrl = (u?: string) => u ? u.replace(/\/+$/,'').toLowerCase() : u; + const targetUrlNormalized = normalizeUrl(orgUrl); + + const verifySwitch = async (attempts = 5, delayMs = 500): Promise => { + for (let i = 0; i < attempts; i++) { + const currentActiveOrg = PacContext.OrgInfo; + if (currentActiveOrg && normalizeUrl(currentActiveOrg.OrgUrl) === targetUrlNormalized) { + return true; + } + await new Promise(r => setTimeout(r, delayMs)); + } + return false; + }; + + const switchEnvironmentIfNeeded = async (): Promise => { + const currentActiveOrg = PacContext.OrgInfo; + if (!currentActiveOrg) { + // No active org context -> create auth profile (will also set context) + await createAuthProfileExp(pacWrapper, orgUrl!); + const verified = await verifySwitch(); + if (!verified) { + oneDSLoggerWrapper.getLogger().traceError("MetadataDiffEnvSwitchFailed", "Post-auth verification failed", new Error("Active org mismatch")); + return false; + } + vscode.window.showInformationMessage("Auth profile created and environment selected."); + return true; + } + + if (normalizeUrl(currentActiveOrg.OrgUrl) === targetUrlNormalized) { + vscode.window.showInformationMessage("Already connected to the specified environment."); + return true; + } + + // Attempt switch with progress UI + const switchResult = await vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: vscode.l10n.t("Switching environment"), + cancellable: false + }, async (progress) => { + progress.report({ message: vscode.l10n.t("Invoking pac org select...") }); + const selectOutput = await pacWrapper.orgSelect(orgUrl!); + if (!selectOutput || selectOutput.Status !== SUCCESS) { + const errMsg = selectOutput?.Errors && selectOutput.Errors.length ? selectOutput.Errors.join("; ") : "Unknown error"; + oneDSLoggerWrapper.getLogger().traceError("MetadataDiffEnvSwitchFailed", errMsg, new Error(errMsg)); + return false; + } + progress.report({ message: vscode.l10n.t("Verifying environment switch...") }); + const verified = await verifySwitch(); + if (!verified) { + // One retry attempt + progress.report({ message: vscode.l10n.t("Retrying environment switch...") }); + const retryOutput = await pacWrapper.orgSelect(orgUrl!); + if (!retryOutput || retryOutput.Status !== SUCCESS) { + const retryErr = retryOutput?.Errors && retryOutput.Errors.length ? retryOutput.Errors.join("; ") : "Unknown retry error"; + oneDSLoggerWrapper.getLogger().traceError("MetadataDiffEnvSwitchRetryFailed", retryErr, new Error(retryErr)); + return false; + } + const verifiedRetry = await verifySwitch(); + if (!verifiedRetry) { + oneDSLoggerWrapper.getLogger().traceError("MetadataDiffEnvSwitchVerificationFailed", "Verification failed after retry", new Error("verification failed")); + return false; + } + } + return true; + }); + + if (switchResult) { + vscode.window.showInformationMessage("Environment switched successfully."); + return true; + } + vscode.window.showErrorMessage("Failed to switch the environment."); + return false; + }; + + const switched = await switchEnvironmentIfNeeded(); + if (!switched) { + return; // Abort flow if environment not ready + } + + const ws = getWorkspaceAndWebsiteId(); + if (!ws) { return; } + const success = await executeDiffWorkflow(context, pacTerminal, ws.websiteId, vscode.l10n.t("Downloading website metadata")); + if (success) { + vscode.window.showInformationMessage(vscode.l10n.t("You can now view the comparison")); + } else { + vscode.window.showErrorMessage("Failed to download metadata."); + } + + } + catch (error) { + oneDSLoggerWrapper.getLogger().traceError(Constants.EventNames.METADATA_DIFF_REFRESH_FAILED, error as string, error as Error, { methodName: null }, {}); + } + }); + + vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.generateReport", async () => { + oneDSLoggerWrapper.getLogger().traceInfo( + Constants.EventNames.METADATA_DIFF_COMMAND_EXECUTED, + { command: 'generateReport', ...getBaseEventInfo() } + ); + try { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders) { + vscode.window.showErrorMessage("No workspace folder open"); + return; + } + const storagePath = context.storageUri?.fsPath; + if (!storagePath) { + vscode.window.showErrorMessage("Storage path not found"); + return; + } + + // Generate 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; + } catch (error) { + oneDSLoggerWrapper.getLogger().traceError( + Constants.EventNames.METADATA_DIFF_REPORT_FAILED, + error as string, + error as Error + ); + vscode.window.showErrorMessage("Failed to generate metadata diff report"); + } + }); + + vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.exportReport", async () => { + oneDSLoggerWrapper.getLogger().traceInfo( + Constants.EventNames.METADATA_DIFF_COMMAND_EXECUTED, + { command: 'exportReport', ...getBaseEventInfo() } + ); + try { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders) { + vscode.window.showErrorMessage("No workspace folder open"); + return; + } + + const storagePath = context.storageUri?.fsPath; + if (!storagePath) { + vscode.window.showErrorMessage("Storage path not found"); + return; + } + + // Get diff files + const diffFiles = await getAllDiffFiles(workspaceFolders[0].uri.fsPath, storagePath); + + // Create report object + const report: MetadataDiffReport = { + generatedOn: new Date().toISOString(), + files: diffFiles + }; + + // Save dialog + const saveUri = await vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file('metadata-diff-report.json'), + filters: { + 'JSON files': ['json'] + } + }); + + if (saveUri) { + fs.writeFileSync(saveUri.fsPath, JSON.stringify(report, null, 2)); + vscode.window.showInformationMessage("Report exported successfully"); + } + } catch (error) { + oneDSLoggerWrapper.getLogger().traceError( + Constants.EventNames.METADATA_DIFF_REPORT_FAILED, + error as string, + error as Error + ); + vscode.window.showErrorMessage("Failed to export metadata diff report"); + } + }); + + vscode.commands.registerCommand("microsoft.powerplatform.pages.metadataDiff.importReport", async () => { + oneDSLoggerWrapper.getLogger().traceInfo( + Constants.EventNames.METADATA_DIFF_COMMAND_EXECUTED, + { command: 'importReport', ...getBaseEventInfo() } + ); + try { + const fileUri = await vscode.window.showOpenDialog({ + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: false, + filters: { + 'JSON files': ['json'] + } + }); + + if (fileUri && fileUri[0]) { + const reportContent = fs.readFileSync(fileUri[0].fsPath, 'utf8'); + const report = JSON.parse(reportContent) as MetadataDiffReport; + + // Use existing provider if present so Actions Hub keeps reference; otherwise create & register new provider. + // Accessing static private for backward compatibility without altering class surface area. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const existingProvider = (MetadataDiffDesktop as any)._treeDataProvider as MetadataDiffTreeDataProvider | undefined; + const treeDataProvider = existingProvider || new MetadataDiffTreeDataProvider(context); + if (!existingProvider) { + MetadataDiffDesktop.setTreeDataProvider(treeDataProvider); + ActionsHubTreeDataProvider.setMetadataDiffProvider(treeDataProvider); + } + await treeDataProvider.setDiffFiles(report.files); // triggers refresh + context updates + vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); + + vscode.window.showInformationMessage("Report imported successfully"); + } + } catch (error) { + oneDSLoggerWrapper.getLogger().traceError( + Constants.EventNames.METADATA_DIFF_REPORT_FAILED, + error as string, + error as Error + ); + vscode.window.showErrorMessage("Failed to import metadata diff report"); + } + }); + +} diff --git a/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts b/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts new file mode 100644 index 000000000..2d00433b2 --- /dev/null +++ b/src/client/power-pages/metadata-diff/MetadataDiffDesktop.ts @@ -0,0 +1,92 @@ +/* + * 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 { getBaseEventInfo } from "../actions-hub/TelemetryHelper"; +import { MetadataDiffTreeDataProvider } from "../../../common/power-pages/metadata-diff/MetadataDiffTreeDataProvider"; +import { PacTerminal } from "../../lib/PacTerminal"; +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(); + vscode.commands.executeCommand("setContext", + "microsoft.powerplatform.pages.metadataDiff.hasData", false); + } + } + + /** + * Allows external commands to replace the active provider + * allowing subsequent resets to use 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(Constants.EventNames.METADATA_DIFF_ENABLED, { + isEnabled: isMetadataDiffEnabled.toString(), + ...getBaseEventInfo() + }); + + 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; + ActionsHubTreeDataProvider.setMetadataDiffProvider(treeDataProvider); + vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); + MetadataDiffDesktop._isInitialized = true; + oneDSLoggerWrapper.getLogger().traceInfo(Constants.EventNames.METADATA_DIFF_INITIALIZED, getBaseEventInfo()); + + } catch (exception) { + const exceptionError = exception as Error; + oneDSLoggerWrapper.getLogger().traceError(Constants.EventNames.METADATA_DIFF_INITIALIZATION_FAILED, exceptionError.message, exceptionError, getBaseEventInfo()); + } + } + + // getPagesList removed: model version & site metadata now sourced from Actions Hub website cache (fetchWebsites) +} 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..05cccbafa --- /dev/null +++ b/src/client/power-pages/metadata-diff/MetadataDiffUtils.ts @@ -0,0 +1,250 @@ +/* + * 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(); + + const escapeHtml = (value: string | undefined) => + (value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + + let body = `

Power Pages Metadata Diff Report

`; + body += `

Generated on: ${escapeHtml(generatedOn)}

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

No differences found.

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

${escapeHtml(folder)}

`; + } else { + body += `

Root

`; + } + body += `
    `; + for (const file of files.sort((a, b) => a.relativePath.localeCompare(b.relativePath))) { + 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/common/ecs-features/ecsFeatureGates.ts b/src/common/ecs-features/ecsFeatureGates.ts index bec19a58b..c52285123 100644 --- a/src/common/ecs-features/ecsFeatureGates.ts +++ b/src/common/ecs-features/ecsFeatureGates.ts @@ -98,7 +98,7 @@ export const { fallback: { enableCodeQlScan: false, } -}) +}); export const { feature: EnableOpenInDesktop @@ -120,3 +120,13 @@ export const { disallowedDuplicateFileHandlingOrgs: "", } }); + +export const { + feature: EnableMetadataDiff +} = getFeatureConfigs({ + teamName: PowerPagesClientName, + description: 'Enable Metadata Diff comparison in VS Code', + fallback: { + enableMetadataDiff: false, + }, +}); diff --git a/src/common/power-pages/metadata-diff/Constants.ts b/src/common/power-pages/metadata-diff/Constants.ts new file mode 100644 index 000000000..4862cfece --- /dev/null +++ b/src/common/power-pages/metadata-diff/Constants.ts @@ -0,0 +1,20 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +export const Constants = { + EventNames: { + METADATA_DIFF_ENABLED: "metadataDiffEnabled", + METADATA_DIFF_INITIALIZED: "metadataDiffInitialized", + METADATA_DIFF_COMMAND_EXECUTED: "metadataDiffCommandExecuted", + METADATA_DIFF_REFRESH_FAILED: "metadataDiffRefreshFailed", + METADATA_DIFF_INITIALIZATION_FAILED: "metadataDiffInitializationFailed", + METADATA_DIFF_CURRENT_ENV_FETCH_FAILED: "metadataDiffCurrentEnvFetchFailed", + ORGANIZATION_URL_MISSING: "Organization URL is missing in the results.", + EMPTY_RESULTS_ARRAY: "Results array is empty or not an array.", + PAC_AUTH_OUTPUT_FAILURE: "pacAuthCreateOutput is missing or unsuccessful.", + METADATA_DIFF_REPORT_FAILED: "metadataDiffReportFailed" + } +}; +export const SUCCESS = "Success"; diff --git a/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts b/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts new file mode 100644 index 000000000..94506ed07 --- /dev/null +++ b/src/common/power-pages/metadata-diff/MetadataDiffTreeDataProvider.ts @@ -0,0 +1,393 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import * as vscode from "vscode"; +import * as fs from "fs"; +import * as path from "path"; +import { MetadataDiffTreeItem } from "./tree-items/MetadataDiffTreeItem"; +import { Constants } from "./Constants"; +import { oneDSLoggerWrapper } from "../../OneDSLoggerTelemetry/oneDSLoggerWrapper"; +import { MetadataDiffFileItem } from "./tree-items/MetadataDiffFileItem"; +import { MetadataDiffFolderItem } from "./tree-items/MetadataDiffFolderItem"; + +// Internal utility directories created under storage that should not be +// mistaken for the downloaded website folder when recomputing diffs. +const IGNORE_STORAGE_DIRS = new Set([ + 'tempDiff', // created for one‑sided / placeholder diff views + 'imported_diff' // created when importing a saved report +]); + +interface DiffFile { + relativePath: string; + changes: string; + type: string; + workspaceContent?: string; + storageContent?: string; +} + +export class MetadataDiffTreeDataProvider implements vscode.TreeDataProvider { + private readonly _disposables: vscode.Disposable[] = []; + private readonly _context: vscode.ExtensionContext; + private _onDidChangeTreeData: vscode.EventEmitter = new vscode.EventEmitter(); + readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event; + private _diffItems: MetadataDiffTreeItem[] = []; + // Emits when the diff data has been fully populated for the first time + private _onDataLoaded: vscode.EventEmitter = new vscode.EventEmitter(); + public readonly onDataLoaded: vscode.Event = this._onDataLoaded.event; + private _dataLoadedNotified = false; + + constructor(context: vscode.ExtensionContext) { + this._context = context; + } + + public static initialize(context: vscode.ExtensionContext): MetadataDiffTreeDataProvider { + return new MetadataDiffTreeDataProvider(context); + } + + private refresh(): void { + this._onDidChangeTreeData.fire(); + } + + /** + * Recompute the diff tree without clearing the downloaded site storage. + * Used after in-place edits (e.g. discard local changes) so that we don't + * wipe the remote snapshot but still update the UI & contexts. + */ + public async recomputeDiff(): Promise { + try { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { return; } + const workspacePath = workspaceFolders[0].uri.fsPath; + const storagePath = this._context.storageUri?.fsPath; + if (!storagePath) { return; } + + const diffFiles = await this.getDiffFiles(workspacePath, storagePath); + const items = this.buildTreeHierarchy(diffFiles); + this._diffItems = items; + vscode.commands.executeCommand("setContext", "microsoft.powerplatform.pages.metadataDiff.hasData", this._diffItems.length > 0); + this.refresh(); + vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); + if (!this._dataLoadedNotified && this._diffItems.length > 0) { + this._dataLoadedNotified = true; + this._onDataLoaded.fire(); + } + } catch (error) { + oneDSLoggerWrapper.getLogger().traceError( + Constants.EventNames.METADATA_DIFF_CURRENT_ENV_FETCH_FAILED, + error as string, + error as Error, + { methodName: this.recomputeDiff }, + {} + ); + } + } + + clearItems(): void { + this._diffItems = []; + this._dataLoadedNotified = false; // allow message again after reset + // Reset any stored data + const storagePath = this._context.storageUri?.fsPath; + if (storagePath && fs.existsSync(storagePath)) { + try { + fs.rmSync(storagePath, { recursive: true, force: true }); + fs.mkdirSync(storagePath, { recursive: true }); + } catch (error) { + console.error('Error cleaning storage path:', error); + } + } + // Set context to show welcome message again + vscode.commands.executeCommand("setContext", "microsoft.powerplatform.pages.metadataDiff.hasData", false); + this._onDidChangeTreeData.fire(); + // Also refresh Actions Hub so the integrated root node updates + vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); + } + + getTreeItem(element: MetadataDiffTreeItem): vscode.TreeItem | Thenable { + return element; + } + + private getAllFilesRecursively(dir: string, fileList: string[] = []): string[] { + const files = fs.readdirSync(dir); + + for (const file of files) { + const fullPath = path.join(dir, file); + if (fs.statSync(fullPath).isDirectory()) { + this.getAllFilesRecursively(fullPath, fileList); + } else { + fileList.push(fullPath); + } + } + + return fileList; + } + + private async getDiffFiles(workspacePath: string, storagePath: string): Promise> { + 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 => { + if (f.startsWith('.')) { return false; } + if (IGNORE_STORAGE_DIRS.has(f)) { return false; } + const full = path.join(storagePath, f); + return fs.statSync(full).isDirectory(); + }); + + if (folders.length === 0) { + return undefined; + } + + // If multiple candidate folders exist (e.g. multiple downloads), + // pick the most recently modified directory instead of relying on + // filesystem enumeration order, which is not guaranteed. + let chosen = folders[0]; + if (folders.length > 1) { + let latestMTime = -1; + for (const f of folders) { + const stat = fs.statSync(path.join(storagePath, f)); + const mtime = stat.mtimeMs; + if (mtime > latestMTime) { + latestMTime = mtime; + chosen = f; + } + } + } + return path.join(storagePath, chosen); + } catch (error) { + console.error('Error finding website path:', error); + } + return undefined; + } + + async getChildren(element?: MetadataDiffTreeItem): Promise { + if (element) { + return element.getChildren(); + } + + // If we have imported diff items, return those + if (this._diffItems && this._diffItems.length > 0) { + return this._diffItems; + } + + try { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders) { + return []; + } + + const workspacePath = workspaceFolders[0].uri.fsPath; + const storagePath = this._context.storageUri?.fsPath; + if (!storagePath) { + throw new Error("Storage path is not defined"); + } + + const diffFiles = await this.getDiffFiles(workspacePath, storagePath); + if (diffFiles.size === 0) { + // Explicitly clear cache & contexts when no differences + this._diffItems = []; + vscode.commands.executeCommand("setContext", "microsoft.powerplatform.pages.metadataDiff.hasData", false); + return []; + } + + const items = this.buildTreeHierarchy(diffFiles); + // Cache for Actions Hub wrapper which reads the private field directly + this._diffItems = items; + // Update contexts so welcome content & root node state update + vscode.commands.executeCommand("setContext", "microsoft.powerplatform.pages.metadataDiff.hasData", this._diffItems.length > 0); + // Refresh Actions Hub so the Metadata Diff group re-renders with data + vscode.commands.executeCommand("microsoft.powerplatform.pages.actionsHub.refresh"); + if (!this._dataLoadedNotified && this._diffItems.length > 0) { + this._dataLoadedNotified = true; + this._onDataLoaded.fire(); + } + return items; + } catch (error) { + oneDSLoggerWrapper.getLogger().traceError( + Constants.EventNames.METADATA_DIFF_CURRENT_ENV_FETCH_FAILED, + error as string, + error as Error, + { methodName: this.getChildren }, + {} + ); + return null; + } + } + + private buildTreeHierarchy(filePathMap: Map): MetadataDiffTreeItem[] { + const 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, + relativePath + ); + 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 { + // Changed labels to align with updated wording (Local vs Environment) + if (!workspaceFile) return 'Only in Environment'; + if (!storageFile) return 'Only in Local'; + return 'Modified'; + } + + async setDiffFiles(files: DiffFile[]): Promise { + const rootNode = new MetadataDiffFolderItem(''); + const sortedFiles = files.sort((a, b) => a.relativePath.localeCompare(b.relativePath)); + + // Get temp directory for storing imported file contents + const tempDir = path.join(this._context.storageUri?.fsPath || '', 'imported_diff'); + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } + + 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, + file.relativePath + ); + fileNode.description = file.changes; + fileNode.iconPath = new vscode.ThemeIcon("file"); + fileNode.command = { + command: 'microsoft.powerplatform.pages.metadataDiff.openDiff', + title: 'Show Diff', + arguments: [workspaceFilePath, storageFilePath] + }; + + currentNode.getChildrenMap().set(fileName, fileNode); + } + + this._diffItems = Array.from(rootNode.getChildrenMap().values()); + this._onDidChangeTreeData.fire(); + // Mark that we now have data and refresh Actions Hub view + 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(); + } + } +} 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..bb643f3c4 --- /dev/null +++ b/src/common/power-pages/metadata-diff/tree-items/MetadataDiffFileItem.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 * as vscode from "vscode"; +import { MetadataDiffTreeItem } from "./MetadataDiffTreeItem"; + +export class MetadataDiffFileItem extends MetadataDiffTreeItem { + constructor(label: string, workspaceFile?: string, storageFile?: string, hasDiff = true, relativePath?: string) { + // 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; + // Keep relative path even if a side is missing so commands (discard/import) can resolve target + (this as unknown as { relativePath?: string }).relativePath = relativePath; + this.hasDiff = hasDiff; + this.iconPath = new vscode.ThemeIcon("file"); + + if (hasDiff && (workspaceFile || storageFile)) { + this.command = { + command: 'microsoft.powerplatform.pages.metadataDiff.openDiff', + title: 'Show Diff', + arguments: [workspaceFile, storageFile] + }; + } + } + + public readonly workspaceFile?: string; + public readonly storageFile?: string; + public readonly hasDiff: boolean; + // relative path within the website/site root + public readonly relativePath?: string; +} 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..4071c287b --- /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: "microsoft.powerplatform.pages.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/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; }