diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index 9e08b54a8..82b36f155 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -242,6 +242,8 @@ "Session Details": "Session Details", "Changing environment...": "Changing environment...", "Current": "Current", + "Local": "Local", + "Local Site": "Local Site", "Site management URL not found for the selected site. Please try again after refreshing the environment.": "Site management URL not found for the selected site. Please try again after refreshing the environment.", "Be careful when you're updating public sites. The changes you make are visible to anyone immediately. Do you want to continue?": "Be careful when you're updating public sites. The changes you make are visible to anyone immediately. Do you want to continue?", "Current site path not found.": "Current site path not found.", @@ -345,17 +347,79 @@ "Deleted locally": "Deleted locally", "Download is complete. You can now view the report.": "Download is complete. You can now view the report.", "Select an environment to compare with": "Select an environment to compare with", + "Select a website to compare with": "Select a website to compare with", + "No sites found in the selected environment.": "No sites found in the selected environment.", + "Matching Site": "Matching Site", + "Enhanced Data Model": "Enhanced Data Model", + "Standard Data Model": "Standard Data Model", + "The website you selected is different from the local site. Are you sure you want to compare with this website?": "The website you selected is different from the local site. Are you sure you want to compare with this website?", + "Yes, Don't Ask Again": "Yes, Don't Ask Again", "The website was not found in the selected environment. Please select a different environment.": "The website was not found in the selected environment. Please select a different environment.", - "Fetching websites from the selected environment...": "Fetching websites from the selected environment...", "Discard Changes": "Discard Changes", "Show Diff": "Show Diff", "All changed files are binary files (e.g., images) and cannot be displayed in the diff viewer. You can view them individually in the file tree.": "All changed files are binary files (e.g., images) and cannot be displayed in the diff viewer. You can view them individually in the file tree.", + "1 file changed": "1 file changed", + "Save Report": "Save Report", + "Save HTML Report": "Save HTML Report", + "Open Report": "Open Report", + "Metadata Diff Report": "Metadata Diff Report", + "Power Pages Site Comparison Results": "Power Pages Site Comparison Results", + "Total Changes": "Total Changes", + "Added": "Added", + "Deleted": "Deleted", + "Comparison Details": "Comparison Details", + "Site Name:": "Site Name:", + "Environment:": "Environment:", + "Generated:": "Generated:", + "Changed Files": "Changed Files", + "(click to expand diff)": "(click to expand diff)", + "Expand All": "Expand All", + "Collapse All": "Collapse All", + "Generated by": "Generated by", + "Power Platform Tools VS Code Extension": "Power Platform Tools VS Code Extension", + "Added Locally": "Added Locally", + "Deleted Locally": "Deleted Locally", + "Unknown": "Unknown", + "Binary file - content comparison not available": "Binary file - content comparison not available", + "Unable to read file contents": "Unable to read file contents", + "Unable to read local file": "Unable to read local file", + "Unable to read remote file": "Unable to read remote file", + "Unable to read one or both files": "Unable to read one or both files", + "Exporting comparison...": "Exporting comparison...", + "Importing comparison...": "Importing comparison...", + "Invalid file format. The file does not contain valid metadata diff data.": "Invalid file format. The file does not contain valid metadata diff data.", + "Unsupported version. This file was created with a newer version of the extension.": "Unsupported version. This file was created with a newer version of the extension.", + "This file was exported with a newer version of the extension. Please update your extension to import this file.": "This file was exported with a newer version of the extension. Please update your extension to import this file.", + "Metadata Diff JSON": "Metadata Diff JSON", + "Binary file content is not available in imported comparisons. The original file was not included in the export.": "Binary file content is not available in imported comparisons. The original file was not included in the export.", + "An imported comparison already exists for this site. Do you want to replace it?": "An imported comparison already exists for this site. Do you want to replace it?", + "Replace": "Replace", + "Comparison has been refreshed with the latest data from the environment.": "Comparison has been refreshed with the latest data from the environment.", + "Cannot refresh an imported comparison. Import a new file or run a new comparison.": "Cannot refresh an imported comparison. Import a new file or run a new comparison.", + "Fetching websites from '{0}'.../Message shown while fetching websites from an environment. {0} is the environment name.": { + "message": "Fetching websites from '{0}'...", + "comment": [ + "Message shown while fetching websites from an environment. {0} is the environment name." + ] + }, "Downloading {0} site metadata ([details](command:microsoft.powerplatform.pages.actionsHub.showOutputChannel \"Show download output\")).../This is a markdown formatting which must persist across translations.": { "message": "Downloading {0} site metadata ([details](command:microsoft.powerplatform.pages.actionsHub.showOutputChannel \"Show download output\"))...", "comment": [ "This is a markdown formatting which must persist across translations." ] }, + "{0} ({1}) ↔ {2} (Local)/Comparison label showing remote and local sites. Format: 'Remote (Env) ↔ Local (Local)'.": { + "message": "{0} ({1}) ↔ {2} (Local)", + "comment": [ + "Comparison label showing remote and local sites. Format: 'Remote (Env) ↔ Local (Local)'." + ] + }, + "{0} files changed/Description showing the number of changed files. 'files' is plural.": { + "message": "{0} files changed", + "comment": [ + "Description showing the number of changed files. 'files' is plural." + ] + }, "{0} ({1} file)/This is the site label showing the number of changed files. 'file' is singular.": { "message": "{0} ({1} file)", "comment": [ @@ -416,6 +480,48 @@ "Message shown when binary files are skipped in the multi-diff view." ] }, + "HTML report saved successfully to '{0}'./Success message when HTML report is saved. {0} is the file path.": { + "message": "HTML report saved successfully to '{0}'.", + "comment": [ + "Success message when HTML report is saved. {0} is the file path." + ] + }, + "Failed to generate HTML report: {0}/Error message when HTML report generation fails. {0} is the error message.": { + "message": "Failed to generate HTML report: {0}", + "comment": [ + "Error message when HTML report generation fails. {0} is the error message." + ] + }, + "{0} files changed (Imported {1})/Description for imported comparison. {0} is file count, {1} is formatted date.": { + "message": "{0} files changed (Imported {1})", + "comment": [ + "Description for imported comparison. {0} is file count, {1} is formatted date." + ] + }, + "Missing required field: {0}/Error when importing a file that is missing a required field.": { + "message": "Missing required field: {0}", + "comment": [ + "Error when importing a file that is missing a required field." + ] + }, + "Comparison exported successfully to '{0}'./Success message when export is saved. {0} is the file path.": { + "message": "Comparison exported successfully to '{0}'.", + "comment": [ + "Success message when export is saved. {0} is the file path." + ] + }, + "Comparison for '{0}' imported successfully./Success message when import is complete. {0} is the site name.": { + "message": "Comparison for '{0}' imported successfully.", + "comment": [ + "Success message when import is complete. {0} is the site name." + ] + }, + "Refreshing comparison for {0} ([details](command:microsoft.powerplatform.pages.actionsHub.showOutputChannel \"Show download output\")).../This is a markdown formatting which must persist across translations.": { + "message": "Refreshing comparison for {0} ([details](command:microsoft.powerplatform.pages.actionsHub.showOutputChannel \"Show download output\"))...", + "comment": [ + "This is a markdown formatting which must persist across translations." + ] + }, "Friendly name: {0}/{0} is the website name": { "message": "Friendly name: {0}", "comment": [ @@ -524,6 +630,10 @@ "{0} is the cluster geo name" ] }, + "Import Metadata Diff": "Import Metadata Diff", + "Failed to import comparison: {0}": "Failed to import comparison: {0}", + "Export Metadata Diff": "Export Metadata Diff", + "Failed to export comparison: {0}": "Failed to export comparison: {0}", "Power Platform Tools: PAC CLI": "Power Platform Tools: PAC CLI", "Command completed successfully.": "Command completed successfully.", "PAC Telemetry enabled": "PAC Telemetry enabled", diff --git a/loc/translations-export/vscode-powerplatform.xlf b/loc/translations-export/vscode-powerplatform.xlf index 9424f011f..a4327a145 100644 --- a/loc/translations-export/vscode-powerplatform.xlf +++ b/loc/translations-export/vscode-powerplatform.xlf @@ -4,6 +4,12 @@ End of Code Block + + (click to expand diff) + + + 1 file changed + @PowerPages is not yet available in your region. @@ -46,6 +52,12 @@ Add content snippet name (name should be unique) + + Added + + + Added Locally + Added default query suite to config: {0} @@ -59,6 +71,9 @@ All changed files are binary files (e.g., images) and cannot be displayed in the diff viewer. You can view them individually in the file tree. + + An imported comparison already exists for this site. Do you want to replace it? + Analysis complete! Results saved to: {0} @@ -107,6 +122,12 @@ Be careful when you're updating public sites. The changes you make are visible to anyone immediately. Do you want to continue? + + Binary file - content comparison not available + + + Binary file content is not available in imported comparisons. The original file was not included in the export. + Browse... @@ -125,6 +146,12 @@ Cannot install pac CLI: {0} {0} represents the error message returned from the exception + + Cannot refresh an imported comparison. Import a new file or run a new comparison. + + + Changed Files + Changing environment... @@ -217,6 +244,9 @@ Check the CodeQL extension panel for available queries. CodeQL version: {0} + + Collapse All + Command completed successfully (exit code: {0}) @@ -236,6 +266,20 @@ Check the CodeQL extension panel for available queries. Comparing files... + + Comparison Details + + + Comparison exported successfully to '{0}'. + Success message when export is saved. {0} is the file path. + + + Comparison for '{0}' imported successfully. + Success message when import is complete. {0} is the site name. + + + Comparison has been refreshed with the latest data from the environment. + Confirm @@ -298,6 +342,12 @@ Check the CodeQL extension panel for available queries. Default Environment: {0} The {0} represents profile's resource/environment URL + + Deleted + + + Deleted Locally + Deleted locally @@ -340,6 +390,9 @@ The {3} represents Solution's Type (Managed or Unmanaged), but that test is loca Edit the site + + Enhanced Data Model + Enter Organization ID @@ -365,6 +418,9 @@ The {3} represents Solution's Type (Managed or Unmanaged), but that test is loca Environment changed successfully. + + Environment: + Error accessing CodeQL extension: {0} @@ -398,12 +454,21 @@ The {3} represents Solution's Type (Managed or Unmanaged), but that test is loca Executing: {0} + + Expand All + Explain the following code snippet: Explain the following code {% include 'Page Copy'%} + + Export Metadata Diff + + + Exporting comparison... + Extension installed but API not available yet. Opening as text file... @@ -424,9 +489,16 @@ The {3} represents Solution's Type (Managed or Unmanaged), but that test is loca Failed to enable PAC telemetry. + + Failed to export comparison: {0} + Failed to fetch some files. + + Failed to generate HTML report: {0} + Error message when HTML report generation fails. {0} is the error message. + Failed to generate VS Code Desktop URL: {0} @@ -442,6 +514,9 @@ The {3} represents Solution's Type (Managed or Unmanaged), but that test is loca Failed to handle Power Pages URI: {0} + + Failed to import comparison: {0} + Failed to install SARIF viewer extension. Opening results as text file. @@ -460,8 +535,9 @@ The {3} represents Solution's Type (Managed or Unmanaged), but that test is loca Feedback - - Fetching websites from the selected environment... + + Fetching websites from '{0}'... + Message shown while fetching websites from an environment. {0} is the environment name. Fetching your file ... @@ -498,6 +574,12 @@ The {3} represents Solution's Type (Managed or Unmanaged), but that test is loca GETTING STARTED + + Generated by + + + Generated: + Generating a new Power Pages site... @@ -516,6 +598,10 @@ The {3} represents Solution's Type (Managed or Unmanaged), but that test is loca Getting website endpoint... + + HTML report saved successfully to '{0}'. + Success message when HTML report is saved. {0} is the file path. + Here are a few suggestions to get you started @@ -537,6 +623,12 @@ Return to this chat and @powerpages can help you write and edit your website cod Hi! Your Microsoft account doesn’t currently support Copilot. Contact your admin for details. + + Import Metadata Diff + + + Importing comparison... + In your own words, describe what you need. You can get help with writing code for Power Pages sites in HTML, CSS, and JS languages. @@ -566,6 +658,9 @@ Return to this chat and @powerpages can help you write and edit your website cod Instance url: {0} {0} is the Instance Url + + Invalid file format. The file does not contain valid metadata diff data. + Learn more about Copilot @@ -578,6 +673,12 @@ Return to this chat and @powerpages can help you write and edit your website cod Line: {0} + + Local + + + Local Site + Login @@ -597,15 +698,28 @@ Return to this chat and @powerpages can help you write and edit your website cod Managed + + Matching Site + Maximum 30 characters allowed Metadata Diff + + Metadata Diff JSON + + + Metadata Diff Report + Microsoft wants your feedback + + Missing required field: {0} + Error when importing a file that is missing a required field. + Missing required site information for reactivation. @@ -672,6 +786,9 @@ The {3} represents Dataverse Environment's Organization ID (GUID) No sites found + + No sites found in the selected environment. + No webpages found @@ -697,6 +814,9 @@ The {3} represents Dataverse Environment's Organization ID (GUID) Open Folder + + Open Report + Open Results @@ -780,12 +900,18 @@ 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 Site Comparison Results + Power Pages site download completed successfully. Would you like to open the downloaded site folder? Power Pages studio URL is not available + + Power Platform Tools VS Code Extension + Power Platform Tools: PAC CLI @@ -821,6 +947,13 @@ The {3} represents Dataverse Environment's Organization ID (GUID) Ready to select download folder + + Refreshing comparison for {0} ([details](command:microsoft.powerplatform.pages.actionsHub.showOutputChannel "Show download output"))... + This is a markdown formatting which must persist across translations. + + + Replace + Response data is empty @@ -839,6 +972,12 @@ The {3} represents Dataverse Environment's Organization ID (GUID) SARIF viewer extension not found. + + Save HTML Report + + + Save Report + Saving your file ... @@ -855,6 +994,9 @@ The {3} represents Dataverse Environment's Organization ID (GUID) Select Type + + Select a website to compare with + Select an environment @@ -895,6 +1037,9 @@ The {3} represents Dataverse Environment's Organization ID (GUID) Site Details + + Site Name: + Site download cancelled. Authentication is required to proceed. @@ -923,6 +1068,9 @@ The {3} represents Dataverse Environment's Organization ID (GUID) Something went wrong. Don’t worry, you can try again. + + Standard Data Model + Starting CodeQL analysis for: {0} @@ -975,6 +1123,9 @@ The {3} represents Dataverse Environment's Organization ID (GUID) The website was not found in the selected environment. Please select a different environment. + + The website you selected is different from the local site. Are you sure you want to compare with this website? + There was a permissions problem with the server @@ -984,6 +1135,9 @@ The {3} represents Dataverse Environment's Organization ID (GUID) There’s a problem on the back end + + This file was exported with a newer version of the extension. Please update your extension to import this file. + Thumbs Down @@ -1000,6 +1154,9 @@ The {3} represents Dataverse Environment's Organization ID (GUID) Tools + + Total Changes + Try @powerpages with GitHub Copilot @@ -1024,13 +1181,31 @@ The {3} represents Dataverse Environment's Organization ID (GUID) Unable to locate website root folder. + + Unable to read file contents + + + Unable to read local file + + + Unable to read one or both files + + + Unable to read remote file + Unique name: {0} {0} is the Unique name + + Unknown + Unmanaged + + Unsupported version. This file was created with a newer version of the extension. + Update Extension @@ -1124,6 +1299,9 @@ The {3} represents Dataverse Environment's Organization ID (GUID) Yes + + Yes, Don't Ask Again + You are currently connected to a different environment. Would you like to switch to the required environment? @@ -1154,6 +1332,10 @@ The {3} represents Dataverse Environment's Organization ID (GUID) {0} ({1} files) This is the site label showing the number of changed files. 'files' is plural. + + {0} ({1}) ↔ {2} (Local) + Comparison label showing remote and local sites. Format: 'Remote (Env) ↔ Local (Local)'. + {0} binary file(s) (e.g., images) were skipped as they cannot be displayed in the diff viewer. You can view them individually in the file tree. Message shown when binary files are skipped in the multi-diff view. @@ -1162,6 +1344,14 @@ The {3} represents Dataverse Environment's Organization ID (GUID) {0} created! {0} will be replaced by the entity type. + + {0} files changed + Description showing the number of changed files. 'files' is plural. + + + {0} files changed (Imported {1}) + Description for imported comparison. {0} is file count, {1} is formatted date. + {0}: {1} (Remote ↔ Local) Title for the diff editor when comparing a single file. @@ -1247,6 +1437,9 @@ The fifth line should be '[TRANSLATION HERE](command:powerplatform-walkthrough.s Documentation + + Download HTML Report + Download Site @@ -1259,6 +1452,9 @@ The fifth line should be '[TRANSLATION HERE](command:powerplatform-walkthrough.s Environments & Solutions + + Export Comparison + File explorer @@ -1274,6 +1470,9 @@ The fifth line should be '[TRANSLATION HERE](command:powerplatform-walkthrough.s The seventh line should be '[TRANSLATION HERE](command:powerplatform-walkthrough.fileSystem-documentation).', keeping brackets and the text in the parentheses unmodified The eighth line should be '[TRANSLATION HERE](command:powerplatform-walkthrough.fileSystem-open-folder)', keeping brackets and the text in the parentheses unmodified + + Import Comparison + Initialize Web Extension @@ -1371,6 +1570,9 @@ The second line should be '[TRANSLATION HERE](command:powerplatform-walkthrough. Refresh + + Refresh Comparison + Remove Comparison @@ -1410,6 +1612,9 @@ The second line should be '[TRANSLATION HERE](command:powerplatform-walkthrough. Show PAC Solution Help + + Show a confirmation dialog when comparing with a different website than the local site. Set to false to suppress this confirmation. + Site Details diff --git a/package.json b/package.json index 8d847e556..2b1a726c0 100644 --- a/package.json +++ b/package.json @@ -572,6 +572,30 @@ "category": "Powerpages", "title": "%microsoft-powerapps-portals.compareWithEnvironment.title%", "when": "microsoft.powerplatform.pages.metadataDiffEnabled" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.generateHtmlReport", + "title": "%microsoft.powerplatform.pages.actionsHub.metadataDiff.generateHtmlReport.title%", + "icon": "$(file-code)", + "when": "microsoft.powerplatform.pages.metadataDiffEnabled" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.export", + "title": "%microsoft.powerplatform.pages.actionsHub.metadataDiff.export.title%", + "icon": "$(export)", + "when": "microsoft.powerplatform.pages.metadataDiffEnabled" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.import", + "title": "%microsoft.powerplatform.pages.actionsHub.metadataDiff.import.title%", + "icon": "$(cloud-download)", + "when": "microsoft.powerplatform.pages.metadataDiffEnabled" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.resync", + "title": "%microsoft.powerplatform.pages.actionsHub.metadataDiff.resync.title%", + "icon": "$(sync)", + "when": "microsoft.powerplatform.pages.metadataDiffEnabled" } ], "configuration": { @@ -703,6 +727,11 @@ "type": "string", "description": "%microsoft.powerplatform.pages.actionsHub.configuration.downloadSite.description%", "default": "" + }, + "powerPlatform.pages.confirmDifferentWebsiteComparison": { + "type": "boolean", + "description": "%microsoft.powerplatform.pages.actionsHub.configuration.confirmDifferentWebsiteComparison.description%", + "default": true } } }, @@ -1087,6 +1116,22 @@ "command": "microsoft.powerplatform.pages.actionsHub.activeSite.compareWithLocal", "when": "never" }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.openFile", + "when": "never" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.openAll", + "when": "never" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.clear", + "when": "never" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.removeSite", + "when": "never" + }, { "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.viewAsTree", "when": "never" @@ -1094,6 +1139,42 @@ { "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.viewAsList", "when": "never" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.discardFile", + "when": "never" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.discardFolder", + "when": "never" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.sortByName", + "when": "never" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.sortByPath", + "when": "never" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.sortByStatus", + "when": "never" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.generateHtmlReport", + "when": "never" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.export", + "when": "never" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.import", + "when": "never" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.resync", + "when": "never" } ], "view/title": [ @@ -1278,25 +1359,85 @@ "group": "siteAction@8" }, { - "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.openAll", + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.export", "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffSite && microsoft.powerplatform.pages.metadataDiffEnabled", "group": "inline@1" }, { - "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.removeSite", + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.resync", "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffSite && microsoft.powerplatform.pages.metadataDiffEnabled", "group": "inline@2" }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.openAll", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffSite && microsoft.powerplatform.pages.metadataDiffEnabled", + "group": "inline@3" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.removeSite", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffSite && microsoft.powerplatform.pages.metadataDiffEnabled", + "group": "inline@4" + }, { "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.openAll", "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffSite && microsoft.powerplatform.pages.metadataDiffEnabled", "group": "siteAction@1" }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.resync", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffSite && microsoft.powerplatform.pages.metadataDiffEnabled", + "group": "siteAction@2" + }, { "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.removeSite", "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffSite && microsoft.powerplatform.pages.metadataDiffEnabled", + "group": "siteAction@3" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.generateHtmlReport", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffSite && microsoft.powerplatform.pages.metadataDiffEnabled", + "group": "siteAction@4" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.export", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffSite && microsoft.powerplatform.pages.metadataDiffEnabled", + "group": "siteAction@5" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.export", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffSiteImported && microsoft.powerplatform.pages.metadataDiffEnabled", + "group": "inline@1" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.openAll", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffSiteImported && microsoft.powerplatform.pages.metadataDiffEnabled", + "group": "inline@2" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.removeSite", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffSiteImported && microsoft.powerplatform.pages.metadataDiffEnabled", + "group": "inline@3" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.openAll", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffSiteImported && microsoft.powerplatform.pages.metadataDiffEnabled", + "group": "siteAction@1" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.removeSite", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffSiteImported && microsoft.powerplatform.pages.metadataDiffEnabled", "group": "siteAction@2" }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.generateHtmlReport", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffSiteImported && microsoft.powerplatform.pages.metadataDiffEnabled", + "group": "siteAction@3" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.export", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffSiteImported && microsoft.powerplatform.pages.metadataDiffEnabled", + "group": "siteAction@4" + }, { "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.viewAsTree", "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffGroupWithResults && microsoft.powerplatform.pages.metadataDiffEnabled && microsoft.powerplatform.pages.metadataDiffViewMode == 'list'", @@ -1312,6 +1453,16 @@ "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffGroupWithResults && microsoft.powerplatform.pages.metadataDiffEnabled", "group": "inline@3" }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.import", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffGroup && microsoft.powerplatform.pages.metadataDiffEnabled", + "group": "inline@1" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.import", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffGroupWithResults && microsoft.powerplatform.pages.metadataDiffEnabled", + "group": "inline@1" + }, { "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.viewAsTree", "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffGroupWithResults && microsoft.powerplatform.pages.metadataDiffEnabled && microsoft.powerplatform.pages.metadataDiffViewMode == 'list'", @@ -1327,6 +1478,16 @@ "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffGroupWithResults && microsoft.powerplatform.pages.metadataDiffEnabled", "group": "groupAction@2" }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.import", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffGroup && microsoft.powerplatform.pages.metadataDiffEnabled", + "group": "groupAction@1" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.import", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffGroupWithResults && microsoft.powerplatform.pages.metadataDiffEnabled", + "group": "groupAction@3" + }, { "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.sortByName", "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffGroupWithResults && microsoft.powerplatform.pages.metadataDiffEnabled && microsoft.powerplatform.pages.metadataDiffViewMode == 'list'", diff --git a/package.nls.json b/package.nls.json index 934011764..c3c006a52 100644 --- a/package.nls.json +++ b/package.nls.json @@ -117,6 +117,7 @@ "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.confirmDifferentWebsiteComparison.description": "Show a confirmation dialog when comparing with a different website than the local site. Set to false to suppress this confirmation.", "microsoft.powerplatform.pages.actionsHub.activeSite.compareWithLocal.title": "Compare with Local", "microsoft.powerplatform.pages.actionsHub.metadataDiff.openFile.title": "Open Comparison", "microsoft.powerplatform.pages.actionsHub.metadataDiff.openAll.title": "Open All Diffs", @@ -129,5 +130,9 @@ "microsoft.powerplatform.pages.actionsHub.metadataDiff.sortByName.title": "Sort by Name", "microsoft.powerplatform.pages.actionsHub.metadataDiff.sortByPath.title": "Sort by Path", "microsoft.powerplatform.pages.actionsHub.metadataDiff.sortByStatus.title": "Sort by Status", + "microsoft.powerplatform.pages.actionsHub.metadataDiff.generateHtmlReport.title": "Download HTML Report", + "microsoft.powerplatform.pages.actionsHub.metadataDiff.export.title": "Export Comparison", + "microsoft.powerplatform.pages.actionsHub.metadataDiff.import.title": "Import Comparison", + "microsoft.powerplatform.pages.actionsHub.metadataDiff.resync.title": "Refresh Comparison", "microsoft-powerapps-portals.compareWithEnvironment.title": "Compare with Environment" } diff --git a/src/client/lib/EnvAndSolutionTreeView.ts b/src/client/lib/EnvAndSolutionTreeView.ts index a7edf0b08..92097d408 100644 --- a/src/client/lib/EnvAndSolutionTreeView.ts +++ b/src/client/lib/EnvAndSolutionTreeView.ts @@ -81,7 +81,7 @@ export class EnvAndSolutionTreeView implements vscode.TreeDataProvider { - vscode.env.clipboard.writeText((item.model as OrgListOutput).EnvironmentId); + vscode.env.clipboard.writeText((item.model as OrgListOutput).EnvironmentIdentifier.Id); }), vscode.commands.registerCommand("pacCLI.envAndSolutionsPanel.copyEnvironmentUrl", (item: EnvOrSolutionTreeItem) => { vscode.env.clipboard.writeText((item.model as OrgListOutput).EnvironmentUrl); @@ -135,7 +135,7 @@ class EnvOrSolutionTreeItem extends vscode.TreeItem { this.tooltip = vscode.l10n.t( { message: "Name: {0}\nURL: {1}\nEnvironment ID: {2}\nOrganization ID: {3}", - args: [model.FriendlyName, model.EnvironmentUrl, model.EnvironmentId, model.OrganizationId], + args: [model.FriendlyName, model.EnvironmentUrl, model.EnvironmentIdentifier.Id, model.OrganizationId], comment: [ "This is a multi-line tooltip", "The {0} represents Dataverse Environment's Friendly / Display name", diff --git a/src/client/pac/PacTypes.ts b/src/client/pac/PacTypes.ts index de37b0f63..1ef6a42e1 100644 --- a/src/client/pac/PacTypes.ts +++ b/src/client/pac/PacTypes.ts @@ -55,9 +55,12 @@ export type PacSolutionListOutput = PacOutputWithResultList; export type OrgListOutput = { FriendlyName: string, OrganizationId: string, - EnvironmentId: string, + EnvironmentIdentifier: { + Id: string + }, EnvironmentUrl: string, - IsActive: boolean + IsActive: boolean, + UniqueName: string } export type PacOrgListOutput = PacOutputWithResultList; diff --git a/src/client/pac/PacWrapper.ts b/src/client/pac/PacWrapper.ts index 130e94664..dd40c127b 100644 --- a/src/client/pac/PacWrapper.ts +++ b/src/client/pac/PacWrapper.ts @@ -310,10 +310,17 @@ export class PacWrapper { public async downloadSiteWithProgress( downloadPath: string, websiteId: string, - dataModelVersion: 1 | 2 + dataModelVersion: 1 | 2, + environmentUrl?: string ): Promise { + const pacArguments = ["pages", "download", "--path", downloadPath, "--webSiteId", websiteId, "--modelVersion", dataModelVersion.toString(), "--overwrite"]; + + if (environmentUrl) { + pacArguments.push("--environment", environmentUrl); + } + return this.pacInterop.executeCommandWithProgress( - new PacArguments("pages", "download", "--path", downloadPath, "--webSiteId", websiteId, "--modelVersion", dataModelVersion.toString(), "--overwrite") + new PacArguments(...pacArguments) ); } diff --git a/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts b/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts index 9f8e2d4a1..136e9a3b2 100644 --- a/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts +++ b/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts @@ -49,6 +49,10 @@ import { MetadataDiffDecorationProvider } from "./MetadataDiffDecorationProvider import { removeSiteComparison } from "./handlers/metadata-diff/RemoveSiteHandler"; import { discardLocalChanges } from "./handlers/metadata-diff/DiscardLocalChangesHandler"; import { discardFolderChanges } from "./handlers/metadata-diff/DiscardFolderChangesHandler"; +import { generateHtmlReport } from "./handlers/metadata-diff/GenerateHtmlReportHandler"; +import { exportMetadataDiff } from "./handlers/metadata-diff/ExportMetadataDiffHandler"; +import { importMetadataDiff } from "./handlers/metadata-diff/ImportMetadataDiffHandler"; +import { resyncMetadataDiff } from "./handlers/metadata-diff/ResyncMetadataDiffHandler"; export class ActionsHubTreeDataProvider implements vscode.TreeDataProvider { private readonly _disposables: vscode.Disposable[] = []; @@ -107,7 +111,7 @@ export class ActionsHubTreeDataProvider implements vscode.TreeDataProvider(item1: T, item return date2 - date1; // Sort in descending order (newest first) } -export const fetchWebsites = async (): Promise<{ activeSites: IWebsiteDetails[], inactiveSites: IWebsiteDetails[], otherSites: IOtherSiteInfo[] }> => { +export const fetchWebsites = async (orgInfo: OrgInfo, shouldReturnOtherSites: boolean): Promise<{ activeSites: IWebsiteDetails[], inactiveSites: IWebsiteDetails[], otherSites: IOtherSiteInfo[] }> => { traceInfo(Constants.EventNames.ACTIONS_HUB_FETCH_WEBSITES_CALLED, { methodName: fetchWebsites.name }); try { - const orgInfo = PacContext.OrgInfo; if (ArtemisContext.ServiceResponse?.stamp && orgInfo) { let allSites: IWebsiteDetails[] = []; let activeWebsiteDetails: IWebsiteDetails[] = []; @@ -167,7 +209,7 @@ export const fetchWebsites = async (): Promise<{ activeSites: IWebsiteDetails[], inactiveWebsiteDetails.sort(sortByCreatedOn); const currentEnvSiteIds = createKnownSiteIdsSet(activeWebsiteDetails, inactiveWebsiteDetails); - const otherSites = findOtherSites(currentEnvSiteIds); + const otherSites = shouldReturnOtherSites ? findOtherSites(currentEnvSiteIds) : []; return { activeSites: activeWebsiteDetails, inactiveSites: inactiveWebsiteDetails, otherSites: otherSites }; } diff --git a/src/client/power-pages/actions-hub/Constants.ts b/src/client/power-pages/actions-hub/Constants.ts index 0cd53e8cb..cecd70442 100644 --- a/src/client/power-pages/actions-hub/Constants.ts +++ b/src/client/power-pages/actions-hub/Constants.ts @@ -22,6 +22,7 @@ export const Constants = { METADATA_DIFF_GROUP: "metadataDiffGroup", METADATA_DIFF_GROUP_WITH_RESULTS: "metadataDiffGroupWithResults", METADATA_DIFF_SITE: "metadataDiffSite", + METADATA_DIFF_SITE_IMPORTED: "metadataDiffSiteImported", METADATA_DIFF_FOLDER: "metadataDiffFolder", METADATA_DIFF_FILE: "metadataDiffFile", }, @@ -37,10 +38,15 @@ export const Constants = { METADATA_DIFF_SORT_BY_NAME: "microsoft.powerplatform.pages.actionsHub.metadataDiff.sortByName", METADATA_DIFF_SORT_BY_PATH: "microsoft.powerplatform.pages.actionsHub.metadataDiff.sortByPath", METADATA_DIFF_SORT_BY_STATUS: "microsoft.powerplatform.pages.actionsHub.metadataDiff.sortByStatus", + METADATA_DIFF_GENERATE_HTML_REPORT: "microsoft.powerplatform.pages.actionsHub.metadataDiff.generateHtmlReport", + METADATA_DIFF_EXPORT: "microsoft.powerplatform.pages.actionsHub.metadataDiff.export", + METADATA_DIFF_IMPORT: "microsoft.powerplatform.pages.actionsHub.metadataDiff.import", + METADATA_DIFF_RESYNC: "microsoft.powerplatform.pages.actionsHub.metadataDiff.resync", COMPARE_WITH_ENVIRONMENT: "microsoft-powerapps-portals.compareWithEnvironment", }, Icons: { SITE: new vscode.ThemeIcon('globe'), + IMPORTED_SITE: new vscode.ThemeIcon('cloud-download'), SITE_GROUP: vscode.ThemeIcon.Folder, OTHER_SITES: new vscode.ThemeIcon('archive'), TOOLS: new vscode.ThemeIcon('tools'), @@ -59,6 +65,8 @@ export const Constants = { SESSION_DETAILS: vscode.l10n.t("Session Details"), CHANGING_ENVIRONMENT: vscode.l10n.t("Changing environment..."), CURRENT: vscode.l10n.t("Current"), + LOCAL: vscode.l10n.t("Local"), + LOCAL_SITE: vscode.l10n.t("Local Site"), SITE_MANAGEMENT_URL_NOT_FOUND: vscode.l10n.t("Site management URL not found for the selected site. Please try again after refreshing the environment."), SITE_UPLOAD_CONFIRMATION: vscode.l10n.t(`Be careful when you're updating public sites. The changes you make are visible to anyone immediately. Do you want to continue?`), YES: vscode.l10n.t("Yes"), @@ -175,17 +183,77 @@ export const Constants = { METADATA_DIFF_DELETED: vscode.l10n.t("Deleted locally"), COMPARE_WITH_LOCAL_COMPLETED: vscode.l10n.t("Download is complete. You can now view the report."), SELECT_ENVIRONMENT_TO_COMPARE: vscode.l10n.t("Select an environment to compare with"), + SELECT_WEBSITE_TO_COMPARE: vscode.l10n.t("Select a website to compare with"), + NO_SITES_FOUND_IN_ENVIRONMENT: vscode.l10n.t("No sites found in the selected environment."), + MATCHING_SITE_INDICATOR: vscode.l10n.t("Matching Site"), + ENHANCED_DATA_MODEL: vscode.l10n.t("Enhanced Data Model"), + STANDARD_DATA_MODEL: vscode.l10n.t("Standard Data Model"), + DIFFERENT_WEBSITE_CONFIRMATION: vscode.l10n.t("The website you selected is different from the local site. Are you sure you want to compare with this website?"), + YES_DONT_ASK_AGAIN: vscode.l10n.t("Yes, Don't Ask Again"), WEBSITE_NOT_FOUND_IN_ENVIRONMENT: vscode.l10n.t("The website was not found in the selected environment. Please select a different environment."), - FETCHING_WEBSITES_FROM_ENVIRONMENT: vscode.l10n.t("Fetching websites from the selected environment..."), DISCARD_CHANGES: vscode.l10n.t("Discard Changes"), SHOW_DIFF: vscode.l10n.t("Show Diff"), METADATA_DIFF_ONLY_BINARY_FILES: vscode.l10n.t("All changed files are binary files (e.g., images) and cannot be displayed in the diff viewer. You can view them individually in the file tree."), + FILE_COUNT_DESCRIPTION_SINGULAR: vscode.l10n.t("1 file changed"), + SAVE_REPORT: vscode.l10n.t("Save Report"), + SAVE_HTML_REPORT_TITLE: vscode.l10n.t("Save HTML Report"), + OPEN_REPORT: vscode.l10n.t("Open Report"), + // HTML Report labels + HTML_REPORT_TITLE: vscode.l10n.t("Metadata Diff Report"), + HTML_REPORT_SUBTITLE: vscode.l10n.t("Power Pages Site Comparison Results"), + HTML_REPORT_TOTAL_CHANGES: vscode.l10n.t("Total Changes"), + HTML_REPORT_ADDED: vscode.l10n.t("Added"), + HTML_REPORT_MODIFIED: vscode.l10n.t("Modified"), + HTML_REPORT_DELETED: vscode.l10n.t("Deleted"), + HTML_REPORT_COMPARISON_DETAILS: vscode.l10n.t("Comparison Details"), + HTML_REPORT_SITE_NAME_LABEL: vscode.l10n.t("Site Name:"), + HTML_REPORT_ENVIRONMENT_LABEL: vscode.l10n.t("Environment:"), + HTML_REPORT_GENERATED_LABEL: vscode.l10n.t("Generated:"), + HTML_REPORT_CHANGED_FILES: vscode.l10n.t("Changed Files"), + HTML_REPORT_CLICK_TO_EXPAND: vscode.l10n.t("(click to expand diff)"), + HTML_REPORT_EXPAND_ALL: vscode.l10n.t("Expand All"), + HTML_REPORT_COLLAPSE_ALL: vscode.l10n.t("Collapse All"), + HTML_REPORT_GENERATED_BY: vscode.l10n.t("Generated by"), + HTML_REPORT_EXTENSION_NAME: vscode.l10n.t("Power Platform Tools VS Code Extension"), + // HTML Report status texts + HTML_REPORT_STATUS_MODIFIED: vscode.l10n.t("Modified"), + HTML_REPORT_STATUS_ADDED: vscode.l10n.t("Added Locally"), + HTML_REPORT_STATUS_DELETED: vscode.l10n.t("Deleted Locally"), + HTML_REPORT_STATUS_UNKNOWN: vscode.l10n.t("Unknown"), + // HTML Report diff messages + HTML_REPORT_BINARY_FILE_MESSAGE: vscode.l10n.t("Binary file - content comparison not available"), + HTML_REPORT_UNABLE_TO_READ_CONTENTS: vscode.l10n.t("Unable to read file contents"), + HTML_REPORT_UNABLE_TO_READ_LOCAL: vscode.l10n.t("Unable to read local file"), + HTML_REPORT_UNABLE_TO_READ_REMOTE: vscode.l10n.t("Unable to read remote file"), + HTML_REPORT_UNABLE_TO_READ_BOTH: vscode.l10n.t("Unable to read one or both files"), + // Export/Import strings + METADATA_DIFF_EXPORT_PROGRESS: vscode.l10n.t("Exporting comparison..."), + METADATA_DIFF_EXPORT_TITLE: vscode.l10n.t("Export Metadata Diff"), + METADATA_DIFF_IMPORT_PROGRESS: vscode.l10n.t("Importing comparison..."), + METADATA_DIFF_EXPORT_INVALID_FILE: vscode.l10n.t("Invalid file format. The file does not contain valid metadata diff data."), + METADATA_DIFF_EXPORT_UNSUPPORTED_VERSION: vscode.l10n.t("Unsupported version. This file was created with a newer version of the extension."), + METADATA_DIFF_EXPORT_NEWER_EXTENSION_VERSION: vscode.l10n.t("This file was exported with a newer version of the extension. Please update your extension to import this file."), + METADATA_DIFF_EXPORT_FILTER_NAME: vscode.l10n.t("Metadata Diff JSON"), + METADATA_DIFF_BINARY_FILE_NOT_AVAILABLE: vscode.l10n.t("Binary file content is not available in imported comparisons. The original file was not included in the export."), + METADATA_DIFF_REPLACE_EXISTING_IMPORT: vscode.l10n.t("An imported comparison already exists for this site. Do you want to replace it?"), + REPLACE: vscode.l10n.t("Replace"), + METADATA_DIFF_RESYNC_COMPLETED: vscode.l10n.t("Comparison has been refreshed with the latest data from the environment."), + METADATA_DIFF_CANNOT_RESYNC_IMPORTED: vscode.l10n.t("Cannot refresh an imported comparison. Import a new file or run a new comparison."), }, /** * Functions that return localized strings with dynamic parameters. * Use these for strings that require runtime values. */ StringFunctions: { + /** + * Returns the fetching websites message with environment name + */ + FETCHING_WEBSITES_FROM_ENVIRONMENT: (environmentName: string) => + vscode.l10n.t({ + message: "Fetching websites from '{0}'...", + args: [environmentName], + comment: ["Message shown while fetching websites from an environment. {0} is the environment name."] + }), /** * Returns the downloading site message with site name */ @@ -195,6 +263,25 @@ export const Constants = { args: [siteName], comment: ["This is a markdown formatting which must persist across translations."] }), + /** + * Returns the comparison label showing remote and local site without file count + * Format: " (Environment Name) <-> (Local)" + */ + COMPARISON_LABEL: (remoteSiteName: string, environmentName: string, localSiteName: string) => + vscode.l10n.t({ + message: "{0} ({1}) ↔ {2} (Local)", + args: [remoteSiteName, environmentName, localSiteName], + comment: ["Comparison label showing remote and local sites. Format: 'Remote (Env) ↔ Local (Local)'."] + }), + /** + * Returns the file count description (plural: "X files changed") + */ + FILE_COUNT_DESCRIPTION_PLURAL: (fileCount: number) => + vscode.l10n.t({ + message: "{0} files changed", + args: [fileCount], + comment: ["Description showing the number of changed files. 'files' is plural."] + }), /** * Returns the site label with file count (singular: "1 file") */ @@ -285,6 +372,87 @@ export const Constants = { args: [count], comment: ["Message shown when binary files are skipped in the multi-diff view."] }), + /** + * Returns the success message when HTML report is saved + */ + HTML_REPORT_SAVED_SUCCESS: (filePath: string) => + vscode.l10n.t({ + message: "HTML report saved successfully to '{0}'.", + args: [filePath], + comment: ["Success message when HTML report is saved. {0} is the file path."] + }), + /** + * Returns the error message when HTML report generation fails + */ + HTML_REPORT_GENERATION_FAILED: (errorMessage: string) => + vscode.l10n.t({ + message: "Failed to generate HTML report: {0}", + args: [errorMessage], + comment: ["Error message when HTML report generation fails. {0} is the error message."] + }), + /** + * Returns the description for imported comparison showing file count and export date + */ + IMPORTED_COMPARISON_DESCRIPTION: (fileCount: number, formattedDate: string) => + vscode.l10n.t({ + message: "{0} files changed (Imported {1})", + args: [fileCount, formattedDate], + comment: ["Description for imported comparison. {0} is file count, {1} is formatted date."] + }), + /** + * Returns the error message when a required field is missing in import + */ + METADATA_DIFF_MISSING_REQUIRED_FIELD: (fieldName: string) => + vscode.l10n.t({ + message: "Missing required field: {0}", + args: [fieldName], + comment: ["Error when importing a file that is missing a required field."] + }), + /** + * Returns the success message when export is complete + */ + METADATA_DIFF_EXPORT_SUCCESS: (filePath: string) => + vscode.l10n.t({ + message: "Comparison exported successfully to '{0}'.", + args: [filePath], + comment: ["Success message when export is saved. {0} is the file path."] + }), + /** + * Returns the error message when export fails + */ + METADATA_DIFF_EXPORT_FAILED: (errorMessage: string) => + vscode.l10n.t({ + message: "Failed to export comparison: {0}", + args: [errorMessage], + comment: ["Error message when export fails. {0} is the error message."] + }), + /** + * Returns the success message when import is complete + */ + METADATA_DIFF_IMPORT_SUCCESS: (siteName: string) => + vscode.l10n.t({ + message: "Comparison for '{0}' imported successfully.", + args: [siteName], + comment: ["Success message when import is complete. {0} is the site name."] + }), + /** + * Returns the error message when import fails + */ + METADATA_DIFF_IMPORT_FAILED: (errorMessage: string) => + vscode.l10n.t({ + message: "Failed to import comparison: {0}", + args: [errorMessage], + comment: ["Error message when import fails. {0} is the error message."] + }), + /** + * Returns the progress message when resyncing a site comparison + */ + RESYNCING_SITE_COMPARISON: (siteName: string) => + vscode.l10n.t({ + message: "Refreshing comparison for {0} ([details](command:microsoft.powerplatform.pages.actionsHub.showOutputChannel \"Show download output\"))...", + args: [siteName], + comment: ["This is a markdown formatting which must persist across translations."] + }), }, EventNames: { ACTIONS_HUB_ENABLED: "ActionsHubEnabled", @@ -416,11 +584,27 @@ export const Constants = { ACTIONS_HUB_METADATA_DIFF_SORT_MODE_CHANGED: "ActionsHubMetadataDiffSortModeChanged", ACTIONS_HUB_COMPARE_WITH_ENVIRONMENT_CALLED: "ActionsHubCompareWithEnvironmentCalled", ACTIONS_HUB_COMPARE_WITH_ENVIRONMENT_ENVIRONMENT_SELECTED: "ActionsHubCompareWithEnvironmentEnvironmentSelected", + ACTIONS_HUB_COMPARE_WITH_ENVIRONMENT_WEBSITE_SELECTED: "ActionsHubCompareWithEnvironmentWebsiteSelected", ACTIONS_HUB_COMPARE_WITH_ENVIRONMENT_CANCELLED: "ActionsHubCompareWithEnvironmentCancelled", ACTIONS_HUB_COMPARE_WITH_ENVIRONMENT_WEBSITE_NOT_FOUND: "ActionsHubCompareWithEnvironmentWebsiteNotFound", ACTIONS_HUB_COMPARE_WITH_ENVIRONMENT_COMPLETED: "ActionsHubCompareWithEnvironmentCompleted", ACTIONS_HUB_COMPARE_WITH_ENVIRONMENT_FAILED: "ActionsHubCompareWithEnvironmentFailed", ACTIONS_HUB_COMPARE_WITH_ENVIRONMENT_NO_DIFFERENCES: "ActionsHubCompareWithEnvironmentNoDifferences", + ACTIONS_HUB_METADATA_DIFF_SITE_DOWNLOAD_COMPLETED: "ActionsHubMetadataDiffSiteDownloadCompleted", + ACTIONS_HUB_METADATA_DIFF_GENERATE_HTML_REPORT_CALLED: "ActionsHubMetadataDiffGenerateHtmlReportCalled", + ACTIONS_HUB_METADATA_DIFF_HTML_REPORT_SAVED: "ActionsHubMetadataDiffHtmlReportSaved", + ACTIONS_HUB_METADATA_DIFF_HTML_REPORT_FAILED: "ActionsHubMetadataDiffHtmlReportFailed", + ACTIONS_HUB_METADATA_DIFF_EXPORT_CALLED: "ActionsHubMetadataDiffExportCalled", + ACTIONS_HUB_METADATA_DIFF_EXPORT_CANCELLED: "ActionsHubMetadataDiffExportCancelled", + ACTIONS_HUB_METADATA_DIFF_EXPORT_SUCCESS: "ActionsHubMetadataDiffExportSuccess", + ACTIONS_HUB_METADATA_DIFF_EXPORT_FAILED: "ActionsHubMetadataDiffExportFailed", + ACTIONS_HUB_METADATA_DIFF_IMPORT_CALLED: "ActionsHubMetadataDiffImportCalled", + ACTIONS_HUB_METADATA_DIFF_IMPORT_SUCCESS: "ActionsHubMetadataDiffImportSuccess", + ACTIONS_HUB_METADATA_DIFF_IMPORT_FAILED: "ActionsHubMetadataDiffImportFailed", + ACTIONS_HUB_METADATA_DIFF_RESYNC_CALLED: "ActionsHubMetadataDiffResyncCalled", + ACTIONS_HUB_METADATA_DIFF_RESYNC_COMPLETED: "ActionsHubMetadataDiffResyncCompleted", + ACTIONS_HUB_METADATA_DIFF_RESYNC_FAILED: "ActionsHubMetadataDiffResyncFailed", + ACTIONS_HUB_METADATA_DIFF_RESYNC_NO_DIFFERENCES: "ActionsHubMetadataDiffResyncNoDifferences", }, StudioEndpoints: { TEST: "https://make.test.powerpages.microsoft.com", diff --git a/src/client/power-pages/actions-hub/MetadataDiffContext.ts b/src/client/power-pages/actions-hub/MetadataDiffContext.ts index 1a846720e..766095762 100644 --- a/src/client/power-pages/actions-hub/MetadataDiffContext.ts +++ b/src/client/power-pages/actions-hub/MetadataDiffContext.ts @@ -4,7 +4,7 @@ */ import * as vscode from "vscode"; -import { IFileComparisonResult } from "./models/IFileComparisonResult"; +import { IFileComparisonResult, ISiteComparisonResults } from "./models/IFileComparisonResult"; /** * Enum for metadata diff view modes @@ -23,15 +23,6 @@ export enum MetadataDiffSortMode { Status = "status" } -/** - * Interface for storing comparison results per site - */ -export interface ISiteComparisonResults { - siteName: string; - environmentName: string; - comparisonResults: IFileComparisonResult[]; -} - const VIEW_MODE_CONTEXT_KEY = "microsoft.powerplatform.pages.metadataDiffViewMode"; const VIEW_MODE_STORAGE_KEY = "microsoft.powerplatform.pages.metadataDiff.viewModePreference"; const SORT_MODE_CONTEXT_KEY = "microsoft.powerplatform.pages.metadataDiffSortMode"; @@ -51,6 +42,37 @@ class MetadataDiffContextClass { private _onChanged: vscode.EventEmitter = new vscode.EventEmitter(); public readonly onChanged: vscode.Event = this._onChanged.event; + /** + * Generate a unique key for storing site comparison results + * @param websiteId The website ID + * @param environmentId The environment ID + * @param isImported Whether this is an imported comparison + * @returns A unique key string + */ + public getUniqueKey(websiteId: string, environmentId: string, isImported: boolean = false): string { + return isImported + ? `${websiteId}_${environmentId}_imported` + : `${websiteId}_${environmentId}`; + } + + /** + * Check if there is an imported comparison for the given website and environment + * @param websiteId The website ID + * @param environmentId The environment ID + * @returns True if an imported comparison exists + */ + public hasImportedComparison(websiteId: string, environmentId: string): boolean { + const key = this.getUniqueKey(websiteId, environmentId, true); + return this._siteResults.has(key); + } + + /** + * Get the extension context for accessing global storage + */ + public get extensionContext(): vscode.ExtensionContext | undefined { + return this._extensionContext; + } + /** * Initialize the context with the extension context for state persistence. * This should be called once during extension activation. @@ -132,28 +154,70 @@ class MetadataDiffContextClass { /** * Set results for a specific site. If results already exist for this site, they are replaced. * @param results The comparison results - * @param siteName The name of the site + * @param siteName The name of the remote site + * @param localSiteName The name of the local site * @param environmentName The name of the environment + * @param websiteId The website ID + * @param environmentId The environment ID + * @param isImported Whether this is an imported comparison + * @param exportedAt ISO 8601 timestamp when the comparison was exported (only for imported comparisons) + * @param dataModelVersion The data model version of the site (1 = Standard, 2 = Enhanced) */ - public setResults(results: IFileComparisonResult[], siteName: string, environmentName: string): void { + public setResults( + results: IFileComparisonResult[], + siteName: string, + localSiteName: string, + environmentName: string, + websiteId: string, + environmentId: string, + isImported: boolean = false, + exportedAt?: string, + dataModelVersion?: 1 | 2 + ): void { + const key = this.getUniqueKey(websiteId, environmentId, isImported); if (results.length > 0) { - this._siteResults.set(siteName, { + this._siteResults.set(key, { siteName, + localSiteName, environmentName, - comparisonResults: results + websiteId, + environmentId, + comparisonResults: results, + isImported, + exportedAt, + dataModelVersion }); } else { // If no results, remove the site from the map - this._siteResults.delete(siteName); + this._siteResults.delete(key); } this._onChanged.fire(); } /** - * Clear results for a specific site + * Clear results for a specific site using the unique key + * @param websiteId The website ID + * @param environmentId The environment ID + * @param isImported Whether this is an imported comparison + */ + public clearSiteByKey(websiteId: string, environmentId: string, isImported: boolean = false): void { + const key = this.getUniqueKey(websiteId, environmentId, isImported); + this._siteResults.delete(key); + this._onChanged.fire(); + } + + /** + * Clear results for a specific site by site name + * @deprecated Use clearSiteByKey instead for better precision */ public clearSite(siteName: string): void { - this._siteResults.delete(siteName); + // Find and remove the site by name (backward compatibility) + for (const [key, result] of this._siteResults.entries()) { + if (result.siteName === siteName) { + this._siteResults.delete(key); + break; + } + } this._onChanged.fire(); } @@ -164,17 +228,20 @@ class MetadataDiffContextClass { * @param siteName The name of the site */ public removeFile(relativePath: string, siteName: string): void { - const siteResult = this._siteResults.get(siteName); - if (siteResult) { - siteResult.comparisonResults = siteResult.comparisonResults.filter( - result => result.relativePath !== relativePath - ); - - if (siteResult.comparisonResults.length === 0) { - this._siteResults.delete(siteName); + // Find the site by name + for (const [key, siteResult] of this._siteResults.entries()) { + if (siteResult.siteName === siteName) { + siteResult.comparisonResults = siteResult.comparisonResults.filter( + result => result.relativePath !== relativePath + ); + + if (siteResult.comparisonResults.length === 0) { + this._siteResults.delete(key); + } + + this._onChanged.fire(); + break; } - - this._onChanged.fire(); } } diff --git a/src/client/power-pages/actions-hub/handlers/metadata-diff/CompareWithEnvironmentHandler.ts b/src/client/power-pages/actions-hub/handlers/metadata-diff/CompareWithEnvironmentHandler.ts index 5306b9f12..439d870d2 100644 --- a/src/client/power-pages/actions-hub/handlers/metadata-diff/CompareWithEnvironmentHandler.ts +++ b/src/client/power-pages/actions-hub/handlers/metadata-diff/CompareWithEnvironmentHandler.ts @@ -5,45 +5,64 @@ import * as vscode from "vscode"; import { PacTerminal } from "../../../../lib/PacTerminal"; +import PacContext from "../../../../pac/PacContext"; import { Constants } from "../../Constants"; import { traceError, traceInfo } from "../../TelemetryHelper"; import { showProgressWithNotification } from "../../../../../common/utilities/Utils"; import { SUCCESS } from "../../../../../common/constants"; -import { OrgInfo, OrgListOutput } from "../../../../pac/PacTypes"; +import { OrgInfo } from "../../../../pac/PacTypes"; import { IWebsiteDetails } from "../../../../../common/services/Interfaces"; -import { getAllWebsites } from "../../../../../common/utilities/WebsiteUtil"; import { resolveSiteFromWorkspace, prepareSiteStoragePath, processComparisonResults } from "./MetadataDiffUtils"; +import { getWebsiteName } from "../../../../../common/utilities/WorkspaceInfoFinderUtil"; +import { fetchWebsites } from "../../ActionsHubUtils"; /** * Environment quick pick item with full org info */ interface EnvironmentQuickPickItem extends vscode.QuickPickItem { orgInfo: OrgInfo; + iconPath?: vscode.ThemeIcon | { light: vscode.Uri; dark: vscode.Uri }; } /** - * Gets the list of environments accessible to the user + * Website quick pick item with full website details + */ +interface WebsiteQuickPickItem extends vscode.QuickPickItem { + websiteDetails: IWebsiteDetails; + iconPath?: vscode.ThemeIcon; +} + +/** + * Gets the list of environments accessible to the user, excluding the currently connected environment * @param pacTerminal The PAC terminal instance + * @param context The extension context for accessing icons * @returns Array of environment quick pick items with org info */ -async function getEnvironmentList(pacTerminal: PacTerminal): Promise { +async function getEnvironmentList(pacTerminal: PacTerminal, context: vscode.ExtensionContext): Promise { const pacWrapper = pacTerminal.getWrapper(); const envListOutput = await pacWrapper.orgList(); if (envListOutput && envListOutput.Status === SUCCESS && envListOutput.Results) { - const envList = envListOutput.Results as OrgListOutput[]; + const currentEnvironmentId = PacContext.OrgInfo?.EnvironmentId; + const envList = envListOutput.Results.filter( + (env) => env.EnvironmentIdentifier.Id !== currentEnvironmentId + ); return envList.map((env) => ({ label: env.FriendlyName, detail: env.EnvironmentUrl, description: "", + iconPath: { + light: vscode.Uri.joinPath(context.extensionUri, 'src', 'client', 'assets', 'environment-icon', 'light', 'environment.svg'), + dark: vscode.Uri.joinPath(context.extensionUri, 'src', 'client', 'assets', 'environment-icon', 'dark', 'environment.svg') + }, orgInfo: { OrgId: env.OrganizationId, - UniqueName: "", + UniqueName: env.UniqueName, FriendlyName: env.FriendlyName, OrgUrl: env.EnvironmentUrl, UserEmail: "", UserId: "", - EnvironmentId: env.EnvironmentId + EnvironmentId: env.EnvironmentIdentifier.Id } })); } @@ -52,13 +71,79 @@ async function getEnvironmentList(pacTerminal: PacTerminal): Promise website.websiteRecordId === websiteId); +function isWebsiteQuickPickItem(item: vscode.QuickPickItem | WebsiteQuickPickItem): item is WebsiteQuickPickItem { + return 'websiteDetails' in item; +} + +/** + * Converts website details to quick pick items for selection + * @param activeSites List of active websites + * @param inactiveSites List of inactive websites + * @param localSiteId The website record ID from the local workspace (to prioritize matching site) + * @returns Array of website quick pick items sorted by: matching site first, then active status, then name, with separators between groups + */ +function getWebsiteQuickPickItems(activeSites: IWebsiteDetails[], inactiveSites: IWebsiteDetails[], localSiteId: string): (WebsiteQuickPickItem | vscode.QuickPickItem)[] { + const normalizedLocalSiteId = localSiteId.toLowerCase(); + + // Sort function for websites within a group + const sortByMatchingThenName = (a: IWebsiteDetails, b: IWebsiteDetails): number => { + const aMatches = a.websiteRecordId?.toLowerCase() === normalizedLocalSiteId; + const bMatches = b.websiteRecordId?.toLowerCase() === normalizedLocalSiteId; + + // Matching site comes first + if (aMatches && !bMatches) { + return -1; + } + if (!aMatches && bMatches) { + return 1; + } + + // Sort by name alphabetically + const nameA = a.name || ""; + const nameB = b.name || ""; + return nameA.localeCompare(nameB); + }; + + // Sort each group + const sortedActiveSites = [...activeSites].sort(sortByMatchingThenName); + const sortedInactiveSites = [...inactiveSites].sort(sortByMatchingThenName); + + const createWebsiteItem = (website: IWebsiteDetails): WebsiteQuickPickItem => { + const isMatchingSite = website.websiteRecordId?.toLowerCase() === normalizedLocalSiteId; + const dataModelText = website.dataModel === "Enhanced" ? Constants.Strings.ENHANCED_DATA_MODEL : Constants.Strings.STANDARD_DATA_MODEL; + const matchingIndicator = isMatchingSite ? ` • ${Constants.Strings.MATCHING_SITE_INDICATOR}` : ""; + return { + label: website.name, + detail: website.websiteUrl, + description: `${dataModelText}${matchingIndicator}`, + iconPath: Constants.Icons.SITE, + websiteDetails: website + }; + }; + + const result: (WebsiteQuickPickItem | vscode.QuickPickItem)[] = []; + + // Add active sites with separator + if (sortedActiveSites.length > 0) { + result.push({ + label: Constants.Strings.ACTIVE_SITES, + kind: vscode.QuickPickItemKind.Separator + }); + result.push(...sortedActiveSites.map(createWebsiteItem)); + } + + // Add inactive sites with separator + if (sortedInactiveSites.length > 0) { + result.push({ + label: Constants.Strings.INACTIVE_SITES, + kind: vscode.QuickPickItemKind.Separator + }); + result.push(...sortedInactiveSites.map(createWebsiteItem)); + } + + return result; } export const compareWithEnvironment = (pacTerminal: PacTerminal, context: vscode.ExtensionContext) => async (resource: vscode.Uri): Promise => { @@ -87,7 +172,7 @@ export const compareWithEnvironment = (pacTerminal: PacTerminal, context: vscode } // Show environment picker - const environmentList = await getEnvironmentList(pacTerminal); + const environmentList = await getEnvironmentList(pacTerminal, context); if (environmentList.length === 0) { await vscode.window.showErrorMessage(Constants.Strings.NO_ENVIRONMENTS_FOUND); @@ -113,28 +198,78 @@ export const compareWithEnvironment = (pacTerminal: PacTerminal, context: vscode environmentName: selectedEnv.label }); - // Fetch websites from selected environment - let websiteDetails: IWebsiteDetails | undefined; + // Fetch websites from selected environment and show picker + let activeSites: IWebsiteDetails[] = []; + let inactiveSites: IWebsiteDetails[] = []; await showProgressWithNotification( - Constants.Strings.FETCHING_WEBSITES_FROM_ENVIRONMENT, + Constants.StringFunctions.FETCHING_WEBSITES_FROM_ENVIRONMENT(selectedEnv.label), async () => { - const websites = await getAllWebsites(selectedOrgInfo); - websiteDetails = findWebsiteById(websites, siteResolution.siteId); + const result = await fetchWebsites(selectedOrgInfo, false); + activeSites = result.activeSites; + inactiveSites = result.inactiveSites; return true; } ); - if (!websiteDetails) { + if (activeSites.length === 0 && inactiveSites.length === 0) { traceInfo(Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_ENVIRONMENT_WEBSITE_NOT_FOUND, { methodName: compareWithEnvironment.name, - websiteId: siteResolution.siteId, environmentId: selectedOrgInfo.EnvironmentId }); - await vscode.window.showErrorMessage(Constants.Strings.WEBSITE_NOT_FOUND_IN_ENVIRONMENT); + await vscode.window.showErrorMessage(Constants.Strings.NO_SITES_FOUND_IN_ENVIRONMENT); return; } + // Show website picker + const websiteQuickPickItems = getWebsiteQuickPickItems(activeSites, inactiveSites, siteResolution.siteId); + const selectedWebsite = await vscode.window.showQuickPick(websiteQuickPickItems, { + placeHolder: Constants.Strings.SELECT_WEBSITE_TO_COMPARE + }); + + if (!selectedWebsite || !isWebsiteQuickPickItem(selectedWebsite)) { + traceInfo(Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_ENVIRONMENT_CANCELLED, { + methodName: compareWithEnvironment.name, + reason: "User cancelled website selection" + }); + return; + } + + const websiteDetails = selectedWebsite.websiteDetails; + + // Check if selected website is different from local site and show confirmation + const isMatchingSite = websiteDetails.websiteRecordId?.toLowerCase() === siteResolution.siteId.toLowerCase(); + if (!isMatchingSite) { + const config = vscode.workspace.getConfiguration(Constants.Strings.CONFIGURATION_NAME); + const shouldConfirm = config.get("confirmDifferentWebsiteComparison", true); + + if (shouldConfirm) { + const confirmation = await vscode.window.showWarningMessage( + Constants.Strings.DIFFERENT_WEBSITE_CONFIRMATION, + { modal: true }, + Constants.Strings.YES, + Constants.Strings.YES_DONT_ASK_AGAIN + ); + + if (confirmation === Constants.Strings.YES_DONT_ASK_AGAIN) { + await config.update("confirmDifferentWebsiteComparison", false, vscode.ConfigurationTarget.Global); + } else if (confirmation !== Constants.Strings.YES) { + traceInfo(Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_ENVIRONMENT_CANCELLED, { + methodName: compareWithEnvironment.name, + reason: "User cancelled after different website confirmation" + }); + return; + } + } + } + + traceInfo(Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_ENVIRONMENT_WEBSITE_SELECTED, { + methodName: compareWithEnvironment.name, + websiteId: websiteDetails.websiteRecordId, + websiteName: websiteDetails.name, + environmentId: selectedOrgInfo.EnvironmentId + }); + // Determine data model version const dataModelVersion = websiteDetails.dataModel === "Enhanced" ? 2 : 1; @@ -147,17 +282,17 @@ export const compareWithEnvironment = (pacTerminal: PacTerminal, context: vscode const siteStoragePath = prepareSiteStoragePath(storagePath, websiteDetails.websiteRecordId); const pacWrapper = pacTerminal.getWrapper(); - // Select the environment before downloading - await pacWrapper.orgSelect(selectedOrgInfo.OrgUrl); - + const downloadStartTime = Date.now(); const success = await showProgressWithNotification( Constants.StringFunctions.DOWNLOADING_SITE_FOR_COMPARISON(websiteDetails.name), async () => pacWrapper.downloadSiteWithProgress( siteStoragePath, websiteDetails!.websiteRecordId, - dataModelVersion + dataModelVersion, + selectedEnv.orgInfo.OrgUrl ) ); + const downloadDurationMs = Date.now() - downloadStartTime; if (!success) { traceError( @@ -173,16 +308,33 @@ export const compareWithEnvironment = (pacTerminal: PacTerminal, context: vscode return; } - await processComparisonResults( + traceInfo(Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_SITE_DOWNLOAD_COMPLETED, { + methodName: compareWithEnvironment.name, + siteId: websiteDetails.websiteRecordId, + environmentId: selectedOrgInfo.EnvironmentId, + dataModelVersion: dataModelVersion, + downloadDurationMs: downloadDurationMs + }); + + // Get local site name from website.yml + const localSiteName = getWebsiteName(siteResolution.localSitePath) || Constants.Strings.LOCAL_SITE; + + const hasDifferences = await processComparisonResults( siteStoragePath, siteResolution.localSitePath, websiteDetails.name, + localSiteName, selectedEnv.label, compareWithEnvironment.name, websiteDetails.websiteRecordId, Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_ENVIRONMENT_COMPLETED, - Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_ENVIRONMENT_NO_DIFFERENCES + Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_ENVIRONMENT_NO_DIFFERENCES, + siteResolution.comparisonSubPath, + selectedOrgInfo.EnvironmentId, + dataModelVersion ); - await vscode.window.showInformationMessage(Constants.Strings.COMPARE_WITH_LOCAL_COMPLETED); + if (hasDifferences) { + await vscode.window.showInformationMessage(Constants.Strings.COMPARE_WITH_LOCAL_COMPLETED); + } }; diff --git a/src/client/power-pages/actions-hub/handlers/metadata-diff/CompareWithLocalHandler.ts b/src/client/power-pages/actions-hub/handlers/metadata-diff/CompareWithLocalHandler.ts index ac18ef8ff..1d69c7c2e 100644 --- a/src/client/power-pages/actions-hub/handlers/metadata-diff/CompareWithLocalHandler.ts +++ b/src/client/power-pages/actions-hub/handlers/metadata-diff/CompareWithLocalHandler.ts @@ -10,6 +10,7 @@ import { traceError, traceInfo } from "../../TelemetryHelper"; import { SiteTreeItem } from "../../tree-items/SiteTreeItem"; import { showProgressWithNotification } from "../../../../../common/utilities/Utils"; import PacContext from "../../../../pac/PacContext"; +import { getWebsiteName } from "../../../../../common/utilities/WorkspaceInfoFinderUtil"; import { resolveSiteFromWorkspace, prepareSiteStoragePath, processComparisonResults } from "./MetadataDiffUtils"; export const compareWithLocal = (pacTerminal: PacTerminal, context: vscode.ExtensionContext) => async (siteTreeItem: SiteTreeItem): Promise => { @@ -48,6 +49,7 @@ export const compareWithLocal = (pacTerminal: PacTerminal, context: vscode.Exten const siteStoragePath = prepareSiteStoragePath(storagePath, siteTreeItem.siteInfo.websiteId); const pacWrapper = pacTerminal.getWrapper(); + const downloadStartTime = Date.now(); const success = await showProgressWithNotification( Constants.StringFunctions.DOWNLOADING_SITE_FOR_COMPARISON(siteTreeItem.siteInfo.name), async () => pacWrapper.downloadSiteWithProgress( @@ -56,6 +58,7 @@ export const compareWithLocal = (pacTerminal: PacTerminal, context: vscode.Exten siteTreeItem.siteInfo.dataModelVersion ) ); + const downloadDurationMs = Date.now() - downloadStartTime; if (!success) { traceError( @@ -73,16 +76,32 @@ export const compareWithLocal = (pacTerminal: PacTerminal, context: vscode.Exten const environmentName = PacContext.AuthInfo?.OrganizationFriendlyName || ""; - await processComparisonResults( + traceInfo(Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_SITE_DOWNLOAD_COMPLETED, { + methodName: compareWithLocal.name, + siteId: siteTreeItem.siteInfo.websiteId, + dataModelVersion: siteTreeItem.siteInfo.dataModelVersion, + downloadDurationMs: downloadDurationMs + }); + + // Get local site name from website.yml + const localSiteName = getWebsiteName(siteResolution.localSitePath) || Constants.Strings.LOCAL_SITE; + + const hasDifferences = await processComparisonResults( siteStoragePath, siteResolution.localSitePath, siteTreeItem.siteInfo.name, + localSiteName, environmentName, compareWithLocal.name, siteTreeItem.siteInfo.websiteId, Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_LOCAL_COMPLETED, - Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_LOCAL_NO_DIFFERENCES + Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_LOCAL_NO_DIFFERENCES, + undefined, // comparisonSubPath + undefined, // environmentId - will use current context + siteTreeItem.siteInfo.dataModelVersion ); - await vscode.window.showInformationMessage(Constants.Strings.COMPARE_WITH_LOCAL_COMPLETED); + if (hasDifferences) { + await vscode.window.showInformationMessage(Constants.Strings.COMPARE_WITH_LOCAL_COMPLETED); + } } diff --git a/src/client/power-pages/actions-hub/handlers/metadata-diff/ExportMetadataDiffHandler.ts b/src/client/power-pages/actions-hub/handlers/metadata-diff/ExportMetadataDiffHandler.ts new file mode 100644 index 000000000..1224a9207 --- /dev/null +++ b/src/client/power-pages/actions-hub/handlers/metadata-diff/ExportMetadataDiffHandler.ts @@ -0,0 +1,142 @@ +/* + * 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 { MetadataDiffSiteTreeItem } from "../../tree-items/metadata-diff/MetadataDiffSiteTreeItem"; +import { Constants } from "../../Constants"; +import { traceError, traceInfo } from "../../TelemetryHelper"; +import { IMetadataDiffExport, IExportableFileComparisonResult, METADATA_DIFF_EXPORT_VERSION } from "../../models/IMetadataDiffExport"; +import { isBinaryFile } from "../../ActionsHubUtils"; +import { getExtensionVersion } from "../../../../../common/utilities/Utils"; +import { FileComparisonStatus } from "../../models/IFileComparisonResult"; + +/** + * Exports metadata diff results to a JSON file + * @param siteTreeItem The site tree item containing the comparison results + */ +export async function exportMetadataDiff(siteTreeItem: MetadataDiffSiteTreeItem): Promise { + traceInfo(Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_EXPORT_CALLED, { + methodName: exportMetadataDiff.name, + websiteId: siteTreeItem.websiteId, + fileCount: siteTreeItem.comparisonResults.length.toString() + }); + + try { + // Generate filename with site name and date + const date = new Date(); + const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; + const safeSiteName = siteTreeItem.siteName.replace(/[^a-zA-Z0-9-_]/g, "_"); + const defaultFilename = `${safeSiteName}-diff-${dateStr}.json`; + + // Show save dialog first (before progress) + const saveUri = await vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file(defaultFilename), + filters: { + [Constants.Strings.METADATA_DIFF_EXPORT_FILTER_NAME]: ["json"] + }, + title: Constants.Strings.METADATA_DIFF_EXPORT_TITLE + }); + + if (!saveUri) { + traceInfo(Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_EXPORT_CANCELLED, { + methodName: exportMetadataDiff.name, + websiteId: siteTreeItem.websiteId + }); + return; // User cancelled + } + + // Now show progress while doing the actual work + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: Constants.Strings.METADATA_DIFF_EXPORT_PROGRESS, + cancellable: false + }, + async () => { + // Build the export data + const files: IExportableFileComparisonResult[] = []; + + for (const result of siteTreeItem.comparisonResults) { + // Use includeSvg=false since SVG is text-based and can be exported + const isBinary = isBinaryFile(result.relativePath, false); + + let localContent: string | null = null; + let remoteContent: string | null = null; + + // Only include content for non-binary files + if (!isBinary) { + // Read local content for added and modified files + if (result.status !== FileComparisonStatus.DELETED && fs.existsSync(result.localPath)) { + try { + const content = fs.readFileSync(result.localPath); + localContent = content.toString("base64"); + } catch (e) { + // File not readable, content will be null + } + } + + // Read remote content for deleted and modified files + if (result.status !== FileComparisonStatus.ADDED && fs.existsSync(result.remotePath)) { + try { + const content = fs.readFileSync(result.remotePath); + remoteContent = content.toString("base64"); + } catch (e) { + // File not readable, content will be null + } + } + } + + files.push({ + relativePath: result.relativePath, + status: result.status, + localContent, + remoteContent + }); + } + + const exportData: IMetadataDiffExport = { + version: METADATA_DIFF_EXPORT_VERSION, + extensionVersion: getExtensionVersion(), + exportedAt: new Date().toISOString(), + websiteId: siteTreeItem.websiteId, + websiteName: siteTreeItem.siteName, + environmentId: siteTreeItem.environmentId, + environmentName: siteTreeItem.environmentName, + localSiteName: siteTreeItem.localSiteName, + files + }; + + // Write the file + const jsonContent = JSON.stringify(exportData, null, 2); + fs.writeFileSync(saveUri.fsPath, jsonContent, "utf8"); + + traceInfo(Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_EXPORT_SUCCESS, { + methodName: exportMetadataDiff.name, + websiteId: siteTreeItem.websiteId, + fileCount: files.length.toString(), + exportPath: saveUri.fsPath + }); + } + ); + + // Show success message after progress completes + vscode.window.showInformationMessage( + Constants.StringFunctions.METADATA_DIFF_EXPORT_SUCCESS(saveUri.fsPath) + ); + } catch (error) { + traceError( + Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_EXPORT_FAILED, + error as Error, + { + methodName: exportMetadataDiff.name, + websiteId: siteTreeItem.websiteId + } + ); + vscode.window.showErrorMessage( + Constants.StringFunctions.METADATA_DIFF_EXPORT_FAILED((error as Error).message) + ); + } +} diff --git a/src/client/power-pages/actions-hub/handlers/metadata-diff/GenerateHtmlReportHandler.ts b/src/client/power-pages/actions-hub/handlers/metadata-diff/GenerateHtmlReportHandler.ts new file mode 100644 index 000000000..8dd506043 --- /dev/null +++ b/src/client/power-pages/actions-hub/handlers/metadata-diff/GenerateHtmlReportHandler.ts @@ -0,0 +1,909 @@ +/* + * 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 { MetadataDiffSiteTreeItem } from "../../tree-items/metadata-diff/MetadataDiffSiteTreeItem"; +import { IFileComparisonResult, FileComparisonStatus } from "../../models/IFileComparisonResult"; +import { Constants } from "../../Constants"; +import { traceInfo, traceError } from "../../TelemetryHelper"; +import { isBinaryFile } from "../../ActionsHubUtils"; + +/** + * Represents the content of a file comparison + */ +interface IFileContentComparison { + result: IFileComparisonResult; + localContent: string | null; + remoteContent: string | null; + isBinary: boolean; +} + +/** + * Generates an HTML report for the metadata diff results. + * The report can be saved and shared with non-technical users. + */ +export async function generateHtmlReport(treeItem: MetadataDiffSiteTreeItem): Promise { + traceInfo(Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_GENERATE_HTML_REPORT_CALLED, { + siteName: treeItem.siteName, + fileCount: treeItem.comparisonResults.length + }); + + try { + // Read file contents for all non-binary files + const fileContents = await readFileContents(treeItem.comparisonResults); + + const htmlContent = generateHtmlContent( + fileContents, + treeItem.siteName, + treeItem.environmentName + ); + + // Prompt user to save the file + const defaultFileName = `metadata-diff-report-${treeItem.siteName.replace(/[^a-zA-Z0-9]/g, "-")}-${formatDateForFileName(new Date())}.html`; + + const saveUri = await vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file(defaultFileName), + filters: { + // eslint-disable-next-line @typescript-eslint/naming-convention + "HTML Files": ["html"], + // eslint-disable-next-line @typescript-eslint/naming-convention + "All Files": ["*"] + }, + saveLabel: Constants.Strings.SAVE_REPORT, + title: Constants.Strings.SAVE_HTML_REPORT_TITLE + }); + + if (!saveUri) { + // User cancelled the save dialog + return; + } + + // Write the HTML content to the file + const encoder = new TextEncoder(); + await vscode.workspace.fs.writeFile(saveUri, encoder.encode(htmlContent)); + + // Show success message with option to open the file + const openAction = Constants.Strings.OPEN_REPORT; + const result = await vscode.window.showInformationMessage( + Constants.StringFunctions.HTML_REPORT_SAVED_SUCCESS(saveUri.fsPath), + openAction + ); + + if (result === openAction) { + // Open the HTML file in a VS Code webview + showHtmlReportInWebview(htmlContent, treeItem.siteName); + } + + traceInfo(Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_HTML_REPORT_SAVED, { + siteName: treeItem.siteName, + fileCount: treeItem.comparisonResults.length, + filePath: saveUri.fsPath + }); + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + traceError( + Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_HTML_REPORT_FAILED, + error as Error, + { siteName: treeItem.siteName } + ); + + vscode.window.showErrorMessage(Constants.StringFunctions.HTML_REPORT_GENERATION_FAILED(errorMessage)); + } +} + +/** + * Formats a date for use in a file name (YYYY-MM-DD-HHmmss) + */ +function formatDateForFileName(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + const seconds = String(date.getSeconds()).padStart(2, "0"); + return `${year}-${month}-${day}-${hours}${minutes}${seconds}`; +} + +/** + * Formats a date for display in the report (includes timezone) + */ +function formatDateForDisplay(date: Date): string { + const options: Intl.DateTimeFormatOptions = { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + timeZoneName: "short" + }; + return date.toLocaleString(undefined, options); +} + +/** + * Reads file contents for all comparison results + * Skips binary files and handles missing files gracefully + */ +async function readFileContents(comparisonResults: IFileComparisonResult[]): Promise { + const fileContents: IFileContentComparison[] = []; + + for (const result of comparisonResults) { + const isBinary = isBinaryFile(result.relativePath); + + if (isBinary) { + fileContents.push({ + result, + localContent: null, + remoteContent: null, + isBinary: true + }); + continue; + } + + let localContent: string | null = null; + let remoteContent: string | null = null; + + try { + if (result.status !== FileComparisonStatus.DELETED) { + const localUri = vscode.Uri.file(result.localPath); + const localData = await vscode.workspace.fs.readFile(localUri); + localContent = new TextDecoder().decode(localData); + } + } catch { + // File may not exist or be readable + localContent = null; + } + + try { + if (result.status !== FileComparisonStatus.ADDED) { + const remoteUri = vscode.Uri.file(result.remotePath); + const remoteData = await vscode.workspace.fs.readFile(remoteUri); + remoteContent = new TextDecoder().decode(remoteData); + } + } catch { + // File may not exist or be readable + remoteContent = null; + } + + fileContents.push({ + result, + localContent, + remoteContent, + isBinary: false + }); + } + + return fileContents; +} + +/** + * Gets the status display text + */ +function getStatusText(status: IFileComparisonResult["status"]): string { + switch (status) { + case FileComparisonStatus.MODIFIED: + return Constants.Strings.HTML_REPORT_STATUS_MODIFIED; + case FileComparisonStatus.ADDED: + return Constants.Strings.HTML_REPORT_STATUS_ADDED; + case FileComparisonStatus.DELETED: + return Constants.Strings.HTML_REPORT_STATUS_DELETED; + default: + return Constants.Strings.HTML_REPORT_STATUS_UNKNOWN; + } +} + +/** + * Gets the CSS class for a status badge + */ +function getStatusClass(status: IFileComparisonResult["status"]): string { + switch (status) { + case FileComparisonStatus.MODIFIED: + return "status-modified"; + case FileComparisonStatus.ADDED: + return "status-added"; + case FileComparisonStatus.DELETED: + return "status-deleted"; + default: + return ""; + } +} + +/** + * Groups comparison results by status + */ +function groupByStatus(results: IFileComparisonResult[]): Map { + const grouped = new Map(); + + for (const result of results) { + const existing = grouped.get(result.status) || []; + existing.push(result); + grouped.set(result.status, existing); + } + + return grouped; +} + +/** + * Generates a simple line-by-line diff HTML + */ +function generateDiffHtml(fileContent: IFileContentComparison): string { + if (fileContent.isBinary) { + return `
${escapeHtml(Constants.Strings.HTML_REPORT_BINARY_FILE_MESSAGE)}
`; + } + + const { localContent, remoteContent, result } = fileContent; + + // Handle cases where content couldn't be read + if (localContent === null && remoteContent === null) { + return `
${escapeHtml(Constants.Strings.HTML_REPORT_UNABLE_TO_READ_CONTENTS)}
`; + } + + // For added files, show all lines as additions + if (result.status === FileComparisonStatus.ADDED) { + if (localContent === null) { + return `
${escapeHtml(Constants.Strings.HTML_REPORT_UNABLE_TO_READ_LOCAL)}
`; + } + const lines = localContent.split("\n"); + const diffLines = lines.map((line, idx) => + `
${idx + 1}+ ${escapeHtml(line)}
` + ).join(""); + return `
${diffLines}
`; + } + + // For deleted files, show all lines as deletions + if (result.status === FileComparisonStatus.DELETED) { + if (remoteContent === null) { + return `
${escapeHtml(Constants.Strings.HTML_REPORT_UNABLE_TO_READ_REMOTE)}
`; + } + const lines = remoteContent.split("\n"); + const diffLines = lines.map((line, idx) => + `
${idx + 1}- ${escapeHtml(line)}
` + ).join(""); + return `
${diffLines}
`; + } + + // For modified files, compute a simple diff + if (localContent === null || remoteContent === null) { + return `
${escapeHtml(Constants.Strings.HTML_REPORT_UNABLE_TO_READ_BOTH)}
`; + } + + const diffLines = computeSimpleDiff(remoteContent, localContent); + return `
${diffLines}
`; +} + +/** + * Computes a simple line-by-line diff between two texts + * Uses a basic longest common subsequence approach + */ +function computeSimpleDiff(oldText: string, newText: string): string { + const oldLines = oldText.split("\n"); + const newLines = newText.split("\n"); + + // Simple diff using LCS + const lcs = computeLCS(oldLines, newLines); + const result: string[] = []; + + let oldIdx = 0; + let newIdx = 0; + let oldLineNum = 1; + let newLineNum = 1; + + for (const commonLine of lcs) { + // Output deletions (lines in old but not in common) + while (oldIdx < oldLines.length && oldLines[oldIdx] !== commonLine) { + result.push(`
${oldLineNum}- ${escapeHtml(oldLines[oldIdx])}
`); + oldIdx++; + oldLineNum++; + } + + // Output additions (lines in new but not in common) + while (newIdx < newLines.length && newLines[newIdx] !== commonLine) { + result.push(`
${newLineNum}+ ${escapeHtml(newLines[newIdx])}
`); + newIdx++; + newLineNum++; + } + + // Output the common line + result.push(`
${oldLineNum}${newLineNum} ${escapeHtml(commonLine)}
`); + oldIdx++; + newIdx++; + oldLineNum++; + newLineNum++; + } + + // Output remaining deletions + while (oldIdx < oldLines.length) { + result.push(`
${oldLineNum}- ${escapeHtml(oldLines[oldIdx])}
`); + oldIdx++; + oldLineNum++; + } + + // Output remaining additions + while (newIdx < newLines.length) { + result.push(`
${newLineNum}+ ${escapeHtml(newLines[newIdx])}
`); + newIdx++; + newLineNum++; + } + + return result.join(""); +} + +/** + * Computes the longest common subsequence of two string arrays + */ +function computeLCS(arr1: string[], arr2: string[]): string[] { + const m = arr1.length; + const n = arr2.length; + + // Create DP table + const dp: number[][] = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0)); + + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + if (arr1[i - 1] === arr2[j - 1]) { + dp[i][j] = dp[i - 1][j - 1] + 1; + } else { + dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); + } + } + } + + // Backtrack to find LCS + const lcs: string[] = []; + let i = m; + let j = n; + + while (i > 0 && j > 0) { + if (arr1[i - 1] === arr2[j - 1]) { + lcs.unshift(arr1[i - 1]); + i--; + j--; + } else if (dp[i - 1][j] > dp[i][j - 1]) { + i--; + } else { + j--; + } + } + + return lcs; +} + +/** + * Generates the HTML content for the report + */ +function generateHtmlContent( + fileContents: IFileContentComparison[], + siteName: string, + environmentName: string +): string { + const generatedDate = formatDateForDisplay(new Date()); + const comparisonResults = fileContents.map(fc => fc.result); + const groupedResults = groupByStatus(comparisonResults); + + const addedCount = groupedResults.get(FileComparisonStatus.ADDED)?.length || 0; + const modifiedCount = groupedResults.get(FileComparisonStatus.MODIFIED)?.length || 0; + const deletedCount = groupedResults.get(FileComparisonStatus.DELETED)?.length || 0; + + // Sort results by path + const sortedFileContents = [...fileContents].sort((a, b) => + a.result.relativePath.localeCompare(b.result.relativePath) + ); + + const fileRows = sortedFileContents.map((fc, index) => { + const result = fc.result; + const diffHtml = generateDiffHtml(fc); + + return ` +
+
+ ${escapeHtml(result.relativePath)} + ${escapeHtml(getStatusText(result.status))} + +
+ +
`; + }).join(""); + + return ` + + + + + ${escapeHtml(Constants.Strings.HTML_REPORT_TITLE)} - ${escapeHtml(siteName)} + + + + +
+
+

${escapeHtml(Constants.Strings.HTML_REPORT_TITLE)}

+

${escapeHtml(Constants.Strings.HTML_REPORT_SUBTITLE)}

+
+ +
+
+
${comparisonResults.length}
+
${escapeHtml(Constants.Strings.HTML_REPORT_TOTAL_CHANGES)}
+
+
+
${addedCount}
+
${escapeHtml(Constants.Strings.HTML_REPORT_ADDED)}
+
+
+
${modifiedCount}
+
${escapeHtml(Constants.Strings.HTML_REPORT_MODIFIED)}
+
+
+
${deletedCount}
+
${escapeHtml(Constants.Strings.HTML_REPORT_DELETED)}
+
+
+ +
+

${escapeHtml(Constants.Strings.HTML_REPORT_COMPARISON_DETAILS)}

+
+
+ ${escapeHtml(Constants.Strings.HTML_REPORT_SITE_NAME_LABEL)} + ${escapeHtml(siteName)} +
+
+ ${escapeHtml(Constants.Strings.HTML_REPORT_ENVIRONMENT_LABEL)} + ${escapeHtml(environmentName)} +
+
+ ${escapeHtml(Constants.Strings.HTML_REPORT_GENERATED_LABEL)} + ${escapeHtml(generatedDate)} +
+
+
+ +
+
+

${escapeHtml(Constants.Strings.HTML_REPORT_CHANGED_FILES)} ${escapeHtml(Constants.Strings.HTML_REPORT_CLICK_TO_EXPAND)}

+
+ + +
+
+ ${fileRows} +
+ + +
+ +`; +} + +/** + * Escapes HTML special characters to prevent XSS + */ +function escapeHtml(unsafe: string): string { + return unsafe + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * Shows the HTML report in a VS Code webview panel + */ +function showHtmlReportInWebview(htmlContent: string, siteName: string): void { + const panel = vscode.window.createWebviewPanel( + "metadataDiffReport", + `${Constants.Strings.HTML_REPORT_TITLE} - ${siteName}`, + vscode.ViewColumn.One, + { + enableScripts: true, + retainContextWhenHidden: false + } + ); + + panel.webview.html = htmlContent; +} diff --git a/src/client/power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler.ts b/src/client/power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler.ts new file mode 100644 index 000000000..63b1360f3 --- /dev/null +++ b/src/client/power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler.ts @@ -0,0 +1,270 @@ +/* + * 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 path from "path"; +import { Constants } from "../../Constants"; +import { traceError, traceInfo } from "../../TelemetryHelper"; +import { IMetadataDiffExport, METADATA_DIFF_EXPORT_VERSION } from "../../models/IMetadataDiffExport"; +import { FileComparisonStatus, IFileComparisonResult } from "../../models/IFileComparisonResult"; +import MetadataDiffContext from "../../MetadataDiffContext"; +import { getExtensionVersion } from "../../../../../common/utilities/Utils"; + +/** + * Compares two semantic version strings + * @param v1 First version string (e.g., "1.2.3") + * @param v2 Second version string (e.g., "1.2.4") + * @returns -1 if v1 < v2, 0 if v1 === v2, 1 if v1 > v2 + */ +function compareVersions(v1: string, v2: string): number { + const parts1 = v1.split(".").map(p => parseInt(p, 10) || 0); + const parts2 = v2.split(".").map(p => parseInt(p, 10) || 0); + + const maxLength = Math.max(parts1.length, parts2.length); + + for (let i = 0; i < maxLength; i++) { + const num1 = parts1[i] || 0; + const num2 = parts2[i] || 0; + + if (num1 < num2) return -1; + if (num1 > num2) return 1; + } + + return 0; +} + +/** + * Validates the import data structure + * @param data The parsed JSON data + * @returns Error message if invalid, undefined if valid + */ +function validateImportData(data: unknown): string | undefined { + if (!data || typeof data !== "object") { + return Constants.Strings.METADATA_DIFF_EXPORT_INVALID_FILE; + } + + const importData = data as Partial; + + // Check version (strict equality) + if (importData.version !== METADATA_DIFF_EXPORT_VERSION) { + return Constants.Strings.METADATA_DIFF_EXPORT_UNSUPPORTED_VERSION; + } + + // Check required fields + const requiredFields: (keyof IMetadataDiffExport)[] = [ + "version", + "extensionVersion", + "exportedAt", + "websiteId", + "websiteName", + "environmentId", + "environmentName", + "localSiteName", + "files" + ]; + + for (const field of requiredFields) { + if (importData[field] === undefined || importData[field] === null) { + return Constants.StringFunctions.METADATA_DIFF_MISSING_REQUIRED_FIELD(field); + } + } + + // Check that extension version is not newer than current + const currentVersion = getExtensionVersion(); + if (importData.extensionVersion && currentVersion) { + if (compareVersions(importData.extensionVersion, currentVersion) > 0) { + return Constants.Strings.METADATA_DIFF_EXPORT_NEWER_EXTENSION_VERSION; + } + } + + // Validate files array + if (!Array.isArray(importData.files)) { + return Constants.StringFunctions.METADATA_DIFF_MISSING_REQUIRED_FIELD("files"); + } + + // Validate each file entry + for (const file of importData.files) { + if (!file.relativePath || typeof file.relativePath !== "string") { + return Constants.StringFunctions.METADATA_DIFF_MISSING_REQUIRED_FIELD("files[].relativePath"); + } + if (!file.status || !Object.values(FileComparisonStatus).includes(file.status)) { + return Constants.StringFunctions.METADATA_DIFF_MISSING_REQUIRED_FIELD("files[].status"); + } + } + + return undefined; +} + +/** + * Imports a metadata diff from a JSON file + */ +export async function importMetadataDiff(): Promise { + traceInfo(Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_IMPORT_CALLED, { + methodName: importMetadataDiff.name + }); + + try { + // Show open dialog first (before progress) + const openUris = await vscode.window.showOpenDialog({ + canSelectMany: false, + filters: { + [Constants.Strings.METADATA_DIFF_EXPORT_FILTER_NAME]: ["json"] + }, + title: vscode.l10n.t("Import Metadata Diff") + }); + + if (!openUris || openUris.length === 0) { + return; // User cancelled + } + + const fileUri = openUris[0]; + + // Read and parse the file first to validate before showing progress + let importData: IMetadataDiffExport; + + try { + const fileContent = fs.readFileSync(fileUri.fsPath, "utf8"); + const parsed = JSON.parse(fileContent); + + // Validate the data + const validationError = validateImportData(parsed); + if (validationError) { + vscode.window.showErrorMessage(validationError); + traceError( + Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_IMPORT_FAILED, + new Error(validationError), + { methodName: importMetadataDiff.name, reason: "validation_failed" } + ); + return; + } + + importData = parsed as IMetadataDiffExport; + } catch (parseError) { + vscode.window.showErrorMessage(Constants.Strings.METADATA_DIFF_EXPORT_INVALID_FILE); + traceError( + Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_IMPORT_FAILED, + parseError as Error, + { methodName: importMetadataDiff.name, reason: "parse_failed" } + ); + return; + } + + // Check if there's already an imported comparison for this site + if (MetadataDiffContext.hasImportedComparison(importData.websiteId, importData.environmentId)) { + const confirmation = await vscode.window.showWarningMessage( + Constants.Strings.METADATA_DIFF_REPLACE_EXISTING_IMPORT, + { modal: true }, + Constants.Strings.REPLACE + ); + + if (confirmation !== Constants.Strings.REPLACE) { + return; + } + } + + // Get the storage path for imported diffs + const extensionContext = MetadataDiffContext.extensionContext; + if (!extensionContext?.globalStorageUri) { + traceError( + Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_IMPORT_FAILED, + new Error("Global storage URI not available"), + { methodName: importMetadataDiff.name, reason: "no_storage" } + ); + return; + } + + // Now show progress while doing the actual file writing work + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: Constants.Strings.METADATA_DIFF_IMPORT_PROGRESS, + cancellable: false + }, + async () => { + const importedDiffsPath = path.join( + extensionContext.globalStorageUri.fsPath, + "imported-diffs", + `${importData.websiteId}_${importData.environmentId}` + ); + + // Create the directory if it doesn't exist + if (!fs.existsSync(importedDiffsPath)) { + fs.mkdirSync(importedDiffsPath, { recursive: true }); + } + + // Write the file contents to the storage + const comparisonResults: IFileComparisonResult[] = []; + + for (const file of importData.files) { + const localPath = path.join(importedDiffsPath, "local", file.relativePath); + const remotePath = path.join(importedDiffsPath, "remote", file.relativePath); + + // Ensure directories exist + const localDir = path.dirname(localPath); + const remoteDir = path.dirname(remotePath); + + if (!fs.existsSync(localDir)) { + fs.mkdirSync(localDir, { recursive: true }); + } + if (!fs.existsSync(remoteDir)) { + fs.mkdirSync(remoteDir, { recursive: true }); + } + + // Write local content if available + if (file.localContent) { + const content = Buffer.from(file.localContent, "base64"); + fs.writeFileSync(localPath, content); + } + + // Write remote content if available + if (file.remoteContent) { + const content = Buffer.from(file.remoteContent, "base64"); + fs.writeFileSync(remotePath, content); + } + + comparisonResults.push({ + relativePath: file.relativePath, + status: file.status, + localPath, + remotePath + }); + } + + // Store the results in the context + MetadataDiffContext.setResults( + comparisonResults, + importData.websiteName, + importData.localSiteName, + importData.environmentName, + importData.websiteId, + importData.environmentId, + true, // isImported + importData.exportedAt + ); + + traceInfo(Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_IMPORT_SUCCESS, { + methodName: importMetadataDiff.name, + websiteId: importData.websiteId, + fileCount: comparisonResults.length.toString() + }); + } + ); + + // Show success message after progress completes + vscode.window.showInformationMessage( + Constants.StringFunctions.METADATA_DIFF_IMPORT_SUCCESS(importData.websiteName) + ); + } catch (error) { + traceError( + Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_IMPORT_FAILED, + error as Error, + { methodName: importMetadataDiff.name } + ); + vscode.window.showErrorMessage( + Constants.StringFunctions.METADATA_DIFF_IMPORT_FAILED((error as Error).message) + ); + } +} diff --git a/src/client/power-pages/actions-hub/handlers/metadata-diff/MetadataDiffUtils.ts b/src/client/power-pages/actions-hub/handlers/metadata-diff/MetadataDiffUtils.ts index 952f0ddd2..6f052291b 100644 --- a/src/client/power-pages/actions-hub/handlers/metadata-diff/MetadataDiffUtils.ts +++ b/src/client/power-pages/actions-hub/handlers/metadata-diff/MetadataDiffUtils.ts @@ -14,6 +14,7 @@ import { showProgressWithNotification } from "../../../../../common/utilities/Ut import { FileComparisonStatus, IFileComparisonResult } from "../../models/IFileComparisonResult"; import { getAllFiles } from "../../ActionsHubUtils"; import MetadataDiffContext from "../../MetadataDiffContext"; +import PacContext from "../../../../pac/PacContext"; /** * Result of resolving site information from workspace @@ -21,6 +22,11 @@ import MetadataDiffContext from "../../MetadataDiffContext"; export interface SiteResolutionResult { siteId: string; localSitePath: string; + /** + * The relative path from site root to the folder user clicked on. + * Empty string means the entire site should be compared. + */ + comparisonSubPath: string; } /** @@ -87,19 +93,34 @@ export function compareFiles(downloadedSitePath: string, localSitePath: string): export function resolveSiteFromWorkspace(workingDirectory: string, resource?: vscode.Uri): SiteResolutionResult | undefined { let siteId: string | undefined; let localSitePath = workingDirectory; + let comparisonSubPath = ""; - // Strategy 1: Check if website.yml exists directly in working directory - siteId = getWebsiteRecordId(workingDirectory); - - // Strategy 2: If resource is provided, traverse up from resource to find website.yml - if (!siteId && resource?.fsPath) { + // Strategy 1: If resource is provided, traverse up from resource to find website.yml + // This takes priority as it identifies the specific site the user clicked on + if (resource?.fsPath) { const websiteYmlFolder = findWebsiteYmlFolder(resource.fsPath); if (websiteYmlFolder) { siteId = getWebsiteRecordId(websiteYmlFolder); localSitePath = websiteYmlFolder; + + // Calculate the relative path from site root to the resource + // This allows comparing only the specific folder the user clicked on + const resourcePath = resource.fsPath; + if (resourcePath.startsWith(websiteYmlFolder)) { + const relativePath = path.relative(websiteYmlFolder, resourcePath); + // Only set comparisonSubPath if the resource is different from the site root + if (relativePath && relativePath !== "." && !relativePath.startsWith("..")) { + comparisonSubPath = relativePath; + } + } } } + // Strategy 2: Check if website.yml exists directly in working directory + if (!siteId) { + siteId = getWebsiteRecordId(workingDirectory); + } + // Strategy 3: Look for a 'site' folder in working directory if (!siteId) { const powerPagesSiteFolder = findPowerPagesSiteFolder(workingDirectory); @@ -117,7 +138,7 @@ export function resolveSiteFromWorkspace(workingDirectory: string, resource?: vs return undefined; } - return { siteId, localSitePath }; + return { siteId, localSitePath, comparisonSubPath }; } /** @@ -139,24 +160,33 @@ export function prepareSiteStoragePath(storagePath: string, websiteId: string): * Processes comparison results and updates the MetadataDiffContext * @param siteStoragePath Path where site was downloaded * @param localSitePath Path to local site - * @param siteName Name of the site being compared + * @param siteName Name of the remote site being compared + * @param localSiteName Name of the local site * @param environmentName Name of the environment * @param methodName Name of the calling method for telemetry * @param siteId Site ID for telemetry * @param completedEventName Telemetry event name for completion * @param noDifferencesEventName Telemetry event name for no differences + * @param comparisonSubPath Optional sub-path to filter comparison results to a specific folder + * @param environmentId Optional environment ID (defaults to current environment if not provided) + * @param dataModelVersion Optional data model version (1 = Standard, 2 = Enhanced) + * @returns True if differences were found, false otherwise */ export async function processComparisonResults( siteStoragePath: string, localSitePath: string, siteName: string, + localSiteName: string, environmentName: string, methodName: string, siteId: string, completedEventName: string, - noDifferencesEventName: string -): Promise { - await showProgressWithNotification( + noDifferencesEventName: string, + comparisonSubPath?: string, + environmentId?: string, + dataModelVersion?: 1 | 2 +): Promise { + const comparisonResults = await showProgressWithNotification( Constants.Strings.COMPARING_FILES, async () => { // Find the actual downloaded site folder (name is not deterministic) @@ -165,30 +195,56 @@ export async function processComparisonResults( .map(entry => entry.name); const siteDownloadPath = path.join(siteStoragePath, downloadedFolders[0]); - const comparisonResults = compareFiles(siteDownloadPath, localSitePath); - - if (comparisonResults.length === 0) { - traceInfo(noDifferencesEventName, { - methodName, - siteId - }); - await vscode.window.showInformationMessage(Constants.Strings.NO_DIFFERENCES_FOUND); - MetadataDiffContext.clear(); - } else { - traceInfo(completedEventName, { - methodName, - siteId, - totalDifferences: comparisonResults.length.toString(), - modifiedFiles: comparisonResults.filter(r => r.status === FileComparisonStatus.MODIFIED).length.toString(), - addedFiles: comparisonResults.filter(r => r.status === FileComparisonStatus.ADDED).length.toString(), - deletedFiles: comparisonResults.filter(r => r.status === FileComparisonStatus.DELETED).length.toString() + let results = compareFiles(siteDownloadPath, localSitePath); + + // Filter results to only include files under the comparison sub-path + if (comparisonSubPath) { + const normalizedSubPath = comparisonSubPath.replace(/\\/g, "/"); + results = results.filter(result => { + const normalizedRelativePath = result.relativePath.replace(/\\/g, "/"); + return normalizedRelativePath.startsWith(normalizedSubPath + "/") || + normalizedRelativePath === normalizedSubPath; }); - - // Store results in the context so the tree view can display them - MetadataDiffContext.setResults(comparisonResults, siteName, environmentName); } - return true; + return results; } ); + + // Handle results after progress notification is dismissed + if (comparisonResults.length === 0) { + traceInfo(noDifferencesEventName, { + methodName, + siteId + }); + // Don't await - show notification without blocking so callers can update UI immediately + vscode.window.showInformationMessage(Constants.Strings.NO_DIFFERENCES_FOUND); + return false; + } else { + traceInfo(completedEventName, { + methodName, + siteId, + totalDifferences: comparisonResults.length.toString(), + modifiedFiles: comparisonResults.filter(r => r.status === FileComparisonStatus.MODIFIED).length.toString(), + addedFiles: comparisonResults.filter(r => r.status === FileComparisonStatus.ADDED).length.toString(), + deletedFiles: comparisonResults.filter(r => r.status === FileComparisonStatus.DELETED).length.toString() + }); + + // Get the environment ID from context if not provided + const resolvedEnvironmentId = environmentId || PacContext.AuthInfo?.EnvironmentId || ""; + + // Store results in the context so the tree view can display them + MetadataDiffContext.setResults( + comparisonResults, + siteName, + localSiteName, + environmentName, + siteId, + resolvedEnvironmentId, + false, // isImported + undefined, // exportedAt + dataModelVersion + ); + return true; + } } diff --git a/src/client/power-pages/actions-hub/handlers/metadata-diff/OpenAllMetadataDiffsHandler.ts b/src/client/power-pages/actions-hub/handlers/metadata-diff/OpenAllMetadataDiffsHandler.ts index de8f35131..8cb658bd6 100644 --- a/src/client/power-pages/actions-hub/handlers/metadata-diff/OpenAllMetadataDiffsHandler.ts +++ b/src/client/power-pages/actions-hub/handlers/metadata-diff/OpenAllMetadataDiffsHandler.ts @@ -8,35 +8,7 @@ import { MetadataDiffSiteTreeItem } from "../../tree-items/metadata-diff/Metadat import { traceInfo } from "../../TelemetryHelper"; import { Constants } from "../../Constants"; import { FileComparisonStatus, IFileComparisonResult } from "../../models/IFileComparisonResult"; - -/** - * Common binary file extensions that cannot be diffed in the text diff viewer - */ -const BINARY_FILE_EXTENSIONS = new Set([ - // Images - ".png", ".jpg", ".jpeg", ".gif", ".ico", ".webp", ".bmp", ".tiff", ".tif", ".svg", - // Fonts - ".woff", ".woff2", ".ttf", ".otf", ".eot", - // Media - ".mp4", ".mp3", ".wav", ".ogg", ".webm", ".avi", ".mov", - // Documents - ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", - // Archives - ".zip", ".rar", ".7z", ".tar", ".gz", - // Other binary - ".exe", ".dll", ".so", ".dylib" -]); - -/** - * Checks if a file is a binary file based on its extension - * @param relativePath The relative path of the file - * @returns True if the file is binary, false otherwise - */ -export function isBinaryFile(relativePath: string): boolean { - const lowerPath = relativePath.toLowerCase(); - const extension = lowerPath.substring(lowerPath.lastIndexOf(".")); - return BINARY_FILE_EXTENSIONS.has(extension); -} +import { isBinaryFile } from "../../ActionsHubUtils"; /** * Opens all file diffs in the multi-diff editor for a specific site diff --git a/src/client/power-pages/actions-hub/handlers/metadata-diff/OpenMetadataDiffFileHandler.ts b/src/client/power-pages/actions-hub/handlers/metadata-diff/OpenMetadataDiffFileHandler.ts index 3ceef113a..cbc6cea25 100644 --- a/src/client/power-pages/actions-hub/handlers/metadata-diff/OpenMetadataDiffFileHandler.ts +++ b/src/client/power-pages/actions-hub/handlers/metadata-diff/OpenMetadataDiffFileHandler.ts @@ -9,24 +9,31 @@ import { MetadataDiffFileTreeItem } from "../../tree-items/metadata-diff/Metadat import { traceInfo } from "../../TelemetryHelper"; import { Constants } from "../../Constants"; import { FileComparisonStatus } from "../../models/IFileComparisonResult"; -import { isBinaryFile } from "./OpenAllMetadataDiffsHandler"; +import { isBinaryFile } from "../../ActionsHubUtils"; /** * Opens a single file diff in the VS Code diff editor */ export async function openMetadataDiffFile(fileItem: MetadataDiffFileTreeItem): Promise { - const { comparisonResult, siteName } = fileItem; + const { comparisonResult, siteName, isImported } = fileItem; traceInfo(Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_OPEN_FILE, { methodName: openMetadataDiffFile.name, relativePath: comparisonResult.relativePath, - status: comparisonResult.status + status: comparisonResult.status, + isImported: isImported }); const title = Constants.StringFunctions.COMPARE_FILE_TITLE(siteName, comparisonResult.relativePath); // Handle binary files - open them directly instead of trying to diff if (isBinaryFile(comparisonResult.relativePath)) { + // For imported comparisons, binary content is not available + if (isImported) { + vscode.window.showInformationMessage(Constants.Strings.METADATA_DIFF_BINARY_FILE_NOT_AVAILABLE); + return; + } + await openBinaryFile(comparisonResult.localPath, comparisonResult.remotePath, comparisonResult.status); return; } diff --git a/src/client/power-pages/actions-hub/handlers/metadata-diff/ResyncMetadataDiffHandler.ts b/src/client/power-pages/actions-hub/handlers/metadata-diff/ResyncMetadataDiffHandler.ts new file mode 100644 index 000000000..82c489e60 --- /dev/null +++ b/src/client/power-pages/actions-hub/handlers/metadata-diff/ResyncMetadataDiffHandler.ts @@ -0,0 +1,122 @@ +/* + * 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 { PacTerminal } from "../../../../lib/PacTerminal"; +import { Constants } from "../../Constants"; +import { traceError, traceInfo } from "../../TelemetryHelper"; +import { showProgressWithNotification } from "../../../../../common/utilities/Utils"; +import { MetadataDiffSiteTreeItem } from "../../tree-items/metadata-diff/MetadataDiffSiteTreeItem"; +import { resolveSiteFromWorkspace, prepareSiteStoragePath, processComparisonResults } from "./MetadataDiffUtils"; +import MetadataDiffContext from "../../MetadataDiffContext"; + +/** + * Re-syncs (refreshes) the comparison results for a specific site by re-downloading + * the site metadata from the environment and re-running the comparison. + * This is useful when the remote site has been updated since the last comparison. + */ +export const resyncMetadataDiff = (pacTerminal: PacTerminal, context: vscode.ExtensionContext) => async (siteItem: MetadataDiffSiteTreeItem): Promise => { + traceInfo(Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_RESYNC_CALLED, { + methodName: resyncMetadataDiff.name, + siteName: siteItem.siteName, + environmentName: siteItem.environmentName, + websiteId: siteItem.websiteId, + environmentId: siteItem.environmentId, + isImported: siteItem.isImported + }); + + // Cannot resync imported comparisons - they are static snapshots + if (siteItem.isImported) { + vscode.window.showWarningMessage(Constants.Strings.METADATA_DIFF_CANNOT_RESYNC_IMPORTED); + return; + } + + const workspaceFolders = vscode.workspace.workspaceFolders; + + if (!workspaceFolders || workspaceFolders.length === 0) { + traceInfo(Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_LOCAL_NO_WORKSPACE, { + methodName: resyncMetadataDiff.name + }); + await vscode.window.showErrorMessage(Constants.Strings.NO_WORKSPACE_FOLDER_OPEN); + return; + } + + const siteResolution = resolveSiteFromWorkspace(workspaceFolders[0].uri.fsPath); + + if (!siteResolution) { + traceInfo(Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_LOCAL_WEBSITE_ID_NOT_FOUND, { + methodName: resyncMetadataDiff.name + }); + await vscode.window.showErrorMessage(Constants.Strings.WEBSITE_ID_NOT_FOUND); + return; + } + + const storagePath = context.storageUri?.fsPath; + + if (!storagePath) { + return; + } + + const siteStoragePath = prepareSiteStoragePath(storagePath, siteItem.websiteId); + const pacWrapper = pacTerminal.getWrapper(); + + // Use the data model version from the existing comparison results + // Default to version 1 if not available (e.g., for older comparisons) + const dataModelVersion: 1 | 2 = siteItem.dataModelVersion ?? 1; + + const downloadStartTime = Date.now(); + const success = await showProgressWithNotification( + Constants.StringFunctions.RESYNCING_SITE_COMPARISON(siteItem.siteName), + async () => pacWrapper.downloadSiteWithProgress( + siteStoragePath, + siteItem.websiteId, + dataModelVersion + ) + ); + const downloadDurationMs = Date.now() - downloadStartTime; + + if (!success) { + traceError( + Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_RESYNC_FAILED, + new Error("MetadataDiff: Action 'resync' failed to download site."), + { + methodName: resyncMetadataDiff.name, + websiteId: siteItem.websiteId, + environmentId: siteItem.environmentId + } + ); + await vscode.window.showErrorMessage(Constants.Strings.COMPARE_WITH_LOCAL_SITE_DOWNLOAD_FAILED); + return; + } + + traceInfo(Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_SITE_DOWNLOAD_COMPLETED, { + methodName: resyncMetadataDiff.name, + siteId: siteItem.websiteId, + environmentId: siteItem.environmentId, + downloadDurationMs: downloadDurationMs + }); + + const hasDifferences = await processComparisonResults( + siteStoragePath, + siteResolution.localSitePath, + siteItem.siteName, + siteItem.localSiteName, + siteItem.environmentName, + resyncMetadataDiff.name, + siteItem.websiteId, + Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_RESYNC_COMPLETED, + Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_RESYNC_NO_DIFFERENCES, + undefined, // No sub-path filtering for resync + siteItem.environmentId, + dataModelVersion + ); + + if (hasDifferences) { + await vscode.window.showInformationMessage(Constants.Strings.METADATA_DIFF_RESYNC_COMPLETED); + } else { + // Remove the comparison node since there are no more differences + MetadataDiffContext.clearSiteByKey(siteItem.websiteId, siteItem.environmentId, siteItem.isImported); + } +}; diff --git a/src/client/power-pages/actions-hub/models/IFileComparisonResult.ts b/src/client/power-pages/actions-hub/models/IFileComparisonResult.ts index a85edce04..641007747 100644 --- a/src/client/power-pages/actions-hub/models/IFileComparisonResult.ts +++ b/src/client/power-pages/actions-hub/models/IFileComparisonResult.ts @@ -20,3 +20,21 @@ export interface IFileComparisonResult { relativePath: string; status: FileComparisonStatusType; } + +/** + * Interface for storing comparison results per site + */ +export interface ISiteComparisonResults { + siteName: string; + localSiteName: string; + environmentName: string; + websiteId: string; + environmentId: string; + comparisonResults: IFileComparisonResult[]; + /** Whether this comparison was imported from an export file */ + isImported?: boolean; + /** ISO 8601 timestamp when the comparison was exported (only set for imported comparisons) */ + exportedAt?: string; + /** The data model version of the site (1 = Standard, 2 = Enhanced) */ + dataModelVersion?: 1 | 2; +} diff --git a/src/client/power-pages/actions-hub/models/IMetadataDiffExport.ts b/src/client/power-pages/actions-hub/models/IMetadataDiffExport.ts new file mode 100644 index 000000000..d22b735c3 --- /dev/null +++ b/src/client/power-pages/actions-hub/models/IMetadataDiffExport.ts @@ -0,0 +1,49 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { FileComparisonStatusType } from "./IFileComparisonResult"; + +/** + * Export format version for metadata diff exports + */ +export const METADATA_DIFF_EXPORT_VERSION = "1.0"; + +/** + * Represents the exported metadata diff data structure + */ +export interface IMetadataDiffExport { + /** Version of the export format */ + version: string; + /** Version of the VS Code extension used to generate this export */ + extensionVersion: string; + /** ISO 8601 timestamp when the export was created */ + exportedAt: string; + /** ID of the website being compared */ + websiteId: string; + /** Name of the website being compared */ + websiteName: string; + /** ID of the environment */ + environmentId: string; + /** Name of the environment */ + environmentName: string; + /** Name of the local site */ + localSiteName: string; + /** Array of file comparison results with content */ + files: IExportableFileComparisonResult[]; +} + +/** + * Represents a file comparison result with content for export + */ +export interface IExportableFileComparisonResult { + /** Relative path of the file within the site */ + relativePath: string; + /** Status of the file comparison (modified, added, deleted) */ + status: FileComparisonStatusType; + /** Base64-encoded content of the local file, null for binary files or deleted files */ + localContent: string | null; + /** Base64-encoded content of the remote file, null for binary files or added files */ + remoteContent: string | null; +} diff --git a/src/client/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFileTreeItem.ts b/src/client/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFileTreeItem.ts index c97fd80a1..796210fc0 100644 --- a/src/client/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFileTreeItem.ts +++ b/src/client/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFileTreeItem.ts @@ -21,10 +21,12 @@ export const METADATA_DIFF_URI_SCHEME = "pp-metadata-diff"; export class MetadataDiffFileTreeItem extends ActionsHubTreeItem { public readonly comparisonResult: IFileComparisonResult; private readonly _siteName: string; + private readonly _isImported: boolean; constructor( comparisonResult: IFileComparisonResult, - siteName: string + siteName: string, + isImported: boolean = false ) { // Use file name as the label const fileName = comparisonResult.relativePath.split(/[/\\]/).pop() || comparisonResult.relativePath; @@ -42,6 +44,7 @@ export class MetadataDiffFileTreeItem extends ActionsHubTreeItem { ); this.comparisonResult = comparisonResult; this._siteName = siteName; + this._isImported = isImported; // Use resourceUri for file-type icon detection and decoration // Encode the status in the URI so FileDecorationProvider can read it @@ -99,4 +102,8 @@ export class MetadataDiffFileTreeItem extends ActionsHubTreeItem { public get siteName(): string { return this._siteName; } + + public get isImported(): boolean { + return this._isImported; + } } diff --git a/src/client/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFolderTreeItem.ts b/src/client/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFolderTreeItem.ts index 3661d1d6d..419e213b6 100644 --- a/src/client/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFolderTreeItem.ts +++ b/src/client/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFolderTreeItem.ts @@ -29,7 +29,28 @@ export class MetadataDiffFolderTreeItem extends ActionsHubTreeItem { } public getChildren(): ActionsHubTreeItem[] { - return Array.from(this.childrenMap.values()); + const children = Array.from(this.childrenMap.values()); + + // Separate folders and files + const folders: MetadataDiffFolderTreeItem[] = []; + const files: MetadataDiffFileTreeItem[] = []; + + for (const child of children) { + if (child instanceof MetadataDiffFolderTreeItem) { + folders.push(child); + } else { + files.push(child); + } + } + + // Sort folders alphabetically by label + folders.sort((a, b) => (a.label as string).localeCompare(b.label as string)); + + // Sort files alphabetically by label + files.sort((a, b) => (a.label as string).localeCompare(b.label as string)); + + // Return folders first, then files + return [...folders, ...files]; } public get siteName(): string { diff --git a/src/client/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffGroupTreeItem.ts b/src/client/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffGroupTreeItem.ts index 387d85022..4a1c228b7 100644 --- a/src/client/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffGroupTreeItem.ts +++ b/src/client/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffGroupTreeItem.ts @@ -38,8 +38,6 @@ export class MetadataDiffGroupTreeItem extends ActionsHubTreeItem { } // Create a site tree item for each site's comparison results - return siteResults.map(siteResult => - new MetadataDiffSiteTreeItem(siteResult.comparisonResults, siteResult.siteName, siteResult.environmentName) - ); + return siteResults.map(siteResult => new MetadataDiffSiteTreeItem(siteResult)); } } diff --git a/src/client/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffSiteTreeItem.ts b/src/client/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffSiteTreeItem.ts index 0f57b9961..2a0ad3526 100644 --- a/src/client/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffSiteTreeItem.ts +++ b/src/client/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffSiteTreeItem.ts @@ -6,7 +6,7 @@ import * as vscode from "vscode"; import { ActionsHubTreeItem } from "../ActionsHubTreeItem"; import { Constants } from "../../Constants"; -import { IFileComparisonResult, FileComparisonStatus } from "../../models/IFileComparisonResult"; +import { IFileComparisonResult, FileComparisonStatus, ISiteComparisonResults } from "../../models/IFileComparisonResult"; import { MetadataDiffFileTreeItem } from "./MetadataDiffFileTreeItem"; import { MetadataDiffFolderTreeItem } from "./MetadataDiffFolderTreeItem"; import MetadataDiffContext, { MetadataDiffSortMode } from "../../MetadataDiffContext"; @@ -18,23 +18,43 @@ import MetadataDiffContext, { MetadataDiffSortMode } from "../../MetadataDiffCon export class MetadataDiffSiteTreeItem extends ActionsHubTreeItem { private readonly _comparisonResults: IFileComparisonResult[]; private readonly _siteName: string; + private readonly _localSiteName: string; private readonly _environmentName: string; + private readonly _websiteId: string; + private readonly _environmentId: string; + private readonly _isImported: boolean; + private readonly _exportedAt?: string; + private readonly _dataModelVersion?: 1 | 2; + + constructor(siteResults: ISiteComparisonResults) { + const fileCount = siteResults.comparisonResults.length; + const isImported = siteResults.isImported ?? false; + + const fileLabel = Constants.StringFunctions.COMPARISON_LABEL(siteResults.siteName, siteResults.environmentName, siteResults.localSiteName); + const description = fileCount === 1 + ? Constants.Strings.FILE_COUNT_DESCRIPTION_SINGULAR + : Constants.StringFunctions.FILE_COUNT_DESCRIPTION_PLURAL(fileCount); + + // Use different icons and context values for imported vs live comparisons + const icon = isImported ? Constants.Icons.IMPORTED_SITE : Constants.Icons.SITE; + const contextValue = isImported ? Constants.ContextValues.METADATA_DIFF_SITE_IMPORTED : Constants.ContextValues.METADATA_DIFF_SITE; - constructor(comparisonResults: IFileComparisonResult[], siteName: string, environmentName: string) { - const fileCount = comparisonResults.length; - const fileLabel = fileCount === 1 - ? Constants.StringFunctions.SITE_WITH_FILE_COUNT_SINGULAR(siteName, fileCount) - : Constants.StringFunctions.SITE_WITH_FILE_COUNT_PLURAL(siteName, fileCount); super( fileLabel, vscode.TreeItemCollapsibleState.Expanded, - Constants.Icons.SITE, - Constants.ContextValues.METADATA_DIFF_SITE, - environmentName // Show environment name as description/subtext + icon, + contextValue, + description ); - this._comparisonResults = comparisonResults; - this._siteName = siteName; - this._environmentName = environmentName; + this._comparisonResults = siteResults.comparisonResults; + this._siteName = siteResults.siteName; + this._localSiteName = siteResults.localSiteName; + this._environmentName = siteResults.environmentName; + this._websiteId = siteResults.websiteId; + this._environmentId = siteResults.environmentId; + this._isImported = isImported; + this._exportedAt = siteResults.exportedAt; + this._dataModelVersion = siteResults.dataModelVersion; } public getChildren(): ActionsHubTreeItem[] { @@ -54,7 +74,7 @@ export class MetadataDiffSiteTreeItem extends ActionsHubTreeItem { // Create flat list of file items return sortedResults.map(result => - new MetadataDiffFileTreeItem(result, this._siteName) + new MetadataDiffFileTreeItem(result, this._siteName, this._isImported) ); } @@ -136,7 +156,8 @@ export class MetadataDiffSiteTreeItem extends ActionsHubTreeItem { // Add the file to the appropriate folder (or root if no folders) const fileItem = new MetadataDiffFileTreeItem( result, - this._siteName + this._siteName, + this._isImported ); if (currentFolder) { @@ -146,7 +167,26 @@ export class MetadataDiffSiteTreeItem extends ActionsHubTreeItem { } } - return Array.from(rootChildren.values()); + // Separate folders and files at root level + const folders: MetadataDiffFolderTreeItem[] = []; + const files: MetadataDiffFileTreeItem[] = []; + + for (const child of rootChildren.values()) { + if (child instanceof MetadataDiffFolderTreeItem) { + folders.push(child); + } else { + files.push(child); + } + } + + // Sort folders alphabetically by label + folders.sort((a, b) => (a.label as string).localeCompare(b.label as string)); + + // Sort files alphabetically by label + files.sort((a, b) => (a.label as string).localeCompare(b.label as string)); + + // Return folders first, then files + return [...folders, ...files]; } public get siteName(): string { @@ -160,4 +200,28 @@ export class MetadataDiffSiteTreeItem extends ActionsHubTreeItem { public get comparisonResults(): IFileComparisonResult[] { return this._comparisonResults; } + + public get websiteId(): string { + return this._websiteId; + } + + public get environmentId(): string { + return this._environmentId; + } + + public get isImported(): boolean { + return this._isImported; + } + + public get exportedAt(): string | undefined { + return this._exportedAt; + } + + public get localSiteName(): string { + return this._localSiteName; + } + + public get dataModelVersion(): (1 | 2) | undefined { + return this._dataModelVersion; + } } 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 e6dc6eb05..d5bb4f87e 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 @@ -49,6 +49,10 @@ import * as ClearMetadataDiffHandler from "../../../../power-pages/actions-hub/h import * as RemoveSiteHandler from "../../../../power-pages/actions-hub/handlers/metadata-diff/RemoveSiteHandler"; import * as DiscardLocalChangesHandler from "../../../../power-pages/actions-hub/handlers/metadata-diff/DiscardLocalChangesHandler"; import * as DiscardFolderChangesHandler from "../../../../power-pages/actions-hub/handlers/metadata-diff/DiscardFolderChangesHandler"; +import * as GenerateHtmlReportHandler from "../../../../power-pages/actions-hub/handlers/metadata-diff/GenerateHtmlReportHandler"; +import * as ExportMetadataDiffHandler from "../../../../power-pages/actions-hub/handlers/metadata-diff/ExportMetadataDiffHandler"; +import * as ImportMetadataDiffHandler from "../../../../power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler"; +import * as ResyncMetadataDiffHandler from "../../../../power-pages/actions-hub/handlers/metadata-diff/ResyncMetadataDiffHandler"; import { ActionsHub } from "../../../../power-pages/actions-hub/ActionsHub"; // Add global type declaration for ArtemisContext @@ -575,6 +579,89 @@ describe("ActionsHubTreeDataProvider", () => { } }); + it('should register generateHtmlReport command', async () => { + sinon.stub(ActionsHub, 'isMetadataDiffEnabled').returns(true); + const mockCommandHandler = sinon.stub(GenerateHtmlReportHandler, 'generateHtmlReport'); + mockCommandHandler.resolves(); + const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false); + actionsHubTreeDataProvider["registerPanel"](); + + expect(registerCommandStub.calledWith(Constants.Commands.METADATA_DIFF_GENERATE_HTML_REPORT)).to.be.true; + + const generateHtmlReportCall = registerCommandStub.getCalls().find(call => + call.args[0] === Constants.Commands.METADATA_DIFF_GENERATE_HTML_REPORT + ); + + if (generateHtmlReportCall) { + await generateHtmlReportCall.args[1](); + expect(mockCommandHandler.calledOnce).to.be.true; + } else { + throw new Error("generateHtmlReport command was not registered"); + } + }); + + it('should register exportMetadataDiff command', async () => { + sinon.stub(ActionsHub, 'isMetadataDiffEnabled').returns(true); + const mockCommandHandler = sinon.stub(ExportMetadataDiffHandler, 'exportMetadataDiff'); + mockCommandHandler.resolves(); + const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false); + actionsHubTreeDataProvider["registerPanel"](); + + expect(registerCommandStub.calledWith(Constants.Commands.METADATA_DIFF_EXPORT)).to.be.true; + + const exportCall = registerCommandStub.getCalls().find(call => + call.args[0] === Constants.Commands.METADATA_DIFF_EXPORT + ); + + if (exportCall) { + await exportCall.args[1](); + expect(mockCommandHandler.calledOnce).to.be.true; + } else { + throw new Error("exportMetadataDiff command was not registered"); + } + }); + + it('should register importMetadataDiff command', async () => { + sinon.stub(ActionsHub, 'isMetadataDiffEnabled').returns(true); + const mockCommandHandler = sinon.stub(ImportMetadataDiffHandler, 'importMetadataDiff'); + mockCommandHandler.resolves(); + const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false); + actionsHubTreeDataProvider["registerPanel"](); + + expect(registerCommandStub.calledWith(Constants.Commands.METADATA_DIFF_IMPORT)).to.be.true; + + const importCall = registerCommandStub.getCalls().find(call => + call.args[0] === Constants.Commands.METADATA_DIFF_IMPORT + ); + + if (importCall) { + await importCall.args[1](); + expect(mockCommandHandler.calledOnce).to.be.true; + } else { + throw new Error("importMetadataDiff command was not registered"); + } + }); + + it('should register resyncMetadataDiff command', async () => { + sinon.stub(ActionsHub, 'isMetadataDiffEnabled').returns(true); + const innerHandler = sinon.stub(); + sinon.stub(ResyncMetadataDiffHandler, 'resyncMetadataDiff').returns(innerHandler); + const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false); + actionsHubTreeDataProvider["registerPanel"](); + + expect(registerCommandStub.calledWith(Constants.Commands.METADATA_DIFF_RESYNC)).to.be.true; + + const resyncCall = registerCommandStub.getCalls().find(call => + call.args[0] === Constants.Commands.METADATA_DIFF_RESYNC + ); + + if (resyncCall) { + expect(resyncCall.args[1]).to.equal(innerHandler); + } else { + throw new Error("resyncMetadataDiff command was not registered"); + } + }); + it('should register showOutputChannel command', async () => { sinon.stub(ActionsHub, 'isMetadataDiffEnabled').returns(true); const showOutputChannelStub = sinon.stub(); diff --git a/src/client/test/Integration/power-pages/actions-hub/ActionsHubUtils.test.ts b/src/client/test/Integration/power-pages/actions-hub/ActionsHubUtils.test.ts index 58d42967b..57a72cffc 100644 --- a/src/client/test/Integration/power-pages/actions-hub/ActionsHubUtils.test.ts +++ b/src/client/test/Integration/power-pages/actions-hub/ActionsHubUtils.test.ts @@ -9,15 +9,15 @@ import * as vscode from 'vscode'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { fetchWebsites, findOtherSites, createKnownSiteIdsSet, getAllFiles } from '../../../../power-pages/actions-hub/ActionsHubUtils'; +import { fetchWebsites, findOtherSites, createKnownSiteIdsSet, getAllFiles, isBinaryFile } from '../../../../power-pages/actions-hub/ActionsHubUtils'; import { Constants } from '../../../../power-pages/actions-hub/Constants'; import { WebsiteDataModel, ServiceEndpointCategory } from '../../../../../common/services/Constants'; import { IWebsiteDetails, IArtemisAPIOrgResponse } from '../../../../../common/services/Interfaces'; -import PacContext from '../../../../pac/PacContext'; import ArtemisContext from '../../../../ArtemisContext'; import * as WebsiteUtils from '../../../../../common/utilities/WebsiteUtil'; import * as WorkspaceInfoFinderUtil from '../../../../../common/utilities/WorkspaceInfoFinderUtil'; import * as TelemetryHelper from '../../../../power-pages/actions-hub/TelemetryHelper'; +import { OrgInfo } from '../../../../pac/PacTypes'; describe('ActionsHubUtils', () => { let sandbox: sinon.SinonSandbox; @@ -51,25 +51,6 @@ describe('ActionsHubUtils', () => { ArtemisContext["_artemisResponse"] = { stamp: ServiceEndpointCategory.TEST, response: artemisResponse }; mockGetActiveWebsites = sandbox.stub(WebsiteUtils, 'getActiveWebsites'); mockGetAllWebsites = sandbox.stub(WebsiteUtils, 'getAllWebsites'); - sinon.stub(PacContext, "OrgInfo").get(() => ({ OrgId: 'test-org-id', EnvironmentId: 'test-env-id' })); - }); - - it('should not call getActiveWebsites and getAllWebsites', async () => { - sinon.stub(PacContext, "OrgInfo").get(() => undefined); - - await fetchWebsites(); - - expect(mockGetActiveWebsites.called).to.be.false; - expect(mockGetAllWebsites.called).to.be.false; - }); - - it('should return empty response when orgInfo is null', async () => { - sinon.stub(PacContext, "OrgInfo").get(() => undefined); - - const response = await fetchWebsites(); - - expect(response.activeSites).to.be.empty; - expect(response.inactiveSites).to.be.empty; }); it('should log the error when there is problem is fetching websites', async () => { @@ -86,7 +67,7 @@ describe('ActionsHubUtils', () => { mockGetActiveWebsites.resolves(activeSites); mockGetAllWebsites.rejects(new Error('Test error')); - const response = await fetchWebsites(); + const response = await fetchWebsites({} as OrgInfo, true); expect(response.activeSites).to.be.empty; expect(response.inactiveSites).to.be.empty; @@ -127,7 +108,7 @@ describe('ActionsHubUtils', () => { mockGetActiveWebsites.resolves(activeSites); mockGetAllWebsites.resolves(allSites); - const response = await fetchWebsites(); + const response = await fetchWebsites({} as OrgInfo, true); expect(response.activeSites).to.deep.equal([...activeSites.map(site => ({ ...site, isCodeSite: false, siteManagementUrl: "https://portalmanagement.com", createdOn: "2025-03-20", creator: "Test Creator" }))]); expect(response.inactiveSites).to.deep.equal(inactiveSites); @@ -363,4 +344,121 @@ describe('ActionsHubUtils', () => { expect(absolutePath).to.equal(path.join(tempDir, 'test.txt')); }); }); + + describe('isBinaryFile', () => { + describe('image files', () => { + it('should return true for common image formats', () => { + expect(isBinaryFile("image.png")).to.be.true; + expect(isBinaryFile("folder/image.png")).to.be.true; + expect(isBinaryFile("image.jpg")).to.be.true; + expect(isBinaryFile("image.jpeg")).to.be.true; + expect(isBinaryFile("animation.gif")).to.be.true; + expect(isBinaryFile("favicon.ico")).to.be.true; + expect(isBinaryFile("image.webp")).to.be.true; + expect(isBinaryFile("bitmap.bmp")).to.be.true; + }); + + it('should treat SVG as binary by default (for diff viewing)', () => { + expect(isBinaryFile("icon.svg")).to.be.true; + expect(isBinaryFile("folder/icon.svg")).to.be.true; + }); + + it('should treat SVG as text when includeSvg is false (for export)', () => { + expect(isBinaryFile("icon.svg", false)).to.be.false; + expect(isBinaryFile("folder/icon.svg", false)).to.be.false; + }); + }); + + describe('font files', () => { + it('should return true for font formats', () => { + expect(isBinaryFile("font.woff")).to.be.true; + expect(isBinaryFile("font.woff2")).to.be.true; + expect(isBinaryFile("font.ttf")).to.be.true; + expect(isBinaryFile("font.otf")).to.be.true; + expect(isBinaryFile("font.eot")).to.be.true; + }); + }); + + describe('media files', () => { + it('should return true for media formats', () => { + expect(isBinaryFile("video.mp4")).to.be.true; + expect(isBinaryFile("audio.mp3")).to.be.true; + expect(isBinaryFile("audio.wav")).to.be.true; + expect(isBinaryFile("audio.ogg")).to.be.true; + }); + }); + + describe('document and archive files', () => { + it('should return true for document formats', () => { + expect(isBinaryFile("document.pdf")).to.be.true; + expect(isBinaryFile("document.doc")).to.be.true; + expect(isBinaryFile("document.docx")).to.be.true; + }); + + it('should return true for archive formats', () => { + expect(isBinaryFile("archive.zip")).to.be.true; + expect(isBinaryFile("archive.rar")).to.be.true; + expect(isBinaryFile("archive.7z")).to.be.true; + }); + }); + + describe('text files', () => { + it('should return false for text formats', () => { + expect(isBinaryFile("page.html")).to.be.false; + expect(isBinaryFile("styles.css")).to.be.false; + expect(isBinaryFile("script.js")).to.be.false; + expect(isBinaryFile("data.json")).to.be.false; + expect(isBinaryFile("config.xml")).to.be.false; + expect(isBinaryFile("readme.txt")).to.be.false; + expect(isBinaryFile("config.yml")).to.be.false; + expect(isBinaryFile("config.yaml")).to.be.false; + expect(isBinaryFile("readme.md")).to.be.false; + }); + }); + + describe('case insensitivity', () => { + it('should handle uppercase extensions', () => { + expect(isBinaryFile("IMAGE.PNG")).to.be.true; + expect(isBinaryFile("IMAGE.JPG")).to.be.true; + }); + + it('should handle mixed case extensions', () => { + expect(isBinaryFile("image.Png")).to.be.true; + expect(isBinaryFile("folder/IMAGE.JpG")).to.be.true; + }); + }); + + describe('edge cases', () => { + it('should return false for files without extension', () => { + expect(isBinaryFile("Makefile")).to.be.false; + expect(isBinaryFile("README")).to.be.false; + }); + + it('should handle files with multiple dots', () => { + expect(isBinaryFile("file.backup.png")).to.be.true; + expect(isBinaryFile("archive.tar.gz")).to.be.true; + }); + + it('should handle paths with dots in folder names', () => { + expect(isBinaryFile("folder.name/image.png")).to.be.true; + expect(isBinaryFile(".hidden/file.txt")).to.be.false; + }); + + it('should handle empty string', () => { + expect(isBinaryFile("")).to.be.false; + }); + }); + + describe('file paths', () => { + it('should handle Unix-style paths', () => { + expect(isBinaryFile("folder/subfolder/image.png")).to.be.true; + expect(isBinaryFile("/absolute/path/image.jpg")).to.be.true; + }); + + it('should handle Windows-style paths', () => { + expect(isBinaryFile("folder\\subfolder\\image.png")).to.be.true; + expect(isBinaryFile("C:\\Users\\test\\image.jpg")).to.be.true; + }); + }); + }); }); diff --git a/src/client/test/Integration/power-pages/actions-hub/MetadataDiffContext.test.ts b/src/client/test/Integration/power-pages/actions-hub/MetadataDiffContext.test.ts index 2834aa944..2f856711f 100644 --- a/src/client/test/Integration/power-pages/actions-hub/MetadataDiffContext.test.ts +++ b/src/client/test/Integration/power-pages/actions-hub/MetadataDiffContext.test.ts @@ -52,7 +52,7 @@ describe("MetadataDiffContext", () => { } ]; - MetadataDiffContext.setResults(results, "Test Site", "Test Environment"); + MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "test-website-id", "test-environment-id"); expect(MetadataDiffContext.comparisonResults).to.deep.equal(results); }); @@ -67,7 +67,7 @@ describe("MetadataDiffContext", () => { } ]; - MetadataDiffContext.setResults(results, "My Test Site", "Test Environment"); + MetadataDiffContext.setResults(results, "My Test Site", "Local Test Site", "Test Environment", "test-website-id", "test-environment-id"); expect(MetadataDiffContext.siteName).to.equal("My Test Site"); }); @@ -82,13 +82,13 @@ describe("MetadataDiffContext", () => { } ]; - MetadataDiffContext.setResults(results, "Test Site", "Test Environment"); + MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "test-website-id", "test-environment-id"); expect(MetadataDiffContext.isActive).to.be.true; }); it("should set isActive to false when results are empty", () => { - MetadataDiffContext.setResults([], "Test Site", "Test Environment"); + MetadataDiffContext.setResults([], "Test Site", "Local Test Site", "Test Environment", "test-website-id", "test-environment-id"); expect(MetadataDiffContext.isActive).to.be.false; }); @@ -106,7 +106,7 @@ describe("MetadataDiffContext", () => { } ]; - MetadataDiffContext.setResults(results, "Test Site", "Test Environment"); + MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "test-website-id", "test-environment-id"); expect(onChangedSpy.calledOnce).to.be.true; }); @@ -122,7 +122,7 @@ describe("MetadataDiffContext", () => { status: "modified" } ]; - MetadataDiffContext.setResults(results, "Test Site", "Test Environment"); + MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "test-website-id", "test-environment-id"); MetadataDiffContext.clear(); @@ -138,7 +138,7 @@ describe("MetadataDiffContext", () => { status: "modified" } ]; - MetadataDiffContext.setResults(results, "Test Site", "Test Environment"); + MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "test-website-id", "test-environment-id"); MetadataDiffContext.clear(); @@ -154,7 +154,7 @@ describe("MetadataDiffContext", () => { status: "modified" } ]; - MetadataDiffContext.setResults(results, "Test Site", "Test Environment"); + MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "test-website-id", "test-environment-id"); MetadataDiffContext.clear(); @@ -386,7 +386,7 @@ describe("MetadataDiffContext", () => { } ]; - MetadataDiffContext.setResults(results, "Test Site", "Test Environment"); + MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "test-website-id", "test-environment-id"); expect(MetadataDiffContext.comparisonResults).to.have.lengthOf(3); expect(MetadataDiffContext.comparisonResults[0].status).to.equal("modified"); @@ -394,4 +394,170 @@ describe("MetadataDiffContext", () => { expect(MetadataDiffContext.comparisonResults[2].status).to.equal("deleted"); }); }); + + describe("getUniqueKey", () => { + it("should generate key for live comparison", () => { + const key = MetadataDiffContext.getUniqueKey("website-123", "env-456", false); + expect(key).to.equal("website-123_env-456"); + }); + + it("should generate key with _imported suffix for imported comparison", () => { + const key = MetadataDiffContext.getUniqueKey("website-123", "env-456", true); + expect(key).to.equal("website-123_env-456_imported"); + }); + + it("should default isImported to false", () => { + const key = MetadataDiffContext.getUniqueKey("website-123", "env-456"); + expect(key).to.equal("website-123_env-456"); + }); + }); + + describe("hasImportedComparison", () => { + it("should return false when no imported comparison exists", () => { + expect(MetadataDiffContext.hasImportedComparison("website-123", "env-456")).to.be.false; + }); + + it("should return true when imported comparison exists", () => { + const results: IFileComparisonResult[] = [ + { + localPath: "/local/file.txt", + remotePath: "/remote/file.txt", + relativePath: "file.txt", + status: "modified" + } + ]; + MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "website-123", "env-456", true, "2024-01-15T10:30:00Z"); + + expect(MetadataDiffContext.hasImportedComparison("website-123", "env-456")).to.be.true; + }); + + it("should return false when only live comparison exists", () => { + const results: IFileComparisonResult[] = [ + { + localPath: "/local/file.txt", + remotePath: "/remote/file.txt", + relativePath: "file.txt", + status: "modified" + } + ]; + MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "website-123", "env-456", false); + + expect(MetadataDiffContext.hasImportedComparison("website-123", "env-456")).to.be.false; + }); + }); + + describe("clearSiteByKey", () => { + it("should clear live comparison by key", () => { + const results: IFileComparisonResult[] = [ + { + localPath: "/local/file.txt", + remotePath: "/remote/file.txt", + relativePath: "file.txt", + status: "modified" + } + ]; + MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "website-123", "env-456", false); + + expect(MetadataDiffContext.isActive).to.be.true; + + MetadataDiffContext.clearSiteByKey("website-123", "env-456", false); + + expect(MetadataDiffContext.isActive).to.be.false; + }); + + it("should clear imported comparison by key", () => { + const results: IFileComparisonResult[] = [ + { + localPath: "/local/file.txt", + remotePath: "/remote/file.txt", + relativePath: "file.txt", + status: "modified" + } + ]; + MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "website-123", "env-456", true, "2024-01-15T10:30:00Z"); + + expect(MetadataDiffContext.hasImportedComparison("website-123", "env-456")).to.be.true; + + MetadataDiffContext.clearSiteByKey("website-123", "env-456", true); + + expect(MetadataDiffContext.hasImportedComparison("website-123", "env-456")).to.be.false; + }); + + it("should only clear specified comparison type", () => { + const results: IFileComparisonResult[] = [ + { + localPath: "/local/file.txt", + remotePath: "/remote/file.txt", + relativePath: "file.txt", + status: "modified" + } + ]; + // Add both live and imported comparisons + MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "website-123", "env-456", false); + MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "website-123", "env-456", true, "2024-01-15T10:30:00Z"); + + expect(MetadataDiffContext.allSiteResults).to.have.lengthOf(2); + + // Clear only the imported one + MetadataDiffContext.clearSiteByKey("website-123", "env-456", true); + + expect(MetadataDiffContext.allSiteResults).to.have.lengthOf(1); + expect(MetadataDiffContext.hasImportedComparison("website-123", "env-456")).to.be.false; + expect(MetadataDiffContext.isActive).to.be.true; + }); + + it("should fire onChanged event when clearing", () => { + const results: IFileComparisonResult[] = [ + { + localPath: "/local/file.txt", + remotePath: "/remote/file.txt", + relativePath: "file.txt", + status: "modified" + } + ]; + MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "website-123", "env-456", false); + + const onChangedSpy = sandbox.spy(); + MetadataDiffContext.onChanged(onChangedSpy); + + MetadataDiffContext.clearSiteByKey("website-123", "env-456", false); + + expect(onChangedSpy.calledOnce).to.be.true; + }); + }); + + describe("imported comparisons", () => { + it("should store imported comparison with exportedAt timestamp", () => { + const results: IFileComparisonResult[] = [ + { + localPath: "/local/file.txt", + remotePath: "/remote/file.txt", + relativePath: "file.txt", + status: "modified" + } + ]; + const exportedAt = "2024-01-15T10:30:00Z"; + MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "website-123", "env-456", true, exportedAt); + + const siteResults = MetadataDiffContext.allSiteResults; + expect(siteResults).to.have.lengthOf(1); + expect(siteResults[0].isImported).to.be.true; + expect(siteResults[0].exportedAt).to.equal(exportedAt); + }); + + it("should allow both live and imported comparisons for same site", () => { + const results: IFileComparisonResult[] = [ + { + localPath: "/local/file.txt", + remotePath: "/remote/file.txt", + relativePath: "file.txt", + status: "modified" + } + ]; + MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "website-123", "env-456", false); + MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "website-123", "env-456", true, "2024-01-15T10:30:00Z"); + + expect(MetadataDiffContext.allSiteResults).to.have.lengthOf(2); + }); + }); }); diff --git a/src/client/test/Integration/power-pages/actions-hub/handlers/ShowEnvironmentDetailsHandler.test.ts b/src/client/test/Integration/power-pages/actions-hub/handlers/ShowEnvironmentDetailsHandler.test.ts index 2d185a94e..a5ed3fdb0 100644 --- a/src/client/test/Integration/power-pages/actions-hub/handlers/ShowEnvironmentDetailsHandler.test.ts +++ b/src/client/test/Integration/power-pages/actions-hub/handlers/ShowEnvironmentDetailsHandler.test.ts @@ -19,6 +19,8 @@ describe('ShowEnvironmentDetailsHandler', () => { let sandbox: sinon.SinonSandbox; let mockShowInformationMessage: sinon.SinonStub; let traceErrorStub: sinon.SinonStub; + let originalClipboard: typeof vscode.env.clipboard; + let mockWriteText: sinon.SinonStub; const mockAuthInfo: AuthInfo = { UserType: 'user-type', @@ -63,16 +65,29 @@ describe('ShowEnvironmentDetailsHandler', () => { sandbox.stub(TelemetryHelper, "getBaseEventInfo").returns({ foo: 'bar' }); sandbox.stub(TelemetryHelper, "traceInfo"); sandbox.stub(vscode.env, 'sessionId').get(() => 'test-session-id'); + + // Save original clipboard and create mock + originalClipboard = vscode.env.clipboard; + mockWriteText = sandbox.stub().resolves(); + Object.defineProperty(vscode.env, 'clipboard', { + value: { writeText: mockWriteText, readText: sandbox.stub().resolves('') }, + configurable: true + }); }); afterEach(() => { + // Restore original clipboard + Object.defineProperty(vscode.env, 'clipboard', { + value: originalClipboard, + configurable: true + }); sandbox.restore(); }); describe('showEnvironmentDetails', () => { it('should show information notification', async () => { PacContext['_authInfo'] = mockAuthInfo; - mockShowInformationMessage.resolves(Constants.Strings.COPY_TO_CLIPBOARD); + mockShowInformationMessage.resolves(undefined); await showEnvironmentDetails(); @@ -81,7 +96,7 @@ describe('ShowEnvironmentDetailsHandler', () => { it('should have expected heading', async () => { PacContext['_authInfo'] = mockAuthInfo; - mockShowInformationMessage.resolves(Constants.Strings.COPY_TO_CLIPBOARD); + mockShowInformationMessage.resolves(undefined); await showEnvironmentDetails(); @@ -91,7 +106,7 @@ describe('ShowEnvironmentDetailsHandler', () => { it('should be rendered as modal', async () => { PacContext['_authInfo'] = mockAuthInfo; - mockShowInformationMessage.resolves(Constants.Strings.COPY_TO_CLIPBOARD); + mockShowInformationMessage.resolves(undefined); await showEnvironmentDetails(); @@ -102,7 +117,7 @@ describe('ShowEnvironmentDetailsHandler', () => { it('should show environment details when auth info is available', async () => { PacContext['_authInfo'] = mockAuthInfo; PacContext['_orgInfo'] = mockOrgInfo; - mockShowInformationMessage.resolves(Constants.Strings.COPY_TO_CLIPBOARD); + mockShowInformationMessage.resolves(undefined); await showEnvironmentDetails(); @@ -124,7 +139,7 @@ describe('ShowEnvironmentDetailsHandler', () => { it('should handle cases without auth info', async () => { PacContext['_authInfo'] = null; - mockShowInformationMessage.resolves(Constants.Strings.COPY_TO_CLIPBOARD); + mockShowInformationMessage.resolves(undefined); await showEnvironmentDetails(); @@ -139,5 +154,27 @@ describe('ShowEnvironmentDetailsHandler', () => { expect(traceErrorStub.calledOnce).to.be.true; expect(traceErrorStub.firstCall.args[0]).to.equal(Constants.EventNames.ACTIONS_HUB_SHOW_ENVIRONMENT_DETAILS_FAILED); }); + + it('should copy details to clipboard when user clicks copy button', async () => { + PacContext['_authInfo'] = mockAuthInfo; + PacContext['_orgInfo'] = mockOrgInfo; + mockShowInformationMessage.resolves(Constants.Strings.COPY_TO_CLIPBOARD); + + await showEnvironmentDetails(); + + expect(mockWriteText.calledOnce).to.be.true; + const clipboardContent = mockWriteText.firstCall.args[0]; + expect(clipboardContent).to.include("Session ID: test-session-id"); + expect(clipboardContent).to.include("Tenant ID: test-tenant"); + }); + + it('should not copy to clipboard when user dismisses dialog', async () => { + PacContext['_authInfo'] = mockAuthInfo; + mockShowInformationMessage.resolves(undefined); + + await showEnvironmentDetails(); + + expect(mockWriteText.called).to.be.false; + }); }); }); diff --git a/src/client/test/Integration/power-pages/actions-hub/handlers/ShowSiteDetailsHandler.test.ts b/src/client/test/Integration/power-pages/actions-hub/handlers/ShowSiteDetailsHandler.test.ts index 9d7cba2ff..fde1e1be9 100644 --- a/src/client/test/Integration/power-pages/actions-hub/handlers/ShowSiteDetailsHandler.test.ts +++ b/src/client/test/Integration/power-pages/actions-hub/handlers/ShowSiteDetailsHandler.test.ts @@ -16,23 +16,39 @@ import * as TelemetryHelper from '../../../../../power-pages/actions-hub/Telemet describe('ShowSiteDetailsHandler', () => { let sandbox: sinon.SinonSandbox; let mockShowInformationMessage: sinon.SinonStub; + let traceInfoStub: sinon.SinonStub; + let originalClipboard: typeof vscode.env.clipboard; + let mockWriteText: sinon.SinonStub; beforeEach(() => { sandbox = sinon.createSandbox(); mockShowInformationMessage = sandbox.stub(vscode.window, 'showInformationMessage'); sandbox.stub(TelemetryHelper, 'traceError'); sandbox.stub(TelemetryHelper, "getBaseEventInfo").returns({ foo: 'bar' }); - sandbox.stub(TelemetryHelper, "traceInfo"); + traceInfoStub = sandbox.stub(TelemetryHelper, "traceInfo"); sandbox.stub(vscode.env, 'sessionId').get(() => 'test-session-id'); + + // Save original clipboard and create mock + originalClipboard = vscode.env.clipboard; + mockWriteText = sandbox.stub().resolves(); + Object.defineProperty(vscode.env, 'clipboard', { + value: { writeText: mockWriteText, readText: sandbox.stub().resolves('') }, + configurable: true + }); }); afterEach(() => { + // Restore original clipboard + Object.defineProperty(vscode.env, 'clipboard', { + value: originalClipboard, + configurable: true + }); sandbox.restore(); }); describe('showSiteDetails', () => { it('should show information notification', async () => { - mockShowInformationMessage.resolves(Constants.Strings.COPY_TO_CLIPBOARD); + mockShowInformationMessage.resolves(undefined); await showSiteDetails({ siteInfo: { @@ -46,7 +62,7 @@ describe('ShowSiteDetailsHandler', () => { }); it('should have expected heading', async () => { - mockShowInformationMessage.resolves(Constants.Strings.COPY_TO_CLIPBOARD); + mockShowInformationMessage.resolves(undefined); await showSiteDetails({ siteInfo: { @@ -61,7 +77,7 @@ describe('ShowSiteDetailsHandler', () => { }); it('should be rendered as modal', async () => { - mockShowInformationMessage.resolves(Constants.Strings.COPY_TO_CLIPBOARD); + mockShowInformationMessage.resolves(undefined); await showSiteDetails({ siteInfo: { @@ -76,7 +92,7 @@ describe('ShowSiteDetailsHandler', () => { }); it('should show site details', async () => { - mockShowInformationMessage.resolves(Constants.Strings.COPY_TO_CLIPBOARD); + mockShowInformationMessage.resolves(undefined); await showSiteDetails({ siteInfo: { @@ -101,5 +117,54 @@ describe('ShowSiteDetailsHandler', () => { expect(message).to.include("Creator: Test Creator"); expect(message).to.include("Created on: March 20, 2025"); }); + + it('should copy details to clipboard when user clicks copy button', async () => { + mockShowInformationMessage.resolves(Constants.Strings.COPY_TO_CLIPBOARD); + + await showSiteDetails({ + siteInfo: { + name: "Test Site", + websiteId: "test-id", + dataModelVersion: 1, + websiteUrl: 'https://test-site.com', + siteVisibility: SiteVisibility.Public, + createdOn: "2025-03-20T00:00:00Z", + creator: "Test Creator" + } as IWebsiteInfo + } as SiteTreeItem); + + expect(mockWriteText.calledOnce).to.be.true; + const clipboardContent = mockWriteText.firstCall.args[0]; + expect(clipboardContent).to.include("Friendly name: Test Site"); + expect(clipboardContent).to.include("Website Id: test-id"); + }); + + it('should log telemetry when user clicks copy button', async () => { + mockShowInformationMessage.resolves(Constants.Strings.COPY_TO_CLIPBOARD); + + await showSiteDetails({ + siteInfo: { + name: "Test Site", + websiteId: "test-id", + dataModelVersion: 1 + } as IWebsiteInfo + } as SiteTreeItem); + + expect(traceInfoStub.calledWith(Constants.EventNames.ACTIONS_HUB_SHOW_SITE_DETAILS_COPY_TO_CLIPBOARD)).to.be.true; + }); + + it('should not copy to clipboard when user dismisses dialog', async () => { + mockShowInformationMessage.resolves(undefined); + + await showSiteDetails({ + siteInfo: { + name: "Test Site", + websiteId: "test-id", + dataModelVersion: 1 + } as IWebsiteInfo + } as SiteTreeItem); + + expect(mockWriteText.called).to.be.false; + }); }); }); diff --git a/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/ClearMetadataDiffHandler.test.ts b/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/ClearMetadataDiffHandler.test.ts index 816613256..080dc6d28 100644 --- a/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/ClearMetadataDiffHandler.test.ts +++ b/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/ClearMetadataDiffHandler.test.ts @@ -35,7 +35,7 @@ describe("ClearMetadataDiffHandler", () => { status: "modified" } ]; - MetadataDiffContext.setResults(results, "Test Site", "Test Environment"); + MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "test-website-id", "test-environment-id"); clearMetadataDiff(); diff --git a/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/CompareWithEnvironmentHandler.test.ts b/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/CompareWithEnvironmentHandler.test.ts index fd4284650..a98754def 100644 --- a/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/CompareWithEnvironmentHandler.test.ts +++ b/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/CompareWithEnvironmentHandler.test.ts @@ -10,8 +10,11 @@ import { compareWithEnvironment } from "../../../../../../power-pages/actions-hu import { Constants } from "../../../../../../power-pages/actions-hub/Constants"; import { PacTerminal } from "../../../../../../lib/PacTerminal"; import MetadataDiffContext from "../../../../../../power-pages/actions-hub/MetadataDiffContext"; +import PacContext from "../../../../../../pac/PacContext"; import * as TelemetryHelper from "../../../../../../power-pages/actions-hub/TelemetryHelper"; import * as WorkspaceInfoFinderUtil from "../../../../../../../common/utilities/WorkspaceInfoFinderUtil"; +import * as ActionsHubUtils from "../../../../../../power-pages/actions-hub/ActionsHubUtils"; +import { IWebsiteDetails } from "../../../../../../../common/services/Interfaces"; import { SUCCESS } from "../../../../../../../common/constants"; describe("CompareWithEnvironmentHandler", () => { @@ -50,11 +53,23 @@ describe("CompareWithEnvironmentHandler", () => { // Mock ExtensionContext mockExtensionContext = { - storageUri: { fsPath: "/test/storage/path" } + storageUri: { fsPath: "/test/storage/path" }, + extensionUri: vscode.Uri.file("/test/extension") } as unknown as vscode.ExtensionContext; // Clear MetadataDiffContext before each test MetadataDiffContext.clear(); + + // Mock PacContext with current environment + sandbox.stub(PacContext, "OrgInfo").get(() => ({ + OrgId: "current-org-id", + UniqueName: "currentorg", + FriendlyName: "Current Environment", + OrgUrl: "https://current.crm.dynamics.com", + UserEmail: "", + UserId: "", + EnvironmentId: "current-env-id" + })); }); afterEach(() => { @@ -137,12 +152,15 @@ describe("CompareWithEnvironmentHandler", () => { { FriendlyName: "Test Environment", EnvironmentId: "env-id-1", - EnvironmentUrl: "https://test.crm.dynamics.com" + EnvironmentUrl: "https://test.crm.dynamics.com", + OrganizationId: "org-id-1", + UniqueName: "testorg", + EnvironmentIdentifier: { Id: "env-id-1" } } ] }); - mockShowQuickPick.resolves(undefined); // User cancelled + mockShowQuickPick.resolves(undefined); // User cancelled environment selection }); it("should log cancellation telemetry", async () => { @@ -160,6 +178,139 @@ describe("CompareWithEnvironmentHandler", () => { }); }); + describe("when user cancels website selection", () => { + beforeEach(() => { + sandbox.stub(vscode.workspace, "workspaceFolders").get(() => [ + { uri: { fsPath: "/test/workspace" }, name: "workspace", index: 0 } + ]); + sandbox.stub(WorkspaceInfoFinderUtil, "getWebsiteRecordId").returns("test-website-id"); + sandbox.stub(WorkspaceInfoFinderUtil, "findPowerPagesSiteFolder").returns(null); + + mockPacWrapper.orgList.resolves({ + Status: SUCCESS, + Results: [ + { + FriendlyName: "Test Environment", + EnvironmentId: "env-id-1", + EnvironmentUrl: "https://test.crm.dynamics.com", + OrganizationId: "org-id-1", + UniqueName: "testorg", + EnvironmentIdentifier: { Id: "env-id-1" } + } + ] + }); + + sandbox.stub(ActionsHubUtils, "fetchWebsites").resolves({ + activeSites: [ + { + name: "Test Website", + websiteRecordId: "website-id-1", + websiteUrl: "https://test.powerappsportals.com", + dataModel: "Enhanced" + } + ] as unknown as IWebsiteDetails[], + inactiveSites: [], + otherSites: [] + }); + + // First call returns environment selection, second call returns undefined (cancelled website selection) + mockShowQuickPick + .onFirstCall().resolves({ + label: "Test Environment", + detail: "https://test.crm.dynamics.com", + orgInfo: { + OrgId: "org-id-1", + UniqueName: "testorg", + FriendlyName: "Test Environment", + OrgUrl: "https://test.crm.dynamics.com", + UserEmail: "", + UserId: "", + EnvironmentId: "env-id-1" + } + }) + .onSecondCall().resolves(undefined); // User cancelled website selection + }); + + it("should log cancellation telemetry with reason", async () => { + const handler = compareWithEnvironment(mockPacTerminal as unknown as PacTerminal, mockExtensionContext); + await handler({ fsPath: "/test/workspace" } as vscode.Uri); + + expect(traceInfoStub.calledWith(Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_ENVIRONMENT_CANCELLED)).to.be.true; + const cancelCall = traceInfoStub.getCalls().find( + call => call.args[0] === Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_ENVIRONMENT_CANCELLED + ); + expect(cancelCall?.args[1]).to.deep.include({ + reason: "User cancelled website selection" + }); + }); + + it("should not show error message", async () => { + const handler = compareWithEnvironment(mockPacTerminal as unknown as PacTerminal, mockExtensionContext); + await handler({ fsPath: "/test/workspace" } as vscode.Uri); + + expect(mockShowErrorMessage.called).to.be.false; + }); + }); + + describe("when no websites are found in environment", () => { + beforeEach(() => { + sandbox.stub(vscode.workspace, "workspaceFolders").get(() => [ + { uri: { fsPath: "/test/workspace" }, name: "workspace", index: 0 } + ]); + sandbox.stub(WorkspaceInfoFinderUtil, "getWebsiteRecordId").returns("test-website-id"); + sandbox.stub(WorkspaceInfoFinderUtil, "findPowerPagesSiteFolder").returns(null); + + mockPacWrapper.orgList.resolves({ + Status: SUCCESS, + Results: [ + { + FriendlyName: "Test Environment", + EnvironmentId: "env-id-1", + EnvironmentUrl: "https://test.crm.dynamics.com", + OrganizationId: "org-id-1", + UniqueName: "testorg", + EnvironmentIdentifier: { Id: "env-id-1" } + } + ] + }); + + sandbox.stub(ActionsHubUtils, "fetchWebsites").resolves({ + activeSites: [], + inactiveSites: [], + otherSites: [] + }); + + mockShowQuickPick.resolves({ + label: "Test Environment", + detail: "https://test.crm.dynamics.com", + orgInfo: { + OrgId: "org-id-1", + UniqueName: "", + FriendlyName: "Test Environment", + OrgUrl: "https://test.crm.dynamics.com", + UserEmail: "", + UserId: "", + EnvironmentId: "env-id-1" + } + }); + }); + + it("should show no sites found error message", async () => { + const handler = compareWithEnvironment(mockPacTerminal as unknown as PacTerminal, mockExtensionContext); + await handler({ fsPath: "/test/workspace" } as vscode.Uri); + + expect(mockShowErrorMessage.calledOnce).to.be.true; + expect(mockShowErrorMessage.firstCall.args[0]).to.equal(Constants.Strings.NO_SITES_FOUND_IN_ENVIRONMENT); + }); + + it("should log telemetry event", async () => { + const handler = compareWithEnvironment(mockPacTerminal as unknown as PacTerminal, mockExtensionContext); + await handler({ fsPath: "/test/workspace" } as vscode.Uri); + + expect(traceInfoStub.calledWith(Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_ENVIRONMENT_WEBSITE_NOT_FOUND)).to.be.true; + }); + }); + describe("when no environments are found", () => { beforeEach(() => { sandbox.stub(vscode.workspace, "workspaceFolders").get(() => [ @@ -183,6 +334,95 @@ describe("CompareWithEnvironmentHandler", () => { }); }); + describe("when only the current environment is available", () => { + beforeEach(() => { + sandbox.stub(vscode.workspace, "workspaceFolders").get(() => [ + { uri: { fsPath: "/test/workspace" }, name: "workspace", index: 0 } + ]); + sandbox.stub(WorkspaceInfoFinderUtil, "getWebsiteRecordId").returns("test-website-id"); + sandbox.stub(WorkspaceInfoFinderUtil, "findPowerPagesSiteFolder").returns(null); + + // Return only the current environment which should be filtered out + mockPacWrapper.orgList.resolves({ + Status: SUCCESS, + Results: [ + { + FriendlyName: "Current Environment", + EnvironmentId: "current-env-id", + EnvironmentUrl: "https://current.crm.dynamics.com", + OrganizationId: "current-org-id", + UniqueName: "currentorg", + EnvironmentIdentifier: { Id: "current-env-id" } + } + ] + }); + }); + + it("should show no environments error message when current environment is filtered out", async () => { + const handler = compareWithEnvironment(mockPacTerminal as unknown as PacTerminal, mockExtensionContext); + await handler({ fsPath: "/test/workspace" } as vscode.Uri); + + expect(mockShowErrorMessage.calledOnce).to.be.true; + expect(mockShowErrorMessage.firstCall.args[0]).to.equal(Constants.Strings.NO_ENVIRONMENTS_FOUND); + }); + }); + + describe("when filtering out the current environment", () => { + beforeEach(() => { + sandbox.stub(vscode.workspace, "workspaceFolders").get(() => [ + { uri: { fsPath: "/test/workspace" }, name: "workspace", index: 0 } + ]); + sandbox.stub(WorkspaceInfoFinderUtil, "getWebsiteRecordId").returns("test-website-id"); + sandbox.stub(WorkspaceInfoFinderUtil, "findPowerPagesSiteFolder").returns(null); + + // Return multiple environments including the current one + mockPacWrapper.orgList.resolves({ + Status: SUCCESS, + Results: [ + { + FriendlyName: "Current Environment", + EnvironmentId: "current-env-id", + EnvironmentUrl: "https://current.crm.dynamics.com", + OrganizationId: "current-org-id", + UniqueName: "currentorg", + EnvironmentIdentifier: { Id: "current-env-id" } + }, + { + FriendlyName: "Other Environment", + EnvironmentId: "other-env-id", + EnvironmentUrl: "https://other.crm.dynamics.com", + OrganizationId: "other-org-id", + UniqueName: "otherorg", + EnvironmentIdentifier: { Id: "other-env-id" } + } + ] + }); + + mockShowQuickPick.resolves(undefined); // User cancels + }); + + it("should not show the current environment in the quick pick list", async () => { + const handler = compareWithEnvironment(mockPacTerminal as unknown as PacTerminal, mockExtensionContext); + await handler({ fsPath: "/test/workspace" } as vscode.Uri); + + expect(mockShowQuickPick.calledOnce).to.be.true; + const quickPickItems = mockShowQuickPick.firstCall.args[0]; + expect(quickPickItems).to.be.an("array"); + expect(quickPickItems.length).to.equal(1); + expect(quickPickItems[0].label).to.equal("Other Environment"); + }); + + it("should filter out environment based on EnvironmentId", async () => { + const handler = compareWithEnvironment(mockPacTerminal as unknown as PacTerminal, mockExtensionContext); + await handler({ fsPath: "/test/workspace" } as vscode.Uri); + + const quickPickItems = mockShowQuickPick.firstCall.args[0]; + const environmentIds = quickPickItems.map((item: { orgInfo: { EnvironmentId: string } }) => item.orgInfo.EnvironmentId); + expect(environmentIds).to.not.include("current-env-id"); + expect(environmentIds).to.include("other-env-id"); + }); + }); + describe("telemetry", () => { it("should log initial telemetry event when handler is called", async () => { sandbox.stub(vscode.workspace, "workspaceFolders").get(() => undefined); @@ -214,16 +454,53 @@ describe("CompareWithEnvironmentHandler", () => { { FriendlyName: "Test Environment", EnvironmentId: "env-id-1", - EnvironmentUrl: "https://test.crm.dynamics.com" + EnvironmentUrl: "https://test.crm.dynamics.com", + OrganizationId: "org-id-1", + UniqueName: "testorg", + EnvironmentIdentifier: { Id: "env-id-1" } } ] }); - mockShowQuickPick.resolves({ - label: "Test Environment", - detail: "https://test.crm.dynamics.com", - environmentId: "env-id-1" + sandbox.stub(ActionsHubUtils, "fetchWebsites").resolves({ + activeSites: [ + { + name: "Test Website", + websiteRecordId: "website-id-1", + websiteUrl: "https://test.powerappsportals.com", + dataModel: "Enhanced" + } + ] as unknown as IWebsiteDetails[], + inactiveSites: [], + otherSites: [] }); + + // First call returns environment selection, second call returns website selection + mockShowQuickPick + .onFirstCall().resolves({ + label: "Test Environment", + detail: "https://test.crm.dynamics.com", + orgInfo: { + OrgId: "org-id-1", + UniqueName: "testorg", + FriendlyName: "Test Environment", + OrgUrl: "https://test.crm.dynamics.com", + UserEmail: "", + UserId: "", + EnvironmentId: "env-id-1" + } + }) + .onSecondCall().resolves({ + label: "Test Website", + detail: "https://test.powerappsportals.com", + description: Constants.Strings.ENHANCED_DATA_MODEL, + websiteDetails: { + name: "Test Website", + websiteRecordId: "website-id-1", + websiteUrl: "https://test.powerappsportals.com", + dataModel: "Enhanced" + } + }); }); it("should return early without error when storage path is undefined", async () => { @@ -242,5 +519,291 @@ describe("CompareWithEnvironmentHandler", () => { } }); }); + + describe("when user selects a different website", () => { + let mockShowWarningMessage: sinon.SinonStub; + + beforeEach(() => { + sandbox.stub(vscode.workspace, "workspaceFolders").get(() => [ + { uri: { fsPath: "/test/workspace" }, name: "workspace", index: 0 } + ]); + sandbox.stub(WorkspaceInfoFinderUtil, "getWebsiteRecordId").returns("local-website-id"); + sandbox.stub(WorkspaceInfoFinderUtil, "findPowerPagesSiteFolder").returns(null); + mockShowWarningMessage = sandbox.stub(vscode.window, "showWarningMessage"); + + mockPacWrapper.orgList.resolves({ + Status: SUCCESS, + Results: [ + { + FriendlyName: "Test Environment", + EnvironmentId: "env-id-1", + EnvironmentUrl: "https://test.crm.dynamics.com", + OrganizationId: "org-id-1", + UniqueName: "testorg", + EnvironmentIdentifier: { Id: "env-id-1" } + } + ] + }); + + sandbox.stub(ActionsHubUtils, "fetchWebsites").resolves({ + activeSites: [ + { + name: "Different Website", + websiteRecordId: "different-website-id", + websiteUrl: "https://different.powerappsportals.com", + dataModel: "Enhanced" + } + ] as unknown as IWebsiteDetails[], + inactiveSites: [], + otherSites: [] + }); + + // First call returns environment selection, second call returns different website + mockShowQuickPick + .onFirstCall().resolves({ + label: "Test Environment", + detail: "https://test.crm.dynamics.com", + orgInfo: { + OrgId: "org-id-1", + UniqueName: "testorg", + FriendlyName: "Test Environment", + OrgUrl: "https://test.crm.dynamics.com", + UserEmail: "", + UserId: "", + EnvironmentId: "env-id-1" + } + }) + .onSecondCall().resolves({ + label: "Different Website", + detail: "https://different.powerappsportals.com", + description: Constants.Strings.ENHANCED_DATA_MODEL, + websiteDetails: { + name: "Different Website", + websiteRecordId: "different-website-id", + websiteUrl: "https://different.powerappsportals.com", + dataModel: "Enhanced" + } + }); + }); + + it("should show confirmation dialog when selected website is different from local", async () => { + mockShowWarningMessage.resolves(undefined); // User cancelled + + const handler = compareWithEnvironment(mockPacTerminal as unknown as PacTerminal, mockExtensionContext); + await handler({ fsPath: "/test/workspace" } as vscode.Uri); + + expect(mockShowWarningMessage.calledOnce).to.be.true; + expect(mockShowWarningMessage.firstCall.args[0]).to.equal(Constants.Strings.DIFFERENT_WEBSITE_CONFIRMATION); + expect(mockShowWarningMessage.firstCall.args[1]).to.deep.equal({ modal: true }); + }); + + it("should cancel operation when user declines confirmation", async () => { + mockShowWarningMessage.resolves(undefined); // User cancelled + + const handler = compareWithEnvironment(mockPacTerminal as unknown as PacTerminal, mockExtensionContext); + await handler({ fsPath: "/test/workspace" } as vscode.Uri); + + expect(traceInfoStub.calledWith(Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_ENVIRONMENT_CANCELLED)).to.be.true; + const cancelCall = traceInfoStub.getCalls().find( + call => call.args[0] === Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_ENVIRONMENT_CANCELLED + ); + expect(cancelCall?.args[1]).to.deep.include({ + reason: "User cancelled after different website confirmation" + }); + }); + }); + + describe("when user selects the matching website", () => { + let mockShowWarningMessage: sinon.SinonStub; + + beforeEach(() => { + sandbox.stub(vscode.workspace, "workspaceFolders").get(() => [ + { uri: { fsPath: "/test/workspace" }, name: "workspace", index: 0 } + ]); + sandbox.stub(WorkspaceInfoFinderUtil, "getWebsiteRecordId").returns("matching-website-id"); + sandbox.stub(WorkspaceInfoFinderUtil, "findPowerPagesSiteFolder").returns(null); + mockShowWarningMessage = sandbox.stub(vscode.window, "showWarningMessage"); + + mockPacWrapper.orgList.resolves({ + Status: SUCCESS, + Results: [ + { + FriendlyName: "Test Environment", + EnvironmentId: "env-id-1", + EnvironmentUrl: "https://test.crm.dynamics.com", + OrganizationId: "org-id-1", + UniqueName: "testorg", + EnvironmentIdentifier: { Id: "env-id-1" } + } + ] + }); + + sandbox.stub(ActionsHubUtils, "fetchWebsites").resolves({ + activeSites: [ + { + name: "Matching Website", + websiteRecordId: "matching-website-id", + websiteUrl: "https://matching.powerappsportals.com", + dataModel: "Enhanced" + } + ] as unknown as IWebsiteDetails[], + inactiveSites: [], + otherSites: [] + }); + + // First call returns environment selection, second call returns matching website + mockShowQuickPick + .onFirstCall().resolves({ + label: "Test Environment", + detail: "https://test.crm.dynamics.com", + orgInfo: { + OrgId: "org-id-1", + UniqueName: "testorg", + FriendlyName: "Test Environment", + OrgUrl: "https://test.crm.dynamics.com", + UserEmail: "", + UserId: "", + EnvironmentId: "env-id-1" + } + }) + .onSecondCall().resolves({ + label: "Matching Website", + detail: "https://matching.powerappsportals.com", + description: `${Constants.Strings.ENHANCED_DATA_MODEL} • ${Constants.Strings.MATCHING_SITE_INDICATOR}`, + websiteDetails: { + name: "Matching Website", + websiteRecordId: "matching-website-id", + websiteUrl: "https://matching.powerappsportals.com", + dataModel: "Enhanced" + } + }); + }); + + it("should not show confirmation dialog when selected website matches local", async () => { + const handler = compareWithEnvironment(mockPacTerminal as unknown as PacTerminal, mockExtensionContext); + await handler({ fsPath: "/test/workspace" } as vscode.Uri); + + expect(mockShowWarningMessage.called).to.be.false; + }); + }); + + describe("finding website ID from resource path", () => { + it("should prioritize resource path over workspace root for finding website ID", async () => { + sandbox.stub(vscode.workspace, "workspaceFolders").get(() => [ + { uri: { fsPath: "/test/workspace" }, name: "workspace", index: 0 } + ]); + + const findWebsiteYmlFolderStub = sandbox.stub(WorkspaceInfoFinderUtil, "findWebsiteYmlFolder") + .returns("/test/workspace/nested/site-folder"); + + const getWebsiteRecordIdStub = sandbox.stub(WorkspaceInfoFinderUtil, "getWebsiteRecordId"); + getWebsiteRecordIdStub.withArgs("/test/workspace/nested/site-folder").returns("nested-site-id"); + getWebsiteRecordIdStub.withArgs("/test/workspace").returns("workspace-site-id"); + + sandbox.stub(WorkspaceInfoFinderUtil, "findPowerPagesSiteFolder").returns(null); + + mockPacWrapper.orgList.resolves({ + Status: SUCCESS, + Results: [] + }); + + const resourceUri = { fsPath: "/test/workspace/nested/site-folder/file.html" } as vscode.Uri; + + const handler = compareWithEnvironment(mockPacTerminal as unknown as PacTerminal, mockExtensionContext); + await handler(resourceUri); + + // findWebsiteYmlFolder should be called with the resource path + expect(findWebsiteYmlFolderStub.calledWith(resourceUri.fsPath)).to.be.true; + }); + }); + + describe("quick pick icons", () => { + beforeEach(() => { + sandbox.stub(vscode.workspace, "workspaceFolders").get(() => [ + { uri: { fsPath: "/test/workspace" }, name: "workspace", index: 0 } + ]); + sandbox.stub(WorkspaceInfoFinderUtil, "getWebsiteRecordId").returns("test-website-id"); + sandbox.stub(WorkspaceInfoFinderUtil, "findPowerPagesSiteFolder").returns(null); + + mockPacWrapper.orgList.resolves({ + Status: SUCCESS, + Results: [ + { + FriendlyName: "Test Environment", + EnvironmentId: "env-id-1", + EnvironmentUrl: "https://test.crm.dynamics.com", + OrganizationId: "org-id-1", + UniqueName: "testorg", + EnvironmentIdentifier: { Id: "env-id-1" } + } + ] + }); + + sandbox.stub(ActionsHubUtils, "fetchWebsites").resolves({ + activeSites: [ + { + name: "Test Website", + websiteRecordId: "website-id-1", + websiteUrl: "https://test.powerappsportals.com", + dataModel: "Enhanced" + } + ] as unknown as IWebsiteDetails[], + inactiveSites: [], + otherSites: [] + }); + }); + + it("should include environment icons in quick pick items", async () => { + mockShowQuickPick.resolves(undefined); // Cancel to stop flow early + + const handler = compareWithEnvironment(mockPacTerminal as unknown as PacTerminal, mockExtensionContext); + await handler({ fsPath: "/test/workspace" } as vscode.Uri); + + // Verify that showQuickPick was called with items containing iconPath + expect(mockShowQuickPick.called).to.be.true; + const quickPickItems = mockShowQuickPick.firstCall.args[0]; + expect(quickPickItems).to.be.an("array"); + expect(quickPickItems[0]).to.have.property("iconPath"); + expect(quickPickItems[0].iconPath).to.have.property("light"); + expect(quickPickItems[0].iconPath).to.have.property("dark"); + }); + + it("should include website icons in quick pick items with separators", async () => { + // First call returns environment selection, second call is for websites + mockShowQuickPick + .onFirstCall().resolves({ + label: "Test Environment", + detail: "https://test.crm.dynamics.com", + orgInfo: { + OrgId: "org-id-1", + UniqueName: "testorg", + FriendlyName: "Test Environment", + OrgUrl: "https://test.crm.dynamics.com", + UserEmail: "", + UserId: "", + EnvironmentId: "env-id-1" + } + }) + .onSecondCall().resolves(undefined); // Cancel to stop flow early + + const handler = compareWithEnvironment(mockPacTerminal as unknown as PacTerminal, mockExtensionContext); + await handler({ fsPath: "/test/workspace" } as vscode.Uri); + + // Verify that the second showQuickPick was called with items containing separators and website icons + expect(mockShowQuickPick.calledTwice).to.be.true; + const websiteQuickPickItems = mockShowQuickPick.secondCall.args[0]; + expect(websiteQuickPickItems).to.be.an("array"); + + // First item should be a separator for "Active Sites" + expect(websiteQuickPickItems[0]).to.have.property("kind"); + expect(websiteQuickPickItems[0].kind).to.equal(vscode.QuickPickItemKind.Separator); + expect(websiteQuickPickItems[0].label).to.equal(Constants.Strings.ACTIVE_SITES); + + // Second item should be the actual website with globe icon + expect(websiteQuickPickItems[1]).to.have.property("iconPath"); + expect(websiteQuickPickItems[1].iconPath).to.be.instanceOf(vscode.ThemeIcon); + expect((websiteQuickPickItems[1].iconPath as vscode.ThemeIcon).id).to.equal("globe"); + }); + }); }); }); diff --git a/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/ExportMetadataDiffHandler.test.ts b/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/ExportMetadataDiffHandler.test.ts new file mode 100644 index 000000000..abf217068 --- /dev/null +++ b/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/ExportMetadataDiffHandler.test.ts @@ -0,0 +1,188 @@ +/* + * 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 { expect } from "chai"; +import sinon from "sinon"; +import { exportMetadataDiff } from "../../../../../../power-pages/actions-hub/handlers/metadata-diff/ExportMetadataDiffHandler"; +import { MetadataDiffSiteTreeItem } from "../../../../../../power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffSiteTreeItem"; +import { FileComparisonStatus, IFileComparisonResult, ISiteComparisonResults } from "../../../../../../power-pages/actions-hub/models/IFileComparisonResult"; +import * as TelemetryHelper from "../../../../../../power-pages/actions-hub/TelemetryHelper"; + +describe("ExportMetadataDiffHandler", () => { + let sandbox: sinon.SinonSandbox; + let showSaveDialogStub: sinon.SinonStub; + let showInformationMessageStub: sinon.SinonStub; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + showSaveDialogStub = sandbox.stub(vscode.window, "showSaveDialog"); + showInformationMessageStub = sandbox.stub(vscode.window, "showInformationMessage"); + // Stub telemetry helpers + sandbox.stub(TelemetryHelper, "traceInfo"); + sandbox.stub(TelemetryHelper, "traceError"); + }); + + afterEach(() => { + sandbox.restore(); + }); + + function createSiteResults( + comparisonResults: IFileComparisonResult[], + siteName = "Test Site", + localSiteName = "Local Test Site", + environmentName = "Test Environment", + websiteId = "test-website-id", + environmentId = "test-environment-id" + ): ISiteComparisonResults { + return { + comparisonResults, + siteName, + localSiteName, + environmentName, + websiteId, + environmentId + }; + } + + function createMockTreeItem( + comparisonResults: IFileComparisonResult[], + siteName = "Test Site" + ): MetadataDiffSiteTreeItem { + return new MetadataDiffSiteTreeItem(createSiteResults(comparisonResults, siteName)); + } + + describe("exportMetadataDiff", () => { + it("should prompt user to save file", async () => { + const mockResults: IFileComparisonResult[] = [ + { localPath: "/local/file.html", remotePath: "/remote/file.html", relativePath: "file.html", status: FileComparisonStatus.MODIFIED } + ]; + const treeItem = createMockTreeItem(mockResults); + + showSaveDialogStub.resolves(undefined); // User cancelled + + await exportMetadataDiff(treeItem); + + expect(showSaveDialogStub.calledOnce).to.be.true; + expect(showSaveDialogStub.firstCall.args[0]).to.have.property("filters"); + }); + + it("should not show success message when user cancels save dialog", async () => { + const mockResults: IFileComparisonResult[] = [ + { localPath: "/local/file.html", remotePath: "/remote/file.html", relativePath: "file.html", status: FileComparisonStatus.MODIFIED } + ]; + const treeItem = createMockTreeItem(mockResults); + + showSaveDialogStub.resolves(undefined); + + await exportMetadataDiff(treeItem); + + expect(showSaveDialogStub.calledOnce).to.be.true; + expect(showInformationMessageStub.called).to.be.false; + }); + + it("should have JSON filter in save dialog", async () => { + const mockResults: IFileComparisonResult[] = [ + { localPath: "/local/file.html", remotePath: "/remote/file.html", relativePath: "file.html", status: FileComparisonStatus.MODIFIED } + ]; + const treeItem = createMockTreeItem(mockResults); + + showSaveDialogStub.resolves(undefined); + + await exportMetadataDiff(treeItem); + + const saveDialogOptions = showSaveDialogStub.firstCall.args[0]; + expect(saveDialogOptions.filters).to.have.property("Metadata Diff JSON"); + expect(saveDialogOptions.filters["Metadata Diff JSON"]).to.include("json"); + }); + + it("should use site name in default file name", async () => { + const mockResults: IFileComparisonResult[] = [ + { localPath: "/local/file.html", remotePath: "/remote/file.html", relativePath: "file.html", status: FileComparisonStatus.MODIFIED } + ]; + const treeItem = createMockTreeItem(mockResults, "My Test Site"); + + showSaveDialogStub.resolves(undefined); + + await exportMetadataDiff(treeItem); + + const saveDialogOptions = showSaveDialogStub.firstCall.args[0]; + expect(saveDialogOptions.defaultUri.fsPath).to.include("My_Test_Site"); + }); + + it("should sanitize special characters from site name in default file name", async () => { + const mockResults: IFileComparisonResult[] = [ + { localPath: "/local/file.html", remotePath: "/remote/file.html", relativePath: "file.html", status: FileComparisonStatus.MODIFIED } + ]; + const treeItem = createMockTreeItem(mockResults, "Site/With:Special*Chars"); + + showSaveDialogStub.resolves(undefined); + + await exportMetadataDiff(treeItem); + + const saveDialogOptions = showSaveDialogStub.firstCall.args[0]; + // Special characters should be replaced with underscores + expect(saveDialogOptions.defaultUri.fsPath).to.include("Site_With_Special_Chars"); + }); + + it("should include -diff- in default file name", async () => { + const mockResults: IFileComparisonResult[] = [ + { localPath: "/local/file.html", remotePath: "/remote/file.html", relativePath: "file.html", status: FileComparisonStatus.MODIFIED } + ]; + const treeItem = createMockTreeItem(mockResults); + + showSaveDialogStub.resolves(undefined); + + await exportMetadataDiff(treeItem); + + const saveDialogOptions = showSaveDialogStub.firstCall.args[0]; + expect(saveDialogOptions.defaultUri.fsPath).to.include("-diff-"); + }); + + it("should include .json extension in default file name", async () => { + const mockResults: IFileComparisonResult[] = [ + { localPath: "/local/file.html", remotePath: "/remote/file.html", relativePath: "file.html", status: FileComparisonStatus.MODIFIED } + ]; + const treeItem = createMockTreeItem(mockResults); + + showSaveDialogStub.resolves(undefined); + + await exportMetadataDiff(treeItem); + + const saveDialogOptions = showSaveDialogStub.firstCall.args[0]; + expect(saveDialogOptions.defaultUri.fsPath).to.include(".json"); + }); + + it("should log telemetry on export start", async () => { + const mockResults: IFileComparisonResult[] = [ + { localPath: "/local/file.html", remotePath: "/remote/file.html", relativePath: "file.html", status: FileComparisonStatus.MODIFIED } + ]; + const treeItem = createMockTreeItem(mockResults); + const traceInfoStub = TelemetryHelper.traceInfo as sinon.SinonStub; + + showSaveDialogStub.resolves(undefined); + + await exportMetadataDiff(treeItem); + + expect(traceInfoStub.called).to.be.true; + expect(traceInfoStub.firstCall.args[0]).to.equal("ActionsHubMetadataDiffExportCalled"); + }); + + it("should include file count in telemetry", async () => { + const mockResults: IFileComparisonResult[] = [ + { localPath: "/local/file1.html", remotePath: "/remote/file1.html", relativePath: "file1.html", status: FileComparisonStatus.MODIFIED }, + { localPath: "/local/file2.html", remotePath: "/remote/file2.html", relativePath: "file2.html", status: FileComparisonStatus.ADDED } + ]; + const treeItem = createMockTreeItem(mockResults); + const traceInfoStub = TelemetryHelper.traceInfo as sinon.SinonStub; + + showSaveDialogStub.resolves(undefined); + + await exportMetadataDiff(treeItem); + + expect(traceInfoStub.firstCall.args[1]).to.have.property("fileCount", "2"); + }); + }); +}); diff --git a/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/GenerateHtmlReportHandler.test.ts b/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/GenerateHtmlReportHandler.test.ts new file mode 100644 index 000000000..93fc03910 --- /dev/null +++ b/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/GenerateHtmlReportHandler.test.ts @@ -0,0 +1,190 @@ +/* + * 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 { expect } from "chai"; +import sinon from "sinon"; +import { generateHtmlReport } from "../../../../../../power-pages/actions-hub/handlers/metadata-diff/GenerateHtmlReportHandler"; +import { MetadataDiffSiteTreeItem } from "../../../../../../power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffSiteTreeItem"; +import { FileComparisonStatus, IFileComparisonResult, ISiteComparisonResults } from "../../../../../../power-pages/actions-hub/models/IFileComparisonResult"; +import * as TelemetryHelper from "../../../../../../power-pages/actions-hub/TelemetryHelper"; + +describe("GenerateHtmlReportHandler", () => { + let sandbox: sinon.SinonSandbox; + let showSaveDialogStub: sinon.SinonStub; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + showSaveDialogStub = sandbox.stub(vscode.window, "showSaveDialog"); + // Stub other methods to prevent actual calls during tests + sandbox.stub(vscode.window, "showInformationMessage"); + sandbox.stub(vscode.window, "showErrorMessage"); + sandbox.stub(vscode.env, "openExternal"); + // Stub telemetry helpers + sandbox.stub(TelemetryHelper, "traceInfo"); + sandbox.stub(TelemetryHelper, "traceError"); + }); + + afterEach(() => { + sandbox.restore(); + }); + + function createSiteResults(comparisonResults: IFileComparisonResult[], siteName = "Test Site", localSiteName = "Local Test Site", environmentName = "Test Environment"): ISiteComparisonResults { + return { + comparisonResults, + siteName, + localSiteName, + environmentName, + websiteId: "test-website-id", + environmentId: "test-environment-id" + }; + } + + function createMockTreeItem(comparisonResults: IFileComparisonResult[], siteName = "Test Site", localSiteName = "Local Test Site", environmentName = "Test Environment"): MetadataDiffSiteTreeItem { + return new MetadataDiffSiteTreeItem(createSiteResults(comparisonResults, siteName, localSiteName, environmentName)); + } + + describe("generateHtmlReport", () => { + it("should prompt user to save file", async () => { + const mockResults: IFileComparisonResult[] = [ + { localPath: "/local/file.html", remotePath: "/remote/file.html", relativePath: "file.html", status: FileComparisonStatus.MODIFIED } + ]; + const treeItem = createMockTreeItem(mockResults); + + showSaveDialogStub.resolves(undefined); // User cancelled + + await generateHtmlReport(treeItem); + + expect(showSaveDialogStub.calledOnce).to.be.true; + expect(showSaveDialogStub.firstCall.args[0]).to.have.property("filters"); + }); + + it("should not show success message when user cancels save dialog", async () => { + const mockResults: IFileComparisonResult[] = [ + { localPath: "/local/file.html", remotePath: "/remote/file.html", relativePath: "file.html", status: FileComparisonStatus.MODIFIED } + ]; + const treeItem = createMockTreeItem(mockResults); + + showSaveDialogStub.resolves(undefined); + + // When user cancels, the function should return early without showing success message + await generateHtmlReport(treeItem); + + // No error should be thrown and the function should complete + expect(showSaveDialogStub.calledOnce).to.be.true; + }); + + it("should have HTML filter in save dialog", async () => { + const mockResults: IFileComparisonResult[] = [ + { localPath: "/local/file.html", remotePath: "/remote/file.html", relativePath: "file.html", status: FileComparisonStatus.MODIFIED } + ]; + const treeItem = createMockTreeItem(mockResults); + + showSaveDialogStub.resolves(undefined); + + await generateHtmlReport(treeItem); + + const saveDialogOptions = showSaveDialogStub.firstCall.args[0]; + expect(saveDialogOptions.filters).to.have.property("HTML Files"); + expect(saveDialogOptions.filters["HTML Files"]).to.include("html"); + }); + + it("should use site name in default file name", async () => { + const mockResults: IFileComparisonResult[] = [ + { localPath: "/local/file.html", remotePath: "/remote/file.html", relativePath: "file.html", status: FileComparisonStatus.MODIFIED } + ]; + const treeItem = createMockTreeItem(mockResults, "My Test Site"); + + showSaveDialogStub.resolves(undefined); + + await generateHtmlReport(treeItem); + + const saveDialogOptions = showSaveDialogStub.firstCall.args[0]; + expect(saveDialogOptions.defaultUri.fsPath).to.include("My-Test-Site"); + }); + + it("should sanitize special characters from site name in default file name", async () => { + const mockResults: IFileComparisonResult[] = [ + { localPath: "/local/file.html", remotePath: "/remote/file.html", relativePath: "file.html", status: FileComparisonStatus.MODIFIED } + ]; + const treeItem = createMockTreeItem(mockResults, "Site/With:Special*Chars"); + + showSaveDialogStub.resolves(undefined); + + await generateHtmlReport(treeItem); + + const saveDialogOptions = showSaveDialogStub.firstCall.args[0]; + // Special characters should be replaced with hyphens + expect(saveDialogOptions.defaultUri.fsPath).to.include("Site-With-Special-Chars"); + }); + + it("should include metadata-diff-report prefix in default file name", async () => { + const mockResults: IFileComparisonResult[] = [ + { localPath: "/local/file.html", remotePath: "/remote/file.html", relativePath: "file.html", status: FileComparisonStatus.MODIFIED } + ]; + const treeItem = createMockTreeItem(mockResults); + + showSaveDialogStub.resolves(undefined); + + await generateHtmlReport(treeItem); + + const saveDialogOptions = showSaveDialogStub.firstCall.args[0]; + expect(saveDialogOptions.defaultUri.fsPath).to.include("metadata-diff-report"); + }); + }); + + describe("telemetry", () => { + it("should call traceInfo when generateHtmlReport is invoked", async () => { + const mockResults: IFileComparisonResult[] = [ + { localPath: "/local/file.html", remotePath: "/remote/file.html", relativePath: "file.html", status: FileComparisonStatus.MODIFIED } + ]; + const treeItem = createMockTreeItem(mockResults, "Telemetry Test Site"); + + showSaveDialogStub.resolves(undefined); + + await generateHtmlReport(treeItem); + + const traceInfoStub = TelemetryHelper.traceInfo as sinon.SinonStub; + expect(traceInfoStub.called).to.be.true; + expect(traceInfoStub.firstCall.args[0]).to.equal("ActionsHubMetadataDiffGenerateHtmlReportCalled"); + expect(traceInfoStub.firstCall.args[1]).to.deep.include({ + siteName: "Telemetry Test Site", + fileCount: 1 + }); + }); + + it("should include correct file count in telemetry for multiple files", async () => { + const mockResults: IFileComparisonResult[] = [ + { localPath: "/local/file1.html", remotePath: "/remote/file1.html", relativePath: "file1.html", status: FileComparisonStatus.MODIFIED }, + { localPath: "/local/file2.html", remotePath: "/remote/file2.html", relativePath: "file2.html", status: FileComparisonStatus.ADDED }, + { localPath: "/local/file3.html", remotePath: "/remote/file3.html", relativePath: "file3.html", status: FileComparisonStatus.DELETED } + ]; + const treeItem = createMockTreeItem(mockResults); + + showSaveDialogStub.resolves(undefined); + + await generateHtmlReport(treeItem); + + const traceInfoStub = TelemetryHelper.traceInfo as sinon.SinonStub; + expect(traceInfoStub.firstCall.args[1]).to.deep.include({ + fileCount: 3 + }); + }); + + it("should not call traceError when user cancels save dialog", async () => { + const mockResults: IFileComparisonResult[] = [ + { localPath: "/local/file.html", remotePath: "/remote/file.html", relativePath: "file.html", status: FileComparisonStatus.MODIFIED } + ]; + const treeItem = createMockTreeItem(mockResults); + + showSaveDialogStub.resolves(undefined); + + await generateHtmlReport(treeItem); + + const traceErrorStub = TelemetryHelper.traceError as sinon.SinonStub; + expect(traceErrorStub.called).to.be.false; + }); + }); +}); diff --git a/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler.test.ts b/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler.test.ts new file mode 100644 index 000000000..2069950c0 --- /dev/null +++ b/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler.test.ts @@ -0,0 +1,107 @@ +/* + * 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 { expect } from "chai"; +import sinon from "sinon"; +import * as TelemetryHelper from "../../../../../../power-pages/actions-hub/TelemetryHelper"; +import MetadataDiffContext from "../../../../../../power-pages/actions-hub/MetadataDiffContext"; + +describe("ImportMetadataDiffHandler", () => { + let sandbox: sinon.SinonSandbox; + let showOpenDialogStub: sinon.SinonStub; + let showInformationMessageStub: sinon.SinonStub; + let showErrorMessageStub: sinon.SinonStub; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + showOpenDialogStub = sandbox.stub(vscode.window, "showOpenDialog"); + showInformationMessageStub = sandbox.stub(vscode.window, "showInformationMessage"); + showErrorMessageStub = sandbox.stub(vscode.window, "showErrorMessage"); + sandbox.stub(vscode.window, "showWarningMessage"); + // Stub telemetry helpers + sandbox.stub(TelemetryHelper, "traceInfo"); + sandbox.stub(TelemetryHelper, "traceError"); + // Clear context before each test + MetadataDiffContext.clear(); + }); + + afterEach(() => { + sandbox.restore(); + MetadataDiffContext.clear(); + }); + + describe("importMetadataDiff", () => { + it("should prompt user to select file", async () => { + showOpenDialogStub.resolves(undefined); // User cancelled + + // Import the module dynamically to ensure stubs are in place + const { importMetadataDiff } = await import("../../../../../../power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler"); + + await importMetadataDiff(); + + expect(showOpenDialogStub.calledOnce).to.be.true; + expect(showOpenDialogStub.firstCall.args[0]).to.have.property("filters"); + }); + + it("should not proceed when user cancels file selection", async () => { + showOpenDialogStub.resolves(undefined); + + const { importMetadataDiff } = await import("../../../../../../power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler"); + + await importMetadataDiff(); + + expect(showOpenDialogStub.calledOnce).to.be.true; + expect(showInformationMessageStub.called).to.be.false; + expect(showErrorMessageStub.called).to.be.false; + }); + + it("should not proceed when user selects empty array", async () => { + showOpenDialogStub.resolves([]); + + const { importMetadataDiff } = await import("../../../../../../power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler"); + + await importMetadataDiff(); + + expect(showOpenDialogStub.calledOnce).to.be.true; + expect(showInformationMessageStub.called).to.be.false; + }); + + it("should have JSON filter in open dialog", async () => { + showOpenDialogStub.resolves(undefined); + + const { importMetadataDiff } = await import("../../../../../../power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler"); + + await importMetadataDiff(); + + const openDialogOptions = showOpenDialogStub.firstCall.args[0]; + expect(openDialogOptions.filters).to.have.property("Metadata Diff JSON"); + expect(openDialogOptions.filters["Metadata Diff JSON"]).to.include("json"); + }); + + it("should not allow multiple file selection", async () => { + showOpenDialogStub.resolves(undefined); + + const { importMetadataDiff } = await import("../../../../../../power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler"); + + await importMetadataDiff(); + + const openDialogOptions = showOpenDialogStub.firstCall.args[0]; + expect(openDialogOptions.canSelectMany).to.be.false; + }); + + it("should log telemetry on import start", async () => { + const traceInfoStub = TelemetryHelper.traceInfo as sinon.SinonStub; + showOpenDialogStub.resolves(undefined); + + const { importMetadataDiff } = await import("../../../../../../power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler"); + + await importMetadataDiff(); + + expect(traceInfoStub.called).to.be.true; + expect(traceInfoStub.firstCall.args[0]).to.equal("ActionsHubMetadataDiffImportCalled"); + }); + }); +}); diff --git a/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/OpenAllMetadataDiffsHandler.test.ts b/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/OpenAllMetadataDiffsHandler.test.ts index df99795e0..7125e4a54 100644 --- a/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/OpenAllMetadataDiffsHandler.test.ts +++ b/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/OpenAllMetadataDiffsHandler.test.ts @@ -6,10 +6,25 @@ import { expect } from "chai"; import * as sinon from "sinon"; import * as vscode from "vscode"; -import { openAllMetadataDiffs, isBinaryFile } from "../../../../../../power-pages/actions-hub/handlers/metadata-diff/OpenAllMetadataDiffsHandler"; +import { openAllMetadataDiffs } from "../../../../../../power-pages/actions-hub/handlers/metadata-diff/OpenAllMetadataDiffsHandler"; +import { isBinaryFile } from "../../../../../../power-pages/actions-hub/ActionsHubUtils"; import { MetadataDiffSiteTreeItem } from "../../../../../../power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffSiteTreeItem"; import * as TelemetryHelper from "../../../../../../power-pages/actions-hub/TelemetryHelper"; -import { IFileComparisonResult } from "../../../../../../power-pages/actions-hub/models/IFileComparisonResult"; +import { IFileComparisonResult, ISiteComparisonResults } from "../../../../../../power-pages/actions-hub/models/IFileComparisonResult"; + +/** + * Helper function to create ISiteComparisonResults for testing + */ +function createSiteResults(comparisonResults: IFileComparisonResult[], siteName = "Test Site", localSiteName = "Local Test Site", environmentName = "Test Environment"): ISiteComparisonResults { + return { + comparisonResults, + siteName, + localSiteName, + environmentName, + websiteId: "test-website-id", + environmentId: "test-environment-id" + }; +} describe("OpenAllMetadataDiffsHandler", () => { let sandbox: sinon.SinonSandbox; @@ -99,7 +114,7 @@ describe("OpenAllMetadataDiffsHandler", () => { status: "added" } ]; - const siteItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + const siteItem = new MetadataDiffSiteTreeItem(createSiteResults(results)); await openAllMetadataDiffs(siteItem); @@ -112,7 +127,7 @@ describe("OpenAllMetadataDiffsHandler", () => { }); it("should not execute command when no results", async () => { - const siteItem = new MetadataDiffSiteTreeItem([], "Test Site", "Test Environment"); + const siteItem = new MetadataDiffSiteTreeItem(createSiteResults([])); await openAllMetadataDiffs(siteItem); @@ -128,7 +143,7 @@ describe("OpenAllMetadataDiffsHandler", () => { status: "modified" } ]; - const siteItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + const siteItem = new MetadataDiffSiteTreeItem(createSiteResults(results)); await openAllMetadataDiffs(siteItem); @@ -153,7 +168,7 @@ describe("OpenAllMetadataDiffsHandler", () => { status: "added" } ]; - const siteItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + const siteItem = new MetadataDiffSiteTreeItem(createSiteResults(results)); await openAllMetadataDiffs(siteItem); @@ -172,7 +187,7 @@ describe("OpenAllMetadataDiffsHandler", () => { status: "deleted" } ]; - const siteItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + const siteItem = new MetadataDiffSiteTreeItem(createSiteResults(results)); await openAllMetadataDiffs(siteItem); @@ -203,7 +218,7 @@ describe("OpenAllMetadataDiffsHandler", () => { status: "deleted" } ]; - const siteItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + const siteItem = new MetadataDiffSiteTreeItem(createSiteResults(results)); await openAllMetadataDiffs(siteItem); @@ -238,7 +253,7 @@ describe("OpenAllMetadataDiffsHandler", () => { status: "modified" } ]; - const siteItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + const siteItem = new MetadataDiffSiteTreeItem(createSiteResults(results)); await openAllMetadataDiffs(siteItem); @@ -266,7 +281,7 @@ describe("OpenAllMetadataDiffsHandler", () => { status: "added" } ]; - const siteItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + const siteItem = new MetadataDiffSiteTreeItem(createSiteResults(results)); await openAllMetadataDiffs(siteItem); @@ -293,7 +308,7 @@ describe("OpenAllMetadataDiffsHandler", () => { status: "modified" } ]; - const siteItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + const siteItem = new MetadataDiffSiteTreeItem(createSiteResults(results)); await openAllMetadataDiffs(siteItem); diff --git a/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/OpenMetadataDiffFileHandler.test.ts b/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/OpenMetadataDiffFileHandler.test.ts index 0e2796c48..ea0aa3581 100644 --- a/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/OpenMetadataDiffFileHandler.test.ts +++ b/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/OpenMetadataDiffFileHandler.test.ts @@ -14,10 +14,12 @@ import { IFileComparisonResult } from "../../../../../../power-pages/actions-hub describe("OpenMetadataDiffFileHandler", () => { let sandbox: sinon.SinonSandbox; let executeCommandStub: sinon.SinonStub; + let showInformationMessageStub: sinon.SinonStub; beforeEach(() => { sandbox = sinon.createSandbox(); executeCommandStub = sandbox.stub(vscode.commands, "executeCommand"); + showInformationMessageStub = sandbox.stub(vscode.window, "showInformationMessage"); sandbox.stub(TelemetryHelper, "traceInfo"); sandbox.stub(vscode.env, "sessionId").get(() => "test-session-id"); }); @@ -45,7 +47,8 @@ describe("OpenMetadataDiffFileHandler", () => { expect(traceInfoStub.firstCall.args[1]).to.deep.equal({ methodName: "openMetadataDiffFile", relativePath: "folder/file.txt", - status: "modified" + status: "modified", + isImported: false }); }); @@ -218,5 +221,54 @@ describe("OpenMetadataDiffFileHandler", () => { expect(diffCall, `Expected no vscode.diff call for ${ext} file`).to.be.undefined; } }); + + it("should include isImported in telemetry for imported comparisons", async () => { + const traceInfoStub = TelemetryHelper.traceInfo as sinon.SinonStub; + + const comparisonResult: IFileComparisonResult = { + localPath: "/local/file.txt", + remotePath: "/remote/file.txt", + relativePath: "folder/file.txt", + status: "modified" + }; + const fileItem = new MetadataDiffFileTreeItem(comparisonResult, "Test Site", true); + + await openMetadataDiffFile(fileItem); + + expect(traceInfoStub.calledOnce).to.be.true; + expect(traceInfoStub.firstCall.args[1]).to.have.property("isImported", true); + }); + + it("should show information message for binary files in imported comparisons", async () => { + const comparisonResult: IFileComparisonResult = { + localPath: "/local/image.png", + remotePath: "/remote/image.png", + relativePath: "image.png", + status: "modified" + }; + const fileItem = new MetadataDiffFileTreeItem(comparisonResult, "Test Site", true); + + await openMetadataDiffFile(fileItem); + + expect(showInformationMessageStub.calledOnce).to.be.true; + }); + + it("should not open binary file for imported comparisons", async () => { + const comparisonResult: IFileComparisonResult = { + localPath: "/local/image.png", + remotePath: "/remote/image.png", + relativePath: "image.png", + status: "modified" + }; + const fileItem = new MetadataDiffFileTreeItem(comparisonResult, "Test Site", true); + + await openMetadataDiffFile(fileItem); + + // Should not call vscode.open or vscode.diff for imported binary files + const openCall = executeCommandStub.getCalls().find( + call => call.args[0] === "vscode.open" || call.args[0] === "vscode.diff" + ); + expect(openCall).to.be.undefined; + }); }); }); diff --git a/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/ResyncMetadataDiffHandler.test.ts b/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/ResyncMetadataDiffHandler.test.ts new file mode 100644 index 000000000..821b8aca2 --- /dev/null +++ b/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/ResyncMetadataDiffHandler.test.ts @@ -0,0 +1,363 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { expect } from "chai"; +import * as sinon from "sinon"; +import * as vscode from "vscode"; +import { resyncMetadataDiff } from "../../../../../../power-pages/actions-hub/handlers/metadata-diff/ResyncMetadataDiffHandler"; +import { Constants } from "../../../../../../power-pages/actions-hub/Constants"; +import { PacTerminal } from "../../../../../../lib/PacTerminal"; +import { MetadataDiffSiteTreeItem } from "../../../../../../power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffSiteTreeItem"; +import { ISiteComparisonResults, FileComparisonStatus } from "../../../../../../power-pages/actions-hub/models/IFileComparisonResult"; +import MetadataDiffContext from "../../../../../../power-pages/actions-hub/MetadataDiffContext"; +import * as TelemetryHelper from "../../../../../../power-pages/actions-hub/TelemetryHelper"; +import * as WorkspaceInfoFinderUtil from "../../../../../../../common/utilities/WorkspaceInfoFinderUtil"; +import * as MetadataDiffUtils from "../../../../../../power-pages/actions-hub/handlers/metadata-diff/MetadataDiffUtils"; + +describe("ResyncMetadataDiffHandler", () => { + let sandbox: sinon.SinonSandbox; + let mockShowErrorMessage: sinon.SinonStub; + let mockShowWarningMessage: sinon.SinonStub; + let traceInfoStub: sinon.SinonStub; + let mockPacTerminal: sinon.SinonStubbedInstance; + let mockExtensionContext: vscode.ExtensionContext; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + mockShowErrorMessage = sandbox.stub(vscode.window, "showErrorMessage"); + mockShowWarningMessage = sandbox.stub(vscode.window, "showWarningMessage"); + sandbox.stub(vscode.window, "showInformationMessage"); + traceInfoStub = sandbox.stub(TelemetryHelper, "traceInfo"); + sandbox.stub(TelemetryHelper, "traceError"); + sandbox.stub(TelemetryHelper, "getBaseEventInfo").returns({ foo: "bar" }); + sandbox.stub(vscode.env, "sessionId").get(() => "test-session-id"); + + // Mock PacTerminal + mockPacTerminal = sandbox.createStubInstance(PacTerminal); + + // Mock ExtensionContext + mockExtensionContext = { + storageUri: { fsPath: "/test/storage/path" } + } as unknown as vscode.ExtensionContext; + + // Clear MetadataDiffContext before each test + MetadataDiffContext.clear(); + }); + + afterEach(() => { + sandbox.restore(); + MetadataDiffContext.clear(); + }); + + function createMockSiteComparisonResults(overrides: Partial = {}): ISiteComparisonResults { + return { + siteName: "Test Site", + localSiteName: "Local Site", + environmentName: "Test Environment", + websiteId: "test-website-id", + environmentId: "test-environment-id", + comparisonResults: [ + { + localPath: "/local/path/file.html", + remotePath: "/remote/path/file.html", + relativePath: "file.html", + status: FileComparisonStatus.MODIFIED + } + ], + isImported: false, + ...overrides + }; + } + + function createMockSiteTreeItem(overrides: Partial = {}): MetadataDiffSiteTreeItem { + const siteResults = createMockSiteComparisonResults(overrides); + return new MetadataDiffSiteTreeItem(siteResults); + } + + describe("resyncMetadataDiff", () => { + describe("when site is imported", () => { + it("should show warning message and not proceed", async () => { + const siteItem = createMockSiteTreeItem({ isImported: true }); + + const handler = resyncMetadataDiff(mockPacTerminal as unknown as PacTerminal, mockExtensionContext); + await handler(siteItem); + + expect(mockShowWarningMessage.calledOnce).to.be.true; + expect(mockShowWarningMessage.firstCall.args[0]).to.equal(Constants.Strings.METADATA_DIFF_CANNOT_RESYNC_IMPORTED); + }); + + it("should log telemetry event", async () => { + const siteItem = createMockSiteTreeItem({ isImported: true }); + + const handler = resyncMetadataDiff(mockPacTerminal as unknown as PacTerminal, mockExtensionContext); + await handler(siteItem); + + expect(traceInfoStub.calledWith(Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_RESYNC_CALLED)).to.be.true; + const callArgs = traceInfoStub.getCalls().find( + call => call.args[0] === Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_RESYNC_CALLED + )?.args[1]; + expect(callArgs).to.deep.include({ + methodName: "resyncMetadataDiff", + isImported: true + }); + }); + }); + + describe("when no workspace folders exist", () => { + beforeEach(() => { + sandbox.stub(vscode.workspace, "workspaceFolders").get(() => undefined); + }); + + it("should show error message", async () => { + const siteItem = createMockSiteTreeItem(); + + const handler = resyncMetadataDiff(mockPacTerminal as unknown as PacTerminal, mockExtensionContext); + await handler(siteItem); + + expect(mockShowErrorMessage.calledOnce).to.be.true; + expect(mockShowErrorMessage.firstCall.args[0]).to.equal(Constants.Strings.NO_WORKSPACE_FOLDER_OPEN); + }); + + it("should log telemetry event", async () => { + const siteItem = createMockSiteTreeItem(); + + const handler = resyncMetadataDiff(mockPacTerminal as unknown as PacTerminal, mockExtensionContext); + await handler(siteItem); + + expect(traceInfoStub.calledWith(Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_LOCAL_NO_WORKSPACE)).to.be.true; + }); + }); + + describe("when workspace folders are empty", () => { + beforeEach(() => { + sandbox.stub(vscode.workspace, "workspaceFolders").get(() => []); + }); + + it("should show error message", async () => { + const siteItem = createMockSiteTreeItem(); + + const handler = resyncMetadataDiff(mockPacTerminal as unknown as PacTerminal, mockExtensionContext); + await handler(siteItem); + + expect(mockShowErrorMessage.calledOnce).to.be.true; + expect(mockShowErrorMessage.firstCall.args[0]).to.equal(Constants.Strings.NO_WORKSPACE_FOLDER_OPEN); + }); + }); + + describe("when website ID is not found", () => { + beforeEach(() => { + sandbox.stub(vscode.workspace, "workspaceFolders").get(() => [ + { uri: { fsPath: "/test/workspace" }, name: "workspace", index: 0 } + ]); + sandbox.stub(WorkspaceInfoFinderUtil, "getWebsiteRecordId").returns(undefined as unknown as string); + sandbox.stub(WorkspaceInfoFinderUtil, "findPowerPagesSiteFolder").returns(undefined as unknown as string); + }); + + it("should show error message", async () => { + const siteItem = createMockSiteTreeItem(); + + const handler = resyncMetadataDiff(mockPacTerminal as unknown as PacTerminal, mockExtensionContext); + await handler(siteItem); + + expect(mockShowErrorMessage.calledOnce).to.be.true; + expect(mockShowErrorMessage.firstCall.args[0]).to.equal(Constants.Strings.WEBSITE_ID_NOT_FOUND); + }); + + it("should log telemetry event", async () => { + const siteItem = createMockSiteTreeItem(); + + const handler = resyncMetadataDiff(mockPacTerminal as unknown as PacTerminal, mockExtensionContext); + await handler(siteItem); + + expect(traceInfoStub.calledWith(Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_LOCAL_WEBSITE_ID_NOT_FOUND)).to.be.true; + }); + }); + + describe("when storage path is not available", () => { + beforeEach(() => { + sandbox.stub(vscode.workspace, "workspaceFolders").get(() => [ + { uri: { fsPath: "/test/workspace" }, name: "workspace", index: 0 } + ]); + sandbox.stub(WorkspaceInfoFinderUtil, "getWebsiteRecordId").returns("test-site-id"); + }); + + it("should return early without error", async () => { + const contextWithoutStorage = { + storageUri: undefined + } as unknown as vscode.ExtensionContext; + + const siteItem = createMockSiteTreeItem(); + + const handler = resyncMetadataDiff(mockPacTerminal as unknown as PacTerminal, contextWithoutStorage); + await handler(siteItem); + + expect(mockShowErrorMessage.called).to.be.false; + }); + }); + + describe("telemetry", () => { + it("should log initial telemetry event when handler is called", async () => { + sandbox.stub(vscode.workspace, "workspaceFolders").get(() => undefined); + + const siteItem = createMockSiteTreeItem({ + websiteId: "my-site-id", + environmentId: "my-env-id", + siteName: "My Site", + environmentName: "My Environment" + }); + + const handler = resyncMetadataDiff(mockPacTerminal as unknown as PacTerminal, mockExtensionContext); + await handler(siteItem); + + expect(traceInfoStub.calledWith(Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_RESYNC_CALLED)).to.be.true; + const callArgs = traceInfoStub.getCalls().find( + call => call.args[0] === Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_RESYNC_CALLED + )?.args[1]; + expect(callArgs).to.deep.include({ + methodName: "resyncMetadataDiff", + siteName: "My Site", + environmentName: "My Environment", + websiteId: "my-site-id", + environmentId: "my-env-id", + isImported: false + }); + }); + }); + + describe("when resync completes with no differences", () => { + let mockPacWrapper: { + downloadSiteWithProgress: sinon.SinonStub; + }; + let clearSiteByKeyStub: sinon.SinonStub; + let processComparisonResultsStub: sinon.SinonStub; + + beforeEach(() => { + sandbox.stub(vscode.workspace, "workspaceFolders").get(() => [ + { uri: { fsPath: "/test/workspace" }, name: "workspace", index: 0 } + ]); + sandbox.stub(WorkspaceInfoFinderUtil, "getWebsiteRecordId").returns("test-website-id"); + sandbox.stub(WorkspaceInfoFinderUtil, "findPowerPagesSiteFolder").returns(null); + + // Stub prepareSiteStoragePath to return a test path + sandbox.stub(MetadataDiffUtils, "prepareSiteStoragePath").returns("/test/storage/sites-for-comparison/test-website-id"); + + mockPacWrapper = { + downloadSiteWithProgress: sandbox.stub().resolves(true) + }; + mockPacTerminal.getWrapper.returns(mockPacWrapper as unknown as ReturnType); + + // Stub processComparisonResults to return false (no differences) + processComparisonResultsStub = sandbox.stub(MetadataDiffUtils, "processComparisonResults").resolves(false); + + // Set up initial comparison results + MetadataDiffContext.setResults( + [{ + localPath: "/local/path/file.html", + remotePath: "/remote/path/file.html", + relativePath: "file.html", + status: FileComparisonStatus.MODIFIED + }], + "Test Site", + "Local Site", + "Test Environment", + "test-website-id", + "test-environment-id", + false + ); + + clearSiteByKeyStub = sandbox.stub(MetadataDiffContext, "clearSiteByKey"); + }); + + it("should clear the site from MetadataDiffContext when no differences found", async () => { + const siteItem = createMockSiteTreeItem(); + + const handler = resyncMetadataDiff(mockPacTerminal as unknown as PacTerminal, mockExtensionContext); + await handler(siteItem); + + expect(clearSiteByKeyStub.calledOnce).to.be.true; + expect(clearSiteByKeyStub.firstCall.args[0]).to.equal("test-website-id"); + expect(clearSiteByKeyStub.firstCall.args[1]).to.equal("test-environment-id"); + expect(clearSiteByKeyStub.firstCall.args[2]).to.equal(false); + }); + + it("should call processComparisonResults with correct parameters", async () => { + const siteItem = createMockSiteTreeItem({ + dataModelVersion: 2 + }); + + const handler = resyncMetadataDiff(mockPacTerminal as unknown as PacTerminal, mockExtensionContext); + await handler(siteItem); + + expect(processComparisonResultsStub.calledOnce).to.be.true; + const callArgs = processComparisonResultsStub.firstCall.args; + expect(callArgs[2]).to.equal("Test Site"); // siteName + expect(callArgs[3]).to.equal("Local Site"); // localSiteName + expect(callArgs[4]).to.equal("Test Environment"); // environmentName + expect(callArgs[11]).to.equal(2); // dataModelVersion + }); + }); + + describe("when resync completes with differences", () => { + let mockPacWrapper: { + downloadSiteWithProgress: sinon.SinonStub; + }; + let clearSiteByKeyStub: sinon.SinonStub; + let mockShowInformationMessage: sinon.SinonStub; + + beforeEach(() => { + sandbox.restore(); // Restore to avoid conflicts + + sandbox = sinon.createSandbox(); + mockShowErrorMessage = sandbox.stub(vscode.window, "showErrorMessage"); + mockShowWarningMessage = sandbox.stub(vscode.window, "showWarningMessage"); + mockShowInformationMessage = sandbox.stub(vscode.window, "showInformationMessage"); + traceInfoStub = sandbox.stub(TelemetryHelper, "traceInfo"); + sandbox.stub(TelemetryHelper, "traceError"); + sandbox.stub(TelemetryHelper, "getBaseEventInfo").returns({ foo: "bar" }); + sandbox.stub(vscode.env, "sessionId").get(() => "test-session-id"); + + sandbox.stub(vscode.workspace, "workspaceFolders").get(() => [ + { uri: { fsPath: "/test/workspace" }, name: "workspace", index: 0 } + ]); + sandbox.stub(WorkspaceInfoFinderUtil, "getWebsiteRecordId").returns("test-website-id"); + sandbox.stub(WorkspaceInfoFinderUtil, "findPowerPagesSiteFolder").returns(null); + + // Stub prepareSiteStoragePath to return a test path + sandbox.stub(MetadataDiffUtils, "prepareSiteStoragePath").returns("/test/storage/sites-for-comparison/test-website-id"); + + mockPacWrapper = { + downloadSiteWithProgress: sandbox.stub().resolves(true) + }; + + mockPacTerminal = sandbox.createStubInstance(PacTerminal); + mockPacTerminal.getWrapper.returns(mockPacWrapper as unknown as ReturnType); + + // Stub processComparisonResults to return true (has differences) + sandbox.stub(MetadataDiffUtils, "processComparisonResults").resolves(true); + + clearSiteByKeyStub = sandbox.stub(MetadataDiffContext, "clearSiteByKey"); + }); + + it("should not clear the site from MetadataDiffContext when differences are found", async () => { + const siteItem = createMockSiteTreeItem(); + + const handler = resyncMetadataDiff(mockPacTerminal as unknown as PacTerminal, mockExtensionContext); + await handler(siteItem); + + expect(clearSiteByKeyStub.called).to.be.false; + }); + + it("should show success information message when differences are found", async () => { + const siteItem = createMockSiteTreeItem(); + + const handler = resyncMetadataDiff(mockPacTerminal as unknown as PacTerminal, mockExtensionContext); + await handler(siteItem); + + expect(mockShowInformationMessage.calledOnce).to.be.true; + expect(mockShowInformationMessage.firstCall.args[0]).to.equal(Constants.Strings.METADATA_DIFF_RESYNC_COMPLETED); + }); + }); + }); +}); diff --git a/src/client/test/Integration/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFileTreeItem.test.ts b/src/client/test/Integration/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFileTreeItem.test.ts index 6d21ea0cc..b4e220a66 100644 --- a/src/client/test/Integration/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFileTreeItem.test.ts +++ b/src/client/test/Integration/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFileTreeItem.test.ts @@ -182,4 +182,24 @@ describe("MetadataDiffFileTreeItem", () => { expect(tooltipValue).to.include("Deleted"); }); }); + + describe("isImported", () => { + it("should return false by default", () => { + const treeItem = new MetadataDiffFileTreeItem(mockComparisonResult, "Test Site"); + + expect(treeItem.isImported).to.be.false; + }); + + it("should return false when explicitly set to false", () => { + const treeItem = new MetadataDiffFileTreeItem(mockComparisonResult, "Test Site", false); + + expect(treeItem.isImported).to.be.false; + }); + + it("should return true when explicitly set to true", () => { + const treeItem = new MetadataDiffFileTreeItem(mockComparisonResult, "Test Site", true); + + expect(treeItem.isImported).to.be.true; + }); + }); }); diff --git a/src/client/test/Integration/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFolderTreeItem.test.ts b/src/client/test/Integration/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFolderTreeItem.test.ts index 8229f739c..ed0041ad1 100644 --- a/src/client/test/Integration/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFolderTreeItem.test.ts +++ b/src/client/test/Integration/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFolderTreeItem.test.ts @@ -132,6 +132,63 @@ describe("MetadataDiffFolderTreeItem", () => { expect(children).to.include(fileItem); }); + it("should return folders first alphabetically, then files alphabetically", () => { + const treeItem = new MetadataDiffFolderTreeItem("folder", "Test Site", "folder"); + + // Add folders in non-alphabetical order + const folderZ = new MetadataDiffFolderTreeItem("z-folder", "Test Site", "folder/z-folder"); + const folderA = new MetadataDiffFolderTreeItem("a-folder", "Test Site", "folder/a-folder"); + const folderM = new MetadataDiffFolderTreeItem("m-folder", "Test Site", "folder/m-folder"); + + // Add files in non-alphabetical order + const fileZ: IFileComparisonResult = { + localPath: "/local/z-file.txt", + remotePath: "/remote/z-file.txt", + relativePath: "folder/z-file.txt", + status: "modified" + }; + const fileA: IFileComparisonResult = { + localPath: "/local/a-file.txt", + remotePath: "/remote/a-file.txt", + relativePath: "folder/a-file.txt", + status: "added" + }; + const fileM: IFileComparisonResult = { + localPath: "/local/m-file.txt", + remotePath: "/remote/m-file.txt", + relativePath: "folder/m-file.txt", + status: "deleted" + }; + + // Add in random order + treeItem.childrenMap.set("z-file.txt", new MetadataDiffFileTreeItem(fileZ, "Test Site")); + treeItem.childrenMap.set("z-folder", folderZ); + treeItem.childrenMap.set("a-file.txt", new MetadataDiffFileTreeItem(fileA, "Test Site")); + treeItem.childrenMap.set("m-folder", folderM); + treeItem.childrenMap.set("m-file.txt", new MetadataDiffFileTreeItem(fileM, "Test Site")); + treeItem.childrenMap.set("a-folder", folderA); + + const children = treeItem.getChildren(); + + expect(children).to.have.lengthOf(6); + + // First 3 should be folders in alphabetical order + expect(children[0]).to.be.instanceOf(MetadataDiffFolderTreeItem); + expect(children[0].label).to.equal("a-folder"); + expect(children[1]).to.be.instanceOf(MetadataDiffFolderTreeItem); + expect(children[1].label).to.equal("m-folder"); + expect(children[2]).to.be.instanceOf(MetadataDiffFolderTreeItem); + expect(children[2].label).to.equal("z-folder"); + + // Last 3 should be files in alphabetical order + expect(children[3]).to.be.instanceOf(MetadataDiffFileTreeItem); + expect(children[3].label).to.equal("a-file.txt"); + expect(children[4]).to.be.instanceOf(MetadataDiffFileTreeItem); + expect(children[4].label).to.equal("m-file.txt"); + expect(children[5]).to.be.instanceOf(MetadataDiffFileTreeItem); + expect(children[5].label).to.equal("z-file.txt"); + }); + it("should return ActionsHubTreeItem array", () => { const treeItem = new MetadataDiffFolderTreeItem("folder", "Test Site", "folder"); const comparisonResult: IFileComparisonResult = { diff --git a/src/client/test/Integration/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffGroupTreeItem.test.ts b/src/client/test/Integration/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffGroupTreeItem.test.ts index 291d5bf0c..13914e266 100644 --- a/src/client/test/Integration/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffGroupTreeItem.test.ts +++ b/src/client/test/Integration/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffGroupTreeItem.test.ts @@ -53,7 +53,7 @@ describe("MetadataDiffGroupTreeItem", () => { status: "added" } ]; - MetadataDiffContext.setResults(results, "Test Site", "Test Environment"); + MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "test-website-id", "test-environment-id"); const treeItem = new MetadataDiffGroupTreeItem(); expect(treeItem.label).to.equal("Metadata Diff"); @@ -74,7 +74,7 @@ describe("MetadataDiffGroupTreeItem", () => { status: "modified" } ]; - MetadataDiffContext.setResults(results, "Test Site", "Test Environment"); + MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "test-website-id", "test-environment-id"); const treeItem = new MetadataDiffGroupTreeItem(); expect(treeItem.collapsibleState).to.equal(vscode.TreeItemCollapsibleState.Expanded); @@ -101,7 +101,7 @@ describe("MetadataDiffGroupTreeItem", () => { status: "modified" } ]; - MetadataDiffContext.setResults(results, "Test Site", "Test Environment"); + MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "test-website-id", "test-environment-id"); const treeItem = new MetadataDiffGroupTreeItem(); expect(treeItem.contextValue).to.equal(Constants.ContextValues.METADATA_DIFF_GROUP_WITH_RESULTS); @@ -122,7 +122,7 @@ describe("MetadataDiffGroupTreeItem", () => { status: "modified" } ]; - MetadataDiffContext.setResults(results, "Test Site", "Test Environment"); + MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "test-website-id", "test-environment-id"); const treeItem = new MetadataDiffGroupTreeItem(); expect(treeItem.id).to.equal("metadataDiffGroup-withResults"); @@ -147,7 +147,7 @@ describe("MetadataDiffGroupTreeItem", () => { status: "modified" } ]; - MetadataDiffContext.setResults(results, "Test Site", "Test Environment"); + MetadataDiffContext.setResults(results, "Test Site", "Local Test Site", "Test Environment", "test-website-id", "test-environment-id"); const treeItem = new MetadataDiffGroupTreeItem(); const children = treeItem.getChildren(); @@ -173,8 +173,8 @@ describe("MetadataDiffGroupTreeItem", () => { status: "added" } ]; - MetadataDiffContext.setResults(results1, "Site 1", "Test Environment"); - MetadataDiffContext.setResults(results2, "Site 2", "Test Environment"); + MetadataDiffContext.setResults(results1, "Site 1", "Local Site 1", "Test Environment", "test-website-id-1", "test-environment-id"); + MetadataDiffContext.setResults(results2, "Site 2", "Local Site 2", "Test Environment", "test-website-id-2", "test-environment-id"); const treeItem = new MetadataDiffGroupTreeItem(); const children = treeItem.getChildren(); @@ -207,8 +207,8 @@ describe("MetadataDiffGroupTreeItem", () => { status: "deleted" } ]; - MetadataDiffContext.setResults(results1, "Test Site", "Test Environment"); - MetadataDiffContext.setResults(results2, "Test Site", "Test Environment"); + MetadataDiffContext.setResults(results1, "Test Site", "Local Test Site", "Test Environment", "test-website-id", "test-environment-id"); + MetadataDiffContext.setResults(results2, "Test Site", "Local Test Site", "Test Environment", "test-website-id", "test-environment-id"); const treeItem = new MetadataDiffGroupTreeItem(); const children = treeItem.getChildren(); diff --git a/src/client/test/Integration/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffSiteTreeItem.test.ts b/src/client/test/Integration/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffSiteTreeItem.test.ts index fe6020336..478678291 100644 --- a/src/client/test/Integration/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffSiteTreeItem.test.ts +++ b/src/client/test/Integration/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffSiteTreeItem.test.ts @@ -9,10 +9,35 @@ import { MetadataDiffSiteTreeItem } from "../../../../../../power-pages/actions- import { MetadataDiffFileTreeItem } from "../../../../../../power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFileTreeItem"; import { MetadataDiffFolderTreeItem } from "../../../../../../power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFolderTreeItem"; import { ActionsHubTreeItem } from "../../../../../../power-pages/actions-hub/tree-items/ActionsHubTreeItem"; -import { IFileComparisonResult, FileComparisonStatus } from "../../../../../../power-pages/actions-hub/models/IFileComparisonResult"; +import { IFileComparisonResult, FileComparisonStatus, ISiteComparisonResults } from "../../../../../../power-pages/actions-hub/models/IFileComparisonResult"; import { Constants } from "../../../../../../power-pages/actions-hub/Constants"; import MetadataDiffContext, { MetadataDiffViewMode, MetadataDiffSortMode } from "../../../../../../power-pages/actions-hub/MetadataDiffContext"; +/** + * Helper function to create ISiteComparisonResults for testing + */ +function createSiteResults( + comparisonResults: IFileComparisonResult[], + siteName = "Test Site", + localSiteName = "Local Test Site", + environmentName = "Test Environment", + websiteId = "test-website-id", + environmentId = "test-environment-id", + isImported = false, + exportedAt?: string +): ISiteComparisonResults { + return { + comparisonResults, + siteName, + localSiteName, + environmentName, + websiteId, + environmentId, + isImported, + exportedAt + }; +} + describe("MetadataDiffSiteTreeItem", () => { beforeEach(() => { // Reset to default list view mode before each test @@ -21,12 +46,12 @@ describe("MetadataDiffSiteTreeItem", () => { describe("constructor", () => { it("should be an instance of ActionsHubTreeItem", () => { - const treeItem = new MetadataDiffSiteTreeItem([], "Test Site", "Test Environment"); + const treeItem = new MetadataDiffSiteTreeItem(createSiteResults([])); expect(treeItem).to.be.instanceOf(ActionsHubTreeItem); }); - it("should have the expected label with site name and change count", () => { + it("should have the expected label with site name", () => { const results: IFileComparisonResult[] = [ { localPath: "/local/file1.txt", @@ -41,34 +66,55 @@ describe("MetadataDiffSiteTreeItem", () => { status: "added" } ]; - const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + const treeItem = new MetadataDiffSiteTreeItem(createSiteResults(results)); expect(treeItem.label).to.include("Test Site"); - expect(treeItem.label).to.include("2"); + expect(treeItem.label).to.include("Test Environment"); + expect(treeItem.label).to.include("Local Test Site"); }); it("should have expanded collapsible state", () => { - const treeItem = new MetadataDiffSiteTreeItem([], "Test Site", "Test Environment"); + const treeItem = new MetadataDiffSiteTreeItem(createSiteResults([])); expect(treeItem.collapsibleState).to.equal(vscode.TreeItemCollapsibleState.Expanded); }); it("should have the globe icon", () => { - const treeItem = new MetadataDiffSiteTreeItem([], "Test Site", "Test Environment"); + const treeItem = new MetadataDiffSiteTreeItem(createSiteResults([])); expect((treeItem.iconPath as vscode.ThemeIcon).id).to.equal("globe"); }); it("should have the expected context value", () => { - const treeItem = new MetadataDiffSiteTreeItem([], "Test Site", "Test Environment"); + const treeItem = new MetadataDiffSiteTreeItem(createSiteResults([])); expect(treeItem.contextValue).to.equal(Constants.ContextValues.METADATA_DIFF_SITE); }); + + it("should have imported icon and context value for imported comparisons", () => { + const treeItem = new MetadataDiffSiteTreeItem(createSiteResults([], "Test Site", "Local Test Site", "Test Environment", "web-id", "env-id", true, "2024-01-15T10:30:00Z")); + + expect((treeItem.iconPath as vscode.ThemeIcon).id).to.equal("cloud-download"); + expect(treeItem.contextValue).to.equal(Constants.ContextValues.METADATA_DIFF_SITE_IMPORTED); + }); + + it("should store exportedAt for imported comparisons", () => { + const treeItem = new MetadataDiffSiteTreeItem(createSiteResults([ + { + localPath: "/local/file1.txt", + remotePath: "/remote/file1.txt", + relativePath: "file1.txt", + status: "modified" + } + ], "Test Site", "Local Test Site", "Test Environment", "web-id", "env-id", true, "2024-01-15T10:30:00Z")); + + expect(treeItem.exportedAt).to.equal("2024-01-15T10:30:00Z"); + }); }); describe("siteName", () => { it("should return the site name", () => { - const treeItem = new MetadataDiffSiteTreeItem([], "Test Site", "Test Environment"); + const treeItem = new MetadataDiffSiteTreeItem(createSiteResults([])); expect(treeItem.siteName).to.equal("Test Site"); }); @@ -76,15 +122,30 @@ describe("MetadataDiffSiteTreeItem", () => { describe("environmentName", () => { it("should return the environment name", () => { - const treeItem = new MetadataDiffSiteTreeItem([], "Test Site", "My Environment"); + const treeItem = new MetadataDiffSiteTreeItem(createSiteResults([], "Test Site", "Local Test Site", "My Environment")); expect(treeItem.environmentName).to.equal("My Environment"); }); - it("should show environment name as description", () => { - const treeItem = new MetadataDiffSiteTreeItem([], "Test Site", "My Environment"); + it("should show file count as description", () => { + const results: IFileComparisonResult[] = [ + { + localPath: "/local/file1.txt", + remotePath: "/remote/file1.txt", + relativePath: "file1.txt", + status: "modified" + }, + { + localPath: "/local/file2.txt", + remotePath: "/remote/file2.txt", + relativePath: "file2.txt", + status: "added" + } + ]; + const treeItem = new MetadataDiffSiteTreeItem(createSiteResults(results, "Test Site", "Local Test Site", "My Environment")); - expect(treeItem.description).to.equal("My Environment"); + expect(treeItem.description).to.include("2"); + expect(treeItem.description).to.include("files changed"); }); }); @@ -98,19 +159,33 @@ describe("MetadataDiffSiteTreeItem", () => { status: "modified" } ]; - const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + const treeItem = new MetadataDiffSiteTreeItem(createSiteResults(results)); expect(treeItem.comparisonResults).to.deep.equal(results); }); }); + describe("websiteId and environmentId", () => { + it("should return the websiteId", () => { + const treeItem = new MetadataDiffSiteTreeItem(createSiteResults([], "Test Site", "Local Test Site", "Test Environment", "my-website-id", "my-env-id")); + + expect(treeItem.websiteId).to.equal("my-website-id"); + }); + + it("should return the environmentId", () => { + const treeItem = new MetadataDiffSiteTreeItem(createSiteResults([], "Test Site", "Local Test Site", "Test Environment", "my-website-id", "my-env-id")); + + expect(treeItem.environmentId).to.equal("my-env-id"); + }); + }); + describe("getChildren - list view mode", () => { beforeEach(() => { MetadataDiffContext.setViewMode(MetadataDiffViewMode.List); }); it("should return empty array when no results", () => { - const treeItem = new MetadataDiffSiteTreeItem([], "Test Site", "Test Environment"); + const treeItem = new MetadataDiffSiteTreeItem(createSiteResults([])); const children = treeItem.getChildren(); @@ -126,7 +201,7 @@ describe("MetadataDiffSiteTreeItem", () => { status: "modified" } ]; - const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + const treeItem = new MetadataDiffSiteTreeItem(createSiteResults(results)); const children = treeItem.getChildren(); @@ -143,7 +218,7 @@ describe("MetadataDiffSiteTreeItem", () => { status: "modified" } ]; - const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + const treeItem = new MetadataDiffSiteTreeItem(createSiteResults(results)); const children = treeItem.getChildren(); @@ -161,7 +236,7 @@ describe("MetadataDiffSiteTreeItem", () => { status: "modified" } ]; - const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + const treeItem = new MetadataDiffSiteTreeItem(createSiteResults(results)); const children = treeItem.getChildren(); @@ -185,7 +260,7 @@ describe("MetadataDiffSiteTreeItem", () => { status: "added" } ]; - const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + const treeItem = new MetadataDiffSiteTreeItem(createSiteResults(results)); const children = treeItem.getChildren(); @@ -209,7 +284,7 @@ describe("MetadataDiffSiteTreeItem", () => { status: "added" } ]; - const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + const treeItem = new MetadataDiffSiteTreeItem(createSiteResults(results)); const children = treeItem.getChildren(); @@ -240,7 +315,7 @@ describe("MetadataDiffSiteTreeItem", () => { status: FileComparisonStatus.ADDED } ]; - const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + const treeItem = new MetadataDiffSiteTreeItem(createSiteResults(results)); const children = treeItem.getChildren(); @@ -265,7 +340,7 @@ describe("MetadataDiffSiteTreeItem", () => { status: FileComparisonStatus.ADDED } ]; - const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + const treeItem = new MetadataDiffSiteTreeItem(createSiteResults(results)); const children = treeItem.getChildren(); @@ -297,7 +372,7 @@ describe("MetadataDiffSiteTreeItem", () => { status: FileComparisonStatus.DELETED } ]; - const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + const treeItem = new MetadataDiffSiteTreeItem(createSiteResults(results)); const children = treeItem.getChildren(); @@ -324,7 +399,7 @@ describe("MetadataDiffSiteTreeItem", () => { status: FileComparisonStatus.ADDED } ]; - const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + const treeItem = new MetadataDiffSiteTreeItem(createSiteResults(results)); const children = treeItem.getChildren(); @@ -349,7 +424,7 @@ describe("MetadataDiffSiteTreeItem", () => { status: "modified" } ]; - const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + const treeItem = new MetadataDiffSiteTreeItem(createSiteResults(results)); const children = treeItem.getChildren(); @@ -366,7 +441,7 @@ describe("MetadataDiffSiteTreeItem", () => { status: "modified" } ]; - const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + const treeItem = new MetadataDiffSiteTreeItem(createSiteResults(results)); const children = treeItem.getChildren(); @@ -384,7 +459,7 @@ describe("MetadataDiffSiteTreeItem", () => { status: "modified" } ]; - const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + const treeItem = new MetadataDiffSiteTreeItem(createSiteResults(results)); const children = treeItem.getChildren(); @@ -414,7 +489,7 @@ describe("MetadataDiffSiteTreeItem", () => { status: "added" } ]; - const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + const treeItem = new MetadataDiffSiteTreeItem(createSiteResults(results)); const children = treeItem.getChildren(); @@ -427,7 +502,7 @@ describe("MetadataDiffSiteTreeItem", () => { expect(folderChildren).to.have.lengthOf(2); }); - it("should handle mixed root files and folders", () => { + it("should handle mixed root files and folders with folders first alphabetically, then files", () => { const results: IFileComparisonResult[] = [ { localPath: "/local/root-file.txt", @@ -442,17 +517,79 @@ describe("MetadataDiffSiteTreeItem", () => { status: "added" } ]; - const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + const treeItem = new MetadataDiffSiteTreeItem(createSiteResults(results)); const children = treeItem.getChildren(); expect(children).to.have.lengthOf(2); - const fileItem = children.find(c => c instanceof MetadataDiffFileTreeItem); - const folderItem = children.find(c => c instanceof MetadataDiffFolderTreeItem); + // Folders should come first, then files + expect(children[0]).to.be.instanceOf(MetadataDiffFolderTreeItem); + expect(children[0].label).to.equal("folder"); + expect(children[1]).to.be.instanceOf(MetadataDiffFileTreeItem); + expect(children[1].label).to.equal("root-file.txt"); + }); - expect(fileItem).to.not.be.undefined; - expect(folderItem).to.not.be.undefined; + it("should sort folders alphabetically and files alphabetically at root level", () => { + const results: IFileComparisonResult[] = [ + { + localPath: "/local/z-file.txt", + remotePath: "/remote/z-file.txt", + relativePath: "z-file.txt", + status: "modified" + }, + { + localPath: "/local/a-folder/file.txt", + remotePath: "/remote/a-folder/file.txt", + relativePath: "a-folder/file.txt", + status: "added" + }, + { + localPath: "/local/a-file.txt", + remotePath: "/remote/a-file.txt", + relativePath: "a-file.txt", + status: "modified" + }, + { + localPath: "/local/z-folder/file.txt", + remotePath: "/remote/z-folder/file.txt", + relativePath: "z-folder/file.txt", + status: "deleted" + }, + { + localPath: "/local/m-folder/file.txt", + remotePath: "/remote/m-folder/file.txt", + relativePath: "m-folder/file.txt", + status: "modified" + }, + { + localPath: "/local/m-file.txt", + remotePath: "/remote/m-file.txt", + relativePath: "m-file.txt", + status: "added" + } + ]; + const treeItem = new MetadataDiffSiteTreeItem(createSiteResults(results)); + + const children = treeItem.getChildren(); + + expect(children).to.have.lengthOf(6); + + // First 3 should be folders in alphabetical order + expect(children[0]).to.be.instanceOf(MetadataDiffFolderTreeItem); + expect(children[0].label).to.equal("a-folder"); + expect(children[1]).to.be.instanceOf(MetadataDiffFolderTreeItem); + expect(children[1].label).to.equal("m-folder"); + expect(children[2]).to.be.instanceOf(MetadataDiffFolderTreeItem); + expect(children[2].label).to.equal("z-folder"); + + // Last 3 should be files in alphabetical order + expect(children[3]).to.be.instanceOf(MetadataDiffFileTreeItem); + expect(children[3].label).to.equal("a-file.txt"); + expect(children[4]).to.be.instanceOf(MetadataDiffFileTreeItem); + expect(children[4].label).to.equal("m-file.txt"); + expect(children[5]).to.be.instanceOf(MetadataDiffFileTreeItem); + expect(children[5].label).to.equal("z-file.txt"); }); it("should handle backslash path separators", () => { @@ -464,7 +601,7 @@ describe("MetadataDiffSiteTreeItem", () => { status: "modified" } ]; - const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + const treeItem = new MetadataDiffSiteTreeItem(createSiteResults(results)); const children = treeItem.getChildren(); diff --git a/src/common/utilities/WorkspaceInfoFinderUtil.ts b/src/common/utilities/WorkspaceInfoFinderUtil.ts index b6ceb2549..8812befd2 100644 --- a/src/common/utilities/WorkspaceInfoFinderUtil.ts +++ b/src/common/utilities/WorkspaceInfoFinderUtil.ts @@ -74,6 +74,42 @@ export function getWebsiteRecordId(param: { uri: string }[] | string): string { return ""; } +/** + * Gets the website name from the website.yml file in the specified directory + * @param workspaceFolderPath The directory path containing website.yml + * @returns The website name (adx_name or name field), or empty string if not found + */ +export function getWebsiteName(workspaceFolderPath: string): string { + try { + if (!workspaceFolderPath) { + return ""; + } + + // Check for website.yml directly in the folder first + let websiteYmlPath = path.join(workspaceFolderPath, WEBSITE_YML); + if (!fs.existsSync(websiteYmlPath)) { + // Also check inside the .powerpages-site folder + websiteYmlPath = path.join(workspaceFolderPath, POWERPAGES_SITE_FOLDER, WEBSITE_YML); + } + + if (fs.existsSync(websiteYmlPath)) { + const fileContent = fs.readFileSync(websiteYmlPath, 'utf8'); + const parsedYaml = parse(fileContent); + if (parsedYaml) { + // Check for adx_name first, then fallback to name (to support different formats) + if (parsedYaml.adx_name) { + return parsedYaml.adx_name; + } else if (parsedYaml.name) { + return parsedYaml.name; + } + } + } + } catch { + // Silently fail and return empty string + } + return ""; +} + export function findWebsiteYmlFolder(startPath: string): string | null { let currentPath = startPath; while (currentPath) {