diff --git a/.github/instructions/desktop-extension.instructions.md b/.github/instructions/desktop-extension.instructions.md index ff17dc08..f69f389c 100644 --- a/.github/instructions/desktop-extension.instructions.md +++ b/.github/instructions/desktop-extension.instructions.md @@ -1,5 +1,5 @@ --- -applyTo: src/client/** +applyTo: "src/client/**" description: Desktop Extension Setup and Contribution Instructions --- diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index 39b734e3..9e08b54a 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -233,6 +233,7 @@ ] }, "Other Sites": "Other Sites", + "Tools": "Tools", "Active Sites": "Active Sites", "Inactive Sites": "Inactive Sites", "No sites found": "No sites found", @@ -335,16 +336,86 @@ "Error updating config file: {0}": "Error updating config file: {0}", "No workspace folder is open. Please open a folder containing your Power Pages site.": "No workspace folder is open. Please open a folder containing your Power Pages site.", "Website ID not found. Please ensure you have a valid Power Pages site open.": "Website ID not found. Please ensure you have a valid Power Pages site open.", - "Downloading site for comparison...": "Downloading site for comparison...", "Site download failed. Please try again later.": "Site download failed. Please try again later.", "No differences found between the remote site and your local workspace.": "No differences found between the remote site and your local workspace.", "Comparing files...": "Comparing files...", - "Metadata Comparison": "Metadata Comparison", + "Metadata Diff": "Metadata Diff", "Modified": "Modified", "Added locally": "Added locally", "Deleted locally": "Deleted locally", - "{0} ({1} change(s))": "{0} ({1} change(s))", + "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", + "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.", + "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} file)/This is the site label showing the number of changed files. 'file' is singular.": { + "message": "{0} ({1} file)", + "comment": [ + "This is the site label showing the number of changed files. 'file' is singular." + ] + }, + "{0} ({1} files)/This is the site label showing the number of changed files. 'files' is plural.": { + "message": "{0} ({1} files)", + "comment": [ + "This is the site label showing the number of changed files. 'files' is plural." + ] + }, + "Are you sure you want to discard local changes to '{0}'? This action cannot be undone./Confirmation message before discarding local changes to a file.": { + "message": "Are you sure you want to discard local changes to '{0}'? This action cannot be undone.", + "comment": [ + "Confirmation message before discarding local changes to a file." + ] + }, + "Successfully discarded local changes to '{0}'./Success message after discarding local changes to a file.": { + "message": "Successfully discarded local changes to '{0}'.", + "comment": [ + "Success message after discarding local changes to a file." + ] + }, + "Failed to discard local changes: {0}/Error message when discarding local changes fails.": { + "message": "Failed to discard local changes: {0}", + "comment": [ + "Error message when discarding local changes fails." + ] + }, + "Are you sure you want to discard local changes to all {0} files in '{1}'? This action cannot be undone./Confirmation message before discarding all local changes in a folder.": { + "message": "Are you sure you want to discard local changes to all {0} files in '{1}'? This action cannot be undone.", + "comment": [ + "Confirmation message before discarding all local changes in a folder." + ] + }, + "Successfully discarded local changes to {0} files in '{1}'./Success message after discarding all local changes in a folder.": { + "message": "Successfully discarded local changes to {0} files in '{1}'.", + "comment": [ + "Success message after discarding all local changes in a folder." + ] + }, + "Compare: {0} (Remote ↔ Local)/Title for the multi-diff editor when comparing all files in a site.": { + "message": "Compare: {0} (Remote ↔ Local)", + "comment": [ + "Title for the multi-diff editor when comparing all files in a site." + ] + }, + "{0}: {1} (Remote ↔ Local)/Title for the diff editor when comparing a single file.": { + "message": "{0}: {1} (Remote ↔ Local)", + "comment": [ + "Title for the diff editor when comparing a single file." + ] + }, + "{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.": { + "message": "{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.", + "comment": [ + "Message shown when binary files are skipped in the multi-diff view." + ] + }, "Friendly name: {0}/{0} is the website name": { "message": "Friendly name: {0}", "comment": [ @@ -453,8 +524,6 @@ "{0} is the cluster geo name" ] }, - "{0}: {1} (Remote ↔ Local)": "{0}: {1} (Remote ↔ Local)", - "Compare: {0} (Remote ↔ Local)": "Compare: {0} (Remote ↔ Local)", "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 8869ad5e..9424f011 100644 --- a/loc/translations-export/vscode-powerplatform.xlf +++ b/loc/translations-export/vscode-powerplatform.xlf @@ -56,6 +56,9 @@ Adding {0} web file(s). Existing files will be skipped {0} represents the number of web files + + 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. + Analysis complete! Results saved to: {0} @@ -69,6 +72,14 @@ Are you sure you want to delete the Auth Profile {0}-{1}? {0} is the user name, {1} is the URL of environment of the auth profile + + Are you sure you want to discard local changes to '{0}'? This action cannot be undone. + Confirmation message before discarding local changes to a file. + + + Are you sure you want to discard local changes to all {0} files in '{1}'? This action cannot be undone. + Confirmation message before discarding all local changes in a folder. + Arrow icon @@ -218,8 +229,9 @@ Check the CodeQL extension panel for available queries. Command failed with exit code: {0} - + Compare: {0} (Remote ↔ Local) + Title for the multi-diff editor when comparing all files in a site. Comparing files... @@ -289,6 +301,9 @@ Check the CodeQL extension panel for available queries. Deleted locally + + Discard Changes + Dislike something? Tell us more. @@ -315,8 +330,12 @@ The {3} represents Solution's Type (Managed or Unmanaged), but that test is loca Download failed: {0} - - Downloading site for comparison... + + Download is complete. You can now view the report. + + + Downloading {0} site metadata ([details](command:microsoft.powerplatform.pages.actionsHub.showOutputChannel "Show download output"))... + This is a markdown formatting which must persist across translations. Edit the site @@ -398,6 +417,10 @@ The {3} represents Solution's Type (Managed or Unmanaged), but that test is loca Failed to disable PAC telemetry. + + Failed to discard local changes: {0} + Error message when discarding local changes fails. + Failed to enable PAC telemetry. @@ -437,6 +460,9 @@ The {3} represents Solution's Type (Managed or Unmanaged), but that test is loca Feedback + + Fetching websites from the selected environment... + Fetching your file ... @@ -574,8 +600,8 @@ Return to this chat and @powerpages can help you write and edit your website cod Maximum 30 characters allowed - - Metadata Comparison + + Metadata Diff Microsoft wants your feedback @@ -832,6 +858,9 @@ The {3} represents Dataverse Environment's Organization ID (GUID) Select an environment + + Select an environment to compare with + Select folder for CodeQL database @@ -900,6 +929,14 @@ The {3} represents Dataverse Environment's Organization ID (GUID) Submit + + Successfully discarded local changes to '{0}'. + Success message after discarding local changes to a file. + + + Successfully discarded local changes to {0} files in '{1}'. + Success message after discarding all local changes in a folder. + Switching environment... @@ -935,6 +972,9 @@ The {3} represents Dataverse Environment's Organization ID (GUID) The preview shown is for published changes. Please publish any pending changes to see them in the preview. + + The website was not found in the selected environment. Please select a different environment. + There was a permissions problem with the server @@ -957,6 +997,9 @@ The {3} represents Dataverse Environment's Organization ID (GUID) To know more, see <a href="https://go.microsoft.com/fwlink/?linkid=2206366">Copilot in Power Pages documentation. + + Tools + Try @powerpages with GitHub Copilot @@ -1103,15 +1146,25 @@ The {3} represents Dataverse Environment's Organization ID (GUID) pac pcf init - - {0} ({1} change(s)) + + {0} ({1} file) + This is the site label showing the number of changed files. 'file' is singular. + + + {0} ({1} files) + This is the site label showing the number of changed files. 'files' is plural. + + + {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. {0} created! {0} will be replaced by the entity type. - + {0}: {1} (Remote ↔ Local) + Title for the diff editor when comparing a single file. @@ -1143,6 +1196,9 @@ The fifth line should be '[TRANSLATION HERE](command:powerplatform-walkthrough.s Clear Conversation + + Compare with Environment + Compare with Local @@ -1179,6 +1235,12 @@ The fifth line should be '[TRANSLATION HERE](command:powerplatform-walkthrough.s Disable PAC Telemetry + + Discard All Local Changes + + + Discard Local Changes + Discover how you can make complete, modern websites right in Visual Studio Code @@ -1254,6 +1316,9 @@ The second line should be '[TRANSLATION HERE](command:powerplatform-walkthrough. Open All Diffs + + Open Comparison + Open Containing Folder @@ -1306,6 +1371,9 @@ The second line should be '[TRANSLATION HERE](command:powerplatform-walkthrough. Refresh + + Remove Comparison + Reveal in Explorer @@ -1324,9 +1392,6 @@ The second line should be '[TRANSLATION HERE](command:powerplatform-walkthrough. Select Environment - - Show Diff - Show Environment Details @@ -1348,12 +1413,27 @@ The second line should be '[TRANSLATION HERE](command:powerplatform-walkthrough. Site Details + + Sort by Name + + + Sort by Path + + + Sort by Status + 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. Upload Site + + View as List + + + View as Tree + Visual Studio Code for Web enables editing and publishing of web pages on your website. diff --git a/package.json b/package.json index e9c5c9f2..8d847e55 100644 --- a/package.json +++ b/package.json @@ -504,6 +504,7 @@ { "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.openFile", "title": "%microsoft.powerplatform.pages.actionsHub.metadataDiff.openFile.title%", + "icon": "$(go-to-file)", "when": "microsoft.powerplatform.pages.metadataDiffEnabled" }, { @@ -517,6 +518,60 @@ "title": "%microsoft.powerplatform.pages.actionsHub.metadataDiff.clear.title%", "icon": "$(clear-all)", "when": "microsoft.powerplatform.pages.metadataDiffEnabled" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.removeSite", + "title": "%microsoft.powerplatform.pages.actionsHub.metadataDiff.removeSite.title%", + "icon": "$(close)", + "when": "microsoft.powerplatform.pages.metadataDiffEnabled" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.viewAsTree", + "title": "%microsoft.powerplatform.pages.actionsHub.metadataDiff.viewAsTree.title%", + "icon": "$(list-tree)", + "when": "microsoft.powerplatform.pages.metadataDiffEnabled && microsoft.powerplatform.pages.metadataDiffViewMode == 'list'" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.viewAsList", + "title": "%microsoft.powerplatform.pages.actionsHub.metadataDiff.viewAsList.title%", + "icon": "$(list-flat)", + "when": "microsoft.powerplatform.pages.metadataDiffEnabled && microsoft.powerplatform.pages.metadataDiffViewMode == 'tree'" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.discardFile", + "title": "%microsoft.powerplatform.pages.actionsHub.metadataDiff.discardFile.title%", + "icon": "$(discard)", + "when": "microsoft.powerplatform.pages.metadataDiffEnabled" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.discardFolder", + "title": "%microsoft.powerplatform.pages.actionsHub.metadataDiff.discardFolder.title%", + "icon": "$(discard)", + "when": "microsoft.powerplatform.pages.metadataDiffEnabled" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.sortByName", + "title": "%microsoft.powerplatform.pages.actionsHub.metadataDiff.sortByName.title%", + "icon": "$(check)", + "when": "microsoft.powerplatform.pages.metadataDiffEnabled && microsoft.powerplatform.pages.metadataDiffViewMode == 'list'" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.sortByPath", + "title": "%microsoft.powerplatform.pages.actionsHub.metadataDiff.sortByPath.title%", + "icon": "$(check)", + "when": "microsoft.powerplatform.pages.metadataDiffEnabled && microsoft.powerplatform.pages.metadataDiffViewMode == 'list'" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.sortByStatus", + "title": "%microsoft.powerplatform.pages.actionsHub.metadataDiff.sortByStatus.title%", + "icon": "$(check)", + "when": "microsoft.powerplatform.pages.metadataDiffEnabled && microsoft.powerplatform.pages.metadataDiffViewMode == 'list'" + }, + { + "command": "microsoft-powerapps-portals.compareWithEnvironment", + "category": "Powerpages", + "title": "%microsoft-powerapps-portals.compareWithEnvironment.title%", + "when": "microsoft.powerplatform.pages.metadataDiffEnabled" } ], "configuration": { @@ -804,6 +859,11 @@ { "command": "microsoft-powerapps-portals.pagetemplate", "group": "1_powerpages@2" + }, + { + "command": "microsoft-powerapps-portals.compareWithEnvironment", + "group": "2_compare@1", + "when": "microsoft.powerplatform.pages.metadataDiffEnabled" } ], "editor/title": [ @@ -1026,6 +1086,14 @@ { "command": "microsoft.powerplatform.pages.actionsHub.activeSite.compareWithLocal", "when": "never" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.viewAsTree", + "when": "never" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.viewAsList", + "when": "never" } ], "view/title": [ @@ -1211,13 +1279,98 @@ }, { "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.openAll", - "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffGroup && microsoft.powerplatform.pages.metadataDiffEnabled", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffSite && microsoft.powerplatform.pages.metadataDiffEnabled", "group": "inline@1" }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.removeSite", + "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": "siteAction@1" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.removeSite", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffSite && microsoft.powerplatform.pages.metadataDiffEnabled", + "group": "siteAction@2" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.viewAsTree", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffGroupWithResults && microsoft.powerplatform.pages.metadataDiffEnabled && microsoft.powerplatform.pages.metadataDiffViewMode == 'list'", + "group": "inline@2" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.viewAsList", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffGroupWithResults && microsoft.powerplatform.pages.metadataDiffEnabled && microsoft.powerplatform.pages.metadataDiffViewMode == 'tree'", + "group": "inline@2" + }, { "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.clear", - "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffGroup && microsoft.powerplatform.pages.metadataDiffEnabled", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffGroupWithResults && microsoft.powerplatform.pages.metadataDiffEnabled", + "group": "inline@3" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.viewAsTree", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffGroupWithResults && microsoft.powerplatform.pages.metadataDiffEnabled && microsoft.powerplatform.pages.metadataDiffViewMode == 'list'", + "group": "groupAction@1" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.viewAsList", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffGroupWithResults && microsoft.powerplatform.pages.metadataDiffEnabled && microsoft.powerplatform.pages.metadataDiffViewMode == 'tree'", + "group": "groupAction@1" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.clear", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffGroupWithResults && microsoft.powerplatform.pages.metadataDiffEnabled", + "group": "groupAction@2" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.sortByName", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffGroupWithResults && microsoft.powerplatform.pages.metadataDiffEnabled && microsoft.powerplatform.pages.metadataDiffViewMode == 'list'", + "group": "sortAction@1" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.sortByPath", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffGroupWithResults && microsoft.powerplatform.pages.metadataDiffEnabled && microsoft.powerplatform.pages.metadataDiffViewMode == 'list'", + "group": "sortAction@2" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.sortByStatus", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffGroupWithResults && microsoft.powerplatform.pages.metadataDiffEnabled && microsoft.powerplatform.pages.metadataDiffViewMode == 'list'", + "group": "sortAction@3" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.openFile", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffFile && microsoft.powerplatform.pages.metadataDiffEnabled", + "group": "inline@1" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.discardFile", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffFile && microsoft.powerplatform.pages.metadataDiffEnabled", "group": "inline@2" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.discardFolder", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffFolder && microsoft.powerplatform.pages.metadataDiffEnabled", + "group": "inline@1" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.discardFolder", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffFolder && microsoft.powerplatform.pages.metadataDiffEnabled", + "group": "folderAction@1" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.openFile", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffFile && microsoft.powerplatform.pages.metadataDiffEnabled", + "group": "fileAction@1" + }, + { + "command": "microsoft.powerplatform.pages.actionsHub.metadataDiff.discardFile", + "when": "view == microsoft.powerplatform.pages.actionsHub && viewItem == metadataDiffFile && microsoft.powerplatform.pages.metadataDiffEnabled", + "group": "fileAction@2" } ] }, diff --git a/package.nls.json b/package.nls.json index 5363b4ca..93401176 100644 --- a/package.nls.json +++ b/package.nls.json @@ -118,7 +118,16 @@ "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.activeSite.compareWithLocal.title": "Compare with Local", - "microsoft.powerplatform.pages.actionsHub.metadataDiff.openFile.title": "Show Diff", + "microsoft.powerplatform.pages.actionsHub.metadataDiff.openFile.title": "Open Comparison", "microsoft.powerplatform.pages.actionsHub.metadataDiff.openAll.title": "Open All Diffs", - "microsoft.powerplatform.pages.actionsHub.metadataDiff.clear.title": "Clear Comparison" + "microsoft.powerplatform.pages.actionsHub.metadataDiff.clear.title": "Clear Comparison", + "microsoft.powerplatform.pages.actionsHub.metadataDiff.removeSite.title": "Remove Comparison", + "microsoft.powerplatform.pages.actionsHub.metadataDiff.viewAsTree.title": "View as Tree", + "microsoft.powerplatform.pages.actionsHub.metadataDiff.viewAsList.title": "View as List", + "microsoft.powerplatform.pages.actionsHub.metadataDiff.discardFile.title": "Discard Local Changes", + "microsoft.powerplatform.pages.actionsHub.metadataDiff.discardFolder.title": "Discard All Local Changes", + "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-powerapps-portals.compareWithEnvironment.title": "Compare with Environment" } diff --git a/src/client/pac/PacWrapper.ts b/src/client/pac/PacWrapper.ts index d18067e0..130e9466 100644 --- a/src/client/pac/PacWrapper.ts +++ b/src/client/pac/PacWrapper.ts @@ -25,6 +25,7 @@ export interface IPacInterop { executeCommand(args: PacArguments): Promise; executeCommandWithProgress(args: PacArguments): Promise; exit(): void; + showOutputChannel(): void; } export class PacInterop implements IPacInterop { @@ -138,7 +139,6 @@ export class PacInterop implements IPacInterop { const command = `pac ${args.Arguments.join(" ")}`; this.outputChannel.info(vscode.l10n.t("Executing: {0}", command)); - this.outputChannel.show(); return new Promise((resolve) => { const env: NodeJS.ProcessEnv = { ...process.env, 'PP_TOOLS_AUTOMATION_AGENT': this.context.automationAgent }; @@ -188,6 +188,14 @@ export class PacInterop implements IPacInterop { }); }); } + + /** + * Shows the PAC CLI output channel to the user. + * Call this method when you want to display command output to the user. + */ + public showOutputChannel(): void { + this.outputChannel.show(); + } } export class PacWrapper { @@ -318,6 +326,14 @@ export class PacWrapper { // The next operation will create a new process } + /** + * Shows the PAC CLI output channel to the user. + * Call this method when you want to display command output to the user. + */ + public showOutputChannel(): void { + this.pacInterop.showOutputChannel(); + } + public exit(): void { this.pacInterop.exit(); } diff --git a/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts b/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts index 8900915e..9f8e2d4a 100644 --- a/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts +++ b/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts @@ -6,6 +6,7 @@ import * as vscode from "vscode"; import { ActionsHubTreeItem } from "./tree-items/ActionsHubTreeItem"; import { OtherSitesGroupTreeItem } from "./tree-items/OtherSitesGroupTreeItem"; +import { ToolsGroupTreeItem } from "./tree-items/ToolsGroupTreeItem"; import { AccountMismatchTreeItem } from "./tree-items/AccountMismatchTreeItem"; import { Constants } from "./Constants"; import { oneDSLoggerWrapper } from "../../../common/OneDSLoggerTelemetry/oneDSLoggerWrapper"; @@ -37,11 +38,17 @@ import { downloadSite } from "./handlers/DownloadSiteHandler"; import { loginToMatch } from "./handlers/LoginToMatchHandler"; import { ActionsHub } from "./ActionsHub"; import { compareWithLocal } from "./handlers/metadata-diff/CompareWithLocalHandler"; +import { compareWithEnvironment } from "./handlers/metadata-diff/CompareWithEnvironmentHandler"; import MetadataDiffContext from "./MetadataDiffContext"; -import { MetadataDiffGroupTreeItem } from "./tree-items/metadata-diff/MetadataDiffGroupTreeItem"; import { openMetadataDiffFile } from "./handlers/metadata-diff/OpenMetadataDiffFileHandler"; import { openAllMetadataDiffs } from "./handlers/metadata-diff/OpenAllMetadataDiffsHandler"; import { clearMetadataDiff } from "./handlers/metadata-diff/ClearMetadataDiffHandler"; +import { viewAsTree, viewAsList } from "./handlers/metadata-diff/ToggleViewModeHandler"; +import { sortByName, sortByPath, sortByStatus } from "./handlers/metadata-diff/SortModeHandler"; +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"; export class ActionsHubTreeDataProvider implements vscode.TreeDataProvider { private readonly _disposables: vscode.Disposable[] = []; @@ -58,6 +65,10 @@ export class ActionsHubTreeDataProvider implements vscode.TreeDataProvider this._pacTerminal.getWrapper().showOutputChannel()), vscode.commands.registerCommand(Constants.Commands.METADATA_DIFF_OPEN_FILE, openMetadataDiffFile), vscode.commands.registerCommand(Constants.Commands.METADATA_DIFF_OPEN_ALL, openAllMetadataDiffs), - vscode.commands.registerCommand(Constants.Commands.METADATA_DIFF_CLEAR, clearMetadataDiff) + vscode.commands.registerCommand(Constants.Commands.METADATA_DIFF_CLEAR, clearMetadataDiff), + vscode.commands.registerCommand(Constants.Commands.METADATA_DIFF_REMOVE_SITE, removeSiteComparison), + vscode.commands.registerCommand(Constants.Commands.METADATA_DIFF_VIEW_AS_TREE, viewAsTree), + vscode.commands.registerCommand(Constants.Commands.METADATA_DIFF_VIEW_AS_LIST, viewAsList), + vscode.commands.registerCommand(Constants.Commands.METADATA_DIFF_SORT_BY_NAME, sortByName), + vscode.commands.registerCommand(Constants.Commands.METADATA_DIFF_SORT_BY_PATH, sortByPath), + vscode.commands.registerCommand(Constants.Commands.METADATA_DIFF_SORT_BY_STATUS, sortByStatus), + vscode.commands.registerCommand(Constants.Commands.METADATA_DIFF_DISCARD_FILE, discardLocalChanges), + vscode.commands.registerCommand(Constants.Commands.METADATA_DIFF_DISCARD_FOLDER, discardFolderChanges), + MetadataDiffDecorationProvider.getInstance().register() ); } diff --git a/src/client/power-pages/actions-hub/Constants.ts b/src/client/power-pages/actions-hub/Constants.ts index 90f67bf5..0cd53e8c 100644 --- a/src/client/power-pages/actions-hub/Constants.ts +++ b/src/client/power-pages/actions-hub/Constants.ts @@ -15,10 +15,13 @@ export const Constants = { INACTIVE_SITE: "inactiveSite", OTHER_SITE: "otherSite", OTHER_SITES_GROUP: "otherSitesGroup", + TOOLS_GROUP: "toolsGroup", NO_SITES: "noSites", ACCOUNT_MISMATCH: "accountMismatch", LOGIN_PROMPT: "loginPrompt", METADATA_DIFF_GROUP: "metadataDiffGroup", + METADATA_DIFF_GROUP_WITH_RESULTS: "metadataDiffGroupWithResults", + METADATA_DIFF_SITE: "metadataDiffSite", METADATA_DIFF_FOLDER: "metadataDiffFolder", METADATA_DIFF_FILE: "metadataDiffFile", }, @@ -26,19 +29,27 @@ export const Constants = { METADATA_DIFF_OPEN_FILE: "microsoft.powerplatform.pages.actionsHub.metadataDiff.openFile", METADATA_DIFF_OPEN_ALL: "microsoft.powerplatform.pages.actionsHub.metadataDiff.openAll", METADATA_DIFF_CLEAR: "microsoft.powerplatform.pages.actionsHub.metadataDiff.clear", + METADATA_DIFF_REMOVE_SITE: "microsoft.powerplatform.pages.actionsHub.metadataDiff.removeSite", + METADATA_DIFF_VIEW_AS_TREE: "microsoft.powerplatform.pages.actionsHub.metadataDiff.viewAsTree", + METADATA_DIFF_VIEW_AS_LIST: "microsoft.powerplatform.pages.actionsHub.metadataDiff.viewAsList", + METADATA_DIFF_DISCARD_FILE: "microsoft.powerplatform.pages.actionsHub.metadataDiff.discardFile", + METADATA_DIFF_DISCARD_FOLDER: "microsoft.powerplatform.pages.actionsHub.metadataDiff.discardFolder", + 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", + COMPARE_WITH_ENVIRONMENT: "microsoft-powerapps-portals.compareWithEnvironment", }, Icons: { SITE: new vscode.ThemeIcon('globe'), - SITE_GROUP: new vscode.ThemeIcon('folder'), + SITE_GROUP: vscode.ThemeIcon.Folder, OTHER_SITES: new vscode.ThemeIcon('archive'), + TOOLS: new vscode.ThemeIcon('tools'), METADATA_DIFF_GROUP: new vscode.ThemeIcon('diff'), - METADATA_DIFF_FOLDER: new vscode.ThemeIcon('folder'), - METADATA_DIFF_MODIFIED: new vscode.ThemeIcon('diff-modified', new vscode.ThemeColor('gitDecoration.modifiedResourceForeground')), - METADATA_DIFF_ADDED: new vscode.ThemeIcon('diff-added', new vscode.ThemeColor('gitDecoration.addedResourceForeground')), - METADATA_DIFF_DELETED: new vscode.ThemeIcon('diff-removed', new vscode.ThemeColor('gitDecoration.deletedResourceForeground')) + METADATA_DIFF_FOLDER: vscode.ThemeIcon.Folder, }, Strings: { OTHER_SITES: vscode.l10n.t("Other Sites"), + TOOLS: vscode.l10n.t("Tools"), ACTIVE_SITES: vscode.l10n.t("Active Sites"), INACTIVE_SITES: vscode.l10n.t("Inactive Sites"), NO_SITES_FOUND: vscode.l10n.t("No sites found"), @@ -155,14 +166,125 @@ export const Constants = { CODEQL_CONFIG_FILE_UPDATE_ERROR: vscode.l10n.t("Error updating config file: {0}"), NO_WORKSPACE_FOLDER_OPEN: vscode.l10n.t("No workspace folder is open. Please open a folder containing your Power Pages site."), WEBSITE_ID_NOT_FOUND: vscode.l10n.t("Website ID not found. Please ensure you have a valid Power Pages site open."), - DOWNLOADING_SITE_FOR_COMPARISON: vscode.l10n.t("Downloading site for comparison..."), COMPARE_WITH_LOCAL_SITE_DOWNLOAD_FAILED: vscode.l10n.t("Site download failed. Please try again later."), NO_DIFFERENCES_FOUND: vscode.l10n.t("No differences found between the remote site and your local workspace."), COMPARING_FILES: vscode.l10n.t("Comparing files..."), - METADATA_DIFF_GROUP: vscode.l10n.t("Metadata Comparison"), + METADATA_DIFF_GROUP: vscode.l10n.t("Metadata Diff"), METADATA_DIFF_MODIFIED: vscode.l10n.t("Modified"), METADATA_DIFF_ADDED: vscode.l10n.t("Added locally"), 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"), + 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."), + }, + /** + * Functions that return localized strings with dynamic parameters. + * Use these for strings that require runtime values. + */ + StringFunctions: { + /** + * Returns the downloading site message with site name + */ + DOWNLOADING_SITE_FOR_COMPARISON: (siteName: string) => + vscode.l10n.t({ + message: "Downloading {0} site metadata ([details](command:microsoft.powerplatform.pages.actionsHub.showOutputChannel \"Show download output\"))...", + args: [siteName], + comment: ["This is a markdown formatting which must persist across translations."] + }), + /** + * Returns the site label with file count (singular: "1 file") + */ + SITE_WITH_FILE_COUNT_SINGULAR: (siteName: string, fileCount: number) => + vscode.l10n.t({ + message: "{0} ({1} file)", + args: [siteName, fileCount], + comment: ["This is the site label showing the number of changed files. 'file' is singular."] + }), + /** + * Returns the site label with file count (plural: "X files") + */ + SITE_WITH_FILE_COUNT_PLURAL: (siteName: string, fileCount: number) => + vscode.l10n.t({ + message: "{0} ({1} files)", + args: [siteName, fileCount], + comment: ["This is the site label showing the number of changed files. 'files' is plural."] + }), + /** + * Returns the confirmation message for discarding local changes + */ + DISCARD_LOCAL_CHANGES_CONFIRM: (relativePath: string) => + vscode.l10n.t({ + message: "Are you sure you want to discard local changes to '{0}'? This action cannot be undone.", + args: [relativePath], + comment: ["Confirmation message before discarding local changes to a file."] + }), + /** + * Returns the success message after discarding local changes + */ + DISCARD_LOCAL_CHANGES_SUCCESS: (relativePath: string) => + vscode.l10n.t({ + message: "Successfully discarded local changes to '{0}'.", + args: [relativePath], + comment: ["Success message after discarding local changes to a file."] + }), + /** + * Returns the error message when discarding local changes fails + */ + DISCARD_LOCAL_CHANGES_FAILED: (errorMessage: string) => + vscode.l10n.t({ + message: "Failed to discard local changes: {0}", + args: [errorMessage], + comment: ["Error message when discarding local changes fails."] + }), + /** + * Returns the confirmation message for discarding all local changes in a folder + */ + DISCARD_FOLDER_CHANGES_CONFIRM: (folderName: string, fileCount: number) => + vscode.l10n.t({ + message: "Are you sure you want to discard local changes to all {0} files in '{1}'? This action cannot be undone.", + args: [fileCount, folderName], + comment: ["Confirmation message before discarding all local changes in a folder."] + }), + /** + * Returns the success message after discarding all local changes in a folder + */ + DISCARD_FOLDER_CHANGES_SUCCESS: (folderName: string, fileCount: number) => + vscode.l10n.t({ + message: "Successfully discarded local changes to {0} files in '{1}'.", + args: [fileCount, folderName], + comment: ["Success message after discarding all local changes in a folder."] + }), + /** + * Returns the title for comparing all files in a site + */ + COMPARE_ALL_TITLE: (siteName: string) => + vscode.l10n.t({ + message: "Compare: {0} (Remote ↔ Local)", + args: [siteName], + comment: ["Title for the multi-diff editor when comparing all files in a site."] + }), + /** + * Returns the title for comparing a single file + */ + COMPARE_FILE_TITLE: (siteName: string, relativePath: string) => + vscode.l10n.t({ + message: "{0}: {1} (Remote ↔ Local)", + args: [siteName, relativePath], + comment: ["Title for the diff editor when comparing a single file."] + }), + /** + * Returns the message for skipped binary files + */ + METADATA_DIFF_BINARY_FILES_SKIPPED: (count: number) => + vscode.l10n.t({ + message: "{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.", + args: [count], + comment: ["Message shown when binary files are skipped in the multi-diff view."] + }), }, EventNames: { ACTIONS_HUB_ENABLED: "ActionsHubEnabled", @@ -288,6 +410,17 @@ export const Constants = { ACTIONS_HUB_METADATA_DIFF_OPEN_FILE: "ActionsHubMetadataDiffOpenFile", ACTIONS_HUB_METADATA_DIFF_OPEN_ALL: "ActionsHubMetadataDiffOpenAll", ACTIONS_HUB_METADATA_DIFF_CLEAR: "ActionsHubMetadataDiffClear", + ACTIONS_HUB_METADATA_DIFF_DISCARD_FILE: "ActionsHubMetadataDiffDiscardFile", + ACTIONS_HUB_METADATA_DIFF_DISCARD_FOLDER: "ActionsHubMetadataDiffDiscardFolder", + ACTIONS_HUB_METADATA_DIFF_VIEW_MODE_CHANGED: "ActionsHubMetadataDiffViewModeChanged", + 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_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", }, 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 2495e666..1a846720 100644 --- a/src/client/power-pages/actions-hub/MetadataDiffContext.ts +++ b/src/client/power-pages/actions-hub/MetadataDiffContext.ts @@ -6,43 +6,252 @@ import * as vscode from "vscode"; import { IFileComparisonResult } from "./models/IFileComparisonResult"; +/** + * Enum for metadata diff view modes + */ +export enum MetadataDiffViewMode { + Tree = "tree", + List = "list" +} + +/** + * Enum for metadata diff sort modes (used in list view) + */ +export enum MetadataDiffSortMode { + Path = "path", + Name = "name", + 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"; +const SORT_MODE_STORAGE_KEY = "microsoft.powerplatform.pages.metadataDiff.sortModePreference"; + /** * Context for storing metadata diff comparison results * This allows the ActionsHubTreeDataProvider to display the diff results in the tree view + * Supports multiple site comparisons simultaneously */ class MetadataDiffContextClass { - private _comparisonResults: IFileComparisonResult[] = []; - private _siteName: string = ""; - private _isActive: boolean = false; + private _siteResults: Map = new Map(); + private _viewMode: MetadataDiffViewMode = MetadataDiffViewMode.Tree; + private _sortMode: MetadataDiffSortMode = MetadataDiffSortMode.Path; + private _extensionContext: vscode.ExtensionContext | undefined; private _onChanged: vscode.EventEmitter = new vscode.EventEmitter(); public readonly onChanged: vscode.Event = this._onChanged.event; - public get comparisonResults(): IFileComparisonResult[] { - return this._comparisonResults; + /** + * Initialize the context with the extension context for state persistence. + * This should be called once during extension activation. + */ + public initialize(context: vscode.ExtensionContext): void { + this._extensionContext = context; + // Load persisted view mode preference from global state (user-specific, not workspace-specific) + const savedViewMode = context.globalState.get(VIEW_MODE_STORAGE_KEY); + if (savedViewMode) { + this._viewMode = savedViewMode; + } + // Load persisted sort mode preference from global state + const savedSortMode = context.globalState.get(SORT_MODE_STORAGE_KEY); + if (savedSortMode) { + this._sortMode = savedSortMode; + } + // Update VS Code context with loaded view mode and sort mode + this.updateViewModeContext(); + this.updateSortModeContext(); } - public get siteName(): string { - return this._siteName; + /** + * Get all site comparison results + */ + public get allSiteResults(): ISiteComparisonResults[] { + return Array.from(this._siteResults.values()); + } + + /** + * Get comparison results for a specific site + */ + public getSiteResults(siteName: string): ISiteComparisonResults | undefined { + return this._siteResults.get(siteName); } + /** + * Check if there are any active comparison results + */ public get isActive(): boolean { - return this._isActive; + return this._siteResults.size > 0; + } + + /** + * Get the total number of changes across all sites + */ + public get totalChanges(): number { + let total = 0; + for (const siteResult of this._siteResults.values()) { + total += siteResult.comparisonResults.length; + } + return total; + } + + public get viewMode(): MetadataDiffViewMode { + return this._viewMode; } - public setResults(results: IFileComparisonResult[], siteName: string): void { - this._comparisonResults = results; - this._siteName = siteName; - this._isActive = results.length > 0; + public get isTreeView(): boolean { + return this._viewMode === MetadataDiffViewMode.Tree; + } + + public get isListView(): boolean { + return this._viewMode === MetadataDiffViewMode.List; + } + + public get sortMode(): MetadataDiffSortMode { + return this._sortMode; + } + + public setSortMode(mode: MetadataDiffSortMode): void { + if (this._sortMode !== mode) { + this._sortMode = mode; + this.updateSortModeContext(); + this.persistSortMode(); + this._onChanged.fire(); + } + } + + /** + * 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 environmentName The name of the environment + */ + public setResults(results: IFileComparisonResult[], siteName: string, environmentName: string): void { + if (results.length > 0) { + this._siteResults.set(siteName, { + siteName, + environmentName, + comparisonResults: results + }); + } else { + // If no results, remove the site from the map + this._siteResults.delete(siteName); + } + this._onChanged.fire(); + } + + /** + * Clear results for a specific site + */ + public clearSite(siteName: string): void { + this._siteResults.delete(siteName); + this._onChanged.fire(); + } + + /** + * Remove a specific file from the comparison results for a site. + * If this is the last file in the site, the site is removed entirely. + * @param relativePath The relative path of the file to remove + * @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); + } + + this._onChanged.fire(); + } + } + + public toggleViewMode(): void { + this._viewMode = this._viewMode === MetadataDiffViewMode.Tree ? MetadataDiffViewMode.List : MetadataDiffViewMode.Tree; + this.updateViewModeContext(); this._onChanged.fire(); } + public setViewMode(mode: MetadataDiffViewMode): void { + if (this._viewMode !== mode) { + this._viewMode = mode; + this.updateViewModeContext(); + this.persistViewMode(); + this._onChanged.fire(); + } + } + + /** + * Clear all site comparison results + */ public clear(): void { - this._comparisonResults = []; - this._siteName = ""; - this._isActive = false; + this._siteResults.clear(); this._onChanged.fire(); } + + /** + * Update VS Code context for view mode to control command visibility + */ + private updateViewModeContext(): void { + vscode.commands.executeCommand("setContext", VIEW_MODE_CONTEXT_KEY, this._viewMode); + } + + /** + * Persist view mode preference to global state (user-specific, not workspace-specific) + */ + private persistViewMode(): void { + if (this._extensionContext) { + this._extensionContext.globalState.update(VIEW_MODE_STORAGE_KEY, this._viewMode); + } + } + + /** + * Update VS Code context for sort mode to control command visibility + */ + private updateSortModeContext(): void { + vscode.commands.executeCommand("setContext", SORT_MODE_CONTEXT_KEY, this._sortMode); + } + + /** + * Persist sort mode preference to global state (user-specific, not workspace-specific) + */ + private persistSortMode(): void { + if (this._extensionContext) { + this._extensionContext.globalState.update(SORT_MODE_STORAGE_KEY, this._sortMode); + } + } + + // Legacy getters for backward compatibility + /** + * @deprecated Use allSiteResults instead + */ + public get comparisonResults(): IFileComparisonResult[] { + const allResults: IFileComparisonResult[] = []; + for (const siteResult of this._siteResults.values()) { + allResults.push(...siteResult.comparisonResults); + } + return allResults; + } + + /** + * @deprecated Use allSiteResults instead + */ + public get siteName(): string { + const firstSite = this._siteResults.values().next().value; + return firstSite?.siteName || ""; + } } const MetadataDiffContext = new MetadataDiffContextClass(); diff --git a/src/client/power-pages/actions-hub/MetadataDiffDecorationProvider.ts b/src/client/power-pages/actions-hub/MetadataDiffDecorationProvider.ts new file mode 100644 index 00000000..f0febb12 --- /dev/null +++ b/src/client/power-pages/actions-hub/MetadataDiffDecorationProvider.ts @@ -0,0 +1,91 @@ +/* + * 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 { METADATA_DIFF_URI_SCHEME } from "./tree-items/metadata-diff/MetadataDiffFileTreeItem"; +import { FileComparisonStatus } from "./models/IFileComparisonResult"; +import { Constants } from "./Constants"; + +/** + * File decoration provider for metadata diff files. + * Provides colored status badges (M, A, D) similar to Git's change indicators. + */ +export class MetadataDiffDecorationProvider implements vscode.FileDecorationProvider { + private static _instance: MetadataDiffDecorationProvider | undefined; + private _disposable: vscode.Disposable | undefined; + + private _onDidChangeFileDecorations = new vscode.EventEmitter(); + public readonly onDidChangeFileDecorations = this._onDidChangeFileDecorations.event; + + private constructor() { + // Private constructor for singleton + } + + public static getInstance(): MetadataDiffDecorationProvider { + if (!MetadataDiffDecorationProvider._instance) { + MetadataDiffDecorationProvider._instance = new MetadataDiffDecorationProvider(); + } + return MetadataDiffDecorationProvider._instance; + } + + /** + * Register the decoration provider with VS Code + */ + public register(): vscode.Disposable { + if (!this._disposable) { + this._disposable = vscode.window.registerFileDecorationProvider(this); + } + return this._disposable; + } + + /** + * Trigger a refresh of all decorations + */ + public refresh(): void { + this._onDidChangeFileDecorations.fire(undefined); + } + + /** + * Provide file decoration for metadata diff URIs + */ + public provideFileDecoration(uri: vscode.Uri, _: vscode.CancellationToken): vscode.ProviderResult { + // Only handle our custom URI scheme + if (uri.scheme !== METADATA_DIFF_URI_SCHEME) { + return undefined; + } + + // Extract status from query string + const params = new URLSearchParams(uri.query); + const status = params.get("status"); + + switch (status) { + case FileComparisonStatus.MODIFIED: + return new vscode.FileDecoration( + "M", + Constants.Strings.METADATA_DIFF_MODIFIED, + new vscode.ThemeColor("gitDecoration.modifiedResourceForeground") + ); + case FileComparisonStatus.ADDED: + return new vscode.FileDecoration( + "A", + Constants.Strings.METADATA_DIFF_ADDED, + new vscode.ThemeColor("gitDecoration.addedResourceForeground") + ); + case FileComparisonStatus.DELETED: + return new vscode.FileDecoration( + "D", + Constants.Strings.METADATA_DIFF_DELETED, + new vscode.ThemeColor("gitDecoration.deletedResourceForeground") + ); + default: + return undefined; + } + } + + public dispose(): void { + this._disposable?.dispose(); + this._onDidChangeFileDecorations.dispose(); + } +} 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 new file mode 100644 index 00000000..5306b9f1 --- /dev/null +++ b/src/client/power-pages/actions-hub/handlers/metadata-diff/CompareWithEnvironmentHandler.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 { PacTerminal } from "../../../../lib/PacTerminal"; +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 { IWebsiteDetails } from "../../../../../common/services/Interfaces"; +import { getAllWebsites } from "../../../../../common/utilities/WebsiteUtil"; +import { resolveSiteFromWorkspace, prepareSiteStoragePath, processComparisonResults } from "./MetadataDiffUtils"; + +/** + * Environment quick pick item with full org info + */ +interface EnvironmentQuickPickItem extends vscode.QuickPickItem { + orgInfo: OrgInfo; +} + +/** + * Gets the list of environments accessible to the user + * @param pacTerminal The PAC terminal instance + * @returns Array of environment quick pick items with org info + */ +async function getEnvironmentList(pacTerminal: PacTerminal): Promise { + const pacWrapper = pacTerminal.getWrapper(); + const envListOutput = await pacWrapper.orgList(); + + if (envListOutput && envListOutput.Status === SUCCESS && envListOutput.Results) { + const envList = envListOutput.Results as OrgListOutput[]; + return envList.map((env) => ({ + label: env.FriendlyName, + detail: env.EnvironmentUrl, + description: "", + orgInfo: { + OrgId: env.OrganizationId, + UniqueName: "", + FriendlyName: env.FriendlyName, + OrgUrl: env.EnvironmentUrl, + UserEmail: "", + UserId: "", + EnvironmentId: env.EnvironmentId + } + })); + } + + return []; +} + +/** + * Finds a website in the given list by its website record ID + * @param websites List of websites to search + * @param websiteId The website record ID to find + * @returns The matching website details or undefined + */ +function findWebsiteById(websites: IWebsiteDetails[], websiteId: string): IWebsiteDetails | undefined { + return websites.find(website => website.websiteRecordId === websiteId); +} + +export const compareWithEnvironment = (pacTerminal: PacTerminal, context: vscode.ExtensionContext) => async (resource: vscode.Uri): Promise => { + traceInfo(Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_ENVIRONMENT_CALLED, { + methodName: compareWithEnvironment.name + }); + + const workspaceFolders = vscode.workspace.workspaceFolders; + + if (!workspaceFolders || workspaceFolders.length === 0) { + traceInfo(Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_LOCAL_NO_WORKSPACE, { + methodName: compareWithEnvironment.name + }); + await vscode.window.showErrorMessage(Constants.Strings.NO_WORKSPACE_FOLDER_OPEN); + return; + } + + const siteResolution = resolveSiteFromWorkspace(workspaceFolders[0].uri.fsPath, resource); + + if (!siteResolution) { + traceInfo(Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_LOCAL_WEBSITE_ID_NOT_FOUND, { + methodName: compareWithEnvironment.name + }); + await vscode.window.showErrorMessage(Constants.Strings.WEBSITE_ID_NOT_FOUND); + return; + } + + // Show environment picker + const environmentList = await getEnvironmentList(pacTerminal); + + if (environmentList.length === 0) { + await vscode.window.showErrorMessage(Constants.Strings.NO_ENVIRONMENTS_FOUND); + return; + } + + const selectedEnv = await vscode.window.showQuickPick(environmentList, { + placeHolder: Constants.Strings.SELECT_ENVIRONMENT_TO_COMPARE + }); + + if (!selectedEnv) { + traceInfo(Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_ENVIRONMENT_CANCELLED, { + methodName: compareWithEnvironment.name + }); + return; + } + + const selectedOrgInfo = selectedEnv.orgInfo; + + traceInfo(Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_ENVIRONMENT_ENVIRONMENT_SELECTED, { + methodName: compareWithEnvironment.name, + environmentId: selectedOrgInfo.EnvironmentId, + environmentName: selectedEnv.label + }); + + // Fetch websites from selected environment + let websiteDetails: IWebsiteDetails | undefined; + + await showProgressWithNotification( + Constants.Strings.FETCHING_WEBSITES_FROM_ENVIRONMENT, + async () => { + const websites = await getAllWebsites(selectedOrgInfo); + websiteDetails = findWebsiteById(websites, siteResolution.siteId); + return true; + } + ); + + if (!websiteDetails) { + 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); + return; + } + + // Determine data model version + const dataModelVersion = websiteDetails.dataModel === "Enhanced" ? 2 : 1; + + const storagePath = context.storageUri?.fsPath; + + if (!storagePath) { + return; + } + + const siteStoragePath = prepareSiteStoragePath(storagePath, websiteDetails.websiteRecordId); + const pacWrapper = pacTerminal.getWrapper(); + + // Select the environment before downloading + await pacWrapper.orgSelect(selectedOrgInfo.OrgUrl); + + const success = await showProgressWithNotification( + Constants.StringFunctions.DOWNLOADING_SITE_FOR_COMPARISON(websiteDetails.name), + async () => pacWrapper.downloadSiteWithProgress( + siteStoragePath, + websiteDetails!.websiteRecordId, + dataModelVersion + ) + ); + + if (!success) { + traceError( + Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_ENVIRONMENT_FAILED, + new Error("MetadataDiff: Action 'compare with environment' failed to download site."), + { + methodName: compareWithEnvironment.name, + siteId: websiteDetails.websiteRecordId, + dataModelVersion: dataModelVersion.toString() + } + ); + await vscode.window.showErrorMessage(Constants.Strings.COMPARE_WITH_LOCAL_SITE_DOWNLOAD_FAILED); + return; + } + + await processComparisonResults( + siteStoragePath, + siteResolution.localSitePath, + websiteDetails.name, + selectedEnv.label, + compareWithEnvironment.name, + websiteDetails.websiteRecordId, + Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_ENVIRONMENT_COMPLETED, + Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_ENVIRONMENT_NO_DIFFERENCES + ); + + 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 8bee3226..ac18ef8f 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 @@ -4,73 +4,13 @@ */ import * as vscode from "vscode"; -import * as fs from "fs"; -import path from "path"; import { PacTerminal } from "../../../../lib/PacTerminal"; import { Constants } from "../../Constants"; import { traceError, traceInfo } from "../../TelemetryHelper"; import { SiteTreeItem } from "../../tree-items/SiteTreeItem"; -import { findPowerPagesSiteFolder, getWebsiteRecordId } from "../../../../../common/utilities/WorkspaceInfoFinderUtil"; -import { POWERPAGES_SITE_FOLDER } from "../../../../../common/constants"; import { showProgressWithNotification } from "../../../../../common/utilities/Utils"; -import { IFileComparisonResult } from "../../models/IFileComparisonResult"; -import { getAllFiles } from "../../ActionsHubUtils"; -import MetadataDiffContext from "../../MetadataDiffContext"; - -/** - * Compares files between downloaded site and local workspace - * @param downloadedSitePath Path to the downloaded site - * @param localSitePath Path to the local site - * @returns Array of file comparison results - */ -function compareFiles(downloadedSitePath: string, localSitePath: string): IFileComparisonResult[] { - const results: IFileComparisonResult[] = []; - - const downloadedFiles = getAllFiles(downloadedSitePath); - const localFiles = getAllFiles(localSitePath); - - // Check for modified and deleted files (files in remote but may differ locally or not exist) - for (const [relativePath, remotePath] of downloadedFiles) { - const localPath = localFiles.get(relativePath); - - if (localPath) { - // File exists in both - check if content differs - const remoteContent = fs.readFileSync(remotePath); - const localContent = fs.readFileSync(localPath); - - if (!remoteContent.equals(localContent)) { - results.push({ - localPath, - remotePath, - relativePath, - status: "modified" - }); - } - } else { - // File exists in remote but not locally - deleted locally - results.push({ - localPath: path.join(localSitePath, relativePath), - remotePath, - relativePath, - status: "deleted" - }); - } - } - - // Check for added files (files in local but not in remote) - for (const [relativePath, localPath] of localFiles) { - if (!downloadedFiles.has(relativePath)) { - results.push({ - localPath, - remotePath: path.join(downloadedSitePath, relativePath), - relativePath, - status: "added" - }); - } - } - - return results; -} +import PacContext from "../../../../pac/PacContext"; +import { resolveSiteFromWorkspace, prepareSiteStoragePath, processComparisonResults } from "./MetadataDiffUtils"; export const compareWithLocal = (pacTerminal: PacTerminal, context: vscode.ExtensionContext) => async (siteTreeItem: SiteTreeItem): Promise => { traceInfo(Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_LOCAL_CALLED, { @@ -85,26 +25,17 @@ export const compareWithLocal = (pacTerminal: PacTerminal, context: vscode.Exten traceInfo(Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_LOCAL_NO_WORKSPACE, { methodName: compareWithLocal.name }); - vscode.window.showErrorMessage(Constants.Strings.NO_WORKSPACE_FOLDER_OPEN); + await vscode.window.showErrorMessage(Constants.Strings.NO_WORKSPACE_FOLDER_OPEN); return; } - const workingDirectory = workspaceFolders[0].uri.fsPath; - let siteId = getWebsiteRecordId(workingDirectory); - - if (!siteId) { - const powerPagesSiteFolder = findPowerPagesSiteFolder(workingDirectory); + const siteResolution = resolveSiteFromWorkspace(workspaceFolders[0].uri.fsPath); - if (powerPagesSiteFolder) { - siteId = getWebsiteRecordId(path.join(powerPagesSiteFolder, POWERPAGES_SITE_FOLDER)); - } - } - - if (!siteId) { + if (!siteResolution) { traceInfo(Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_LOCAL_WEBSITE_ID_NOT_FOUND, { methodName: compareWithLocal.name }); - vscode.window.showErrorMessage(Constants.Strings.WEBSITE_ID_NOT_FOUND); + await vscode.window.showErrorMessage(Constants.Strings.WEBSITE_ID_NOT_FOUND); return; } @@ -114,18 +45,11 @@ export const compareWithLocal = (pacTerminal: PacTerminal, context: vscode.Exten return; } - const siteStoragePath = path.join(storagePath, "sites-for-comparison"); - - if (fs.existsSync(siteStoragePath)) { - fs.rmSync(siteStoragePath, { recursive: true, force: true }); - } - - fs.mkdirSync(siteStoragePath, { recursive: true }); - + const siteStoragePath = prepareSiteStoragePath(storagePath, siteTreeItem.siteInfo.websiteId); const pacWrapper = pacTerminal.getWrapper(); const success = await showProgressWithNotification( - Constants.Strings.DOWNLOADING_SITE_FOR_COMPARISON, + Constants.StringFunctions.DOWNLOADING_SITE_FOR_COMPARISON(siteTreeItem.siteInfo.name), async () => pacWrapper.downloadSiteWithProgress( siteStoragePath, siteTreeItem.siteInfo.websiteId, @@ -143,51 +67,22 @@ export const compareWithLocal = (pacTerminal: PacTerminal, context: vscode.Exten dataModelVersion: siteTreeItem.siteInfo.dataModelVersion } ); - vscode.window.showErrorMessage(Constants.Strings.COMPARE_WITH_LOCAL_SITE_DOWNLOAD_FAILED); + await vscode.window.showErrorMessage(Constants.Strings.COMPARE_WITH_LOCAL_SITE_DOWNLOAD_FAILED); return; } - // Determine the local site path - let localSitePath = workingDirectory; - const powerPagesSiteFolder = findPowerPagesSiteFolder(workingDirectory); - if (powerPagesSiteFolder) { - localSitePath = path.join(powerPagesSiteFolder, POWERPAGES_SITE_FOLDER); - } - - // Compare files between downloaded site and local workspace - await showProgressWithNotification( - Constants.Strings.COMPARING_FILES, - async () => { - // Find the actual downloaded site folder (name is not deterministic) - const downloadedFolders = fs.readdirSync(siteStoragePath, { withFileTypes: true }) - .filter(entry => entry.isDirectory()) - .map(entry => entry.name); - - const siteDownloadPath = path.join(siteStoragePath, downloadedFolders[0]); - const comparisonResults = compareFiles(siteDownloadPath, localSitePath); - - if (comparisonResults.length === 0) { - traceInfo(Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_LOCAL_NO_DIFFERENCES, { - methodName: compareWithLocal.name, - siteId: siteTreeItem.siteInfo.websiteId - }); - vscode.window.showInformationMessage(Constants.Strings.NO_DIFFERENCES_FOUND); - MetadataDiffContext.clear(); - } else { - traceInfo(Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_LOCAL_COMPLETED, { - methodName: compareWithLocal.name, - siteId: siteTreeItem.siteInfo.websiteId, - totalDifferences: comparisonResults.length.toString(), - modifiedFiles: comparisonResults.filter(r => r.status === "modified").length.toString(), - addedFiles: comparisonResults.filter(r => r.status === "added").length.toString(), - deletedFiles: comparisonResults.filter(r => r.status === "deleted").length.toString() - }); - - // Store results in the context so the tree view can display them - MetadataDiffContext.setResults(comparisonResults, siteTreeItem.siteInfo.name); - } - - return true; - } + const environmentName = PacContext.AuthInfo?.OrganizationFriendlyName || ""; + + await processComparisonResults( + siteStoragePath, + siteResolution.localSitePath, + siteTreeItem.siteInfo.name, + environmentName, + compareWithLocal.name, + siteTreeItem.siteInfo.websiteId, + Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_LOCAL_COMPLETED, + Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_LOCAL_NO_DIFFERENCES ); + + await vscode.window.showInformationMessage(Constants.Strings.COMPARE_WITH_LOCAL_COMPLETED); } diff --git a/src/client/power-pages/actions-hub/handlers/metadata-diff/DiscardFolderChangesHandler.ts b/src/client/power-pages/actions-hub/handlers/metadata-diff/DiscardFolderChangesHandler.ts new file mode 100644 index 00000000..eb74b0b3 --- /dev/null +++ b/src/client/power-pages/actions-hub/handlers/metadata-diff/DiscardFolderChangesHandler.ts @@ -0,0 +1,74 @@ +/* + * 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 { MetadataDiffFolderTreeItem } from "../../tree-items/metadata-diff/MetadataDiffFolderTreeItem"; +import { traceInfo } from "../../TelemetryHelper"; +import { Constants } from "../../Constants"; +import MetadataDiffContext from "../../MetadataDiffContext"; +import { discardSingleFile } from "./DiscardLocalChangesHandler"; + +/** + * Discards local changes for all files in a folder by reverting to the remote versions. + * - For modified files: Copies remote content to local file + * - For added files: Deletes the local file + * - For deleted files: Copies remote file to local path + */ +export async function discardFolderChanges(folderItem: MetadataDiffFolderTreeItem): Promise { + const fileItems = folderItem.getAllFileItems(); + const fileCount = fileItems.length; + + traceInfo(Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_DISCARD_FOLDER, { + methodName: discardFolderChanges.name, + folderPath: folderItem.folderPath, + siteName: folderItem.siteName, + fileCount: fileCount + }); + + const confirmMessage = Constants.StringFunctions.DISCARD_FOLDER_CHANGES_CONFIRM(folderItem.folderPath, fileCount); + + const confirmButton = Constants.Strings.DISCARD_CHANGES; + const result = await vscode.window.showWarningMessage(confirmMessage, { modal: true }, confirmButton); + + if (result !== confirmButton) { + return; + } + + try { + let successCount = 0; + const errors: string[] = []; + + for (const fileItem of fileItems) { + try { + discardSingleFile(fileItem.comparisonResult); + successCount++; + } catch (error) { + errors.push(`${fileItem.comparisonResult.relativePath}: ${error instanceof Error ? error.message : String(error)}`); + } + } + + // Remove all successfully discarded files from the comparison results + for (const fileItem of fileItems) { + // Only remove if not in errors list + const hasError = errors.some(e => e.startsWith(fileItem.comparisonResult.relativePath + ":")); + if (!hasError) { + MetadataDiffContext.removeFile(fileItem.comparisonResult.relativePath, fileItem.siteName); + } + } + + if (errors.length > 0) { + const errorMessage = Constants.StringFunctions.DISCARD_LOCAL_CHANGES_FAILED( + `${successCount}/${fileCount} files succeeded. Errors:\n${errors.join("\n")}` + ); + await vscode.window.showErrorMessage(errorMessage); + } else { + const successMessage = Constants.StringFunctions.DISCARD_FOLDER_CHANGES_SUCCESS(folderItem.folderPath, successCount); + await vscode.window.showInformationMessage(successMessage); + } + } catch (error) { + const errorMessage = Constants.StringFunctions.DISCARD_LOCAL_CHANGES_FAILED(error instanceof Error ? error.message : String(error)); + await vscode.window.showErrorMessage(errorMessage); + } +} diff --git a/src/client/power-pages/actions-hub/handlers/metadata-diff/DiscardLocalChangesHandler.ts b/src/client/power-pages/actions-hub/handlers/metadata-diff/DiscardLocalChangesHandler.ts new file mode 100644 index 00000000..0ecd9b7c --- /dev/null +++ b/src/client/power-pages/actions-hub/handlers/metadata-diff/DiscardLocalChangesHandler.ts @@ -0,0 +1,87 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import * as vscode from "vscode"; +import * as fs from "fs"; +import * as path from "path"; +import { MetadataDiffFileTreeItem } from "../../tree-items/metadata-diff/MetadataDiffFileTreeItem"; +import { traceInfo } from "../../TelemetryHelper"; +import { Constants } from "../../Constants"; +import { FileComparisonStatus, IFileComparisonResult } from "../../models/IFileComparisonResult"; +import MetadataDiffContext from "../../MetadataDiffContext"; + +/** + * Discards changes for a single file without showing any UI prompts. + * This is the core logic shared between single file and folder discard operations. + */ +export function discardSingleFile(comparisonResult: IFileComparisonResult): void { + switch (comparisonResult.status) { + case FileComparisonStatus.MODIFIED: + // Copy remote content to local file + if (fs.existsSync(comparisonResult.remotePath)) { + const remoteContent = fs.readFileSync(comparisonResult.remotePath); + fs.writeFileSync(comparisonResult.localPath, remoteContent); + } + break; + + case FileComparisonStatus.ADDED: + // Delete the local file (it doesn't exist in remote) + if (fs.existsSync(comparisonResult.localPath)) { + fs.unlinkSync(comparisonResult.localPath); + } + break; + + case FileComparisonStatus.DELETED: + // Copy remote file to local path + if (fs.existsSync(comparisonResult.remotePath)) { + // Ensure directory exists + const parentDir = path.dirname(comparisonResult.localPath); + if (!fs.existsSync(parentDir)) { + fs.mkdirSync(parentDir, { recursive: true }); + } + const remoteContent = fs.readFileSync(comparisonResult.remotePath); + fs.writeFileSync(comparisonResult.localPath, remoteContent); + } + break; + } +} + +/** + * Discards local changes for a single file by reverting to the remote version. + * - For modified files: Copies remote content to local file + * - For added files: Deletes the local file + * - For deleted files: Copies remote file to local path + */ +export async function discardLocalChanges(fileItem: MetadataDiffFileTreeItem): Promise { + const { comparisonResult, siteName } = fileItem; + + traceInfo(Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_DISCARD_FILE, { + methodName: discardLocalChanges.name, + relativePath: comparisonResult.relativePath, + status: comparisonResult.status + }); + + const confirmMessage = Constants.StringFunctions.DISCARD_LOCAL_CHANGES_CONFIRM(comparisonResult.localPath); + + const confirmButton = Constants.Strings.DISCARD_CHANGES; + const result = await vscode.window.showWarningMessage(confirmMessage, { modal: true }, confirmButton); + + if (result !== confirmButton) { + return; + } + + try { + discardSingleFile(comparisonResult); + + // Remove this file from the comparison results + MetadataDiffContext.removeFile(comparisonResult.relativePath, siteName); + + const successMessage = Constants.StringFunctions.DISCARD_LOCAL_CHANGES_SUCCESS(comparisonResult.relativePath); + await vscode.window.showInformationMessage(successMessage); + } catch (error) { + const errorMessage = Constants.StringFunctions.DISCARD_LOCAL_CHANGES_FAILED(error instanceof Error ? error.message : String(error)); + await vscode.window.showErrorMessage(errorMessage); + } +} 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 new file mode 100644 index 00000000..952f0ddd --- /dev/null +++ b/src/client/power-pages/actions-hub/handlers/metadata-diff/MetadataDiffUtils.ts @@ -0,0 +1,194 @@ +/* + * 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 { traceInfo } from "../../TelemetryHelper"; +import { findPowerPagesSiteFolder, findWebsiteYmlFolder, getWebsiteRecordId } from "../../../../../common/utilities/WorkspaceInfoFinderUtil"; +import { POWERPAGES_SITE_FOLDER } from "../../../../../common/constants"; +import { showProgressWithNotification } from "../../../../../common/utilities/Utils"; +import { FileComparisonStatus, IFileComparisonResult } from "../../models/IFileComparisonResult"; +import { getAllFiles } from "../../ActionsHubUtils"; +import MetadataDiffContext from "../../MetadataDiffContext"; + +/** + * Result of resolving site information from workspace + */ +export interface SiteResolutionResult { + siteId: string; + localSitePath: string; +} + +/** + * Compares files between downloaded site and local workspace + * @param downloadedSitePath Path to the downloaded site + * @param localSitePath Path to the local site + * @returns Array of file comparison results + */ +export function compareFiles(downloadedSitePath: string, localSitePath: string): IFileComparisonResult[] { + const results: IFileComparisonResult[] = []; + + const downloadedFiles = getAllFiles(downloadedSitePath); + const localFiles = getAllFiles(localSitePath); + + // Check for modified and deleted files (files in remote but may differ locally or not exist) + for (const [relativePath, remotePath] of downloadedFiles) { + const localPath = localFiles.get(relativePath); + + if (localPath) { + // File exists in both - check if content differs + const remoteContent = fs.readFileSync(remotePath); + const localContent = fs.readFileSync(localPath); + + if (!remoteContent.equals(localContent)) { + results.push({ + localPath, + remotePath, + relativePath, + status: FileComparisonStatus.MODIFIED + }); + } + } else { + // File exists in remote but not locally - deleted locally + results.push({ + localPath: path.join(localSitePath, relativePath), + remotePath, + relativePath, + status: FileComparisonStatus.DELETED + }); + } + } + + // Check for added files (files in local but not in remote) + for (const [relativePath, localPath] of localFiles) { + if (!downloadedFiles.has(relativePath)) { + results.push({ + localPath, + remotePath: path.join(downloadedSitePath, relativePath), + relativePath, + status: FileComparisonStatus.ADDED + }); + } + } + + return results; +} + +/** + * Resolves the site ID and local site path from the workspace + * @param workingDirectory The workspace root directory + * @param resource Optional resource URI (e.g., from context menu) + * @returns Site resolution result or undefined if site ID not found + */ +export function resolveSiteFromWorkspace(workingDirectory: string, resource?: vscode.Uri): SiteResolutionResult | undefined { + let siteId: string | undefined; + let localSitePath = workingDirectory; + + // 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) { + const websiteYmlFolder = findWebsiteYmlFolder(resource.fsPath); + if (websiteYmlFolder) { + siteId = getWebsiteRecordId(websiteYmlFolder); + localSitePath = websiteYmlFolder; + } + } + + // Strategy 3: Look for a 'site' folder in working directory + if (!siteId) { + const powerPagesSiteFolder = findPowerPagesSiteFolder(workingDirectory); + + if (powerPagesSiteFolder) { + const siteFolderPath = path.join(powerPagesSiteFolder, POWERPAGES_SITE_FOLDER); + siteId = getWebsiteRecordId(siteFolderPath); + if (siteId) { + localSitePath = siteFolderPath; + } + } + } + + if (!siteId) { + return undefined; + } + + return { siteId, localSitePath }; +} + +/** + * Prepares the storage path for downloading sites for comparison + * @param storagePath The extension storage path + * @returns The prepared site storage path + */ +export function prepareSiteStoragePath(storagePath: string, websiteId: string): string { + const siteStoragePath = path.join(storagePath, "sites-for-comparison", websiteId); + + if (!fs.existsSync(siteStoragePath)) { + fs.mkdirSync(siteStoragePath, { recursive: true }); + } + + return siteStoragePath; +} + +/** + * 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 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 + */ +export async function processComparisonResults( + siteStoragePath: string, + localSitePath: string, + siteName: string, + environmentName: string, + methodName: string, + siteId: string, + completedEventName: string, + noDifferencesEventName: string +): Promise { + await showProgressWithNotification( + Constants.Strings.COMPARING_FILES, + async () => { + // Find the actual downloaded site folder (name is not deterministic) + const downloadedFolders = fs.readdirSync(siteStoragePath, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .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() + }); + + // Store results in the context so the tree view can display them + MetadataDiffContext.setResults(comparisonResults, siteName, environmentName); + } + + 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 dc3e743f..de8f3513 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 @@ -4,15 +4,45 @@ */ import * as vscode from "vscode"; -import { MetadataDiffGroupTreeItem } from "../../tree-items/metadata-diff/MetadataDiffGroupTreeItem"; +import { MetadataDiffSiteTreeItem } from "../../tree-items/metadata-diff/MetadataDiffSiteTreeItem"; import { traceInfo } from "../../TelemetryHelper"; import { Constants } from "../../Constants"; +import { FileComparisonStatus, IFileComparisonResult } from "../../models/IFileComparisonResult"; /** - * Opens all file diffs in the multi-diff editor + * Common binary file extensions that cannot be diffed in the text diff viewer */ -export async function openAllMetadataDiffs(groupItem: MetadataDiffGroupTreeItem): Promise { - const { comparisonResults, siteName } = groupItem; +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); +} + +/** + * Opens all file diffs in the multi-diff editor for a specific site + */ +export async function openAllMetadataDiffs(siteItem: MetadataDiffSiteTreeItem): Promise { + const { comparisonResults, siteName } = siteItem; traceInfo(Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_OPEN_ALL, { methodName: openAllMetadataDiffs.name, @@ -23,21 +53,53 @@ export async function openAllMetadataDiffs(groupItem: MetadataDiffGroupTreeItem) return; } - // Create resource list for the changes editor - const resourceList: [vscode.Uri, vscode.Uri | undefined, vscode.Uri | undefined][] = comparisonResults.map(result => { - const labelUri = vscode.Uri.parse(`diff-label:${result.relativePath}`); - const originalUri = vscode.Uri.file(result.remotePath); - const modifiedUri = vscode.Uri.file(result.localPath); + // Separate text and binary files + const textFiles: IFileComparisonResult[] = []; + const binaryFiles: IFileComparisonResult[] = []; - if (result.status === "deleted") { - return [labelUri, originalUri, undefined]; - } else if (result.status === "added") { - return [labelUri, undefined, modifiedUri]; + for (const result of comparisonResults) { + if (isBinaryFile(result.relativePath)) { + binaryFiles.push(result); } else { - return [labelUri, originalUri, modifiedUri]; + textFiles.push(result); } - }); + } + + // Log binary file count for telemetry + if (binaryFiles.length > 0) { + traceInfo(Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_OPEN_ALL, { + methodName: openAllMetadataDiffs.name, + binaryFilesSkipped: binaryFiles.length.toString(), + textFilesIncluded: textFiles.length.toString() + }); + } + + // Show the multi-diff editor only for text files + if (textFiles.length > 0) { + // Create resource list for the changes editor + const resourceList: [vscode.Uri, vscode.Uri | undefined, vscode.Uri | undefined][] = textFiles.map(result => { + const labelUri = vscode.Uri.parse(`diff-label:${result.relativePath}`); + const originalUri = vscode.Uri.file(result.remotePath); + const modifiedUri = vscode.Uri.file(result.localPath); - const title = vscode.l10n.t("Compare: {0} (Remote ↔ Local)", siteName); - await vscode.commands.executeCommand("vscode.changes", title, resourceList); + if (result.status === FileComparisonStatus.DELETED) { + return [labelUri, originalUri, undefined]; + } else if (result.status === FileComparisonStatus.ADDED) { + return [labelUri, undefined, modifiedUri]; + } else { + return [labelUri, originalUri, modifiedUri]; + } + }); + + const title = Constants.StringFunctions.COMPARE_ALL_TITLE(siteName); + await vscode.commands.executeCommand("vscode.changes", title, resourceList); + } + + // Show info message about binary files if any were skipped + if (binaryFiles.length > 0) { + const message = textFiles.length === 0 + ? Constants.Strings.METADATA_DIFF_ONLY_BINARY_FILES + : Constants.StringFunctions.METADATA_DIFF_BINARY_FILES_SKIPPED(binaryFiles.length); + await vscode.window.showInformationMessage(message); + } } 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 69249212..3ceef113 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 @@ -8,6 +8,8 @@ import * as fs from "fs"; import { MetadataDiffFileTreeItem } from "../../tree-items/metadata-diff/MetadataDiffFileTreeItem"; import { traceInfo } from "../../TelemetryHelper"; import { Constants } from "../../Constants"; +import { FileComparisonStatus } from "../../models/IFileComparisonResult"; +import { isBinaryFile } from "./OpenAllMetadataDiffsHandler"; /** * Opens a single file diff in the VS Code diff editor @@ -21,10 +23,16 @@ export async function openMetadataDiffFile(fileItem: MetadataDiffFileTreeItem): status: comparisonResult.status }); - const title = vscode.l10n.t("{0}: {1} (Remote ↔ Local)", siteName, comparisonResult.relativePath); + 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)) { + await openBinaryFile(comparisonResult.localPath, comparisonResult.remotePath, comparisonResult.status); + return; + } // Handle different diff scenarios based on file status - if (comparisonResult.status === "deleted") { + if (comparisonResult.status === FileComparisonStatus.DELETED) { // File exists in remote but not locally - show remote on left, empty on right const remoteUri = vscode.Uri.file(comparisonResult.remotePath); @@ -32,7 +40,7 @@ export async function openMetadataDiffFile(fileItem: MetadataDiffFileTreeItem): // Show the remote file only (since local doesn't exist) await vscode.commands.executeCommand("vscode.diff", remoteUri, vscode.Uri.parse("untitled:"), title); } - } else if (comparisonResult.status === "added") { + } else if (comparisonResult.status === FileComparisonStatus.ADDED) { // File exists locally but not in remote - show empty on left, local on right const localUri = vscode.Uri.file(comparisonResult.localPath); @@ -49,3 +57,37 @@ export async function openMetadataDiffFile(fileItem: MetadataDiffFileTreeItem): } } } + +/** + * Opens binary files directly since they can't be diffed in text format. + * For modified files, opens both remote and local versions side by side for visual comparison. + * For added files, opens the local version. + * For deleted files, opens the remote version. + */ +async function openBinaryFile(localPath: string, remotePath: string, status: string): Promise { + if (status === FileComparisonStatus.DELETED) { + // For deleted files, the file only exists in remote + if (fs.existsSync(remotePath)) { + await vscode.commands.executeCommand("vscode.open", vscode.Uri.file(remotePath)); + } + } else if (status === FileComparisonStatus.ADDED) { + // For added files, open the local version + if (fs.existsSync(localPath)) { + await vscode.commands.executeCommand("vscode.open", vscode.Uri.file(localPath)); + } + } else { + // For modified files, open both remote and local side by side for visual comparison + if (fs.existsSync(remotePath) && fs.existsSync(localPath)) { + // Open remote file in the first editor group (left side) + await vscode.commands.executeCommand("vscode.open", vscode.Uri.file(remotePath), vscode.ViewColumn.One); + // Open local file in the second editor group (right side) + await vscode.commands.executeCommand("vscode.open", vscode.Uri.file(localPath), vscode.ViewColumn.Two); + } else if (fs.existsSync(localPath)) { + // Fallback: if only local exists, just open it + await vscode.commands.executeCommand("vscode.open", vscode.Uri.file(localPath)); + } else if (fs.existsSync(remotePath)) { + // Fallback: if only remote exists, just open it + await vscode.commands.executeCommand("vscode.open", vscode.Uri.file(remotePath)); + } + } +} diff --git a/src/client/power-pages/actions-hub/handlers/metadata-diff/RemoveSiteHandler.ts b/src/client/power-pages/actions-hub/handlers/metadata-diff/RemoveSiteHandler.ts new file mode 100644 index 00000000..b60e7b41 --- /dev/null +++ b/src/client/power-pages/actions-hub/handlers/metadata-diff/RemoveSiteHandler.ts @@ -0,0 +1,21 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { MetadataDiffSiteTreeItem } from "../../tree-items/metadata-diff/MetadataDiffSiteTreeItem"; +import { traceInfo } from "../../TelemetryHelper"; +import { Constants } from "../../Constants"; +import MetadataDiffContext from "../../MetadataDiffContext"; + +/** + * Removes a specific site's comparison results from the metadata diff view + */ +export function removeSiteComparison(siteItem: MetadataDiffSiteTreeItem): void { + traceInfo(Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_CLEAR, { + methodName: removeSiteComparison.name, + siteName: siteItem.siteName + }); + + MetadataDiffContext.clearSite(siteItem.siteName); +} diff --git a/src/client/power-pages/actions-hub/handlers/metadata-diff/SortModeHandler.ts b/src/client/power-pages/actions-hub/handlers/metadata-diff/SortModeHandler.ts new file mode 100644 index 00000000..4cd2b13a --- /dev/null +++ b/src/client/power-pages/actions-hub/handlers/metadata-diff/SortModeHandler.ts @@ -0,0 +1,44 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import MetadataDiffContext, { MetadataDiffSortMode } from "../../MetadataDiffContext"; +import { traceInfo } from "../../TelemetryHelper"; +import { Constants } from "../../Constants"; + +/** + * Handler for sorting by file name + */ +export const sortByName = (): void => { + traceInfo(Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_SORT_MODE_CHANGED, { + methodName: sortByName.name, + sortMode: MetadataDiffSortMode.Name + }); + + MetadataDiffContext.setSortMode(MetadataDiffSortMode.Name); +}; + +/** + * Handler for sorting by file path + */ +export const sortByPath = (): void => { + traceInfo(Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_SORT_MODE_CHANGED, { + methodName: sortByPath.name, + sortMode: MetadataDiffSortMode.Path + }); + + MetadataDiffContext.setSortMode(MetadataDiffSortMode.Path); +}; + +/** + * Handler for sorting by status + */ +export const sortByStatus = (): void => { + traceInfo(Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_SORT_MODE_CHANGED, { + methodName: sortByStatus.name, + sortMode: MetadataDiffSortMode.Status + }); + + MetadataDiffContext.setSortMode(MetadataDiffSortMode.Status); +}; diff --git a/src/client/power-pages/actions-hub/handlers/metadata-diff/ToggleViewModeHandler.ts b/src/client/power-pages/actions-hub/handlers/metadata-diff/ToggleViewModeHandler.ts new file mode 100644 index 00000000..ff0c5da8 --- /dev/null +++ b/src/client/power-pages/actions-hub/handlers/metadata-diff/ToggleViewModeHandler.ts @@ -0,0 +1,32 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import MetadataDiffContext, { MetadataDiffViewMode } from "../../MetadataDiffContext"; +import { traceInfo } from "../../TelemetryHelper"; +import { Constants } from "../../Constants"; + +/** + * Handler for switching to tree view mode + */ +export const viewAsTree = (): void => { + traceInfo(Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_VIEW_MODE_CHANGED, { + methodName: viewAsTree.name, + viewMode: MetadataDiffViewMode.Tree + }); + + MetadataDiffContext.setViewMode(MetadataDiffViewMode.Tree); +}; + +/** + * Handler for switching to list view mode + */ +export const viewAsList = (): void => { + traceInfo(Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_VIEW_MODE_CHANGED, { + methodName: viewAsList.name, + viewMode: MetadataDiffViewMode.List + }); + + MetadataDiffContext.setViewMode(MetadataDiffViewMode.List); +}; diff --git a/src/client/power-pages/actions-hub/models/IFileComparisonResult.ts b/src/client/power-pages/actions-hub/models/IFileComparisonResult.ts index b9c04115..a85edce0 100644 --- a/src/client/power-pages/actions-hub/models/IFileComparisonResult.ts +++ b/src/client/power-pages/actions-hub/models/IFileComparisonResult.ts @@ -3,9 +3,20 @@ * Licensed under the MIT License. See License.txt in the project root for license information. */ +/** + * Constants for file comparison status values + */ +export const FileComparisonStatus = { + MODIFIED: "modified", + ADDED: "added", + DELETED: "deleted" +} as const; + +export type FileComparisonStatusType = typeof FileComparisonStatus[keyof typeof FileComparisonStatus]; + export interface IFileComparisonResult { localPath: string; remotePath: string; relativePath: string; - status: "modified" | "added" | "deleted"; + status: FileComparisonStatusType; } diff --git a/src/client/power-pages/actions-hub/tree-items/ToolsGroupTreeItem.ts b/src/client/power-pages/actions-hub/tree-items/ToolsGroupTreeItem.ts new file mode 100644 index 00000000..0644fdc3 --- /dev/null +++ b/src/client/power-pages/actions-hub/tree-items/ToolsGroupTreeItem.ts @@ -0,0 +1,32 @@ +/* + * 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 { Constants } from "../Constants"; +import { ActionsHubTreeItem } from "./ActionsHubTreeItem"; +import { ActionsHub } from "../ActionsHub"; +import { MetadataDiffGroupTreeItem } from "./metadata-diff/MetadataDiffGroupTreeItem"; + +export class ToolsGroupTreeItem extends ActionsHubTreeItem { + constructor() { + super( + Constants.Strings.TOOLS, + vscode.TreeItemCollapsibleState.Expanded, + Constants.Icons.TOOLS, + Constants.ContextValues.TOOLS_GROUP + ); + } + + public getChildren(): ActionsHubTreeItem[] { + const children: ActionsHubTreeItem[] = []; + + // Add metadata diff group if the feature is enabled + if (ActionsHub.isMetadataDiffEnabled()) { + children.push(new MetadataDiffGroupTreeItem()); + } + + return children; + } +} 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 8d1c4be3..c97fd80a 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 @@ -6,58 +6,90 @@ import * as vscode from "vscode"; import { ActionsHubTreeItem } from "../ActionsHubTreeItem"; import { Constants } from "../../Constants"; -import { IFileComparisonResult } from "../../models/IFileComparisonResult"; +import { FileComparisonStatus, IFileComparisonResult } from "../../models/IFileComparisonResult"; +import MetadataDiffContext from "../../MetadataDiffContext"; /** - * Tree item representing a single file in the metadata diff comparison + * Custom URI scheme for metadata diff files to enable file decorations + */ +export const METADATA_DIFF_URI_SCHEME = "pp-metadata-diff"; + +/** + * Tree item representing a single file in the metadata diff comparison. + * Uses resourceUri for file-type icons and FileDecorationProvider for status badges. */ export class MetadataDiffFileTreeItem extends ActionsHubTreeItem { public readonly comparisonResult: IFileComparisonResult; private readonly _siteName: string; constructor( - fileName: string, comparisonResult: IFileComparisonResult, siteName: string ) { + // Use file name as the label + const fileName = comparisonResult.relativePath.split(/[/\\]/).pop() || comparisonResult.relativePath; + + // Build description based on view mode + const description = MetadataDiffFileTreeItem.buildDescription(comparisonResult); + super( fileName, vscode.TreeItemCollapsibleState.None, - MetadataDiffFileTreeItem.getIcon(comparisonResult.status), + // Pass empty ThemeIcon - it will be overridden by resourceUri + new vscode.ThemeIcon("file"), Constants.ContextValues.METADATA_DIFF_FILE, - MetadataDiffFileTreeItem.getDescription(comparisonResult.status) + description ); this.comparisonResult = comparisonResult; this._siteName = siteName; + // Use resourceUri for file-type icon detection and decoration + // Encode the status in the URI so FileDecorationProvider can read it + this.resourceUri = vscode.Uri.from({ + scheme: METADATA_DIFF_URI_SCHEME, + path: comparisonResult.relativePath, + query: `status=${comparisonResult.status}` + }); + + // Set tooltip with full local path and status + this.tooltip = new vscode.MarkdownString(`${comparisonResult.localPath} • ${MetadataDiffFileTreeItem.getStatusDescription(comparisonResult.status)}`); + // Set command to open diff when clicked this.command = { command: Constants.Commands.METADATA_DIFF_OPEN_FILE, - title: vscode.l10n.t("Show Diff"), + title: Constants.Strings.SHOW_DIFF, arguments: [this] }; } - private static getIcon(status: IFileComparisonResult["status"]): vscode.ThemeIcon { - switch (status) { - case "modified": - return Constants.Icons.METADATA_DIFF_MODIFIED; - case "added": - return Constants.Icons.METADATA_DIFF_ADDED; - case "deleted": - return Constants.Icons.METADATA_DIFF_DELETED; - default: - return new vscode.ThemeIcon("file"); + /** + * Build the description string based on view mode. + * In list view: shows folder path + * In tree view: empty (folder structure provides context) + */ + private static buildDescription(comparisonResult: IFileComparisonResult): string { + if (MetadataDiffContext.isTreeView) { + return ""; + } + + const pathParts = comparisonResult.relativePath.split(/[/\\]/); + if (pathParts.length > 1) { + // Show parent folder path in list view + return pathParts.slice(0, -1).join("/"); } + return ""; } - private static getDescription(status: IFileComparisonResult["status"]): string { + /** + * Get full status description for tooltip + */ + private static getStatusDescription(status: IFileComparisonResult["status"]): string { switch (status) { - case "modified": + case FileComparisonStatus.MODIFIED: return Constants.Strings.METADATA_DIFF_MODIFIED; - case "added": + case FileComparisonStatus.ADDED: return Constants.Strings.METADATA_DIFF_ADDED; - case "deleted": + case FileComparisonStatus.DELETED: return Constants.Strings.METADATA_DIFF_DELETED; default: return ""; 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 8c23de98..3661d1d6 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 @@ -13,8 +13,10 @@ import { MetadataDiffFileTreeItem } from "./MetadataDiffFileTreeItem"; */ export class MetadataDiffFolderTreeItem extends ActionsHubTreeItem { public readonly childrenMap: Map; + private readonly _siteName: string; + private readonly _folderPath: string; - constructor(folderName: string) { + constructor(folderName: string, siteName: string, folderPath: string) { super( folderName, vscode.TreeItemCollapsibleState.Expanded, @@ -22,9 +24,34 @@ export class MetadataDiffFolderTreeItem extends ActionsHubTreeItem { Constants.ContextValues.METADATA_DIFF_FOLDER ); this.childrenMap = new Map(); + this._siteName = siteName; + this._folderPath = folderPath; } public getChildren(): ActionsHubTreeItem[] { return Array.from(this.childrenMap.values()); } + + public get siteName(): string { + return this._siteName; + } + + public get folderPath(): string { + return this._folderPath; + } + + /** + * Recursively collects all file tree items under this folder + */ + public getAllFileItems(): MetadataDiffFileTreeItem[] { + const files: MetadataDiffFileTreeItem[] = []; + for (const child of this.childrenMap.values()) { + if (child instanceof MetadataDiffFileTreeItem) { + files.push(child); + } else if (child instanceof MetadataDiffFolderTreeItem) { + files.push(...child.getAllFileItems()); + } + } + return files; + } } 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 ae4d41c8..387d8502 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 @@ -6,90 +6,40 @@ import * as vscode from "vscode"; import { ActionsHubTreeItem } from "../ActionsHubTreeItem"; import { Constants } from "../../Constants"; -import { IFileComparisonResult } from "../../models/IFileComparisonResult"; -import { MetadataDiffFileTreeItem } from "./MetadataDiffFileTreeItem"; -import { MetadataDiffFolderTreeItem } from "./MetadataDiffFolderTreeItem"; +import MetadataDiffContext from "../../MetadataDiffContext"; +import { MetadataDiffSiteTreeItem } from "./MetadataDiffSiteTreeItem"; /** - * Root group tree item for showing metadata diff comparison results + * Root group tree item for showing metadata diff comparison results. + * This is always visible under the Tools node and shows site children when comparisons exist. */ export class MetadataDiffGroupTreeItem extends ActionsHubTreeItem { - private readonly _comparisonResults: IFileComparisonResult[]; - private readonly _siteName: string; + constructor() { + const hasResults = MetadataDiffContext.isActive; - constructor(comparisonResults: IFileComparisonResult[], siteName: string) { super( - vscode.l10n.t("{0} ({1} change(s))", Constants.Strings.METADATA_DIFF_GROUP, comparisonResults.length), - vscode.TreeItemCollapsibleState.Expanded, + Constants.Strings.METADATA_DIFF_GROUP, + hasResults ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.None, Constants.Icons.METADATA_DIFF_GROUP, - Constants.ContextValues.METADATA_DIFF_GROUP, - siteName // Show the website name as subtext + hasResults ? Constants.ContextValues.METADATA_DIFF_GROUP_WITH_RESULTS : Constants.ContextValues.METADATA_DIFF_GROUP, + undefined ); - this._comparisonResults = comparisonResults; - this._siteName = siteName; - } - public getChildren(): ActionsHubTreeItem[] { - return this.buildTreeHierarchy(); + // Set unique id that changes based on state to ensure VS Code respects our collapsibleState + // Without this, VS Code may cache the collapsed state from when there were no results + this.id = hasResults ? "metadataDiffGroup-withResults" : "metadataDiffGroup-noResults"; } - /** - * Build a hierarchical tree structure from flat file comparison results - */ - private buildTreeHierarchy(): ActionsHubTreeItem[] { - // Create a map to hold folder structures at the root level - const rootChildren = new Map(); - - for (const result of this._comparisonResults) { - const parts = result.relativePath.split(/[/\\]/); - let currentFolder: MetadataDiffFolderTreeItem | undefined; - - // Process all parts except the last one (which is the file name) - for (let i = 0; i < parts.length - 1; i++) { - const folderName = parts[i]; - - if (i === 0) { - // Look in root children - let folder = rootChildren.get(folderName) as MetadataDiffFolderTreeItem | undefined; - if (!folder) { - folder = new MetadataDiffFolderTreeItem(folderName); - rootChildren.set(folderName, folder); - } - currentFolder = folder; - } else if (currentFolder) { - // Look in current folder's children - let folder = currentFolder.childrenMap.get(folderName) as MetadataDiffFolderTreeItem | undefined; - if (!folder) { - folder = new MetadataDiffFolderTreeItem(folderName); - currentFolder.childrenMap.set(folderName, folder); - } - currentFolder = folder; - } - } - - // Add the file to the appropriate folder (or root if no folders) - const fileName = parts[parts.length - 1]; - const fileItem = new MetadataDiffFileTreeItem( - fileName, - result, - this._siteName - ); + public getChildren(): ActionsHubTreeItem[] { + const siteResults = MetadataDiffContext.allSiteResults; - if (currentFolder) { - currentFolder.childrenMap.set(fileName, fileItem); - } else { - rootChildren.set(fileName, fileItem); - } + if (siteResults.length === 0) { + return []; } - return Array.from(rootChildren.values()); - } - - public get siteName(): string { - return this._siteName; - } - - public get comparisonResults(): IFileComparisonResult[] { - return this._comparisonResults; + // Create a site tree item for each site's comparison results + return siteResults.map(siteResult => + new MetadataDiffSiteTreeItem(siteResult.comparisonResults, siteResult.siteName, siteResult.environmentName) + ); } } 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 new file mode 100644 index 00000000..0f57b996 --- /dev/null +++ b/src/client/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffSiteTreeItem.ts @@ -0,0 +1,163 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import * as vscode from "vscode"; +import { ActionsHubTreeItem } from "../ActionsHubTreeItem"; +import { Constants } from "../../Constants"; +import { IFileComparisonResult, FileComparisonStatus } from "../../models/IFileComparisonResult"; +import { MetadataDiffFileTreeItem } from "./MetadataDiffFileTreeItem"; +import { MetadataDiffFolderTreeItem } from "./MetadataDiffFolderTreeItem"; +import MetadataDiffContext, { MetadataDiffSortMode } from "../../MetadataDiffContext"; + +/** + * Tree item representing a single website's metadata diff results. + * This is a child of MetadataDiffGroupTreeItem and contains the actual diff files. + */ +export class MetadataDiffSiteTreeItem extends ActionsHubTreeItem { + private readonly _comparisonResults: IFileComparisonResult[]; + private readonly _siteName: string; + private readonly _environmentName: string; + + 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 + ); + this._comparisonResults = comparisonResults; + this._siteName = siteName; + this._environmentName = environmentName; + } + + public getChildren(): ActionsHubTreeItem[] { + if (MetadataDiffContext.isTreeView) { + return this.buildTreeHierarchy(); + } + return this.buildFlatFileList(); + } + + /** + * Build a flat list of file tree items (Git-style list view). + * Files are sorted based on the current sort mode. + */ + private buildFlatFileList(): ActionsHubTreeItem[] { + // Sort results based on current sort mode + const sortedResults = this.sortResults([...this._comparisonResults]); + + // Create flat list of file items + return sortedResults.map(result => + new MetadataDiffFileTreeItem(result, this._siteName) + ); + } + + /** + * Sort comparison results based on the current sort mode + */ + private sortResults(results: IFileComparisonResult[]): IFileComparisonResult[] { + const sortMode = MetadataDiffContext.sortMode; + + switch (sortMode) { + case MetadataDiffSortMode.Name: + // Sort by file name only + return results.sort((a, b) => { + const nameA = a.relativePath.split(/[/\\]/).pop() || a.relativePath; + const nameB = b.relativePath.split(/[/\\]/).pop() || b.relativePath; + return nameA.localeCompare(nameB); + }); + + case MetadataDiffSortMode.Status: + // Sort by status (Added, Deleted, Modified), then by path + return results.sort((a, b) => { + const statusOrder = { + [FileComparisonStatus.ADDED]: 1, + [FileComparisonStatus.DELETED]: 2, + [FileComparisonStatus.MODIFIED]: 3 + }; + const statusCompare = statusOrder[a.status] - statusOrder[b.status]; + if (statusCompare !== 0) { + return statusCompare; + } + return a.relativePath.localeCompare(b.relativePath); + }); + + case MetadataDiffSortMode.Path: + default: + // Sort by full relative path (default) + return results.sort((a, b) => + a.relativePath.localeCompare(b.relativePath) + ); + } + } + + /** + * Build a hierarchical tree structure from flat file comparison results + */ + private buildTreeHierarchy(): ActionsHubTreeItem[] { + // Create a map to hold folder structures at the root level + const rootChildren = new Map(); + + for (const result of this._comparisonResults) { + const parts = result.relativePath.split(/[/\\]/); + let currentFolder: MetadataDiffFolderTreeItem | undefined; + let currentPath = ""; + + // Process all parts except the last one (which is the file name) + for (let i = 0; i < parts.length - 1; i++) { + const folderName = parts[i]; + currentPath = currentPath ? `${currentPath}/${folderName}` : folderName; + + if (i === 0) { + // Look in root children + let folder = rootChildren.get(folderName) as MetadataDiffFolderTreeItem | undefined; + if (!folder) { + folder = new MetadataDiffFolderTreeItem(folderName, this._siteName, currentPath); + rootChildren.set(folderName, folder); + } + currentFolder = folder; + } else if (currentFolder) { + // Look in current folder's children + let folder = currentFolder.childrenMap.get(folderName) as MetadataDiffFolderTreeItem | undefined; + if (!folder) { + folder = new MetadataDiffFolderTreeItem(folderName, this._siteName, currentPath); + currentFolder.childrenMap.set(folderName, folder); + } + currentFolder = folder; + } + } + + // Add the file to the appropriate folder (or root if no folders) + const fileItem = new MetadataDiffFileTreeItem( + result, + this._siteName + ); + + if (currentFolder) { + currentFolder.childrenMap.set(result.relativePath, fileItem); + } else { + rootChildren.set(result.relativePath, fileItem); + } + } + + return Array.from(rootChildren.values()); + } + + public get siteName(): string { + return this._siteName; + } + + public get environmentName(): string { + return this._environmentName; + } + + public get comparisonResults(): IFileComparisonResult[] { + return this._comparisonResults; + } +} diff --git a/src/client/test/Integration/PacWrapper.test.ts b/src/client/test/Integration/PacWrapper.test.ts index 7a111c1d..47c3e0c4 100644 --- a/src/client/test/Integration/PacWrapper.test.ts +++ b/src/client/test/Integration/PacWrapper.test.ts @@ -31,6 +31,10 @@ class MockPacInterop implements IPacInterop { // no-op } + public showOutputChannel(): void { + // no-op + } + } describe('PacWrapper', () => { 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 dc2e8d3e..e6dc6eb0 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 @@ -11,6 +11,7 @@ import { ActionsHubTreeDataProvider } from "../../../../power-pages/actions-hub/ import { oneDSLoggerWrapper } from "../../../../../common/OneDSLoggerTelemetry/oneDSLoggerWrapper"; import { EnvironmentGroupTreeItem } from "../../../../power-pages/actions-hub/tree-items/EnvironmentGroupTreeItem"; import { OtherSitesGroupTreeItem } from "../../../../power-pages/actions-hub/tree-items/OtherSitesGroupTreeItem"; +import { ToolsGroupTreeItem } from "../../../../power-pages/actions-hub/tree-items/ToolsGroupTreeItem"; import { ActionsHubTreeItem } from "../../../../power-pages/actions-hub/tree-items/ActionsHubTreeItem"; import { PacTerminal } from "../../../../lib/PacTerminal"; import { PacWrapper } from "../../../../pac/PacWrapper"; @@ -38,6 +39,17 @@ import * as ShowSiteDetailsHandler from "../../../../power-pages/actions-hub/han import * as DownloadSiteHandler from "../../../../power-pages/actions-hub/handlers/DownloadSiteHandler"; import * as LoginToMatchHandler from "../../../../power-pages/actions-hub/handlers/LoginToMatchHandler"; import * as RunCodeQLScreeningHandler from "../../../../power-pages/actions-hub/handlers/code-ql/RunCodeQlScreeningHandler"; +import * as SortModeHandler from "../../../../power-pages/actions-hub/handlers/metadata-diff/SortModeHandler"; +import * as ToggleViewModeHandler from "../../../../power-pages/actions-hub/handlers/metadata-diff/ToggleViewModeHandler"; +import * as CompareWithLocalHandler from "../../../../power-pages/actions-hub/handlers/metadata-diff/CompareWithLocalHandler"; +import * as CompareWithEnvironmentHandler from "../../../../power-pages/actions-hub/handlers/metadata-diff/CompareWithEnvironmentHandler"; +import * as OpenMetadataDiffFileHandler from "../../../../power-pages/actions-hub/handlers/metadata-diff/OpenMetadataDiffFileHandler"; +import * as OpenAllMetadataDiffsHandler from "../../../../power-pages/actions-hub/handlers/metadata-diff/OpenAllMetadataDiffsHandler"; +import * as ClearMetadataDiffHandler from "../../../../power-pages/actions-hub/handlers/metadata-diff/ClearMetadataDiffHandler"; +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 { ActionsHub } from "../../../../power-pages/actions-hub/ActionsHub"; // Add global type declaration for ArtemisContext describe("ActionsHubTreeDataProvider", () => { @@ -70,8 +82,12 @@ describe("ActionsHubTreeDataProvider", () => { beforeEach(() => { registerCommandStub = sinon.stub(vscode.commands, "registerCommand"); context = { - extensionUri: vscode.Uri.parse("https://localhost:3000") - } as vscode.ExtensionContext; + extensionUri: vscode.Uri.parse("https://localhost:3000"), + globalState: { + get: sinon.stub().returns(undefined), + update: sinon.stub().resolves() + } + } as unknown as vscode.ExtensionContext; traceInfoStub = sinon.stub(); traceErrorStub = sinon.stub(); sinon.stub(oneDSLoggerWrapper, "getLogger").returns({ @@ -294,6 +310,294 @@ describe("ActionsHubTreeDataProvider", () => { throw new Error("CodeQL command was not registered"); } }); + + it('should register sortByName command', async () => { + sinon.stub(ActionsHub, 'isMetadataDiffEnabled').returns(true); + const mockCommandHandler = sinon.stub(SortModeHandler, 'sortByName'); + const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false); + actionsHubTreeDataProvider["registerPanel"](); + + expect(registerCommandStub.calledWith(Constants.Commands.METADATA_DIFF_SORT_BY_NAME)).to.be.true; + + const sortByNameCall = registerCommandStub.getCalls().find(call => + call.args[0] === Constants.Commands.METADATA_DIFF_SORT_BY_NAME + ); + + if (sortByNameCall) { + sortByNameCall.args[1](); + expect(mockCommandHandler.calledOnce).to.be.true; + } else { + throw new Error("sortByName command was not registered"); + } + }); + + it('should register sortByPath command', async () => { + sinon.stub(ActionsHub, 'isMetadataDiffEnabled').returns(true); + const mockCommandHandler = sinon.stub(SortModeHandler, 'sortByPath'); + const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false); + actionsHubTreeDataProvider["registerPanel"](); + + expect(registerCommandStub.calledWith(Constants.Commands.METADATA_DIFF_SORT_BY_PATH)).to.be.true; + + const sortByPathCall = registerCommandStub.getCalls().find(call => + call.args[0] === Constants.Commands.METADATA_DIFF_SORT_BY_PATH + ); + + if (sortByPathCall) { + sortByPathCall.args[1](); + expect(mockCommandHandler.calledOnce).to.be.true; + } else { + throw new Error("sortByPath command was not registered"); + } + }); + + it('should register sortByStatus command', async () => { + sinon.stub(ActionsHub, 'isMetadataDiffEnabled').returns(true); + const mockCommandHandler = sinon.stub(SortModeHandler, 'sortByStatus'); + const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false); + actionsHubTreeDataProvider["registerPanel"](); + + expect(registerCommandStub.calledWith(Constants.Commands.METADATA_DIFF_SORT_BY_STATUS)).to.be.true; + + const sortByStatusCall = registerCommandStub.getCalls().find(call => + call.args[0] === Constants.Commands.METADATA_DIFF_SORT_BY_STATUS + ); + + if (sortByStatusCall) { + sortByStatusCall.args[1](); + expect(mockCommandHandler.calledOnce).to.be.true; + } else { + throw new Error("sortByStatus command was not registered"); + } + }); + + it('should register viewAsTree command', async () => { + sinon.stub(ActionsHub, 'isMetadataDiffEnabled').returns(true); + const mockCommandHandler = sinon.stub(ToggleViewModeHandler, 'viewAsTree'); + const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false); + actionsHubTreeDataProvider["registerPanel"](); + + expect(registerCommandStub.calledWith(Constants.Commands.METADATA_DIFF_VIEW_AS_TREE)).to.be.true; + + const viewAsTreeCall = registerCommandStub.getCalls().find(call => + call.args[0] === Constants.Commands.METADATA_DIFF_VIEW_AS_TREE + ); + + if (viewAsTreeCall) { + viewAsTreeCall.args[1](); + expect(mockCommandHandler.calledOnce).to.be.true; + } else { + throw new Error("viewAsTree command was not registered"); + } + }); + + it('should register viewAsList command', async () => { + sinon.stub(ActionsHub, 'isMetadataDiffEnabled').returns(true); + const mockCommandHandler = sinon.stub(ToggleViewModeHandler, 'viewAsList'); + const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false); + actionsHubTreeDataProvider["registerPanel"](); + + expect(registerCommandStub.calledWith(Constants.Commands.METADATA_DIFF_VIEW_AS_LIST)).to.be.true; + + const viewAsListCall = registerCommandStub.getCalls().find(call => + call.args[0] === Constants.Commands.METADATA_DIFF_VIEW_AS_LIST + ); + + if (viewAsListCall) { + viewAsListCall.args[1](); + expect(mockCommandHandler.calledOnce).to.be.true; + } else { + throw new Error("viewAsList command was not registered"); + } + }); + + it('should register compareWithLocal command', async () => { + sinon.stub(ActionsHub, 'isMetadataDiffEnabled').returns(true); + const innerHandler = sinon.stub(); + sinon.stub(CompareWithLocalHandler, 'compareWithLocal').returns(innerHandler); + const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false); + actionsHubTreeDataProvider["registerPanel"](); + + expect(registerCommandStub.calledWith("microsoft.powerplatform.pages.actionsHub.activeSite.compareWithLocal")).to.be.true; + + const compareWithLocalCall = registerCommandStub.getCalls().find(call => + call.args[0] === "microsoft.powerplatform.pages.actionsHub.activeSite.compareWithLocal" + ); + + if (compareWithLocalCall) { + expect(compareWithLocalCall.args[1]).to.equal(innerHandler); + } else { + throw new Error("compareWithLocal command was not registered"); + } + }); + + it('should register compareWithEnvironment command', async () => { + sinon.stub(ActionsHub, 'isMetadataDiffEnabled').returns(true); + const innerHandler = sinon.stub(); + sinon.stub(CompareWithEnvironmentHandler, 'compareWithEnvironment').returns(innerHandler); + const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false); + actionsHubTreeDataProvider["registerPanel"](); + + expect(registerCommandStub.calledWith(Constants.Commands.COMPARE_WITH_ENVIRONMENT)).to.be.true; + + const compareWithEnvironmentCall = registerCommandStub.getCalls().find(call => + call.args[0] === Constants.Commands.COMPARE_WITH_ENVIRONMENT + ); + + if (compareWithEnvironmentCall) { + expect(compareWithEnvironmentCall.args[1]).to.equal(innerHandler); + } else { + throw new Error("compareWithEnvironment command was not registered"); + } + }); + + it('should register openMetadataDiffFile command', async () => { + sinon.stub(ActionsHub, 'isMetadataDiffEnabled').returns(true); + const mockCommandHandler = sinon.stub(OpenMetadataDiffFileHandler, 'openMetadataDiffFile'); + mockCommandHandler.resolves(); + const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false); + actionsHubTreeDataProvider["registerPanel"](); + + expect(registerCommandStub.calledWith(Constants.Commands.METADATA_DIFF_OPEN_FILE)).to.be.true; + + const openFileCall = registerCommandStub.getCalls().find(call => + call.args[0] === Constants.Commands.METADATA_DIFF_OPEN_FILE + ); + + if (openFileCall) { + await openFileCall.args[1](); + expect(mockCommandHandler.calledOnce).to.be.true; + } else { + throw new Error("openMetadataDiffFile command was not registered"); + } + }); + + it('should register openAllMetadataDiffs command', async () => { + sinon.stub(ActionsHub, 'isMetadataDiffEnabled').returns(true); + const mockCommandHandler = sinon.stub(OpenAllMetadataDiffsHandler, 'openAllMetadataDiffs'); + mockCommandHandler.resolves(); + const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false); + actionsHubTreeDataProvider["registerPanel"](); + + expect(registerCommandStub.calledWith(Constants.Commands.METADATA_DIFF_OPEN_ALL)).to.be.true; + + const openAllCall = registerCommandStub.getCalls().find(call => + call.args[0] === Constants.Commands.METADATA_DIFF_OPEN_ALL + ); + + if (openAllCall) { + await openAllCall.args[1](); + expect(mockCommandHandler.calledOnce).to.be.true; + } else { + throw new Error("openAllMetadataDiffs command was not registered"); + } + }); + + it('should register clearMetadataDiff command', async () => { + sinon.stub(ActionsHub, 'isMetadataDiffEnabled').returns(true); + const mockCommandHandler = sinon.stub(ClearMetadataDiffHandler, 'clearMetadataDiff'); + const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false); + actionsHubTreeDataProvider["registerPanel"](); + + expect(registerCommandStub.calledWith(Constants.Commands.METADATA_DIFF_CLEAR)).to.be.true; + + const clearCall = registerCommandStub.getCalls().find(call => + call.args[0] === Constants.Commands.METADATA_DIFF_CLEAR + ); + + if (clearCall) { + clearCall.args[1](); + expect(mockCommandHandler.calledOnce).to.be.true; + } else { + throw new Error("clearMetadataDiff command was not registered"); + } + }); + + it('should register removeSiteComparison command', async () => { + sinon.stub(ActionsHub, 'isMetadataDiffEnabled').returns(true); + const mockCommandHandler = sinon.stub(RemoveSiteHandler, 'removeSiteComparison'); + const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false); + actionsHubTreeDataProvider["registerPanel"](); + + expect(registerCommandStub.calledWith(Constants.Commands.METADATA_DIFF_REMOVE_SITE)).to.be.true; + + const removeSiteCall = registerCommandStub.getCalls().find(call => + call.args[0] === Constants.Commands.METADATA_DIFF_REMOVE_SITE + ); + + if (removeSiteCall) { + removeSiteCall.args[1](); + expect(mockCommandHandler.calledOnce).to.be.true; + } else { + throw new Error("removeSiteComparison command was not registered"); + } + }); + + it('should register discardLocalChanges command', async () => { + sinon.stub(ActionsHub, 'isMetadataDiffEnabled').returns(true); + const mockCommandHandler = sinon.stub(DiscardLocalChangesHandler, 'discardLocalChanges'); + mockCommandHandler.resolves(); + const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false); + actionsHubTreeDataProvider["registerPanel"](); + + expect(registerCommandStub.calledWith(Constants.Commands.METADATA_DIFF_DISCARD_FILE)).to.be.true; + + const discardFileCall = registerCommandStub.getCalls().find(call => + call.args[0] === Constants.Commands.METADATA_DIFF_DISCARD_FILE + ); + + if (discardFileCall) { + await discardFileCall.args[1](); + expect(mockCommandHandler.calledOnce).to.be.true; + } else { + throw new Error("discardLocalChanges command was not registered"); + } + }); + + it('should register discardFolderChanges command', async () => { + sinon.stub(ActionsHub, 'isMetadataDiffEnabled').returns(true); + const mockCommandHandler = sinon.stub(DiscardFolderChangesHandler, 'discardFolderChanges'); + mockCommandHandler.resolves(); + const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false); + actionsHubTreeDataProvider["registerPanel"](); + + expect(registerCommandStub.calledWith(Constants.Commands.METADATA_DIFF_DISCARD_FOLDER)).to.be.true; + + const discardFolderCall = registerCommandStub.getCalls().find(call => + call.args[0] === Constants.Commands.METADATA_DIFF_DISCARD_FOLDER + ); + + if (discardFolderCall) { + await discardFolderCall.args[1](); + expect(mockCommandHandler.calledOnce).to.be.true; + } else { + throw new Error("discardFolderChanges command was not registered"); + } + }); + + it('should register showOutputChannel command', async () => { + sinon.stub(ActionsHub, 'isMetadataDiffEnabled').returns(true); + const showOutputChannelStub = sinon.stub(); + (pacTerminal.getWrapper as sinon.SinonStub).returns({ + ...pacWrapperStub, + showOutputChannel: showOutputChannelStub + }); + const actionsHubTreeDataProvider = ActionsHubTreeDataProvider.initialize(context, pacTerminal, false); + actionsHubTreeDataProvider["registerPanel"](); + + expect(registerCommandStub.calledWith("microsoft.powerplatform.pages.actionsHub.showOutputChannel")).to.be.true; + + const showOutputCall = registerCommandStub.getCalls().find(call => + call.args[0] === "microsoft.powerplatform.pages.actionsHub.showOutputChannel" + ); + + if (showOutputCall) { + showOutputCall.args[1](); + expect(showOutputChannelStub.calledOnce).to.be.true; + } else { + throw new Error("showOutputChannel command was not registered"); + } + }); }); describe('getTreeItem', () => { @@ -390,9 +694,10 @@ describe("ActionsHubTreeDataProvider", () => { expect(result).to.not.be.null; expect(result).to.not.be.undefined; - expect(result).to.have.lengthOf(2); + expect(result).to.have.lengthOf(3); expect(result![0]).to.be.instanceOf(EnvironmentGroupTreeItem); expect(result![1]).to.be.instanceOf(OtherSitesGroupTreeItem); + expect(result![2]).to.be.instanceOf(ToolsGroupTreeItem); const environmentGroup = result![0] as EnvironmentGroupTreeItem; expect(environmentGroup.environmentInfo.currentEnvironmentName).to.equal("TestOrg"); @@ -720,9 +1025,10 @@ describe("ActionsHubTreeDataProvider", () => { expect(result).to.not.be.null; expect(result).to.not.be.undefined; - expect(result).to.have.lengthOf(2); // EnvironmentGroupTreeItem and OtherSitesGroupTreeItem + expect(result).to.have.lengthOf(3); // EnvironmentGroupTreeItem, OtherSitesGroupTreeItem, and ToolsGroupTreeItem expect(result![0]).to.be.instanceOf(EnvironmentGroupTreeItem); expect(result![1]).to.be.instanceOf(OtherSitesGroupTreeItem); + expect(result![2]).to.be.instanceOf(ToolsGroupTreeItem); // Verify telemetry was called for successful account check expect(traceInfoStub.calledTwice).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 881b3efa..2834aa94 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 @@ -5,7 +5,8 @@ import { expect } from "chai"; import * as sinon from "sinon"; -import MetadataDiffContext from "../../../../power-pages/actions-hub/MetadataDiffContext"; +import * as vscode from "vscode"; +import MetadataDiffContext, { MetadataDiffViewMode, MetadataDiffSortMode } from "../../../../power-pages/actions-hub/MetadataDiffContext"; import { IFileComparisonResult } from "../../../../power-pages/actions-hub/models/IFileComparisonResult"; describe("MetadataDiffContext", () => { @@ -15,6 +16,10 @@ describe("MetadataDiffContext", () => { sandbox = sinon.createSandbox(); // Clear the context before each test MetadataDiffContext.clear(); + // Reset view mode to default + MetadataDiffContext.setViewMode(MetadataDiffViewMode.List); + // Reset sort mode to default + MetadataDiffContext.setSortMode(MetadataDiffSortMode.Path); }); afterEach(() => { @@ -47,7 +52,7 @@ describe("MetadataDiffContext", () => { } ]; - MetadataDiffContext.setResults(results, "Test Site"); + MetadataDiffContext.setResults(results, "Test Site", "Test Environment"); expect(MetadataDiffContext.comparisonResults).to.deep.equal(results); }); @@ -62,7 +67,7 @@ describe("MetadataDiffContext", () => { } ]; - MetadataDiffContext.setResults(results, "My Test Site"); + MetadataDiffContext.setResults(results, "My Test Site", "Test Environment"); expect(MetadataDiffContext.siteName).to.equal("My Test Site"); }); @@ -77,13 +82,13 @@ describe("MetadataDiffContext", () => { } ]; - MetadataDiffContext.setResults(results, "Test Site"); + MetadataDiffContext.setResults(results, "Test Site", "Test Environment"); expect(MetadataDiffContext.isActive).to.be.true; }); it("should set isActive to false when results are empty", () => { - MetadataDiffContext.setResults([], "Test Site"); + MetadataDiffContext.setResults([], "Test Site", "Test Environment"); expect(MetadataDiffContext.isActive).to.be.false; }); @@ -101,7 +106,7 @@ describe("MetadataDiffContext", () => { } ]; - MetadataDiffContext.setResults(results, "Test Site"); + MetadataDiffContext.setResults(results, "Test Site", "Test Environment"); expect(onChangedSpy.calledOnce).to.be.true; }); @@ -117,7 +122,7 @@ describe("MetadataDiffContext", () => { status: "modified" } ]; - MetadataDiffContext.setResults(results, "Test Site"); + MetadataDiffContext.setResults(results, "Test Site", "Test Environment"); MetadataDiffContext.clear(); @@ -133,7 +138,7 @@ describe("MetadataDiffContext", () => { status: "modified" } ]; - MetadataDiffContext.setResults(results, "Test Site"); + MetadataDiffContext.setResults(results, "Test Site", "Test Environment"); MetadataDiffContext.clear(); @@ -149,7 +154,7 @@ describe("MetadataDiffContext", () => { status: "modified" } ]; - MetadataDiffContext.setResults(results, "Test Site"); + MetadataDiffContext.setResults(results, "Test Site", "Test Environment"); MetadataDiffContext.clear(); @@ -166,6 +171,198 @@ describe("MetadataDiffContext", () => { }); }); + describe("viewMode", () => { + it("should default to list view mode", () => { + expect(MetadataDiffContext.viewMode).to.equal(MetadataDiffViewMode.List); + }); + + it("should allow setting view mode to tree", () => { + MetadataDiffContext.setViewMode(MetadataDiffViewMode.Tree); + + expect(MetadataDiffContext.viewMode).to.equal(MetadataDiffViewMode.Tree); + }); + + it("should allow setting view mode to list", () => { + MetadataDiffContext.setViewMode(MetadataDiffViewMode.Tree); + MetadataDiffContext.setViewMode(MetadataDiffViewMode.List); + + expect(MetadataDiffContext.viewMode).to.equal(MetadataDiffViewMode.List); + }); + + it("should return true for isTreeView when in tree mode", () => { + MetadataDiffContext.setViewMode(MetadataDiffViewMode.Tree); + + expect(MetadataDiffContext.isTreeView).to.be.true; + expect(MetadataDiffContext.isListView).to.be.false; + }); + + it("should return true for isListView when in list mode", () => { + MetadataDiffContext.setViewMode(MetadataDiffViewMode.List); + + expect(MetadataDiffContext.isListView).to.be.true; + expect(MetadataDiffContext.isTreeView).to.be.false; + }); + + it("should fire onChanged event when view mode changes", () => { + const onChangedSpy = sandbox.spy(); + MetadataDiffContext.onChanged(onChangedSpy); + + MetadataDiffContext.setViewMode(MetadataDiffViewMode.Tree); + + expect(onChangedSpy.calledOnce).to.be.true; + }); + + it("should not fire onChanged event when view mode is set to same value", () => { + MetadataDiffContext.setViewMode(MetadataDiffViewMode.List); + const onChangedSpy = sandbox.spy(); + MetadataDiffContext.onChanged(onChangedSpy); + + MetadataDiffContext.setViewMode(MetadataDiffViewMode.List); + + expect(onChangedSpy.called).to.be.false; + }); + + it("should toggle view mode between tree and list", () => { + expect(MetadataDiffContext.viewMode).to.equal(MetadataDiffViewMode.List); + + MetadataDiffContext.toggleViewMode(); + expect(MetadataDiffContext.viewMode).to.equal(MetadataDiffViewMode.Tree); + + MetadataDiffContext.toggleViewMode(); + expect(MetadataDiffContext.viewMode).to.equal(MetadataDiffViewMode.List); + }); + }); + + describe("initialize", () => { + it("should load persisted view mode from global state", () => { + const mockGlobalState = { + get: sandbox.stub().returns(MetadataDiffViewMode.Tree), + update: sandbox.stub().resolves() + }; + const mockContext = { + globalState: mockGlobalState + } as unknown as vscode.ExtensionContext; + + MetadataDiffContext.initialize(mockContext); + + expect(MetadataDiffContext.viewMode).to.equal(MetadataDiffViewMode.Tree); + }); + + it("should use default view mode when no persisted value exists", () => { + const mockGlobalState = { + get: sandbox.stub().returns(undefined), + update: sandbox.stub().resolves() + }; + const mockContext = { + globalState: mockGlobalState + } as unknown as vscode.ExtensionContext; + + MetadataDiffContext.setViewMode(MetadataDiffViewMode.List); // Reset to default + MetadataDiffContext.initialize(mockContext); + + expect(MetadataDiffContext.viewMode).to.equal(MetadataDiffViewMode.List); + }); + + it("should persist view mode when changed after initialization", () => { + const mockGlobalState = { + get: sandbox.stub().returns(undefined), + update: sandbox.stub().resolves() + }; + const mockContext = { + globalState: mockGlobalState + } as unknown as vscode.ExtensionContext; + + MetadataDiffContext.initialize(mockContext); + MetadataDiffContext.setViewMode(MetadataDiffViewMode.Tree); + + expect(mockGlobalState.update.calledWith( + "microsoft.powerplatform.pages.metadataDiff.viewModePreference", + MetadataDiffViewMode.Tree + )).to.be.true; + }); + }); + + describe("sortMode", () => { + it("should default to path sort mode", () => { + expect(MetadataDiffContext.sortMode).to.equal(MetadataDiffSortMode.Path); + }); + + it("should allow setting sort mode to name", () => { + MetadataDiffContext.setSortMode(MetadataDiffSortMode.Name); + + expect(MetadataDiffContext.sortMode).to.equal(MetadataDiffSortMode.Name); + }); + + it("should allow setting sort mode to status", () => { + MetadataDiffContext.setSortMode(MetadataDiffSortMode.Status); + + expect(MetadataDiffContext.sortMode).to.equal(MetadataDiffSortMode.Status); + }); + + it("should allow setting sort mode to path", () => { + MetadataDiffContext.setSortMode(MetadataDiffSortMode.Name); + MetadataDiffContext.setSortMode(MetadataDiffSortMode.Path); + + expect(MetadataDiffContext.sortMode).to.equal(MetadataDiffSortMode.Path); + }); + + it("should fire onChanged event when sort mode changes", () => { + const onChangedSpy = sandbox.spy(); + MetadataDiffContext.onChanged(onChangedSpy); + + MetadataDiffContext.setSortMode(MetadataDiffSortMode.Name); + + expect(onChangedSpy.calledOnce).to.be.true; + }); + + it("should not fire onChanged event when sort mode is set to same value", () => { + MetadataDiffContext.setSortMode(MetadataDiffSortMode.Path); + const onChangedSpy = sandbox.spy(); + MetadataDiffContext.onChanged(onChangedSpy); + + MetadataDiffContext.setSortMode(MetadataDiffSortMode.Path); + + expect(onChangedSpy.called).to.be.false; + }); + + it("should load persisted sort mode from global state", () => { + const mockGlobalState = { + get: sandbox.stub().callsFake((key: string) => { + if (key === "microsoft.powerplatform.pages.metadataDiff.sortModePreference") { + return MetadataDiffSortMode.Status; + } + return undefined; + }), + update: sandbox.stub().resolves() + }; + const mockContext = { + globalState: mockGlobalState + } as unknown as vscode.ExtensionContext; + + MetadataDiffContext.initialize(mockContext); + + expect(MetadataDiffContext.sortMode).to.equal(MetadataDiffSortMode.Status); + }); + + it("should persist sort mode when changed after initialization", () => { + const mockGlobalState = { + get: sandbox.stub().returns(undefined), + update: sandbox.stub().resolves() + }; + const mockContext = { + globalState: mockGlobalState + } as unknown as vscode.ExtensionContext; + + MetadataDiffContext.initialize(mockContext); + MetadataDiffContext.setSortMode(MetadataDiffSortMode.Name); + + expect(mockGlobalState.update.calledWith( + "microsoft.powerplatform.pages.metadataDiff.sortModePreference", + MetadataDiffSortMode.Name + )).to.be.true; + }); + }); + describe("multiple file comparison results", () => { it("should handle modified, added, and deleted files", () => { const results: IFileComparisonResult[] = [ @@ -189,7 +386,7 @@ describe("MetadataDiffContext", () => { } ]; - MetadataDiffContext.setResults(results, "Test Site"); + MetadataDiffContext.setResults(results, "Test Site", "Test Environment"); expect(MetadataDiffContext.comparisonResults).to.have.lengthOf(3); expect(MetadataDiffContext.comparisonResults[0].status).to.equal("modified"); diff --git a/src/client/test/Integration/power-pages/actions-hub/MetadataDiffDecorationProvider.test.ts b/src/client/test/Integration/power-pages/actions-hub/MetadataDiffDecorationProvider.test.ts new file mode 100644 index 00000000..b88950ce --- /dev/null +++ b/src/client/test/Integration/power-pages/actions-hub/MetadataDiffDecorationProvider.test.ts @@ -0,0 +1,351 @@ +/* + * 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 * as sinon from "sinon"; +import { MetadataDiffDecorationProvider } from "../../../../power-pages/actions-hub/MetadataDiffDecorationProvider"; +import { METADATA_DIFF_URI_SCHEME } from "../../../../power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFileTreeItem"; +import { FileComparisonStatus } from "../../../../power-pages/actions-hub/models/IFileComparisonResult"; +import { Constants } from "../../../../power-pages/actions-hub/Constants"; + +describe("MetadataDiffDecorationProvider", () => { + let sandbox: sinon.SinonSandbox; + let provider: MetadataDiffDecorationProvider; + let cancellationToken: vscode.CancellationToken; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + // Reset singleton instance by accessing it through getInstance + // and disposing if necessary + provider = MetadataDiffDecorationProvider.getInstance(); + cancellationToken = new vscode.CancellationTokenSource().token; + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe("getInstance", () => { + it("should return a singleton instance", () => { + const instance1 = MetadataDiffDecorationProvider.getInstance(); + const instance2 = MetadataDiffDecorationProvider.getInstance(); + + expect(instance1).to.equal(instance2); + }); + + it("should return an instance of MetadataDiffDecorationProvider", () => { + const instance = MetadataDiffDecorationProvider.getInstance(); + + expect(instance).to.be.instanceOf(MetadataDiffDecorationProvider); + }); + }); + + describe("register", () => { + it("should return a disposable", () => { + const disposable = provider.register(); + + expect(disposable).to.not.be.undefined; + }); + + it("should return the same disposable when called multiple times", () => { + const disposable1 = provider.register(); + const disposable2 = provider.register(); + const disposable3 = provider.register(); + + // Should return the same disposable each time since it's already registered + expect(disposable1).to.equal(disposable2); + expect(disposable2).to.equal(disposable3); + }); + }); + + describe("onDidChangeFileDecorations", () => { + it("should have onDidChangeFileDecorations event", () => { + expect(provider.onDidChangeFileDecorations).to.not.be.undefined; + }); + }); + + describe("refresh", () => { + it("should fire onDidChangeFileDecorations event", () => { + let eventFired = false; + const disposable = provider.onDidChangeFileDecorations(() => { + eventFired = true; + }); + + provider.refresh(); + + expect(eventFired).to.be.true; + disposable.dispose(); + }); + + it("should fire event with undefined to refresh all decorations", () => { + let receivedUri: vscode.Uri | vscode.Uri[] | undefined = null as unknown as vscode.Uri | undefined; + const disposable = provider.onDidChangeFileDecorations((uri) => { + receivedUri = uri; + }); + + provider.refresh(); + + expect(receivedUri).to.be.undefined; + disposable.dispose(); + }); + }); + + describe("provideFileDecoration", () => { + describe("URI scheme filtering", () => { + it("should return undefined for non-metadata-diff URIs", () => { + const uri = vscode.Uri.parse("file:///test/file.txt"); + + const decoration = provider.provideFileDecoration(uri, cancellationToken); + + expect(decoration).to.be.undefined; + }); + + it("should return undefined for http URIs", () => { + const uri = vscode.Uri.parse("http://example.com/file.txt"); + + const decoration = provider.provideFileDecoration(uri, cancellationToken); + + expect(decoration).to.be.undefined; + }); + + it("should return undefined for vscode URIs", () => { + const uri = vscode.Uri.parse("vscode://settings/file.txt"); + + const decoration = provider.provideFileDecoration(uri, cancellationToken); + + expect(decoration).to.be.undefined; + }); + + it("should handle metadata-diff URIs", () => { + const uri = vscode.Uri.from({ + scheme: METADATA_DIFF_URI_SCHEME, + path: "folder/file.txt", + query: "status=modified" + }); + + const decoration = provider.provideFileDecoration(uri, cancellationToken); + + expect(decoration).to.not.be.undefined; + }); + }); + + describe("modified status", () => { + it("should return M badge for modified files", () => { + const uri = vscode.Uri.from({ + scheme: METADATA_DIFF_URI_SCHEME, + path: "folder/file.txt", + query: `status=${FileComparisonStatus.MODIFIED}` + }); + + const decoration = provider.provideFileDecoration(uri, cancellationToken) as vscode.FileDecoration; + + expect(decoration.badge).to.equal("M"); + }); + + it("should return modified tooltip for modified files", () => { + const uri = vscode.Uri.from({ + scheme: METADATA_DIFF_URI_SCHEME, + path: "folder/file.txt", + query: `status=${FileComparisonStatus.MODIFIED}` + }); + + const decoration = provider.provideFileDecoration(uri, cancellationToken) as vscode.FileDecoration; + + expect(decoration.tooltip).to.equal(Constants.Strings.METADATA_DIFF_MODIFIED); + }); + + it("should return modified color for modified files", () => { + const uri = vscode.Uri.from({ + scheme: METADATA_DIFF_URI_SCHEME, + path: "folder/file.txt", + query: `status=${FileComparisonStatus.MODIFIED}` + }); + + const decoration = provider.provideFileDecoration(uri, cancellationToken) as vscode.FileDecoration; + + expect(decoration.color).to.be.instanceOf(vscode.ThemeColor); + expect((decoration.color as vscode.ThemeColor).id).to.equal("gitDecoration.modifiedResourceForeground"); + }); + }); + + describe("added status", () => { + it("should return A badge for added files", () => { + const uri = vscode.Uri.from({ + scheme: METADATA_DIFF_URI_SCHEME, + path: "folder/file.txt", + query: `status=${FileComparisonStatus.ADDED}` + }); + + const decoration = provider.provideFileDecoration(uri, cancellationToken) as vscode.FileDecoration; + + expect(decoration.badge).to.equal("A"); + }); + + it("should return added tooltip for added files", () => { + const uri = vscode.Uri.from({ + scheme: METADATA_DIFF_URI_SCHEME, + path: "folder/file.txt", + query: `status=${FileComparisonStatus.ADDED}` + }); + + const decoration = provider.provideFileDecoration(uri, cancellationToken) as vscode.FileDecoration; + + expect(decoration.tooltip).to.equal(Constants.Strings.METADATA_DIFF_ADDED); + }); + + it("should return added color for added files", () => { + const uri = vscode.Uri.from({ + scheme: METADATA_DIFF_URI_SCHEME, + path: "folder/file.txt", + query: `status=${FileComparisonStatus.ADDED}` + }); + + const decoration = provider.provideFileDecoration(uri, cancellationToken) as vscode.FileDecoration; + + expect(decoration.color).to.be.instanceOf(vscode.ThemeColor); + expect((decoration.color as vscode.ThemeColor).id).to.equal("gitDecoration.addedResourceForeground"); + }); + }); + + describe("deleted status", () => { + it("should return D badge for deleted files", () => { + const uri = vscode.Uri.from({ + scheme: METADATA_DIFF_URI_SCHEME, + path: "folder/file.txt", + query: `status=${FileComparisonStatus.DELETED}` + }); + + const decoration = provider.provideFileDecoration(uri, cancellationToken) as vscode.FileDecoration; + + expect(decoration.badge).to.equal("D"); + }); + + it("should return deleted tooltip for deleted files", () => { + const uri = vscode.Uri.from({ + scheme: METADATA_DIFF_URI_SCHEME, + path: "folder/file.txt", + query: `status=${FileComparisonStatus.DELETED}` + }); + + const decoration = provider.provideFileDecoration(uri, cancellationToken) as vscode.FileDecoration; + + expect(decoration.tooltip).to.equal(Constants.Strings.METADATA_DIFF_DELETED); + }); + + it("should return deleted color for deleted files", () => { + const uri = vscode.Uri.from({ + scheme: METADATA_DIFF_URI_SCHEME, + path: "folder/file.txt", + query: `status=${FileComparisonStatus.DELETED}` + }); + + const decoration = provider.provideFileDecoration(uri, cancellationToken) as vscode.FileDecoration; + + expect(decoration.color).to.be.instanceOf(vscode.ThemeColor); + expect((decoration.color as vscode.ThemeColor).id).to.equal("gitDecoration.deletedResourceForeground"); + }); + }); + + describe("unknown status", () => { + it("should return undefined for unknown status", () => { + const uri = vscode.Uri.from({ + scheme: METADATA_DIFF_URI_SCHEME, + path: "folder/file.txt", + query: "status=unknown" + }); + + const decoration = provider.provideFileDecoration(uri, cancellationToken); + + expect(decoration).to.be.undefined; + }); + + it("should return undefined for empty status", () => { + const uri = vscode.Uri.from({ + scheme: METADATA_DIFF_URI_SCHEME, + path: "folder/file.txt", + query: "status=" + }); + + const decoration = provider.provideFileDecoration(uri, cancellationToken); + + expect(decoration).to.be.undefined; + }); + + it("should return undefined for missing status query", () => { + const uri = vscode.Uri.from({ + scheme: METADATA_DIFF_URI_SCHEME, + path: "folder/file.txt", + query: "" + }); + + const decoration = provider.provideFileDecoration(uri, cancellationToken); + + expect(decoration).to.be.undefined; + }); + + it("should return undefined for URI with no query", () => { + const uri = vscode.Uri.from({ + scheme: METADATA_DIFF_URI_SCHEME, + path: "folder/file.txt" + }); + + const decoration = provider.provideFileDecoration(uri, cancellationToken); + + expect(decoration).to.be.undefined; + }); + }); + + describe("edge cases", () => { + it("should handle URIs with special characters in path", () => { + const uri = vscode.Uri.from({ + scheme: METADATA_DIFF_URI_SCHEME, + path: "folder with spaces/file (1).txt", + query: `status=${FileComparisonStatus.MODIFIED}` + }); + + const decoration = provider.provideFileDecoration(uri, cancellationToken) as vscode.FileDecoration; + + expect(decoration.badge).to.equal("M"); + }); + + it("should handle URIs with multiple query parameters", () => { + const uri = vscode.Uri.from({ + scheme: METADATA_DIFF_URI_SCHEME, + path: "folder/file.txt", + query: `status=${FileComparisonStatus.ADDED}&other=value` + }); + + const decoration = provider.provideFileDecoration(uri, cancellationToken) as vscode.FileDecoration; + + expect(decoration.badge).to.equal("A"); + }); + + it("should handle deeply nested file paths", () => { + const uri = vscode.Uri.from({ + scheme: METADATA_DIFF_URI_SCHEME, + path: "a/b/c/d/e/f/g/file.txt", + query: `status=${FileComparisonStatus.DELETED}` + }); + + const decoration = provider.provideFileDecoration(uri, cancellationToken) as vscode.FileDecoration; + + expect(decoration.badge).to.equal("D"); + }); + }); + }); + + describe("dispose", () => { + it("should dispose the provider without errors", () => { + sandbox.stub(vscode.window, "registerFileDecorationProvider").returns({ + dispose: () => { } + }); + + provider.register(); + + expect(() => provider.dispose()).to.not.throw(); + }); + }); +}); 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 6a6ba0a9..81661325 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"); + MetadataDiffContext.setResults(results, "Test Site", "Test Environment"); 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 new file mode 100644 index 00000000..fd428465 --- /dev/null +++ b/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/CompareWithEnvironmentHandler.test.ts @@ -0,0 +1,246 @@ +/* + * 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 { compareWithEnvironment } from "../../../../../../power-pages/actions-hub/handlers/metadata-diff/CompareWithEnvironmentHandler"; +import { Constants } from "../../../../../../power-pages/actions-hub/Constants"; +import { PacTerminal } from "../../../../../../lib/PacTerminal"; +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 { SUCCESS } from "../../../../../../../common/constants"; + +describe("CompareWithEnvironmentHandler", () => { + let sandbox: sinon.SinonSandbox; + let mockShowErrorMessage: sinon.SinonStub; + let mockShowQuickPick: sinon.SinonStub; + let traceInfoStub: sinon.SinonStub; + let mockPacTerminal: sinon.SinonStubbedInstance; + let mockExtensionContext: vscode.ExtensionContext; + let mockPacWrapper: { + orgList: sinon.SinonStub; + orgSelect: sinon.SinonStub; + downloadSiteWithProgress: sinon.SinonStub; + }; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + mockShowErrorMessage = sandbox.stub(vscode.window, "showErrorMessage"); + sandbox.stub(vscode.window, "showInformationMessage"); + mockShowQuickPick = sandbox.stub(vscode.window, "showQuickPick"); + 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 PacWrapper + mockPacWrapper = { + orgList: sandbox.stub(), + orgSelect: sandbox.stub(), + downloadSiteWithProgress: sandbox.stub() + }; + + // Mock PacTerminal + mockPacTerminal = sandbox.createStubInstance(PacTerminal); + mockPacTerminal.getWrapper.returns(mockPacWrapper as unknown as ReturnType); + + // 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(); + }); + + describe("compareWithEnvironment", () => { + describe("when no workspace folders exist", () => { + beforeEach(() => { + sandbox.stub(vscode.workspace, "workspaceFolders").get(() => undefined); + }); + + it("should show error message", async () => { + const handler = compareWithEnvironment(mockPacTerminal as unknown as PacTerminal, mockExtensionContext); + await handler({ fsPath: "/test/path" } as vscode.Uri); + + 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 handler = compareWithEnvironment(mockPacTerminal as unknown as PacTerminal, mockExtensionContext); + await handler({ fsPath: "/test/path" } as vscode.Uri); + + 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 handler = compareWithEnvironment(mockPacTerminal as unknown as PacTerminal, mockExtensionContext); + await handler({ fsPath: "/test/path" } as vscode.Uri); + + 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 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.WEBSITE_ID_NOT_FOUND); + }); + + 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_LOCAL_WEBSITE_ID_NOT_FOUND)).to.be.true; + }); + }); + + describe("when user cancels environment 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" + } + ] + }); + + mockShowQuickPick.resolves(undefined); // User cancelled + }); + + it("should log cancellation telemetry", 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; + }); + + 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 environments are found", () => { + 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: [] + }); + }); + + it("should show no environments 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_ENVIRONMENTS_FOUND); + }); + }); + + describe("telemetry", () => { + it("should log initial telemetry event when handler is called", async () => { + sandbox.stub(vscode.workspace, "workspaceFolders").get(() => undefined); + + 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_CALLED)).to.be.true; + const callArgs = traceInfoStub.getCalls().find( + call => call.args[0] === Constants.EventNames.ACTIONS_HUB_COMPARE_WITH_ENVIRONMENT_CALLED + )?.args[1]; + expect(callArgs).to.deep.include({ + methodName: "compareWithEnvironment" + }); + }); + }); + + 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-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" + } + ] + }); + + mockShowQuickPick.resolves({ + label: "Test Environment", + detail: "https://test.crm.dynamics.com", + environmentId: "env-id-1" + }); + }); + + it("should return early without error when storage path is undefined", async () => { + const contextWithoutStorage = { + storageUri: undefined + } as unknown as vscode.ExtensionContext; + + const handler = compareWithEnvironment(mockPacTerminal as unknown as PacTerminal, contextWithoutStorage); + // The handler should return early without errors + // Since we can't easily mock ArtemisContext in this test structure, + // we verify that it doesn't throw + try { + await handler({ fsPath: "/test/workspace" } as vscode.Uri); + } catch { + // Expected to fail silently when ArtemisContext is not available + } + }); + }); + }); +}); 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 7167aded..df99795e 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,18 +6,20 @@ import { expect } from "chai"; import * as sinon from "sinon"; import * as vscode from "vscode"; -import { openAllMetadataDiffs } from "../../../../../../power-pages/actions-hub/handlers/metadata-diff/OpenAllMetadataDiffsHandler"; -import { MetadataDiffGroupTreeItem } from "../../../../../../power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffGroupTreeItem"; +import { openAllMetadataDiffs, isBinaryFile } from "../../../../../../power-pages/actions-hub/handlers/metadata-diff/OpenAllMetadataDiffsHandler"; +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"; describe("OpenAllMetadataDiffsHandler", () => { 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"); }); @@ -26,6 +28,60 @@ describe("OpenAllMetadataDiffsHandler", () => { sandbox.restore(); }); + describe("isBinaryFile", () => { + it("should return true for image files", () => { + expect(isBinaryFile("test.png")).to.be.true; + expect(isBinaryFile("test.jpg")).to.be.true; + expect(isBinaryFile("test.jpeg")).to.be.true; + expect(isBinaryFile("test.gif")).to.be.true; + expect(isBinaryFile("test.ico")).to.be.true; + expect(isBinaryFile("test.webp")).to.be.true; + expect(isBinaryFile("test.bmp")).to.be.true; + expect(isBinaryFile("test.svg")).to.be.true; + }); + + it("should return true for font files", () => { + expect(isBinaryFile("test.woff")).to.be.true; + expect(isBinaryFile("test.woff2")).to.be.true; + expect(isBinaryFile("test.ttf")).to.be.true; + expect(isBinaryFile("test.otf")).to.be.true; + expect(isBinaryFile("test.eot")).to.be.true; + }); + + it("should return true for media files", () => { + expect(isBinaryFile("test.mp4")).to.be.true; + expect(isBinaryFile("test.mp3")).to.be.true; + expect(isBinaryFile("test.wav")).to.be.true; + }); + + it("should return true for document files", () => { + expect(isBinaryFile("test.pdf")).to.be.true; + expect(isBinaryFile("test.doc")).to.be.true; + expect(isBinaryFile("test.docx")).to.be.true; + }); + + it("should return false for text files", () => { + expect(isBinaryFile("test.txt")).to.be.false; + expect(isBinaryFile("test.html")).to.be.false; + expect(isBinaryFile("test.css")).to.be.false; + expect(isBinaryFile("test.js")).to.be.false; + expect(isBinaryFile("test.json")).to.be.false; + expect(isBinaryFile("test.yml")).to.be.false; + expect(isBinaryFile("test.yaml")).to.be.false; + }); + + it("should be case insensitive", () => { + expect(isBinaryFile("test.PNG")).to.be.true; + expect(isBinaryFile("test.JPG")).to.be.true; + expect(isBinaryFile("folder/TEST.GIF")).to.be.true; + }); + + it("should handle files with paths", () => { + expect(isBinaryFile("folder/subfolder/image.png")).to.be.true; + expect(isBinaryFile("folder\\subfolder\\image.jpg")).to.be.true; + }); + }); + describe("openAllMetadataDiffs", () => { it("should log telemetry event with total files count", async () => { const traceInfoStub = TelemetryHelper.traceInfo as sinon.SinonStub; @@ -43,9 +99,9 @@ describe("OpenAllMetadataDiffsHandler", () => { status: "added" } ]; - const groupItem = new MetadataDiffGroupTreeItem(results, "Test Site"); + const siteItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); - await openAllMetadataDiffs(groupItem); + await openAllMetadataDiffs(siteItem); expect(traceInfoStub.calledOnce).to.be.true; expect(traceInfoStub.firstCall.args[0]).to.equal("ActionsHubMetadataDiffOpenAll"); @@ -56,9 +112,9 @@ describe("OpenAllMetadataDiffsHandler", () => { }); it("should not execute command when no results", async () => { - const groupItem = new MetadataDiffGroupTreeItem([], "Test Site"); + const siteItem = new MetadataDiffSiteTreeItem([], "Test Site", "Test Environment"); - await openAllMetadataDiffs(groupItem); + await openAllMetadataDiffs(siteItem); expect(executeCommandStub.called).to.be.false; }); @@ -72,9 +128,9 @@ describe("OpenAllMetadataDiffsHandler", () => { status: "modified" } ]; - const groupItem = new MetadataDiffGroupTreeItem(results, "Test Site"); + const siteItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); - await openAllMetadataDiffs(groupItem); + await openAllMetadataDiffs(siteItem); expect(executeCommandStub.calledOnce).to.be.true; expect(executeCommandStub.firstCall.args[0]).to.equal("vscode.changes"); @@ -97,9 +153,9 @@ describe("OpenAllMetadataDiffsHandler", () => { status: "added" } ]; - const groupItem = new MetadataDiffGroupTreeItem(results, "Test Site"); + const siteItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); - await openAllMetadataDiffs(groupItem); + await openAllMetadataDiffs(siteItem); const resourceList = executeCommandStub.firstCall.args[2]; const [, originalUri, modifiedUri] = resourceList[0]; @@ -116,9 +172,9 @@ describe("OpenAllMetadataDiffsHandler", () => { status: "deleted" } ]; - const groupItem = new MetadataDiffGroupTreeItem(results, "Test Site"); + const siteItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); - await openAllMetadataDiffs(groupItem); + await openAllMetadataDiffs(siteItem); const resourceList = executeCommandStub.firstCall.args[2]; const [, originalUri, modifiedUri] = resourceList[0]; @@ -147,9 +203,9 @@ describe("OpenAllMetadataDiffsHandler", () => { status: "deleted" } ]; - const groupItem = new MetadataDiffGroupTreeItem(results, "Test Site"); + const siteItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); - await openAllMetadataDiffs(groupItem); + await openAllMetadataDiffs(siteItem); const resourceList = executeCommandStub.firstCall.args[2]; expect(resourceList).to.have.lengthOf(3); @@ -166,5 +222,87 @@ describe("OpenAllMetadataDiffsHandler", () => { expect(resourceList[2][1]).to.not.be.undefined; expect(resourceList[2][2]).to.be.undefined; }); + + it("should filter out binary files from multi-diff view", async () => { + const results: IFileComparisonResult[] = [ + { + localPath: "/local/file.txt", + remotePath: "/remote/file.txt", + relativePath: "file.txt", + status: "modified" + }, + { + localPath: "/local/image.png", + remotePath: "/remote/image.png", + relativePath: "image.png", + status: "modified" + } + ]; + const siteItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + + await openAllMetadataDiffs(siteItem); + + // Should only include text file in multi-diff + const resourceList = executeCommandStub.firstCall.args[2]; + expect(resourceList).to.have.lengthOf(1); + expect(resourceList[0][0].toString()).to.include("file.txt"); + + // Should show info message about binary files + expect(showInformationMessageStub.calledOnce).to.be.true; + }); + + it("should show message when all files are binary", async () => { + const results: IFileComparisonResult[] = [ + { + localPath: "/local/image1.png", + remotePath: "/remote/image1.png", + relativePath: "image1.png", + status: "modified" + }, + { + localPath: "/local/image2.jpg", + remotePath: "/remote/image2.jpg", + relativePath: "image2.jpg", + status: "added" + } + ]; + const siteItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + + await openAllMetadataDiffs(siteItem); + + // Should not call vscode.changes when only binary files + expect(executeCommandStub.called).to.be.false; + + // Should show info message about all files being binary + expect(showInformationMessageStub.calledOnce).to.be.true; + }); + + it("should log binary file count in telemetry", async () => { + const traceInfoStub = TelemetryHelper.traceInfo as sinon.SinonStub; + const results: IFileComparisonResult[] = [ + { + localPath: "/local/file.txt", + remotePath: "/remote/file.txt", + relativePath: "file.txt", + status: "modified" + }, + { + localPath: "/local/image.png", + remotePath: "/remote/image.png", + relativePath: "image.png", + status: "modified" + } + ]; + const siteItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + + await openAllMetadataDiffs(siteItem); + + // Should have two telemetry calls: one for total, one for binary stats + expect(traceInfoStub.calledTwice).to.be.true; + expect(traceInfoStub.secondCall.args[1]).to.deep.include({ + binaryFilesSkipped: "1", + textFilesIncluded: "1" + }); + }); }); }); 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 8f9f2e92..0e2796c4 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 @@ -36,7 +36,7 @@ describe("OpenMetadataDiffFileHandler", () => { relativePath: "folder/file.txt", status: "modified" }; - const fileItem = new MetadataDiffFileTreeItem("file.txt", comparisonResult, "Test Site"); + const fileItem = new MetadataDiffFileTreeItem(comparisonResult, "Test Site"); await openMetadataDiffFile(fileItem); @@ -56,7 +56,7 @@ describe("OpenMetadataDiffFileHandler", () => { relativePath: "file.txt", status: "modified" }; - const fileItem = new MetadataDiffFileTreeItem("file.txt", comparisonResult, "Test Site"); + const fileItem = new MetadataDiffFileTreeItem(comparisonResult, "Test Site"); await openMetadataDiffFile(fileItem); @@ -78,7 +78,7 @@ describe("OpenMetadataDiffFileHandler", () => { relativePath: "added.txt", status: "added" }; - const fileItem = new MetadataDiffFileTreeItem("added.txt", comparisonResult, "Test Site"); + const fileItem = new MetadataDiffFileTreeItem(comparisonResult, "Test Site"); await openMetadataDiffFile(fileItem); @@ -99,7 +99,7 @@ describe("OpenMetadataDiffFileHandler", () => { relativePath: "deleted.txt", status: "deleted" }; - const fileItem = new MetadataDiffFileTreeItem("deleted.txt", comparisonResult, "Test Site"); + const fileItem = new MetadataDiffFileTreeItem(comparisonResult, "Test Site"); await openMetadataDiffFile(fileItem); @@ -120,7 +120,7 @@ describe("OpenMetadataDiffFileHandler", () => { relativePath: "folder/file.txt", status: "modified" }; - const fileItem = new MetadataDiffFileTreeItem("file.txt", comparisonResult, "My Site"); + const fileItem = new MetadataDiffFileTreeItem(comparisonResult, "My Site"); await openMetadataDiffFile(fileItem); @@ -133,5 +133,90 @@ describe("OpenMetadataDiffFileHandler", () => { expect(title).to.include("folder/file.txt"); } }); + + it("should open binary files side by side for modified binary files", async () => { + // Note: Since we cannot stub fs.existsSync, this test verifies the handler + // doesn't throw errors for binary files. The actual vscode.open calls + // depend on file existence which we cannot mock. + const comparisonResult: IFileComparisonResult = { + localPath: "/local/image.png", + remotePath: "/remote/image.png", + relativePath: "image.png", + status: "modified" + }; + const fileItem = new MetadataDiffFileTreeItem(comparisonResult, "Test Site"); + + // Should not throw + await openMetadataDiffFile(fileItem); + + // Verify no vscode.diff was called (binary files should use vscode.open) + const diffCall = executeCommandStub.getCalls().find( + call => call.args[0] === "vscode.diff" + ); + expect(diffCall).to.be.undefined; + }); + + it("should open local file for added binary files", async () => { + const comparisonResult: IFileComparisonResult = { + localPath: "/local/image.png", + remotePath: "/remote/image.png", + relativePath: "image.png", + status: "added" + }; + const fileItem = new MetadataDiffFileTreeItem(comparisonResult, "Test Site"); + + // Should not throw + await openMetadataDiffFile(fileItem); + + // Verify no vscode.diff was called for binary files + const diffCall = executeCommandStub.getCalls().find( + call => call.args[0] === "vscode.diff" + ); + expect(diffCall).to.be.undefined; + }); + + it("should open remote file for deleted binary files", async () => { + const comparisonResult: IFileComparisonResult = { + localPath: "/local/image.png", + remotePath: "/remote/image.png", + relativePath: "image.png", + status: "deleted" + }; + const fileItem = new MetadataDiffFileTreeItem(comparisonResult, "Test Site"); + + // Should not throw + await openMetadataDiffFile(fileItem); + + // Verify no vscode.diff was called for binary files + const diffCall = executeCommandStub.getCalls().find( + call => call.args[0] === "vscode.diff" + ); + expect(diffCall).to.be.undefined; + }); + + it("should handle various binary file extensions without using diff command", async () => { + const binaryExtensions = [".jpg", ".jpeg", ".gif", ".ico", ".webp", ".pdf", ".woff", ".mp4"]; + + for (const ext of binaryExtensions) { + executeCommandStub.resetHistory(); + + const comparisonResult: IFileComparisonResult = { + localPath: `/local/file${ext}`, + remotePath: `/remote/file${ext}`, + relativePath: `file${ext}`, + status: "modified" + }; + const fileItem = new MetadataDiffFileTreeItem(comparisonResult, "Test Site"); + + // Should not throw + await openMetadataDiffFile(fileItem); + + // Binary files should not use vscode.diff command + const diffCall = executeCommandStub.getCalls().find( + call => call.args[0] === "vscode.diff" + ); + expect(diffCall, `Expected no vscode.diff call for ${ext} file`).to.be.undefined; + } + }); }); }); diff --git a/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/SortModeHandler.test.ts b/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/SortModeHandler.test.ts new file mode 100644 index 00000000..14760756 --- /dev/null +++ b/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/SortModeHandler.test.ts @@ -0,0 +1,160 @@ +/* + * 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 { sortByName, sortByPath, sortByStatus } from "../../../../../../power-pages/actions-hub/handlers/metadata-diff/SortModeHandler"; +import MetadataDiffContext, { MetadataDiffSortMode } from "../../../../../../power-pages/actions-hub/MetadataDiffContext"; +import * as TelemetryHelper from "../../../../../../power-pages/actions-hub/TelemetryHelper"; +import { Constants } from "../../../../../../power-pages/actions-hub/Constants"; + +describe("SortModeHandler", () => { + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + sandbox.stub(TelemetryHelper, "traceInfo"); + + // Initialize MetadataDiffContext with a mock extension context to enable setSortMode + const mockGlobalState = { + get: sandbox.stub().returns(undefined), + update: sandbox.stub().resolves() + }; + const mockContext = { + globalState: mockGlobalState + } as unknown as vscode.ExtensionContext; + MetadataDiffContext.initialize(mockContext); + + // Reset sort mode to default state before each test + MetadataDiffContext.setSortMode(MetadataDiffSortMode.Path); + }); + + afterEach(() => { + sandbox.restore(); + MetadataDiffContext.clear(); + }); + + describe("sortByName", () => { + it("should set sort mode to name", () => { + MetadataDiffContext.setSortMode(MetadataDiffSortMode.Path); + + sortByName(); + + expect(MetadataDiffContext.sortMode).to.equal(MetadataDiffSortMode.Name); + }); + + it("should log telemetry event with name sort mode", () => { + const traceInfoStub = TelemetryHelper.traceInfo as sinon.SinonStub; + + sortByName(); + + expect(traceInfoStub.calledOnce).to.be.true; + expect(traceInfoStub.firstCall.args[0]).to.equal(Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_SORT_MODE_CHANGED); + expect(traceInfoStub.firstCall.args[1]).to.deep.equal({ + methodName: "sortByName", + sortMode: MetadataDiffSortMode.Name + }); + }); + + it("should work when already in name sort mode", () => { + MetadataDiffContext.setSortMode(MetadataDiffSortMode.Name); + + sortByName(); + + expect(MetadataDiffContext.sortMode).to.equal(MetadataDiffSortMode.Name); + }); + }); + + describe("sortByPath", () => { + it("should set sort mode to path", () => { + MetadataDiffContext.setSortMode(MetadataDiffSortMode.Name); + + sortByPath(); + + expect(MetadataDiffContext.sortMode).to.equal(MetadataDiffSortMode.Path); + }); + + it("should log telemetry event with path sort mode", () => { + const traceInfoStub = TelemetryHelper.traceInfo as sinon.SinonStub; + + sortByPath(); + + expect(traceInfoStub.calledOnce).to.be.true; + expect(traceInfoStub.firstCall.args[0]).to.equal(Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_SORT_MODE_CHANGED); + expect(traceInfoStub.firstCall.args[1]).to.deep.equal({ + methodName: "sortByPath", + sortMode: MetadataDiffSortMode.Path + }); + }); + + it("should work when already in path sort mode", () => { + MetadataDiffContext.setSortMode(MetadataDiffSortMode.Path); + + sortByPath(); + + expect(MetadataDiffContext.sortMode).to.equal(MetadataDiffSortMode.Path); + }); + }); + + describe("sortByStatus", () => { + it("should set sort mode to status", () => { + MetadataDiffContext.setSortMode(MetadataDiffSortMode.Path); + + sortByStatus(); + + expect(MetadataDiffContext.sortMode).to.equal(MetadataDiffSortMode.Status); + }); + + it("should log telemetry event with status sort mode", () => { + const traceInfoStub = TelemetryHelper.traceInfo as sinon.SinonStub; + + sortByStatus(); + + expect(traceInfoStub.calledOnce).to.be.true; + expect(traceInfoStub.firstCall.args[0]).to.equal(Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_SORT_MODE_CHANGED); + expect(traceInfoStub.firstCall.args[1]).to.deep.equal({ + methodName: "sortByStatus", + sortMode: MetadataDiffSortMode.Status + }); + }); + + it("should work when already in status sort mode", () => { + MetadataDiffContext.setSortMode(MetadataDiffSortMode.Status); + + sortByStatus(); + + expect(MetadataDiffContext.sortMode).to.equal(MetadataDiffSortMode.Status); + }); + }); + + describe("sort mode switching workflow", () => { + it("should allow switching between all sort modes", () => { + expect(MetadataDiffContext.sortMode).to.equal(MetadataDiffSortMode.Path); + + sortByName(); + expect(MetadataDiffContext.sortMode).to.equal(MetadataDiffSortMode.Name); + + sortByStatus(); + expect(MetadataDiffContext.sortMode).to.equal(MetadataDiffSortMode.Status); + + sortByPath(); + expect(MetadataDiffContext.sortMode).to.equal(MetadataDiffSortMode.Path); + }); + + it("should log telemetry for each sort mode change", () => { + const traceInfoStub = TelemetryHelper.traceInfo as sinon.SinonStub; + + sortByName(); + sortByStatus(); + sortByPath(); + + expect(traceInfoStub.calledThrice).to.be.true; + expect(traceInfoStub.firstCall.args[1].sortMode).to.equal(MetadataDiffSortMode.Name); + expect(traceInfoStub.secondCall.args[1].sortMode).to.equal(MetadataDiffSortMode.Status); + expect(traceInfoStub.thirdCall.args[1].sortMode).to.equal(MetadataDiffSortMode.Path); + }); + }); +}); diff --git a/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/ToggleViewModeHandler.test.ts b/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/ToggleViewModeHandler.test.ts new file mode 100644 index 00000000..1a67e933 --- /dev/null +++ b/src/client/test/Integration/power-pages/actions-hub/handlers/metadata-diff/ToggleViewModeHandler.test.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 { expect } from "chai"; +import * as sinon from "sinon"; +import * as vscode from "vscode"; +import { viewAsTree, viewAsList } from "../../../../../../power-pages/actions-hub/handlers/metadata-diff/ToggleViewModeHandler"; +import MetadataDiffContext, { MetadataDiffViewMode } from "../../../../../../power-pages/actions-hub/MetadataDiffContext"; +import * as TelemetryHelper from "../../../../../../power-pages/actions-hub/TelemetryHelper"; +import { Constants } from "../../../../../../power-pages/actions-hub/Constants"; + +describe("ToggleViewModeHandler", () => { + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + sandbox.stub(TelemetryHelper, "traceInfo"); + + // Initialize MetadataDiffContext with a mock extension context to enable setViewMode + const mockGlobalState = { + get: sandbox.stub().returns(undefined), + update: sandbox.stub().resolves() + }; + const mockContext = { + globalState: mockGlobalState + } as unknown as vscode.ExtensionContext; + MetadataDiffContext.initialize(mockContext); + + // Reset view mode to default state before each test + MetadataDiffContext.setViewMode(MetadataDiffViewMode.List); + }); + + afterEach(() => { + sandbox.restore(); + MetadataDiffContext.clear(); + }); + + describe("viewAsTree", () => { + it("should set view mode to tree", () => { + MetadataDiffContext.setViewMode(MetadataDiffViewMode.List); + + viewAsTree(); + + expect(MetadataDiffContext.viewMode).to.equal(MetadataDiffViewMode.Tree); + }); + + it("should log telemetry event with tree view mode", () => { + const traceInfoStub = TelemetryHelper.traceInfo as sinon.SinonStub; + + viewAsTree(); + + expect(traceInfoStub.calledOnce).to.be.true; + expect(traceInfoStub.firstCall.args[0]).to.equal(Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_VIEW_MODE_CHANGED); + expect(traceInfoStub.firstCall.args[1]).to.deep.equal({ + methodName: "viewAsTree", + viewMode: MetadataDiffViewMode.Tree + }); + }); + + it("should work when already in tree mode", () => { + MetadataDiffContext.setViewMode(MetadataDiffViewMode.Tree); + + viewAsTree(); + + expect(MetadataDiffContext.viewMode).to.equal(MetadataDiffViewMode.Tree); + }); + + it("should set isTreeView to true after switching", () => { + MetadataDiffContext.setViewMode(MetadataDiffViewMode.List); + + viewAsTree(); + + expect(MetadataDiffContext.isTreeView).to.be.true; + expect(MetadataDiffContext.isListView).to.be.false; + }); + }); + + describe("viewAsList", () => { + it("should set view mode to list", () => { + MetadataDiffContext.setViewMode(MetadataDiffViewMode.Tree); + + viewAsList(); + + expect(MetadataDiffContext.viewMode).to.equal(MetadataDiffViewMode.List); + }); + + it("should log telemetry event with list view mode", () => { + const traceInfoStub = TelemetryHelper.traceInfo as sinon.SinonStub; + + viewAsList(); + + expect(traceInfoStub.calledOnce).to.be.true; + expect(traceInfoStub.firstCall.args[0]).to.equal(Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_VIEW_MODE_CHANGED); + expect(traceInfoStub.firstCall.args[1]).to.deep.equal({ + methodName: "viewAsList", + viewMode: MetadataDiffViewMode.List + }); + }); + + it("should work when already in list mode", () => { + MetadataDiffContext.setViewMode(MetadataDiffViewMode.List); + + viewAsList(); + + expect(MetadataDiffContext.viewMode).to.equal(MetadataDiffViewMode.List); + }); + + it("should set isListView to true after switching", () => { + MetadataDiffContext.setViewMode(MetadataDiffViewMode.Tree); + + viewAsList(); + + expect(MetadataDiffContext.isListView).to.be.true; + expect(MetadataDiffContext.isTreeView).to.be.false; + }); + }); + + describe("view mode toggling workflow", () => { + it("should allow switching from list to tree and back", () => { + expect(MetadataDiffContext.viewMode).to.equal(MetadataDiffViewMode.List); + + viewAsTree(); + expect(MetadataDiffContext.viewMode).to.equal(MetadataDiffViewMode.Tree); + + viewAsList(); + expect(MetadataDiffContext.viewMode).to.equal(MetadataDiffViewMode.List); + }); + + it("should log telemetry for each view mode change", () => { + const traceInfoStub = TelemetryHelper.traceInfo as sinon.SinonStub; + + viewAsTree(); + viewAsList(); + + expect(traceInfoStub.calledTwice).to.be.true; + expect(traceInfoStub.firstCall.args[1].viewMode).to.equal(MetadataDiffViewMode.Tree); + expect(traceInfoStub.secondCall.args[1].viewMode).to.equal(MetadataDiffViewMode.List); + }); + }); +}); diff --git a/src/client/test/Integration/power-pages/actions-hub/tree-items/ToolsGroupTreeItem.test.ts b/src/client/test/Integration/power-pages/actions-hub/tree-items/ToolsGroupTreeItem.test.ts new file mode 100644 index 00000000..657b0d91 --- /dev/null +++ b/src/client/test/Integration/power-pages/actions-hub/tree-items/ToolsGroupTreeItem.test.ts @@ -0,0 +1,76 @@ +/* + * 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 sinon from "sinon"; +import { expect } from "chai"; +import { ActionsHubTreeItem } from "../../../../../power-pages/actions-hub/tree-items/ActionsHubTreeItem"; +import { ToolsGroupTreeItem } from "../../../../../power-pages/actions-hub/tree-items/ToolsGroupTreeItem"; +import { MetadataDiffGroupTreeItem } from "../../../../../power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffGroupTreeItem"; +import * as ActionsHubModule from "../../../../../power-pages/actions-hub/ActionsHub"; + +describe('ToolsGroupTreeItem', () => { + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should be of type ActionsHubTreeItem', () => { + sandbox.stub(ActionsHubModule.ActionsHub, 'isMetadataDiffEnabled').returns(false); + const treeItem = new ToolsGroupTreeItem(); + + expect(treeItem).to.be.instanceOf(ActionsHubTreeItem); + }); + + it('should have the expected label', () => { + sandbox.stub(ActionsHubModule.ActionsHub, 'isMetadataDiffEnabled').returns(false); + const treeItem = new ToolsGroupTreeItem(); + + expect(treeItem.label).to.be.equal("Tools"); + }); + + it('should have the expected collapsibleState', () => { + sandbox.stub(ActionsHubModule.ActionsHub, 'isMetadataDiffEnabled').returns(false); + const treeItem = new ToolsGroupTreeItem(); + + expect(treeItem.collapsibleState).to.be.equal(vscode.TreeItemCollapsibleState.Expanded); + }); + + it('should have the expected icon', () => { + sandbox.stub(ActionsHubModule.ActionsHub, 'isMetadataDiffEnabled').returns(false); + const treeItem = new ToolsGroupTreeItem(); + + expect((treeItem.iconPath as vscode.ThemeIcon).id).to.be.equal('tools'); + }); + + it('should have the expected contextValue', () => { + sandbox.stub(ActionsHubModule.ActionsHub, 'isMetadataDiffEnabled').returns(false); + const treeItem = new ToolsGroupTreeItem(); + + expect(treeItem.contextValue).to.be.equal("toolsGroup"); + }); + + it('should return empty children array when metadata diff is disabled', () => { + sandbox.stub(ActionsHubModule.ActionsHub, 'isMetadataDiffEnabled').returns(false); + const treeItem = new ToolsGroupTreeItem(); + + expect(treeItem.getChildren()).to.be.an('array').that.is.empty; + }); + + it('should return MetadataDiffGroupTreeItem when metadata diff is enabled', () => { + sandbox.stub(ActionsHubModule.ActionsHub, 'isMetadataDiffEnabled').returns(true); + const treeItem = new ToolsGroupTreeItem(); + + const children = treeItem.getChildren(); + + expect(children).to.have.lengthOf(1); + expect(children[0]).to.be.instanceOf(MetadataDiffGroupTreeItem); + }); +}); 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 b79aad68..6d21ea0c 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 @@ -5,7 +5,7 @@ import * as vscode from "vscode"; import { expect } from "chai"; -import { MetadataDiffFileTreeItem } from "../../../../../../power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFileTreeItem"; +import { MetadataDiffFileTreeItem, METADATA_DIFF_URI_SCHEME } from "../../../../../../power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFileTreeItem"; import { ActionsHubTreeItem } from "../../../../../../power-pages/actions-hub/tree-items/ActionsHubTreeItem"; import { IFileComparisonResult } from "../../../../../../power-pages/actions-hub/models/IFileComparisonResult"; import { Constants } from "../../../../../../power-pages/actions-hub/Constants"; @@ -24,86 +24,115 @@ describe("MetadataDiffFileTreeItem", () => { describe("constructor", () => { it("should be an instance of ActionsHubTreeItem", () => { - const treeItem = new MetadataDiffFileTreeItem("file.txt", mockComparisonResult, "Test Site"); + const treeItem = new MetadataDiffFileTreeItem(mockComparisonResult, "Test Site"); expect(treeItem).to.be.instanceOf(ActionsHubTreeItem); }); - it("should have the expected label", () => { - const treeItem = new MetadataDiffFileTreeItem("myfile.txt", mockComparisonResult, "Test Site"); + it("should have the file name as label", () => { + const treeItem = new MetadataDiffFileTreeItem(mockComparisonResult, "Test Site"); + + expect(treeItem.label).to.equal("file.txt"); + }); + + it("should extract file name from path with multiple folders", () => { + const result: IFileComparisonResult = { + ...mockComparisonResult, + relativePath: "folder/subfolder/myfile.txt" + }; + const treeItem = new MetadataDiffFileTreeItem(result, "Test Site"); expect(treeItem.label).to.equal("myfile.txt"); }); it("should have None collapsible state", () => { - const treeItem = new MetadataDiffFileTreeItem("file.txt", mockComparisonResult, "Test Site"); + const treeItem = new MetadataDiffFileTreeItem(mockComparisonResult, "Test Site"); expect(treeItem.collapsibleState).to.equal(vscode.TreeItemCollapsibleState.None); }); it("should have the expected context value", () => { - const treeItem = new MetadataDiffFileTreeItem("file.txt", mockComparisonResult, "Test Site"); + const treeItem = new MetadataDiffFileTreeItem(mockComparisonResult, "Test Site"); expect(treeItem.contextValue).to.equal(Constants.ContextValues.METADATA_DIFF_FILE); }); }); - describe("icon", () => { - it("should have diff-modified icon for modified files", () => { + describe("resourceUri", () => { + it("should have resourceUri with custom scheme", () => { + const treeItem = new MetadataDiffFileTreeItem(mockComparisonResult, "Test Site"); + + expect(treeItem.resourceUri).to.not.be.undefined; + expect(treeItem.resourceUri?.scheme).to.equal(METADATA_DIFF_URI_SCHEME); + }); + + it("should have relativePath as URI path", () => { + const treeItem = new MetadataDiffFileTreeItem(mockComparisonResult, "Test Site"); + + expect(treeItem.resourceUri?.path).to.equal("folder/file.txt"); + }); + + it("should encode status in query string for modified files", () => { const result: IFileComparisonResult = { ...mockComparisonResult, status: "modified" }; - const treeItem = new MetadataDiffFileTreeItem("file.txt", result, "Test Site"); + const treeItem = new MetadataDiffFileTreeItem(result, "Test Site"); - expect((treeItem.iconPath as vscode.ThemeIcon).id).to.equal("diff-modified"); + expect(treeItem.resourceUri?.query).to.equal("status=modified"); }); - it("should have diff-added icon for added files", () => { + it("should encode status in query string for added files", () => { const result: IFileComparisonResult = { ...mockComparisonResult, status: "added" }; - const treeItem = new MetadataDiffFileTreeItem("file.txt", result, "Test Site"); + const treeItem = new MetadataDiffFileTreeItem(result, "Test Site"); - expect((treeItem.iconPath as vscode.ThemeIcon).id).to.equal("diff-added"); + expect(treeItem.resourceUri?.query).to.equal("status=added"); }); - it("should have diff-removed icon for deleted files", () => { + it("should encode status in query string for deleted files", () => { const result: IFileComparisonResult = { ...mockComparisonResult, status: "deleted" }; - const treeItem = new MetadataDiffFileTreeItem("file.txt", result, "Test Site"); + const treeItem = new MetadataDiffFileTreeItem(result, "Test Site"); - expect((treeItem.iconPath as vscode.ThemeIcon).id).to.equal("diff-removed"); + expect(treeItem.resourceUri?.query).to.equal("status=deleted"); }); }); describe("description", () => { - it("should have Modified description for modified files", () => { - const result: IFileComparisonResult = { ...mockComparisonResult, status: "modified" }; - const treeItem = new MetadataDiffFileTreeItem("file.txt", result, "Test Site"); + it("should show folder path in list view mode", () => { + const treeItem = new MetadataDiffFileTreeItem(mockComparisonResult, "Test Site"); - expect(treeItem.description).to.equal(Constants.Strings.METADATA_DIFF_MODIFIED); + // In list view (default), description should contain the folder path + expect(treeItem.description).to.equal("folder"); }); - it("should have Added locally description for added files", () => { - const result: IFileComparisonResult = { ...mockComparisonResult, status: "added" }; - const treeItem = new MetadataDiffFileTreeItem("file.txt", result, "Test Site"); + it("should show full folder path for nested files in list view", () => { + const result: IFileComparisonResult = { + ...mockComparisonResult, + relativePath: "folder/subfolder/deep/file.txt" + }; + const treeItem = new MetadataDiffFileTreeItem(result, "Test Site"); - expect(treeItem.description).to.equal(Constants.Strings.METADATA_DIFF_ADDED); + expect(treeItem.description).to.equal("folder/subfolder/deep"); }); - it("should have Deleted locally description for deleted files", () => { - const result: IFileComparisonResult = { ...mockComparisonResult, status: "deleted" }; - const treeItem = new MetadataDiffFileTreeItem("file.txt", result, "Test Site"); + it("should have empty description for root files", () => { + const result: IFileComparisonResult = { + ...mockComparisonResult, + relativePath: "root-file.txt" + }; + const treeItem = new MetadataDiffFileTreeItem(result, "Test Site"); - expect(treeItem.description).to.equal(Constants.Strings.METADATA_DIFF_DELETED); + expect(treeItem.description).to.equal(""); }); }); describe("command", () => { it("should have command set to open diff file", () => { - const treeItem = new MetadataDiffFileTreeItem("file.txt", mockComparisonResult, "Test Site"); + const treeItem = new MetadataDiffFileTreeItem(mockComparisonResult, "Test Site"); expect(treeItem.command).to.not.be.undefined; expect(treeItem.command?.command).to.equal(Constants.Commands.METADATA_DIFF_OPEN_FILE); }); it("should have the tree item as argument in command", () => { - const treeItem = new MetadataDiffFileTreeItem("file.txt", mockComparisonResult, "Test Site"); + const treeItem = new MetadataDiffFileTreeItem(mockComparisonResult, "Test Site"); expect(treeItem.command?.arguments).to.have.lengthOf(1); expect(treeItem.command?.arguments?.[0]).to.equal(treeItem); @@ -112,7 +141,7 @@ describe("MetadataDiffFileTreeItem", () => { describe("comparisonResult", () => { it("should return the comparison result", () => { - const treeItem = new MetadataDiffFileTreeItem("file.txt", mockComparisonResult, "Test Site"); + const treeItem = new MetadataDiffFileTreeItem(mockComparisonResult, "Test Site"); expect(treeItem.comparisonResult).to.deep.equal(mockComparisonResult); }); @@ -120,9 +149,37 @@ describe("MetadataDiffFileTreeItem", () => { describe("siteName", () => { it("should return the site name", () => { - const treeItem = new MetadataDiffFileTreeItem("file.txt", mockComparisonResult, "My Site"); + const treeItem = new MetadataDiffFileTreeItem(mockComparisonResult, "My Site"); expect(treeItem.siteName).to.equal("My Site"); }); }); + + describe("tooltip", () => { + it("should have tooltip with local path and status", () => { + const result: IFileComparisonResult = { ...mockComparisonResult, status: "modified" }; + const treeItem = new MetadataDiffFileTreeItem(result, "Test Site"); + + expect(treeItem.tooltip).to.be.instanceOf(vscode.MarkdownString); + const tooltipValue = (treeItem.tooltip as vscode.MarkdownString).value; + expect(tooltipValue).to.include("/local/file.txt"); + expect(tooltipValue).to.include("Modified"); + }); + + it("should show Added status in tooltip for added files", () => { + const result: IFileComparisonResult = { ...mockComparisonResult, status: "added" }; + const treeItem = new MetadataDiffFileTreeItem(result, "Test Site"); + + const tooltipValue = (treeItem.tooltip as vscode.MarkdownString).value; + expect(tooltipValue).to.include("Added"); + }); + + it("should show Deleted status in tooltip for deleted files", () => { + const result: IFileComparisonResult = { ...mockComparisonResult, status: "deleted" }; + const treeItem = new MetadataDiffFileTreeItem(result, "Test Site"); + + const tooltipValue = (treeItem.tooltip as vscode.MarkdownString).value; + expect(tooltipValue).to.include("Deleted"); + }); + }); }); 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 272351f8..8229f739 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 @@ -14,52 +14,59 @@ import { Constants } from "../../../../../../power-pages/actions-hub/Constants"; describe("MetadataDiffFolderTreeItem", () => { describe("constructor", () => { it("should be an instance of ActionsHubTreeItem", () => { - const treeItem = new MetadataDiffFolderTreeItem("folder"); + const treeItem = new MetadataDiffFolderTreeItem("folder", "Test Site", "folder"); expect(treeItem).to.be.instanceOf(ActionsHubTreeItem); }); it("should have the expected label", () => { - const treeItem = new MetadataDiffFolderTreeItem("myFolder"); + const treeItem = new MetadataDiffFolderTreeItem("myFolder", "Test Site", "myFolder"); expect(treeItem.label).to.equal("myFolder"); }); it("should have Expanded collapsible state", () => { - const treeItem = new MetadataDiffFolderTreeItem("folder"); + const treeItem = new MetadataDiffFolderTreeItem("folder", "Test Site", "folder"); expect(treeItem.collapsibleState).to.equal(vscode.TreeItemCollapsibleState.Expanded); }); it("should have the folder icon", () => { - const treeItem = new MetadataDiffFolderTreeItem("folder"); + const treeItem = new MetadataDiffFolderTreeItem("folder", "Test Site", "folder"); expect((treeItem.iconPath as vscode.ThemeIcon).id).to.equal("folder"); }); it("should have the expected context value", () => { - const treeItem = new MetadataDiffFolderTreeItem("folder"); + const treeItem = new MetadataDiffFolderTreeItem("folder", "Test Site", "folder"); expect(treeItem.contextValue).to.equal(Constants.ContextValues.METADATA_DIFF_FOLDER); }); it("should have empty children map initially", () => { - const treeItem = new MetadataDiffFolderTreeItem("folder"); + const treeItem = new MetadataDiffFolderTreeItem("folder", "Test Site", "folder"); expect(treeItem.childrenMap.size).to.equal(0); }); + + it("should store siteName and folderPath", () => { + const treeItem = new MetadataDiffFolderTreeItem("folder", "Test Site", "root/folder"); + + expect(treeItem.siteName).to.equal("Test Site"); + expect(treeItem.folderPath).to.equal("root/folder"); + }); }); describe("childrenMap", () => { it("should allow adding file children", () => { - const treeItem = new MetadataDiffFolderTreeItem("folder"); + const treeItem = new MetadataDiffFolderTreeItem("folder", "Test Site", "folder"); const comparisonResult: IFileComparisonResult = { localPath: "/local/file.txt", remotePath: "/remote/file.txt", relativePath: "folder/file.txt", status: "modified" }; - const fileItem = new MetadataDiffFileTreeItem("file.txt", comparisonResult, "Test Site"); + const fileItem = new MetadataDiffFileTreeItem(comparisonResult, "Test Site"); treeItem.childrenMap.set("file.txt", fileItem); @@ -68,8 +75,8 @@ describe("MetadataDiffFolderTreeItem", () => { }); it("should allow adding folder children", () => { - const treeItem = new MetadataDiffFolderTreeItem("parent"); - const childFolder = new MetadataDiffFolderTreeItem("child"); + const treeItem = new MetadataDiffFolderTreeItem("parent", "Test Site", "parent"); + const childFolder = new MetadataDiffFolderTreeItem("child", "Test Site", "parent/child"); treeItem.childrenMap.set("child", childFolder); @@ -78,15 +85,15 @@ describe("MetadataDiffFolderTreeItem", () => { }); it("should allow adding mixed children", () => { - const treeItem = new MetadataDiffFolderTreeItem("folder"); - const childFolder = new MetadataDiffFolderTreeItem("subfolder"); + const treeItem = new MetadataDiffFolderTreeItem("folder", "Test Site", "folder"); + const childFolder = new MetadataDiffFolderTreeItem("subfolder", "Test Site", "folder/subfolder"); const comparisonResult: IFileComparisonResult = { localPath: "/local/file.txt", remotePath: "/remote/file.txt", relativePath: "folder/file.txt", status: "modified" }; - const fileItem = new MetadataDiffFileTreeItem("file.txt", comparisonResult, "Test Site"); + const fileItem = new MetadataDiffFileTreeItem(comparisonResult, "Test Site"); treeItem.childrenMap.set("subfolder", childFolder); treeItem.childrenMap.set("file.txt", fileItem); @@ -97,7 +104,7 @@ describe("MetadataDiffFolderTreeItem", () => { describe("getChildren", () => { it("should return empty array when no children", () => { - const treeItem = new MetadataDiffFolderTreeItem("folder"); + const treeItem = new MetadataDiffFolderTreeItem("folder", "Test Site", "folder"); const children = treeItem.getChildren(); @@ -105,15 +112,15 @@ describe("MetadataDiffFolderTreeItem", () => { }); it("should return all children from childrenMap", () => { - const treeItem = new MetadataDiffFolderTreeItem("folder"); - const childFolder = new MetadataDiffFolderTreeItem("subfolder"); + const treeItem = new MetadataDiffFolderTreeItem("folder", "Test Site", "folder"); + const childFolder = new MetadataDiffFolderTreeItem("subfolder", "Test Site", "folder/subfolder"); const comparisonResult: IFileComparisonResult = { localPath: "/local/file.txt", remotePath: "/remote/file.txt", relativePath: "folder/file.txt", status: "modified" }; - const fileItem = new MetadataDiffFileTreeItem("file.txt", comparisonResult, "Test Site"); + const fileItem = new MetadataDiffFileTreeItem(comparisonResult, "Test Site"); treeItem.childrenMap.set("subfolder", childFolder); treeItem.childrenMap.set("file.txt", fileItem); @@ -126,14 +133,14 @@ describe("MetadataDiffFolderTreeItem", () => { }); it("should return ActionsHubTreeItem array", () => { - const treeItem = new MetadataDiffFolderTreeItem("folder"); + const treeItem = new MetadataDiffFolderTreeItem("folder", "Test Site", "folder"); const comparisonResult: IFileComparisonResult = { localPath: "/local/file.txt", remotePath: "/remote/file.txt", relativePath: "folder/file.txt", status: "modified" }; - const fileItem = new MetadataDiffFileTreeItem("file.txt", comparisonResult, "Test Site"); + const fileItem = new MetadataDiffFileTreeItem(comparisonResult, "Test Site"); treeItem.childrenMap.set("file.txt", fileItem); @@ -145,18 +152,74 @@ describe("MetadataDiffFolderTreeItem", () => { }); }); + describe("getAllFileItems", () => { + it("should return empty array when no children", () => { + const treeItem = new MetadataDiffFolderTreeItem("folder", "Test Site", "folder"); + + const files = treeItem.getAllFileItems(); + + expect(files).to.have.lengthOf(0); + }); + + it("should return direct file children", () => { + const treeItem = new MetadataDiffFolderTreeItem("folder", "Test Site", "folder"); + const comparisonResult: IFileComparisonResult = { + localPath: "/local/file.txt", + remotePath: "/remote/file.txt", + relativePath: "folder/file.txt", + status: "modified" + }; + const fileItem = new MetadataDiffFileTreeItem(comparisonResult, "Test Site"); + treeItem.childrenMap.set("file.txt", fileItem); + + const files = treeItem.getAllFileItems(); + + expect(files).to.have.lengthOf(1); + expect(files[0]).to.equal(fileItem); + }); + + it("should return files from nested folders recursively", () => { + const rootFolder = new MetadataDiffFolderTreeItem("root", "Test Site", "root"); + const childFolder = new MetadataDiffFolderTreeItem("child", "Test Site", "root/child"); + const comparisonResult1: IFileComparisonResult = { + localPath: "/local/file1.txt", + remotePath: "/remote/file1.txt", + relativePath: "root/file1.txt", + status: "modified" + }; + const comparisonResult2: IFileComparisonResult = { + localPath: "/local/file2.txt", + remotePath: "/remote/file2.txt", + relativePath: "root/child/file2.txt", + status: "added" + }; + const fileItem1 = new MetadataDiffFileTreeItem(comparisonResult1, "Test Site"); + const fileItem2 = new MetadataDiffFileTreeItem(comparisonResult2, "Test Site"); + + childFolder.childrenMap.set("file2.txt", fileItem2); + rootFolder.childrenMap.set("file1.txt", fileItem1); + rootFolder.childrenMap.set("child", childFolder); + + const files = rootFolder.getAllFileItems(); + + expect(files).to.have.lengthOf(2); + expect(files).to.include(fileItem1); + expect(files).to.include(fileItem2); + }); + }); + describe("nested folders", () => { it("should support deeply nested folder structure", () => { - const rootFolder = new MetadataDiffFolderTreeItem("root"); - const level1Folder = new MetadataDiffFolderTreeItem("level1"); - const level2Folder = new MetadataDiffFolderTreeItem("level2"); + const rootFolder = new MetadataDiffFolderTreeItem("root", "Test Site", "root"); + const level1Folder = new MetadataDiffFolderTreeItem("level1", "Test Site", "root/level1"); + const level2Folder = new MetadataDiffFolderTreeItem("level2", "Test Site", "root/level1/level2"); const comparisonResult: IFileComparisonResult = { localPath: "/local/file.txt", remotePath: "/remote/file.txt", relativePath: "root/level1/level2/file.txt", status: "modified" }; - const fileItem = new MetadataDiffFileTreeItem("file.txt", comparisonResult, "Test Site"); + const fileItem = new MetadataDiffFileTreeItem(comparisonResult, "Test Site"); level2Folder.childrenMap.set("file.txt", fileItem); level1Folder.childrenMap.set("level2", level2Folder); 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 fb7115e0..291d5bf0 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 @@ -6,21 +6,39 @@ import * as vscode from "vscode"; import { expect } from "chai"; import { MetadataDiffGroupTreeItem } from "../../../../../../power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffGroupTreeItem"; -import { MetadataDiffFolderTreeItem } from "../../../../../../power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFolderTreeItem"; -import { MetadataDiffFileTreeItem } from "../../../../../../power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFileTreeItem"; +import { MetadataDiffSiteTreeItem } from "../../../../../../power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffSiteTreeItem"; import { ActionsHubTreeItem } from "../../../../../../power-pages/actions-hub/tree-items/ActionsHubTreeItem"; import { IFileComparisonResult } from "../../../../../../power-pages/actions-hub/models/IFileComparisonResult"; import { Constants } from "../../../../../../power-pages/actions-hub/Constants"; +import MetadataDiffContext, { MetadataDiffViewMode } from "../../../../../../power-pages/actions-hub/MetadataDiffContext"; describe("MetadataDiffGroupTreeItem", () => { + beforeEach(() => { + // Reset to default list view mode before each test + MetadataDiffContext.setViewMode(MetadataDiffViewMode.List); + // Clear all site results before each test + MetadataDiffContext.clear(); + }); + + afterEach(() => { + // Clean up after each test + MetadataDiffContext.clear(); + }); + describe("constructor", () => { it("should be an instance of ActionsHubTreeItem", () => { - const treeItem = new MetadataDiffGroupTreeItem([], "Test Site"); + const treeItem = new MetadataDiffGroupTreeItem(); expect(treeItem).to.be.instanceOf(ActionsHubTreeItem); }); - it("should have the expected label with change count", () => { + it("should have the base label when no results", () => { + const treeItem = new MetadataDiffGroupTreeItem(); + + expect(treeItem.label).to.equal("Metadata Diff"); + }); + + it("should have the same label even when results exist", () => { const results: IFileComparisonResult[] = [ { localPath: "/local/file1.txt", @@ -35,46 +53,67 @@ describe("MetadataDiffGroupTreeItem", () => { status: "added" } ]; - const treeItem = new MetadataDiffGroupTreeItem(results, "Test Site"); + MetadataDiffContext.setResults(results, "Test Site", "Test Environment"); + const treeItem = new MetadataDiffGroupTreeItem(); - expect(treeItem.label).to.include("2"); + expect(treeItem.label).to.equal("Metadata Diff"); }); - it("should have expanded collapsible state", () => { - const treeItem = new MetadataDiffGroupTreeItem([], "Test Site"); + it("should have None collapsible state when no results", () => { + const treeItem = new MetadataDiffGroupTreeItem(); + + expect(treeItem.collapsibleState).to.equal(vscode.TreeItemCollapsibleState.None); + }); + + it("should have expanded state when results exist", () => { + const results: IFileComparisonResult[] = [ + { + localPath: "/local/file1.txt", + remotePath: "/remote/file1.txt", + relativePath: "file1.txt", + status: "modified" + } + ]; + MetadataDiffContext.setResults(results, "Test Site", "Test Environment"); + const treeItem = new MetadataDiffGroupTreeItem(); expect(treeItem.collapsibleState).to.equal(vscode.TreeItemCollapsibleState.Expanded); }); it("should have the diff icon", () => { - const treeItem = new MetadataDiffGroupTreeItem([], "Test Site"); + const treeItem = new MetadataDiffGroupTreeItem(); expect((treeItem.iconPath as vscode.ThemeIcon).id).to.equal("diff"); }); - it("should have the expected context value", () => { - const treeItem = new MetadataDiffGroupTreeItem([], "Test Site"); + it("should have METADATA_DIFF_GROUP context value when no results", () => { + const treeItem = new MetadataDiffGroupTreeItem(); expect(treeItem.contextValue).to.equal(Constants.ContextValues.METADATA_DIFF_GROUP); }); - it("should have site name as description", () => { - const treeItem = new MetadataDiffGroupTreeItem([], "My Test Site"); + it("should have METADATA_DIFF_GROUP_WITH_RESULTS context value when results exist", () => { + const results: IFileComparisonResult[] = [ + { + localPath: "/local/file.txt", + remotePath: "/remote/file.txt", + relativePath: "file.txt", + status: "modified" + } + ]; + MetadataDiffContext.setResults(results, "Test Site", "Test Environment"); + const treeItem = new MetadataDiffGroupTreeItem(); - expect(treeItem.description).to.equal("My Test Site"); + expect(treeItem.contextValue).to.equal(Constants.ContextValues.METADATA_DIFF_GROUP_WITH_RESULTS); }); - }); - describe("siteName", () => { - it("should return the site name", () => { - const treeItem = new MetadataDiffGroupTreeItem([], "Test Site"); + it("should have id without results suffix when no results", () => { + const treeItem = new MetadataDiffGroupTreeItem(); - expect(treeItem.siteName).to.equal("Test Site"); + expect(treeItem.id).to.equal("metadataDiffGroup-noResults"); }); - }); - describe("comparisonResults", () => { - it("should return the comparison results", () => { + it("should have id with results suffix when results exist", () => { const results: IFileComparisonResult[] = [ { localPath: "/local/file.txt", @@ -83,22 +122,23 @@ describe("MetadataDiffGroupTreeItem", () => { status: "modified" } ]; - const treeItem = new MetadataDiffGroupTreeItem(results, "Test Site"); + MetadataDiffContext.setResults(results, "Test Site", "Test Environment"); + const treeItem = new MetadataDiffGroupTreeItem(); - expect(treeItem.comparisonResults).to.deep.equal(results); + expect(treeItem.id).to.equal("metadataDiffGroup-withResults"); }); }); describe("getChildren", () => { it("should return empty array when no results", () => { - const treeItem = new MetadataDiffGroupTreeItem([], "Test Site"); + const treeItem = new MetadataDiffGroupTreeItem(); const children = treeItem.getChildren(); expect(children).to.have.lengthOf(0); }); - it("should return file items for files in root", () => { + it("should return site tree items for each site's results", () => { const results: IFileComparisonResult[] = [ { localPath: "/local/file.txt", @@ -107,128 +147,75 @@ describe("MetadataDiffGroupTreeItem", () => { status: "modified" } ]; - const treeItem = new MetadataDiffGroupTreeItem(results, "Test Site"); + MetadataDiffContext.setResults(results, "Test Site", "Test Environment"); + const treeItem = new MetadataDiffGroupTreeItem(); const children = treeItem.getChildren(); expect(children).to.have.lengthOf(1); - expect(children[0]).to.be.instanceOf(MetadataDiffFileTreeItem); + expect(children[0]).to.be.instanceOf(MetadataDiffSiteTreeItem); }); - it("should return folder items for files in folders", () => { - const results: IFileComparisonResult[] = [ + it("should return multiple site tree items when multiple sites have results", () => { + const results1: IFileComparisonResult[] = [ { - localPath: "/local/folder/file.txt", - remotePath: "/remote/folder/file.txt", - relativePath: "folder/file.txt", + localPath: "/local/file1.txt", + remotePath: "/remote/file1.txt", + relativePath: "file1.txt", status: "modified" } ]; - const treeItem = new MetadataDiffGroupTreeItem(results, "Test Site"); - - const children = treeItem.getChildren(); - - expect(children).to.have.lengthOf(1); - expect(children[0]).to.be.instanceOf(MetadataDiffFolderTreeItem); - expect(children[0].label).to.equal("folder"); - }); - - it("should create nested folder hierarchy", () => { - const results: IFileComparisonResult[] = [ + const results2: IFileComparisonResult[] = [ { - localPath: "/local/folder/subfolder/file.txt", - remotePath: "/remote/folder/subfolder/file.txt", - relativePath: "folder/subfolder/file.txt", - status: "modified" + localPath: "/local/file2.txt", + remotePath: "/remote/file2.txt", + relativePath: "file2.txt", + status: "added" } ]; - const treeItem = new MetadataDiffGroupTreeItem(results, "Test Site"); + MetadataDiffContext.setResults(results1, "Site 1", "Test Environment"); + MetadataDiffContext.setResults(results2, "Site 2", "Test Environment"); + const treeItem = new MetadataDiffGroupTreeItem(); const children = treeItem.getChildren(); - expect(children).to.have.lengthOf(1); - expect(children[0]).to.be.instanceOf(MetadataDiffFolderTreeItem); - - const folderItem = children[0] as MetadataDiffFolderTreeItem; - const subChildren = folderItem.getChildren(); - - expect(subChildren).to.have.lengthOf(1); - expect(subChildren[0]).to.be.instanceOf(MetadataDiffFolderTreeItem); - expect(subChildren[0].label).to.equal("subfolder"); + expect(children).to.have.lengthOf(2); + expect(children[0]).to.be.instanceOf(MetadataDiffSiteTreeItem); + expect(children[1]).to.be.instanceOf(MetadataDiffSiteTreeItem); }); - it("should group files in the same folder", () => { - const results: IFileComparisonResult[] = [ + it("should replace existing site results when same site is compared again", () => { + const results1: IFileComparisonResult[] = [ { - localPath: "/local/folder/file1.txt", - remotePath: "/remote/folder/file1.txt", - relativePath: "folder/file1.txt", + localPath: "/local/file1.txt", + remotePath: "/remote/file1.txt", + relativePath: "file1.txt", status: "modified" - }, - { - localPath: "/local/folder/file2.txt", - remotePath: "/remote/folder/file2.txt", - relativePath: "folder/file2.txt", - status: "added" } ]; - const treeItem = new MetadataDiffGroupTreeItem(results, "Test Site"); - - const children = treeItem.getChildren(); - - expect(children).to.have.lengthOf(1); - expect(children[0]).to.be.instanceOf(MetadataDiffFolderTreeItem); - - const folderItem = children[0] as MetadataDiffFolderTreeItem; - const folderChildren = folderItem.getChildren(); - - expect(folderChildren).to.have.lengthOf(2); - }); - - it("should handle mixed root files and folders", () => { - const results: IFileComparisonResult[] = [ + const results2: IFileComparisonResult[] = [ { - localPath: "/local/root-file.txt", - remotePath: "/remote/root-file.txt", - relativePath: "root-file.txt", - status: "modified" - }, - { - localPath: "/local/folder/nested-file.txt", - remotePath: "/remote/folder/nested-file.txt", - relativePath: "folder/nested-file.txt", + localPath: "/local/file2.txt", + remotePath: "/remote/file2.txt", + relativePath: "file2.txt", status: "added" - } - ]; - const treeItem = new MetadataDiffGroupTreeItem(results, "Test Site"); - - 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); - - expect(fileItem).to.not.be.undefined; - expect(folderItem).to.not.be.undefined; - }); - - it("should handle backslash path separators", () => { - const results: IFileComparisonResult[] = [ + }, { - localPath: "C:\\local\\folder\\file.txt", - remotePath: "C:\\remote\\folder\\file.txt", - relativePath: "folder\\file.txt", - status: "modified" + localPath: "/local/file3.txt", + remotePath: "/remote/file3.txt", + relativePath: "file3.txt", + status: "deleted" } ]; - const treeItem = new MetadataDiffGroupTreeItem(results, "Test Site"); + MetadataDiffContext.setResults(results1, "Test Site", "Test Environment"); + MetadataDiffContext.setResults(results2, "Test Site", "Test Environment"); + const treeItem = new MetadataDiffGroupTreeItem(); const children = treeItem.getChildren(); expect(children).to.have.lengthOf(1); - expect(children[0]).to.be.instanceOf(MetadataDiffFolderTreeItem); - expect(children[0].label).to.equal("folder"); + const siteItem = children[0] as MetadataDiffSiteTreeItem; + expect(siteItem.comparisonResults).to.have.lengthOf(2); }); }); }); 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 new file mode 100644 index 00000000..fe602033 --- /dev/null +++ b/src/client/test/Integration/power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffSiteTreeItem.test.ts @@ -0,0 +1,476 @@ +/* + * 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 { MetadataDiffSiteTreeItem } from "../../../../../../power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffSiteTreeItem"; +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 { Constants } from "../../../../../../power-pages/actions-hub/Constants"; +import MetadataDiffContext, { MetadataDiffViewMode, MetadataDiffSortMode } from "../../../../../../power-pages/actions-hub/MetadataDiffContext"; + +describe("MetadataDiffSiteTreeItem", () => { + beforeEach(() => { + // Reset to default list view mode before each test + MetadataDiffContext.setViewMode(MetadataDiffViewMode.List); + }); + + describe("constructor", () => { + it("should be an instance of ActionsHubTreeItem", () => { + const treeItem = new MetadataDiffSiteTreeItem([], "Test Site", "Test Environment"); + + expect(treeItem).to.be.instanceOf(ActionsHubTreeItem); + }); + + it("should have the expected label with site name and change count", () => { + 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(results, "Test Site", "Test Environment"); + + expect(treeItem.label).to.include("Test Site"); + expect(treeItem.label).to.include("2"); + }); + + it("should have expanded collapsible state", () => { + const treeItem = new MetadataDiffSiteTreeItem([], "Test Site", "Test Environment"); + + expect(treeItem.collapsibleState).to.equal(vscode.TreeItemCollapsibleState.Expanded); + }); + + it("should have the globe icon", () => { + const treeItem = new MetadataDiffSiteTreeItem([], "Test Site", "Test Environment"); + + 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"); + + expect(treeItem.contextValue).to.equal(Constants.ContextValues.METADATA_DIFF_SITE); + }); + }); + + describe("siteName", () => { + it("should return the site name", () => { + const treeItem = new MetadataDiffSiteTreeItem([], "Test Site", "Test Environment"); + + expect(treeItem.siteName).to.equal("Test Site"); + }); + }); + + describe("environmentName", () => { + it("should return the environment name", () => { + const treeItem = new MetadataDiffSiteTreeItem([], "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"); + + expect(treeItem.description).to.equal("My Environment"); + }); + }); + + describe("comparisonResults", () => { + it("should return the comparison results", () => { + const results: IFileComparisonResult[] = [ + { + localPath: "/local/file.txt", + remotePath: "/remote/file.txt", + relativePath: "file.txt", + status: "modified" + } + ]; + const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + + expect(treeItem.comparisonResults).to.deep.equal(results); + }); + }); + + 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 children = treeItem.getChildren(); + + expect(children).to.have.lengthOf(0); + }); + + it("should return file items as flat list", () => { + const results: IFileComparisonResult[] = [ + { + localPath: "/local/file.txt", + remotePath: "/remote/file.txt", + relativePath: "file.txt", + status: "modified" + } + ]; + const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + + const children = treeItem.getChildren(); + + expect(children).to.have.lengthOf(1); + expect(children[0]).to.be.instanceOf(MetadataDiffFileTreeItem); + }); + + it("should return file items directly without folder hierarchy", () => { + const results: IFileComparisonResult[] = [ + { + localPath: "/local/folder/file.txt", + remotePath: "/remote/folder/file.txt", + relativePath: "folder/file.txt", + status: "modified" + } + ]; + const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + + const children = treeItem.getChildren(); + + expect(children).to.have.lengthOf(1); + expect(children[0]).to.be.instanceOf(MetadataDiffFileTreeItem); + expect(children[0].label).to.equal("file.txt"); + }); + + it("should show folder path in description", () => { + const results: IFileComparisonResult[] = [ + { + localPath: "/local/folder/subfolder/file.txt", + remotePath: "/remote/folder/subfolder/file.txt", + relativePath: "folder/subfolder/file.txt", + status: "modified" + } + ]; + const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + + const children = treeItem.getChildren(); + + expect(children).to.have.lengthOf(1); + expect(children[0]).to.be.instanceOf(MetadataDiffFileTreeItem); + expect(children[0].description).to.equal("folder/subfolder"); + }); + + it("should return all files in flat list", () => { + const results: IFileComparisonResult[] = [ + { + localPath: "/local/folder/file1.txt", + remotePath: "/remote/folder/file1.txt", + relativePath: "folder/file1.txt", + status: "modified" + }, + { + localPath: "/local/folder/file2.txt", + remotePath: "/remote/folder/file2.txt", + relativePath: "folder/file2.txt", + status: "added" + } + ]; + const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + + const children = treeItem.getChildren(); + + expect(children).to.have.lengthOf(2); + expect(children[0]).to.be.instanceOf(MetadataDiffFileTreeItem); + expect(children[1]).to.be.instanceOf(MetadataDiffFileTreeItem); + }); + + it("should sort files by relative path", () => { + const results: IFileComparisonResult[] = [ + { + localPath: "/local/z-folder/file.txt", + remotePath: "/remote/z-folder/file.txt", + relativePath: "z-folder/file.txt", + status: "modified" + }, + { + localPath: "/local/a-folder/file.txt", + remotePath: "/remote/a-folder/file.txt", + relativePath: "a-folder/file.txt", + status: "added" + } + ]; + const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + + const children = treeItem.getChildren(); + + expect(children).to.have.lengthOf(2); + expect((children[0] as MetadataDiffFileTreeItem).comparisonResult.relativePath).to.equal("a-folder/file.txt"); + expect((children[1] as MetadataDiffFileTreeItem).comparisonResult.relativePath).to.equal("z-folder/file.txt"); + }); + }); + + describe("getChildren - list view sorting", () => { + beforeEach(() => { + MetadataDiffContext.setViewMode(MetadataDiffViewMode.List); + }); + + it("should sort by path when sort mode is path", () => { + MetadataDiffContext.setSortMode(MetadataDiffSortMode.Path); + const results: IFileComparisonResult[] = [ + { + localPath: "/local/z-folder/file.txt", + remotePath: "/remote/z-folder/file.txt", + relativePath: "z-folder/file.txt", + status: FileComparisonStatus.MODIFIED + }, + { + localPath: "/local/a-folder/file.txt", + remotePath: "/remote/a-folder/file.txt", + relativePath: "a-folder/file.txt", + status: FileComparisonStatus.ADDED + } + ]; + const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + + const children = treeItem.getChildren(); + + expect(children).to.have.lengthOf(2); + expect((children[0] as MetadataDiffFileTreeItem).comparisonResult.relativePath).to.equal("a-folder/file.txt"); + expect((children[1] as MetadataDiffFileTreeItem).comparisonResult.relativePath).to.equal("z-folder/file.txt"); + }); + + it("should sort by file name when sort mode is name", () => { + MetadataDiffContext.setSortMode(MetadataDiffSortMode.Name); + const results: IFileComparisonResult[] = [ + { + localPath: "/local/a-folder/zebra.txt", + remotePath: "/remote/a-folder/zebra.txt", + relativePath: "a-folder/zebra.txt", + status: FileComparisonStatus.MODIFIED + }, + { + localPath: "/local/z-folder/apple.txt", + remotePath: "/remote/z-folder/apple.txt", + relativePath: "z-folder/apple.txt", + status: FileComparisonStatus.ADDED + } + ]; + const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + + const children = treeItem.getChildren(); + + expect(children).to.have.lengthOf(2); + // Sorted by file name: apple.txt comes before zebra.txt + expect((children[0] as MetadataDiffFileTreeItem).comparisonResult.relativePath).to.equal("z-folder/apple.txt"); + expect((children[1] as MetadataDiffFileTreeItem).comparisonResult.relativePath).to.equal("a-folder/zebra.txt"); + }); + + it("should sort by status when sort mode is status", () => { + MetadataDiffContext.setSortMode(MetadataDiffSortMode.Status); + const results: IFileComparisonResult[] = [ + { + localPath: "/local/file3.txt", + remotePath: "/remote/file3.txt", + relativePath: "file3.txt", + status: FileComparisonStatus.MODIFIED + }, + { + localPath: "/local/file1.txt", + remotePath: "/remote/file1.txt", + relativePath: "file1.txt", + status: FileComparisonStatus.ADDED + }, + { + localPath: "/local/file2.txt", + remotePath: "/remote/file2.txt", + relativePath: "file2.txt", + status: FileComparisonStatus.DELETED + } + ]; + const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + + const children = treeItem.getChildren(); + + expect(children).to.have.lengthOf(3); + // Sorted by status: Added, Deleted, Modified + expect((children[0] as MetadataDiffFileTreeItem).comparisonResult.status).to.equal(FileComparisonStatus.ADDED); + expect((children[1] as MetadataDiffFileTreeItem).comparisonResult.status).to.equal(FileComparisonStatus.DELETED); + expect((children[2] as MetadataDiffFileTreeItem).comparisonResult.status).to.equal(FileComparisonStatus.MODIFIED); + }); + + it("should sort by path within same status when sort mode is status", () => { + MetadataDiffContext.setSortMode(MetadataDiffSortMode.Status); + const results: IFileComparisonResult[] = [ + { + localPath: "/local/z-file.txt", + remotePath: "/remote/z-file.txt", + relativePath: "z-file.txt", + status: FileComparisonStatus.ADDED + }, + { + localPath: "/local/a-file.txt", + remotePath: "/remote/a-file.txt", + relativePath: "a-file.txt", + status: FileComparisonStatus.ADDED + } + ]; + const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + + const children = treeItem.getChildren(); + + expect(children).to.have.lengthOf(2); + // Both are added, so should be sorted by path: a-file.txt before z-file.txt + expect((children[0] as MetadataDiffFileTreeItem).comparisonResult.relativePath).to.equal("a-file.txt"); + expect((children[1] as MetadataDiffFileTreeItem).comparisonResult.relativePath).to.equal("z-file.txt"); + }); + }); + + describe("getChildren - tree view mode", () => { + beforeEach(() => { + MetadataDiffContext.setViewMode(MetadataDiffViewMode.Tree); + }); + + it("should return file items for files in root", () => { + const results: IFileComparisonResult[] = [ + { + localPath: "/local/file.txt", + remotePath: "/remote/file.txt", + relativePath: "file.txt", + status: "modified" + } + ]; + const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + + const children = treeItem.getChildren(); + + expect(children).to.have.lengthOf(1); + expect(children[0]).to.be.instanceOf(MetadataDiffFileTreeItem); + }); + + it("should return folder items for files in folders", () => { + const results: IFileComparisonResult[] = [ + { + localPath: "/local/folder/file.txt", + remotePath: "/remote/folder/file.txt", + relativePath: "folder/file.txt", + status: "modified" + } + ]; + const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + + const children = treeItem.getChildren(); + + expect(children).to.have.lengthOf(1); + expect(children[0]).to.be.instanceOf(MetadataDiffFolderTreeItem); + expect(children[0].label).to.equal("folder"); + }); + + it("should create nested folder hierarchy", () => { + const results: IFileComparisonResult[] = [ + { + localPath: "/local/folder/subfolder/file.txt", + remotePath: "/remote/folder/subfolder/file.txt", + relativePath: "folder/subfolder/file.txt", + status: "modified" + } + ]; + const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + + const children = treeItem.getChildren(); + + expect(children).to.have.lengthOf(1); + expect(children[0]).to.be.instanceOf(MetadataDiffFolderTreeItem); + + const folderItem = children[0] as MetadataDiffFolderTreeItem; + const subChildren = folderItem.getChildren(); + + expect(subChildren).to.have.lengthOf(1); + expect(subChildren[0]).to.be.instanceOf(MetadataDiffFolderTreeItem); + expect(subChildren[0].label).to.equal("subfolder"); + }); + + it("should group files in the same folder", () => { + const results: IFileComparisonResult[] = [ + { + localPath: "/local/folder/file1.txt", + remotePath: "/remote/folder/file1.txt", + relativePath: "folder/file1.txt", + status: "modified" + }, + { + localPath: "/local/folder/file2.txt", + remotePath: "/remote/folder/file2.txt", + relativePath: "folder/file2.txt", + status: "added" + } + ]; + const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + + const children = treeItem.getChildren(); + + expect(children).to.have.lengthOf(1); + expect(children[0]).to.be.instanceOf(MetadataDiffFolderTreeItem); + + const folderItem = children[0] as MetadataDiffFolderTreeItem; + const folderChildren = folderItem.getChildren(); + + expect(folderChildren).to.have.lengthOf(2); + }); + + it("should handle mixed root files and folders", () => { + const results: IFileComparisonResult[] = [ + { + localPath: "/local/root-file.txt", + remotePath: "/remote/root-file.txt", + relativePath: "root-file.txt", + status: "modified" + }, + { + localPath: "/local/folder/nested-file.txt", + remotePath: "/remote/folder/nested-file.txt", + relativePath: "folder/nested-file.txt", + status: "added" + } + ]; + const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + + 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); + + expect(fileItem).to.not.be.undefined; + expect(folderItem).to.not.be.undefined; + }); + + it("should handle backslash path separators", () => { + const results: IFileComparisonResult[] = [ + { + localPath: "C:\\local\\folder\\file.txt", + remotePath: "C:\\remote\\folder\\file.txt", + relativePath: "folder\\file.txt", + status: "modified" + } + ]; + const treeItem = new MetadataDiffSiteTreeItem(results, "Test Site", "Test Environment"); + + const children = treeItem.getChildren(); + + expect(children).to.have.lengthOf(1); + expect(children[0]).to.be.instanceOf(MetadataDiffFolderTreeItem); + expect(children[0].label).to.equal("folder"); + }); + }); +});