diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json
index b2e1a4317..0254c559b 100644
--- a/l10n/bundle.l10n.json
+++ b/l10n/bundle.l10n.json
@@ -132,6 +132,9 @@
"User cancelled environment switch": "User cancelled environment switch",
"User cancelled folder selection": "User cancelled folder selection",
"Download failed: {0}": "Download failed: {0}",
+ "Metadata diff import URI is missing the required 'filePath' query parameter.": "Metadata diff import URI is missing the required 'filePath' query parameter.",
+ "Metadata diff import URI must reference an absolute file path without '..' segments.": "Metadata diff import URI must reference an absolute file path without '..' segments.",
+ "Metadata diff import failed: {0}": "Metadata diff import failed: {0}",
"Site download cancelled. Authentication is required to proceed.": "Site download cancelled. Authentication is required to proceed.",
"Site download cancelled. Correct environment connection is required.": "Site download cancelled. Correct environment connection is required.",
"Site download cancelled. No folder selected.": "Site download cancelled. No folder selected.",
@@ -435,6 +438,7 @@
"Export Site Comparison": "Export Site Comparison",
"Importing comparison...": "Importing comparison...",
"Import Site Comparison": "Import Site Comparison",
+ "Metadata diff file must be a .json file.": "Metadata diff file must be a .json file.",
"Invalid file format. The file does not contain valid metadata diff data.": "Invalid file format. The file does not contain valid metadata diff data.",
"Unsupported version. This file was created with a newer version of the extension.": "Unsupported version. This file was created with a newer version of the extension.",
"This file was exported with a newer version of the extension. Please update your extension to import this file.": "This file was exported with a newer version of the extension. Please update your extension to import this file.",
@@ -613,6 +617,12 @@
"Error message when import fails. {0} is the error message."
]
},
+ "Metadata diff file not found: {0}/Error message when the supplied import file is missing. {0} is the file path.": {
+ "message": "Metadata diff file not found: {0}",
+ "comment": [
+ "Error message when the supplied import file is missing. {0} is the file path."
+ ]
+ },
"Refreshing comparison for {0} ([details](command:microsoft.powerplatform.pages.actionsHub.showOutputChannel \"Show download output\")).../This is a markdown formatting which must persist across translations.": {
"message": "Refreshing comparison for {0} ([details](command:microsoft.powerplatform.pages.actionsHub.showOutputChannel \"Show download output\"))...",
"comment": [
diff --git a/loc/translations-export/vscode-powerplatform.xlf b/loc/translations-export/vscode-powerplatform.xlf
index 4e5da15b4..15b962bee 100644
--- a/loc/translations-export/vscode-powerplatform.xlf
+++ b/loc/translations-export/vscode-powerplatform.xlf
@@ -825,6 +825,22 @@ Return to this chat and @powerpages can help you write and edit your website cod
Maximum 30 characters allowed
+
+ Metadata diff file must be a .json file.
+
+
+ Metadata diff file not found: {0}
+ Error message when the supplied import file is missing. {0} is the file path.
+
+
+ Metadata diff import URI is missing the required 'filePath' query parameter.
+
+
+ Metadata diff import URI must reference an absolute file path without '..' segments.
+
+
+ Metadata diff import failed: {0}
+
Microsoft wants your feedback
diff --git a/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts b/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts
index 02e8c3ac1..d0da31bad 100644
--- a/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts
+++ b/src/client/power-pages/actions-hub/ActionsHubTreeDataProvider.ts
@@ -386,7 +386,9 @@ export class ActionsHubTreeDataProvider implements vscode.TreeDataProvider importMetadataDiff()),
vscode.commands.registerCommand(Constants.Commands.METADATA_DIFF_RESYNC, resyncMetadataDiff(this._pacTerminal, this._context)),
MetadataDiffDecorationProvider.getInstance().register(),
ReadOnlyContentProvider.getInstance().register()
diff --git a/src/client/power-pages/actions-hub/Constants.ts b/src/client/power-pages/actions-hub/Constants.ts
index 993ac8318..7d0e02f97 100644
--- a/src/client/power-pages/actions-hub/Constants.ts
+++ b/src/client/power-pages/actions-hub/Constants.ts
@@ -269,6 +269,7 @@ export const Constants = {
METADATA_DIFF_EXPORT_TITLE: vscode.l10n.t("Export Site Comparison"),
METADATA_DIFF_IMPORT_PROGRESS: vscode.l10n.t("Importing comparison..."),
METADATA_DIFF_IMPORT_TITLE: vscode.l10n.t("Import Site Comparison"),
+ METADATA_DIFF_IMPORT_NOT_JSON: vscode.l10n.t("Metadata diff file must be a .json file."),
METADATA_DIFF_EXPORT_INVALID_FILE: vscode.l10n.t("Invalid file format. The file does not contain valid metadata diff data."),
METADATA_DIFF_EXPORT_UNSUPPORTED_VERSION: vscode.l10n.t("Unsupported version. This file was created with a newer version of the extension."),
METADATA_DIFF_EXPORT_NEWER_EXTENSION_VERSION: vscode.l10n.t("This file was exported with a newer version of the extension. Please update your extension to import this file."),
@@ -520,6 +521,15 @@ export const Constants = {
args: [errorMessage],
comment: ["Error message when import fails. {0} is the error message."]
}),
+ /**
+ * Returns the error message when a pre-supplied import file does not exist
+ */
+ METADATA_DIFF_IMPORT_FILE_NOT_FOUND: (filePath: string) =>
+ vscode.l10n.t({
+ message: "Metadata diff file not found: {0}",
+ args: [filePath],
+ comment: ["Error message when the supplied import file is missing. {0} is the file path."]
+ }),
/**
* Returns the progress message when resyncing a site comparison
*/
@@ -708,6 +718,7 @@ export const Constants = {
ACTIONS_HUB_METADATA_DIFF_IMPORT_CALLED: "ActionsHubMetadataDiffImportCalled",
ACTIONS_HUB_METADATA_DIFF_IMPORT_SUCCESS: "ActionsHubMetadataDiffImportSuccess",
ACTIONS_HUB_METADATA_DIFF_IMPORT_FAILED: "ActionsHubMetadataDiffImportFailed",
+ ACTIONS_HUB_METADATA_DIFF_IMPORT_AUTO_OPEN_FAILED: "ActionsHubMetadataDiffImportAutoOpenFailed",
ACTIONS_HUB_METADATA_DIFF_RESYNC_CALLED: "ActionsHubMetadataDiffResyncCalled",
ACTIONS_HUB_METADATA_DIFF_RESYNC_COMPLETED: "ActionsHubMetadataDiffResyncCompleted",
ACTIONS_HUB_METADATA_DIFF_RESYNC_FAILED: "ActionsHubMetadataDiffResyncFailed",
diff --git a/src/client/power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler.ts b/src/client/power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler.ts
index 51666bd31..01cb0675e 100644
--- a/src/client/power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler.ts
+++ b/src/client/power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler.ts
@@ -8,9 +8,10 @@ import * as fs from "fs";
import path from "path";
import { Constants } from "../../Constants";
import { traceError, traceInfo } from "../../TelemetryHelper";
-import { IMetadataDiffExport, METADATA_DIFF_EXPORT_VERSION } from "../../models/IMetadataDiffExport";
+import { IExportableFileComparisonResult, IMetadataDiffExport, METADATA_DIFF_EXPORT_VERSION } from "../../models/IMetadataDiffExport";
import { FileComparisonStatus, IFileComparisonResult } from "../../models/IFileComparisonResult";
import MetadataDiffContext from "../../MetadataDiffContext";
+import { MetadataDiffFileTreeItem } from "../../tree-items/metadata-diff/MetadataDiffFileTreeItem";
import { getExtensionVersion } from "../../../../../common/utilities/Utils";
/**
@@ -112,26 +113,59 @@ function validateImportData(data: unknown): string | undefined {
/**
* Imports a metadata diff from a JSON file
*/
-export async function importMetadataDiff(): Promise {
+export async function importMetadataDiff(
+ presuppliedFileUri?: vscode.Uri,
+ options?: { openFirstFile?: boolean }
+): Promise {
traceInfo(Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_IMPORT_CALLED, {
- methodName: importMetadataDiff.name
+ methodName: importMetadataDiff.name,
+ source: presuppliedFileUri ? "uri_handler" : "command_palette"
});
try {
- // Show open dialog first (before progress)
- const openUris = await vscode.window.showOpenDialog({
- canSelectMany: false,
- filters: {
- [Constants.Strings.METADATA_DIFF_EXPORT_FILTER_NAME]: ["json"]
- },
- title: Constants.Strings.METADATA_DIFF_IMPORT_TITLE
- });
+ let fileUri: vscode.Uri;
- if (!openUris || openUris.length === 0) {
- return; // User cancelled
- }
+ if (presuppliedFileUri) {
+ // URI-handler path: validate the supplied file before proceeding.
+ if (!fs.existsSync(presuppliedFileUri.fsPath)) {
+ vscode.window.showErrorMessage(
+ Constants.StringFunctions.METADATA_DIFF_IMPORT_FILE_NOT_FOUND(presuppliedFileUri.fsPath)
+ );
+ traceError(
+ Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_IMPORT_FAILED,
+ new Error("Pre-supplied file path does not exist"),
+ { methodName: importMetadataDiff.name, reason: "file_not_found" }
+ );
+ return;
+ }
+ if (!presuppliedFileUri.fsPath.toLowerCase().endsWith(".json")) {
+ vscode.window.showErrorMessage(
+ Constants.Strings.METADATA_DIFF_IMPORT_NOT_JSON
+ );
+ traceError(
+ Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_IMPORT_FAILED,
+ new Error("Pre-supplied file is not .json"),
+ { methodName: importMetadataDiff.name, reason: "wrong_extension" }
+ );
+ return;
+ }
+ fileUri = presuppliedFileUri;
+ } else {
+ // Command path: existing file-picker flow (unchanged).
+ const openUris = await vscode.window.showOpenDialog({
+ canSelectMany: false,
+ filters: {
+ [Constants.Strings.METADATA_DIFF_EXPORT_FILTER_NAME]: ["json"]
+ },
+ title: Constants.Strings.METADATA_DIFF_IMPORT_TITLE
+ });
+
+ if (!openUris || openUris.length === 0) {
+ return; // User cancelled
+ }
- const fileUri = openUris[0];
+ fileUri = openUris[0];
+ }
// Read and parse the file first to validate before showing progress
let importData: IMetadataDiffExport;
@@ -189,6 +223,10 @@ export async function importMetadataDiff(): Promise {
return;
}
+ // Hoisted so the success path can locate the first viewable file after the
+ // progress task completes (used for the optional auto-open below).
+ const comparisonResults: IFileComparisonResult[] = [];
+
// Now show progress while doing the actual file writing work
await vscode.window.withProgress(
{
@@ -215,8 +253,6 @@ export async function importMetadataDiff(): Promise {
fs.mkdirSync(importedDiffsPath, { recursive: true });
// Write the file contents to the storage
- const comparisonResults: IFileComparisonResult[] = [];
-
for (const file of importData.files) {
const localPath = path.join(importedDiffsPath, "local", file.relativePath);
const remotePath = path.join(importedDiffsPath, "remote", file.relativePath);
@@ -291,6 +327,63 @@ export async function importMetadataDiff(): Promise {
vscode.window.showInformationMessage(
Constants.StringFunctions.METADATA_DIFF_IMPORT_SUCCESS(displayName)
);
+
+ // Optional auto-open (opt-in, e.g. from the URI deep-link flow): surface the
+ // first viewable file diff so the user lands directly on a comparison.
+ if (options?.openFirstFile) {
+ // A file can be opened when openMetadataDiffFile has content to show for its
+ // status: modified -> diff (needs both sides), added -> local, deleted -> remote.
+ const isOpenable = (f: IExportableFileComparisonResult): boolean => {
+ switch (f.status) {
+ case FileComparisonStatus.MODIFIED:
+ return f.localContent != null && f.remoteContent != null;
+ case FileComparisonStatus.ADDED:
+ return f.localContent != null;
+ case FileComparisonStatus.DELETED:
+ return f.remoteContent != null;
+ default:
+ return false;
+ }
+ };
+ // Prefer a modified file (shows a real diff); otherwise open the first
+ // openable file of any status so at least one file surfaces.
+ const files = importData.files ?? [];
+ const viewableFile =
+ files.find(f => f.status === FileComparisonStatus.MODIFIED && isOpenable(f)) ??
+ files.find(isOpenable);
+ const comparisonResult = viewableFile
+ ? comparisonResults.find(r => r.relativePath === viewableFile.relativePath)
+ : undefined;
+
+ if (comparisonResult) {
+ // [DIAG] Temporary diagnostics to find why auto-open silently no-ops.
+ let diag = `viewable=${viewableFile?.relativePath}\nstatus=${comparisonResult.status}`
+ + `\nlocalExists=${fs.existsSync(comparisonResult.localPath)}`
+ + `\nremoteExists=${fs.existsSync(comparisonResult.remotePath)}`
+ + `\nlocal=${comparisonResult.localPath}\nremote=${comparisonResult.remotePath}`;
+ try {
+ const fileTreeItem = new MetadataDiffFileTreeItem(comparisonResult, displayName, true);
+ await vscode.commands.executeCommand(
+ Constants.Commands.METADATA_DIFF_OPEN_FILE,
+ fileTreeItem
+ );
+ diag += `\nexecuteCommand=OK`;
+ } catch (error) {
+ // Best-effort: import already succeeded; auto-open failure is non-fatal.
+ diag += `\nERROR=${error instanceof Error ? (error.stack || error.message) : String(error)}`;
+ traceError(
+ Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_IMPORT_AUTO_OPEN_FAILED,
+ error as Error,
+ { methodName: importMetadataDiff.name }
+ );
+ }
+ vscode.window.showInformationMessage(`[auto-open diag]\n${diag}`, { modal: true });
+ } else {
+ // [DIAG] No file matched the openable criteria.
+ const summary = (importData.files ?? []).map(f => `${f.status}:${f.relativePath} L=${f.localContent != null} R=${f.remoteContent != null}`).join("\n");
+ vscode.window.showInformationMessage(`[auto-open diag] no openable file selected.\n${summary}`, { modal: true });
+ }
+ }
} catch (error) {
traceError(
Constants.EventNames.ACTIONS_HUB_METADATA_DIFF_IMPORT_FAILED,
diff --git a/src/client/test/integration/power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler.test.ts b/src/client/test/integration/power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler.test.ts
index a7e769926..6292720b8 100644
--- a/src/client/test/integration/power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler.test.ts
+++ b/src/client/test/integration/power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler.test.ts
@@ -4,10 +4,17 @@
*/
import * as vscode from "vscode";
+// Use the CommonJS require form so sinon stubs the real `fs` singleton. A
+// namespace import (`import * as fs`) is compiled to a `__importStar` wrapper
+// whose properties are non-configurable getters, which sinon cannot replace
+// ("Descriptor for property existsSync is non-configurable and non-writable").
+import fs = require("fs");
import { expect } from "chai";
import sinon from "sinon";
import * as TelemetryHelper from "../../../../../../power-pages/actions-hub/TelemetryHelper";
import MetadataDiffContext from "../../../../../../power-pages/actions-hub/MetadataDiffContext";
+import { Constants } from "../../../../../../power-pages/actions-hub/Constants";
+import { MetadataDiffFileTreeItem } from "../../../../../../power-pages/actions-hub/tree-items/metadata-diff/MetadataDiffFileTreeItem";
describe("ImportMetadataDiffHandler", () => {
let sandbox: sinon.SinonSandbox;
@@ -105,6 +112,190 @@ describe("ImportMetadataDiffHandler", () => {
});
});
+ describe("importMetadataDiff with a pre-supplied URI", () => {
+ it("should skip the open dialog when a URI is supplied", async () => {
+ const existsSyncStub = sandbox.stub(fs, "existsSync").returns(true);
+ // Short-circuit the read so we don't run the full import flow.
+ sandbox.stub(fs, "readFileSync").throws(new Error("stop after dialog check"));
+
+ const { importMetadataDiff } = await import("../../../../../../power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler");
+
+ await importMetadataDiff(vscode.Uri.file("/tmp/diff.json"));
+
+ expect(showOpenDialogStub.called).to.be.false;
+ expect(existsSyncStub.called).to.be.true;
+ });
+
+ it("should show an error when the supplied file does not exist", async () => {
+ sandbox.stub(fs, "existsSync").returns(false);
+
+ const { importMetadataDiff } = await import("../../../../../../power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler");
+
+ await importMetadataDiff(vscode.Uri.file("/tmp/missing.json"));
+
+ expect(showOpenDialogStub.called).to.be.false;
+ expect(showErrorMessageStub.calledOnce).to.be.true;
+ expect(showErrorMessageStub.firstCall.args[0]).to.match(/not found/i);
+ });
+
+ it("should show an error when the supplied file is not a .json file", async () => {
+ sandbox.stub(fs, "existsSync").returns(true);
+
+ const { importMetadataDiff } = await import("../../../../../../power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler");
+
+ await importMetadataDiff(vscode.Uri.file("/tmp/diff.txt"));
+
+ expect(showOpenDialogStub.called).to.be.false;
+ expect(showErrorMessageStub.calledOnce).to.be.true;
+ expect(showErrorMessageStub.firstCall.args[0]).to.match(/\.json/i);
+ });
+
+ it("should record the URI handler source in telemetry", async () => {
+ const traceInfoStub = TelemetryHelper.traceInfo as sinon.SinonStub;
+ sandbox.stub(fs, "existsSync").returns(false);
+
+ const { importMetadataDiff } = await import("../../../../../../power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler");
+
+ await importMetadataDiff(vscode.Uri.file("/tmp/missing.json"));
+
+ expect(traceInfoStub.firstCall.args[0]).to.equal("ActionsHubMetadataDiffImportCalled");
+ expect(traceInfoStub.firstCall.args[1]).to.include({ source: "uri_handler" });
+ });
+
+ it("should still open the dialog when no URI is supplied", async () => {
+ showOpenDialogStub.resolves(undefined);
+
+ const { importMetadataDiff } = await import("../../../../../../power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler");
+
+ await importMetadataDiff();
+
+ expect(showOpenDialogStub.calledOnce).to.be.true;
+ });
+ });
+
+ describe("importMetadataDiff auto-open (openFirstFile)", () => {
+ const FAKE_STORAGE = vscode.Uri.file("/tmp/pp-metadata-diff-test-storage");
+ let executeCommandStub: sinon.SinonStub;
+
+ const makeExport = (files: unknown[]) => ({
+ version: "1.0",
+ extensionVersion: "1.0.0",
+ exportedAt: "2024-01-15T10:30:00Z",
+ localWebsiteId: "local-id",
+ remoteWebsiteId: "remote-id",
+ localWebsiteName: "Local Site",
+ remoteWebsiteName: "Remote Site",
+ environmentId: "env-id",
+ environmentName: "Test Environment",
+ files
+ });
+
+ const modifiedFile = (relativePath: string) => ({
+ relativePath,
+ status: "modified",
+ localContent: Buffer.from("local").toString("base64"),
+ remoteContent: Buffer.from("remote").toString("base64")
+ });
+
+ // Wires up fs + context + command stubs so the supplied URI drives a full,
+ // successful import without touching disk or opening real editors.
+ const stubSuccessfulImport = (inputUri: vscode.Uri, exportData: unknown) => {
+ const inputPath = inputUri.fsPath;
+ // Only the input file "exists"; storage paths report missing so the
+ // write loop just creates dirs/files (all stubbed) without cleanup.
+ sandbox.stub(fs, "existsSync").callsFake((p: fs.PathLike) => p === inputPath);
+ sandbox.stub(fs, "readFileSync").returns(JSON.stringify(exportData));
+ sandbox.stub(fs, "mkdirSync");
+ sandbox.stub(fs, "writeFileSync");
+ sandbox.stub(fs, "rmSync");
+ sandbox.stub(Object.getPrototypeOf(MetadataDiffContext), "extensionContext")
+ .get(() => ({ globalStorageUri: FAKE_STORAGE } as unknown as vscode.ExtensionContext));
+ executeCommandStub = sandbox.stub(vscode.commands, "executeCommand").resolves();
+ };
+
+ const openFileCall = () =>
+ executeCommandStub.getCalls().find(c => c.args[0] === Constants.Commands.METADATA_DIFF_OPEN_FILE);
+
+ it("prefers a modified file with both contents over added/deleted", async () => {
+ const uri = vscode.Uri.file("/tmp/diff.json");
+ stubSuccessfulImport(uri, makeExport([
+ { relativePath: "added.json", status: "added", localContent: Buffer.from("x").toString("base64") },
+ modifiedFile("modified.json")
+ ]));
+
+ const { importMetadataDiff } = await import("../../../../../../power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler");
+
+ await importMetadataDiff(uri, { openFirstFile: true });
+
+ const call = openFileCall();
+ expect(call, "expected the open-file command to be invoked").to.exist;
+ expect(call!.args[1]).to.be.instanceOf(MetadataDiffFileTreeItem);
+ expect(call!.args[1].comparisonResult.relativePath).to.equal("modified.json");
+ expect(showInformationMessageStub.called).to.be.true;
+ });
+
+ it("opens an added/deleted file when no modified file is available", async () => {
+ const uri = vscode.Uri.file("/tmp/diff.json");
+ stubSuccessfulImport(uri, makeExport([
+ { relativePath: "added.json", status: "added", localContent: Buffer.from("x").toString("base64") },
+ { relativePath: "deleted.json", status: "deleted", remoteContent: Buffer.from("y").toString("base64") }
+ ]));
+
+ const { importMetadataDiff } = await import("../../../../../../power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler");
+
+ await importMetadataDiff(uri, { openFirstFile: true });
+
+ const call = openFileCall();
+ expect(call, "expected an openable file to be opened").to.exist;
+ expect(call!.args[1].comparisonResult.relativePath).to.equal("added.json");
+ expect(showInformationMessageStub.called).to.be.true;
+ });
+
+ it("skips files with no openable content for their status", async () => {
+ const uri = vscode.Uri.file("/tmp/diff.json");
+ stubSuccessfulImport(uri, makeExport([
+ // modified but missing remote side, and an added entry with no local content
+ { relativePath: "modified.json", status: "modified", localContent: Buffer.from("local").toString("base64"), remoteContent: null },
+ { relativePath: "added.json", status: "added", localContent: null }
+ ]));
+
+ const { importMetadataDiff } = await import("../../../../../../power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler");
+
+ await importMetadataDiff(uri, { openFirstFile: true });
+
+ expect(openFileCall()).to.be.undefined;
+ expect(showInformationMessageStub.called).to.be.true;
+ });
+
+ it("does not auto-open when openFirstFile is not requested", async () => {
+ const uri = vscode.Uri.file("/tmp/diff.json");
+ stubSuccessfulImport(uri, makeExport([modifiedFile("modified.json")]));
+
+ const { importMetadataDiff } = await import("../../../../../../power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler");
+
+ await importMetadataDiff(uri); // no options
+
+ expect(openFileCall()).to.be.undefined;
+ expect(showInformationMessageStub.called).to.be.true;
+ });
+
+ it("does not fail the import when auto-open throws", async () => {
+ const traceErrorStub = TelemetryHelper.traceError as sinon.SinonStub;
+ const uri = vscode.Uri.file("/tmp/diff.json");
+ stubSuccessfulImport(uri, makeExport([modifiedFile("modified.json")]));
+ executeCommandStub.rejects(new Error("open failed"));
+
+ const { importMetadataDiff } = await import("../../../../../../power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler");
+
+ await importMetadataDiff(uri, { openFirstFile: true });
+
+ // Import already succeeded: success message shown, no error surfaced.
+ expect(showInformationMessageStub.called).to.be.true;
+ expect(showErrorMessageStub.called).to.be.false;
+ expect(traceErrorStub.calledWith("ActionsHubMetadataDiffImportAutoOpenFailed")).to.be.true;
+ });
+ });
+
describe("IMetadataDiffExport format", () => {
it("should support new format with localWebsiteId and remoteWebsiteId", () => {
// This test verifies that the interface supports the new field names
diff --git a/src/client/test/integration/uriHandler.test.ts b/src/client/test/integration/uriHandler.test.ts
index bbdc868cb..d5dc4c7b5 100644
--- a/src/client/test/integration/uriHandler.test.ts
+++ b/src/client/test/integration/uriHandler.test.ts
@@ -3,16 +3,17 @@
* 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 * as vscode from "vscode";
import { UriHandler } from "../../uriHandler/uriHandler";
+import * as ImportMetadataDiffHandler from "../../power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler";
+import { oneDSLoggerWrapper } from "../../../common/OneDSLoggerTelemetry/oneDSLoggerWrapper";
import { URI_CONSTANTS } from "../../uriHandler/constants/uriConstants";
import { PacWrapper } from "../../pac/PacWrapper";
import { AgenticCreateHandler } from "../../uriHandler/handlers/agenticCreateHandler";
import { PacCreateHandler } from "../../uriHandler/handlers/pacCreateHandler";
-// Shape used to stub the private route targets on the prototype without resorting to `any`.
type UriHandlerRoutes = {
pcfInit: () => Promise;
handleOpenPowerPages: (uri: vscode.Uri) => Promise;
@@ -20,17 +21,32 @@ type UriHandlerRoutes = {
describe("UriHandler routing", () => {
let sandbox: sinon.SinonSandbox;
+ let importStub: sinon.SinonStub;
+ let showErrorMessageStub: sinon.SinonStub;
let pcfInitStub: sinon.SinonStub;
let openStub: sinon.SinonStub;
let agenticCreateStub: sinon.SinonStub;
let pacCreateStub: sinon.SinonStub;
let handler: UriHandler;
+ const buildUri = (filePath: string) =>
+ vscode.Uri.parse(
+ `vscode://microsoft-IsvExpTools.powerplatform-vscode/metadataDiffImport?filePath=${encodeURIComponent(filePath)}`
+ );
+
const makeUri = (path: string): vscode.Uri =>
vscode.Uri.parse(`vscode://${URI_CONSTANTS.EXTENSION_ID}${path}`);
beforeEach(() => {
sandbox = sinon.createSandbox();
+ importStub = sandbox.stub(ImportMetadataDiffHandler, "importMetadataDiff").resolves();
+ showErrorMessageStub = sandbox.stub(vscode.window, "showErrorMessage");
+ sandbox.stub(oneDSLoggerWrapper, "getLogger").returns({
+ traceInfo: () => { /* no-op */ },
+ traceError: () => { /* no-op */ },
+ traceWarning: () => { /* no-op */ },
+ featureUsage: () => { /* no-op */ }
+ } as unknown as ReturnType);
const prototype = UriHandler.prototype as unknown as UriHandlerRoutes;
pcfInitStub = sandbox.stub(prototype, "pcfInit").resolves();
openStub = sandbox.stub(prototype, "handleOpenPowerPages").resolves();
@@ -43,6 +59,35 @@ describe("UriHandler routing", () => {
sandbox.restore();
});
+ it("routes /metadataDiffImport and calls importMetadataDiff with the file URI and { openFirstFile: true }", async () => {
+ const absolutePath = "/abs/path/diff.json";
+
+ await handler.handleUri(buildUri(absolutePath));
+
+ expect(importStub.calledOnce).to.be.true;
+ const [fileUriArg, optionsArg] = importStub.firstCall.args;
+ expect(fileUriArg.fsPath).to.equal(vscode.Uri.file(absolutePath).fsPath);
+ expect(optionsArg).to.deep.equal({ openFirstFile: true });
+ expect(showErrorMessageStub.called).to.be.false;
+ });
+
+ it("does not call importMetadataDiff when filePath is missing", async () => {
+ await handler.handleUri(
+ vscode.Uri.parse("vscode://microsoft-IsvExpTools.powerplatform-vscode/metadataDiffImport")
+ );
+
+ expect(importStub.called).to.be.false;
+ expect(showErrorMessageStub.calledOnce).to.be.true;
+ });
+
+ it("does not call importMetadataDiff when filePath is relative or contains '..'", async () => {
+ await handler.handleUri(buildUri("relative/diff.json"));
+ await handler.handleUri(buildUri("/abs/../etc/diff.json"));
+
+ expect(importStub.called).to.be.false;
+ expect(showErrorMessageStub.callCount).to.equal(2);
+ });
+
it("dispatches /pcfInit to the PCF init handler", async () => {
await handler.handleUri(makeUri(URI_CONSTANTS.PATHS.PCF_INIT));
diff --git a/src/client/test/unit/uriHandler.test.ts b/src/client/test/unit/uriHandler.test.ts
index 1fbadef5c..bbb021613 100644
--- a/src/client/test/unit/uriHandler.test.ts
+++ b/src/client/test/unit/uriHandler.test.ts
@@ -4,6 +4,7 @@
*/
import { expect } from "chai";
+import { resolveMetadataDiffImportFilePath } from "../../uriHandler/utils/metadataDiffImportValidation";
// Test URI handling functionality
describe('UriHandler Schema Parameter Tests', () => {
@@ -211,3 +212,47 @@ describe('UriHandler Schema Parameter Tests', () => {
});
});
});
+
+describe('Metadata Diff Import filePath validation', () => {
+ it('reports "missing" when filePath is null', () => {
+ const result = resolveMetadataDiffImportFilePath(null);
+ expect(result.ok).to.be.false;
+ expect(result).to.deep.equal({ ok: false, reason: "missing" });
+ });
+
+ it('reports "missing" when filePath is undefined', () => {
+ const result = resolveMetadataDiffImportFilePath(undefined);
+ expect(result).to.deep.equal({ ok: false, reason: "missing" });
+ });
+
+ it('reports "missing" when filePath is an empty string', () => {
+ const result = resolveMetadataDiffImportFilePath("");
+ expect(result).to.deep.equal({ ok: false, reason: "missing" });
+ });
+
+ it('reports "invalid" for a relative path', () => {
+ const result = resolveMetadataDiffImportFilePath("relative/path/diff.json");
+ expect(result).to.deep.equal({ ok: false, reason: "invalid" });
+ });
+
+ it('reports "invalid" for a path containing ".." segments', () => {
+ const result = resolveMetadataDiffImportFilePath("/Users/test/../../etc/diff.json");
+ expect(result).to.deep.equal({ ok: false, reason: "invalid" });
+ });
+
+ it('reports "invalid" when ".." is hidden behind URL-encoding', () => {
+ // %2E%2E decodes to "..", so traversal attempts must be caught after decoding.
+ const result = resolveMetadataDiffImportFilePath("%2F..%2Fetc%2Fdiff.json");
+ expect(result).to.deep.equal({ ok: false, reason: "invalid" });
+ });
+
+ it('accepts an absolute path and returns the decoded path', () => {
+ const result = resolveMetadataDiffImportFilePath("/Users/test/diff.json");
+ expect(result).to.deep.equal({ ok: true, filePath: "/Users/test/diff.json" });
+ });
+
+ it('decodes percent-encoded absolute paths', () => {
+ const result = resolveMetadataDiffImportFilePath("%2FUsers%2Ftest%20user%2Fdiff.json");
+ expect(result).to.deep.equal({ ok: true, filePath: "/Users/test user/diff.json" });
+ });
+});
diff --git a/src/client/uriHandler/constants/uriConstants.ts b/src/client/uriHandler/constants/uriConstants.ts
index 36cb1473f..daab38835 100644
--- a/src/client/uriHandler/constants/uriConstants.ts
+++ b/src/client/uriHandler/constants/uriConstants.ts
@@ -11,6 +11,7 @@ export const URI_CONSTANTS = {
PATHS: {
PCF_INIT: '/pcfInit',
OPEN: '/open',
+ METADATA_DIFF_IMPORT: '/metadataDiffImport',
AGENTIC_CREATE: '/agenticCreate',
PAC_CREATE: '/pacCreate'
},
@@ -23,6 +24,7 @@ export const URI_CONSTANTS = {
WEBSITE_NAME: 'websitename',
SITE_URL: 'siteurl',
WEBSITE_PREVIEW_URL: 'websitepreviewurl',
+ FILE_PATH: 'filePath',
REGION: 'region',
TENANT_ID: 'tenantid',
SOURCE: 'source',
@@ -58,6 +60,7 @@ export const URI_CONSTANTS = {
export const enum UriPath {
PcfInit = '/pcfInit',
Open = '/open',
+ MetadataDiffImport = '/metadataDiffImport',
AgenticCreate = '/agenticCreate',
PacCreate = '/pacCreate',
}
diff --git a/src/client/uriHandler/constants/uriStrings.ts b/src/client/uriHandler/constants/uriStrings.ts
index e83b88254..9e70e2ff6 100644
--- a/src/client/uriHandler/constants/uriStrings.ts
+++ b/src/client/uriHandler/constants/uriStrings.ts
@@ -19,7 +19,10 @@ export const URI_HANDLER_STRINGS = {
ENV_SWITCH_FAILED: vscode.l10n.t("Failed to switch to the required environment. Please sign in with an account that has access to the target environment using 'pac auth create' command."),
USER_CANCELLED_ENV_SWITCH: vscode.l10n.t("User cancelled environment switch"),
USER_CANCELLED_FOLDER_SELECTION: vscode.l10n.t("User cancelled folder selection"),
- DOWNLOAD_FAILED: vscode.l10n.t("Download failed: {0}")
+ DOWNLOAD_FAILED: vscode.l10n.t("Download failed: {0}"),
+ METADATA_DIFF_IMPORT_MISSING_FILE_PATH: vscode.l10n.t("Metadata diff import URI is missing the required 'filePath' query parameter."),
+ METADATA_DIFF_IMPORT_INVALID_FILE_PATH: vscode.l10n.t("Metadata diff import URI must reference an absolute file path without '..' segments."),
+ METADATA_DIFF_IMPORT_FAILED: vscode.l10n.t("Metadata diff import failed: {0}")
},
INFO: {
DOWNLOAD_CANCELLED_AUTH: vscode.l10n.t("Site download cancelled. Authentication is required to proceed."),
diff --git a/src/client/uriHandler/telemetry/uriHandlerTelemetryEvents.ts b/src/client/uriHandler/telemetry/uriHandlerTelemetryEvents.ts
index a448f2e60..622532edd 100644
--- a/src/client/uriHandler/telemetry/uriHandlerTelemetryEvents.ts
+++ b/src/client/uriHandler/telemetry/uriHandlerTelemetryEvents.ts
@@ -17,6 +17,7 @@ export enum uriHandlerTelemetryEventNames {
URI_HANDLER_DOWNLOAD_COMPLETED = "UriHandlerDownloadCompleted",
URI_HANDLER_FOLDER_OPENED = "UriHandlerFolderOpened",
URI_HANDLER_PCF_INIT_TRIGGERED = "UriHandlerPcfInitTriggered",
+ URI_HANDLER_METADATA_DIFF_IMPORT_TRIGGERED = "UriHandlerMetadataDiffImportTriggered",
URI_HANDLER_AGENTIC_CREATE_TRIGGERED = "UriHandlerAgenticCreateTriggered",
URI_HANDLER_AGENTIC_CREATE_DISABLED = "UriHandlerAgenticCreateDisabled",
URI_HANDLER_AGENTIC_CREATE_FAILED = "UriHandlerAgenticCreateFailed",
diff --git a/src/client/uriHandler/uriHandler.ts b/src/client/uriHandler/uriHandler.ts
index 5b5cbc314..27f7aea74 100644
--- a/src/client/uriHandler/uriHandler.ts
+++ b/src/client/uriHandler/uriHandler.ts
@@ -6,10 +6,12 @@
import * as vscode from "vscode";
import { PacWrapper } from "../pac/PacWrapper";
import { oneDSLoggerWrapper } from "../../common/OneDSLoggerTelemetry/oneDSLoggerWrapper";
-import { UriPath } from "./constants/uriConstants";
+import { UriPath, URI_CONSTANTS } from "./constants/uriConstants";
import { URI_HANDLER_STRINGS } from "./constants/uriStrings";
import { uriHandlerTelemetryEventNames } from "./telemetry/uriHandlerTelemetryEvents";
import { UriHandlerUtils, UriParameters } from "./utils/uriHandlerUtils";
+import { resolveMetadataDiffImportFilePath } from "./utils/metadataDiffImportValidation";
+import { importMetadataDiff } from "../power-pages/actions-hub/handlers/metadata-diff/ImportMetadataDiffHandler";
import { AuthEnvironmentService } from "./utils/authEnvironment";
import { AgenticCreateHandler } from "./handlers/agenticCreateHandler";
import { PacCreateHandler } from "./handlers/pacCreateHandler";
@@ -47,6 +49,7 @@ export class UriHandler implements vscode.UriHandler {
return new Map([
[UriPath.PcfInit, () => this.pcfInit()],
[UriPath.Open, (uri) => this.handleOpenPowerPages(uri)],
+ [UriPath.MetadataDiffImport, (uri) => this.handleMetadataDiffImport(uri)],
[UriPath.AgenticCreate, (uri) => this.agenticCreateHandler.handle(uri)],
[UriPath.PacCreate, (uri) => this.pacCreateHandler.handle(uri)],
]);
@@ -65,6 +68,54 @@ export class UriHandler implements vscode.UriHandler {
// Unrecognized paths are intentionally ignored for forward compatibility.
}
+ // vscode://microsoft-IsvExpTools.powerplatform-vscode/metadataDiffImport?filePath=
+ async handleMetadataDiffImport(uri: vscode.Uri): Promise {
+ const startTime = Date.now();
+ try {
+ const params = new URLSearchParams(uri.query);
+ const rawFilePath = params.get(URI_CONSTANTS.PARAMETERS.FILE_PATH);
+
+ oneDSLoggerWrapper.getLogger().traceInfo(
+ uriHandlerTelemetryEventNames.URI_HANDLER_METADATA_DIFF_IMPORT_TRIGGERED,
+ { timestamp: startTime.toString(), hasFilePath: rawFilePath ? "true" : "false" }
+ );
+
+ const pathResult = resolveMetadataDiffImportFilePath(rawFilePath);
+ if (!pathResult.ok) {
+ const message = pathResult.reason === "missing"
+ ? URI_HANDLER_STRINGS.ERRORS.METADATA_DIFF_IMPORT_MISSING_FILE_PATH
+ : URI_HANDLER_STRINGS.ERRORS.METADATA_DIFF_IMPORT_INVALID_FILE_PATH;
+ vscode.window.showErrorMessage(message);
+ oneDSLoggerWrapper.getLogger().traceInfo(
+ uriHandlerTelemetryEventNames.URI_HANDLER_METADATA_DIFF_IMPORT_TRIGGERED,
+ { success: "false", reason: pathResult.reason, duration: (Date.now() - startTime).toString() }
+ );
+ return;
+ }
+
+ const fileUri = vscode.Uri.file(pathResult.filePath);
+ await importMetadataDiff(fileUri, { openFirstFile: true });
+
+ oneDSLoggerWrapper.getLogger().traceInfo(
+ uriHandlerTelemetryEventNames.URI_HANDLER_METADATA_DIFF_IMPORT_TRIGGERED,
+ { success: "true", duration: (Date.now() - startTime).toString() }
+ );
+ } catch (error) {
+ oneDSLoggerWrapper.getLogger().traceError(
+ uriHandlerTelemetryEventNames.URI_HANDLER_METADATA_DIFF_IMPORT_TRIGGERED,
+ "Metadata diff import via URI failed",
+ error instanceof Error ? error : new Error(String(error)),
+ { duration: (Date.now() - startTime).toString() }
+ );
+ vscode.window.showErrorMessage(
+ URI_HANDLER_STRINGS.ERRORS.METADATA_DIFF_IMPORT_FAILED.replace(
+ "{0}",
+ error instanceof Error ? error.message : String(error)
+ )
+ );
+ }
+ }
+
async pcfInit(): Promise {
const startTime = Date.now();
diff --git a/src/client/uriHandler/utils/metadataDiffImportValidation.ts b/src/client/uriHandler/utils/metadataDiffImportValidation.ts
new file mode 100644
index 000000000..27fa92b73
--- /dev/null
+++ b/src/client/uriHandler/utils/metadataDiffImportValidation.ts
@@ -0,0 +1,45 @@
+/*
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ */
+
+import * as path from "path";
+
+/**
+ * Result of validating the `filePath` query parameter of a
+ * `/metadataDiffImport` URI.
+ *
+ * Kept free of any `vscode` dependency so the validation logic can be
+ * exercised by the plain-mocha unit test runner (which cannot load the
+ * `vscode` runtime module).
+ */
+export type MetadataDiffImportPathResult =
+ | { ok: true; filePath: string }
+ | { ok: false; reason: "missing" | "invalid" };
+
+/**
+ * Validates and decodes the `filePath` supplied on a metadata diff import URI.
+ *
+ * Defense-in-depth: only absolute paths without `..` segments are accepted.
+ * Relative paths and path-traversal attempts are rejected. The user is
+ * implicitly trusting whoever generated the URL — but we shouldn't make path
+ * tricks easy.
+ *
+ * @param rawFilePath The raw, still-encoded value of the `filePath` query parameter.
+ * @returns `{ ok: true, filePath }` with the decoded absolute path, or
+ * `{ ok: false, reason }` describing why it was rejected.
+ */
+export function resolveMetadataDiffImportFilePath(
+ rawFilePath: string | null | undefined
+): MetadataDiffImportPathResult {
+ if (!rawFilePath) {
+ return { ok: false, reason: "missing" };
+ }
+
+ const decoded = decodeURIComponent(rawFilePath);
+ if (!path.isAbsolute(decoded) || decoded.includes("..")) {
+ return { ok: false, reason: "invalid" };
+ }
+
+ return { ok: true, filePath: decoded };
+}