Repository: microsoft/vscode-edge-devtools
Pull request #462 (2021-08-18 00:47:52)

[Feature]: Implement CSS mirror editing
This PR adds the cssMirrorContent function for VSCode hosted DevTools that allows mirror editing for the extension when the modified stylesheet is in the VSCode workspace.

In this PR, we set up the necessary infrastructure to consume the `cssMirrorContent` message from the DevTools (similar to `openinEditor`).  This includes adding a webview event, framesocket event, and the right listeners.

Additionally, this PR separates out the code to parse a URL into a URI which originally lives just in the `onSocketOpenInEditor` function.  This is now used in the new `onSocketCssMirrorContent` function which is similar to `onSocketOpenInEditor`, but instead of revealing, overwrites the file using `vscode.workspace.fs.writeFile(uri, encodedNewContent);`.


Patch:
diff --git a/package.json b/package.json
index 769378a..c9eb005 100644
--- a/package.json
+++ b/package.json
@@ -133,6 +133,18 @@
                 "category": "Microsoft Edge Tools",
                 "enablement": "titleCommandsRegistered && isWorkspaceTrusted",
                 "title": "Launch project"
+            },
+            {
+                "command": "vscode-edge-devtools-view.toggleMirrorEditingOn",
+                "category": "Microsoft Edge Tools",
+                "enablement": "!mirrorEditingEnabled && titleCommandsRegistered && isCdnHostedTools",
+                "title": "Toggle mirror editing on for CSS files in workspace"
+            },
+            {
+                "command": "vscode-edge-devtools-view.toggleMirrorEditingOff",
+                "category": "Microsoft Edge Tools",
+                "enablement": "mirrorEditingEnabled && titleCommandsRegistered && isCdnHostedTools",
+                "title": "Toggle mirror editing off for CSS files in workspace"
             }
         ],
         "configuration": {
@@ -261,6 +273,11 @@
                         "Microsoft Edge Tools for VS Code will use the Edge DevTools light theme",
                         "Microsoft Edge Tools for VS Code will use the Edge DevTools dark theme"
                     ]
+                },
+                "vscode-edge-devtools.mirrorEdits": {
+                    "type": "boolean",
+                    "default": false,
+                    "description": "Enable edits in the styles pane to be automatically applied to the corresponding stylesheet in the workspace. (Only works when targeting Edge Browser versions 94.0.988.0+)"
                 }
             }
         },
@@ -548,6 +565,11 @@
                     "id": "vscode-edge-devtools-view.targets",
                     "name": "Targets"
                 },
+                {
+                    "id": "vscode-edge-devtools-view.mirror-editing",
+                    "name": "CSS Mirror Editing",
+                    "when": "isCdnHostedTools"
+                },
                 {
                     "id": "vscode-edge-devtools-view.help-links",
                     "name": "Helpful links"
@@ -580,6 +602,24 @@
                 "contents": "Launch an instance of Microsoft Edge to begin inspecting and modifying your site.\n[Launch Project](command:vscode-edge-devtools-view.launchProject)",
                 "when": "launchJsonStatus == Supported && isWorkspaceTrusted"
             },
+            {
+                "view": "vscode-edge-devtools-view.mirror-editing",
+                "contents": "CSS mirror editing is an experimental feature that automatically applies changes made in the DevTools panel to CSS files in your workspace."
+            },
+            {
+                "view": "vscode-edge-devtools-view.mirror-editing",
+                "contents": "Mirror editing is currently OFF.\n[Toggle Mirror Editing On](command:vscode-edge-devtools-view.toggleMirrorEditingOn)",
+                "when": "!mirrorEditingEnabled"
+            },
+            {
+                "view": "vscode-edge-devtools-view.mirror-editing",
+                "contents": "Mirror editing is currently ON.\n[Toggle Mirror Editing Off](command:vscode-edge-devtools-view.toggleMirrorEditingOff)",
+                "when": "mirrorEditingEnabled"
+            },
+            {
+                "view": "vscode-edge-devtools-view.mirror-editing",
+                "contents": "Please help us improve this feature by [leaving feedback here](https://github.com/microsoft/vscode-edge-devtools/issues/476)."
+            },
             {
                 "view": "vscode-edge-devtools-view.help-links",
                 "contents": "[Documentation](command:vscode-edge-devtools-view.viewDocumentation)"
diff --git a/src/common/webviewEvents.ts b/src/common/webviewEvents.ts
index 587fc5c..83e8fe3 100644
--- a/src/common/webviewEvents.ts
+++ b/src/common/webviewEvents.ts
@@ -1,12 +1,13 @@
 // Copyright (c) Microsoft Corporation. All rights reserved.
 // Licensed under the MIT License.
 
-export type WebviewEvent = 'getState' | 'getUrl' | 'openInEditor' | 'ready' | 'setState' | 'telemetry' | 'websocket'
+export type WebviewEvent = 'getState' | 'getUrl' | 'openInEditor' | 'cssMirrorContent' | 'ready' | 'setState' | 'telemetry' | 'websocket'
 | 'getVscodeSettings' | 'copyText' | 'focusEditor' | 'focusEditorGroup' | 'openUrl' | 'consoleOutput';
 export const webviewEventNames: WebviewEvent[] = [
     'getState',
     'getUrl',
     'openInEditor',
+    'cssMirrorContent',
     'ready',
     'setState',
     'telemetry',
@@ -20,11 +21,12 @@ export const webviewEventNames: WebviewEvent[] = [
 ];
 
 export type FrameToolsEvent = 'sendMessageToBackend' | 'openInNewTab' | 'recordEnumeratedHistogram' |
-'recordPerformanceHistogram' | 'reportError' | 'openInEditor';
+'recordPerformanceHistogram' | 'reportError' | 'openInEditor' | 'cssMirrorContent';
 export const FrameToolsEventNames: FrameToolsEvent[] = [
     'sendMessageToBackend',
     'openInNewTab',
     'openInEditor',
+    'cssMirrorContent',
     'recordEnumeratedHistogram',
     'recordPerformanceHistogram',
     'reportError',
@@ -64,6 +66,11 @@ export interface IOpenEditorData {
     ignoreTabChanges: boolean;
 }
 
+export interface ICssMirrorContentData {
+    url: string;
+    newContent: string;
+}
+
 /**
  * Parse out the WebviewEvents type from a message and call the appropriate emit event
  *
diff --git a/src/devtoolsPanel.ts b/src/devtoolsPanel.ts
index 54cc6db..9b75657 100644
--- a/src/devtoolsPanel.ts
+++ b/src/devtoolsPanel.ts
@@ -9,6 +9,7 @@ import TelemetryReporter from 'vscode-extension-telemetry';
 import { SettingsProvider } from './common/settingsProvider';
 import {
     encodeMessageForChannel,
+    ICssMirrorContentData,
     IOpenEditorData,
     ITelemetryMeasures,
     ITelemetryProps,
@@ -86,6 +87,7 @@ export class DevToolsPanel {
         this.panelSocket.on('getUrl', msg => this.onSocketGetUrl(msg) as unknown as void);
         this.panelSocket.on('openUrl', msg => this.onSocketOpenUrl(msg) as unknown as void);
         this.panelSocket.on('openInEditor', msg => this.onSocketOpenInEditor(msg) as unknown as void);
+        this.panelSocket.on('cssMirrorContent', msg => this.onSocketCssMirrorContent(msg) as unknown as void);
         this.panelSocket.on('close', () => this.onSocketClose());
         this.panelSocket.on('copyText', msg => this.onSocketCopyText(msg));
         this.panelSocket.on('focusEditor', msg => this.onSocketFocusEditor(msg));
@@ -304,6 +306,69 @@ export class DevToolsPanel {
             return;
         }
 
+        const uri = await this.parseUrlToUri(url);
+        if (uri) {
+            void this.openEditorFromUri(uri, line, column);
+        } else {
+            await ErrorReporter.showErrorDialog({
+                errorCode: ErrorCodes.Error,
+                title: 'Error while opening file in editor.',
+                message: `Could not open document. No workspace mapping was found for '${url}'.`,
+            });
+        }
+    }
+
+    private async onSocketCssMirrorContent(message: string) {
+        if (!vscode.workspace.getConfiguration(SETTINGS_STORE_NAME).get('mirrorEdits')) {
+            return;
+        }
+
+        // Parse message and open the requested file
+        const { url, newContent } = JSON.parse(message) as ICssMirrorContentData;
+
+        const uri = await this.parseUrlToUri(url);
+
+        // Finally open and edit the document if it exists
+        if (uri) {
+            const textEditor = await this.openEditorFromUri(uri);
+            if (textEditor) {
+                const fullRange = this.getDocumentFullRange(textEditor);
+                void textEditor.edit(editBuilder => {
+                    editBuilder.replace(fullRange, newContent);
+                });
+            }
+        } else {
+            await ErrorReporter.showErrorDialog({
+                errorCode: ErrorCodes.Error,
+                title: 'Error while mirroring css content to document.',
+                message: `Could not mirror css changes to document. No workspace mapping was found for '${url}'.`,
+            });
+        }
+    }
+
+    private async openEditorFromUri(uri: vscode.Uri, line?: number, column?: number): Promise<vscode.TextEditor | undefined> {
+        try {
+            const doc = await vscode.workspace.openTextDocument(uri);
+            const viewColumn = this.panel.viewColumn === vscode.ViewColumn.One ? vscode.ViewColumn.Beside : vscode.ViewColumn.One;
+            const selection = line !== undefined && column !== undefined ? new vscode.Range(line, column, line, column) : undefined;
+            return await vscode.window.showTextDocument(doc, { preserveFocus: true, viewColumn, selection });
+        } catch (e) {
+            await ErrorReporter.showErrorDialog({
+                errorCode: ErrorCodes.Error,
+                title: 'Error while opening file in editor.',
+                message: e,
+            });
+        }
+    }
+
+    private getDocumentFullRange(textEditor: vscode.TextEditor): vscode.Range {
+        const firstLine = textEditor.document.lineAt(0);
+        const lastLine = textEditor.document.lineAt(textEditor.document.lineCount - 1);
+        const range =  new vscode.Range(firstLine.range.start, lastLine.range.end);
+        return range;
+    }
+
+    private async parseUrlToUri(url: string): Promise<vscode.Uri | undefined> {
         // Convert the devtools url into a local one
         let sourcePath = url;
         let appendedEntryPoint = false;
@@ -340,36 +405,16 @@ export class DevToolsPanel {
         if (!localSource.origin) {
             // Convert the workspace path into a VS Code url
             const uri = vscode.Uri.file(localSource.path);
-            await this.openInEditor(uri, line, column);
-        } else {
-            // If failed to resolve origin, it's possible entrypoint needs to be updated.
-            // Space at beginning to allow insertion in message below
-            const entryPointErrorMessage = ` Consider updating the 'Default Entrypoint' setting to map to your root html page. The current setting is '${this.config.defaultEntrypoint}'.`;
-            await ErrorReporter.showInformationDialog({
-                errorCode: ErrorCodes.Error,
-                title: 'Unable to open file in editor.',
-                message: `${sourcePath} does not map to a local file.${appendedEntryPoint ? entryPointErrorMessage : ''}`,
-            });
-        }
-    }
-
-    private async openInEditor(uri:vscode.Uri, line: number, column: number){
-        try {
-            const doc = await vscode.workspace.openTextDocument(uri);
-            void vscode.window.showTextDocument(
-                doc,
-                {
-                    preserveFocus: true,
-                    selection: new vscode.Range(line, column, line, column),
-                    viewColumn: vscode.ViewColumn.One,
-            });
-        } catch (e) {
-            await ErrorReporter.showErrorDialog({
-                errorCode: ErrorCodes.Error,
-                title: 'Error while opening file in editor.',
-                message: e,
-            });
+            return uri;
         }
+        // If failed to resolve origin, it's possible entrypoint needs to be updated.
+        // Space at beginning to allow insertion in message below
+        const entryPointErrorMessage = ` Consider updating the 'Default Entrypoint' setting to map to your root html page. The current setting is '${this.config.defaultEntrypoint}'.`;
+        await ErrorReporter.showInformationDialog({
+            errorCode: ErrorCodes.Error,
+            title: 'Unable to open file in editor.',
+            message: `${sourcePath} does not map to a local file.${appendedEntryPoint ? entryPointErrorMessage : ''}`,
+        });
     }
 
     private update() {
@@ -467,9 +512,11 @@ export class DevToolsPanel {
     private setCdnParameters(msg: {revision: string, isHeadless: boolean}) {
         if (msg.revision !== '') {
             this.config.isCdnHostedTools = true;
+            void vscode.commands.executeCommand('setContext', 'isCdnHostedTools', true);
             this.devtoolsBaseUri = `https://devtools.azureedge.net/serve_file/${msg.revision}/vscode_app.html`;
         } else {
             this.config.isCdnHostedTools = false;
+            void vscode.commands.executeCommand('setContext', 'isCdnHostedTools', false);
             this.devtoolsBaseUri = '';
         }
         this.isHeadless = msg.isHeadless;
diff --git a/src/extension.ts b/src/extension.ts
index 8da5612..2db10af 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -167,6 +167,20 @@ export function activate(context: vscode.ExtensionContext): void {
             telemetryReporter.sendTelemetryEvent('user/buttonPress', { 'VSCode.buttonCode': buttonCode.viewDocumentation });
             void vscode.env.openExternal(vscode.Uri.parse('https://docs.microsoft.com/en-us/microsoft-edge/visual-studio-code/microsoft-edge-devtools-extension'));
         }));
+
+    const settingsConfig = vscode.workspace.getConfiguration(SETTINGS_STORE_NAME);
+    const mirrorEditsEnabled = settingsConfig.get('mirrorEdits');
+    void vscode.commands.executeCommand('setContext', 'mirrorEditingEnabled', mirrorEditsEnabled);
+    context.subscriptions.push(vscode.commands.registerCommand(`${SETTINGS_VIEW_NAME}.toggleMirrorEditingOn`, () => {
+            void settingsConfig.update('mirrorEdits', true, true);
+            void vscode.commands.executeCommand('setContext', 'mirrorEditingEnabled', true);
+        }));
+    context.subscriptions.push(vscode.commands.registerCommand(`${SETTINGS_VIEW_NAME}.toggleMirrorEditingOff`, () => {
+        void settingsConfig.update('mirrorEdits', false, true);
+        void vscode.commands.executeCommand('setContext', 'mirrorEditingEnabled', false);
+        }));
+    void settingsConfig.update('mirrorEdits', true, true);
+    void vscode.commands.executeCommand('setContext', 'mirrorEditingEnabled', true);
     void vscode.commands.executeCommand('setContext', 'titleCommandsRegistered', true);
     void reportFileExtensionTypes(telemetryReporter);
     reportExtensionSettings(telemetryReporter);
diff --git a/src/host_beta/messageRouter.ts b/src/host_beta/messageRouter.ts
index f6a9877..9c515f1 100644
--- a/src/host_beta/messageRouter.ts
+++ b/src/host_beta/messageRouter.ts
@@ -4,6 +4,7 @@
 import {
     encodeMessageForChannel,
     FrameToolsEvent,
+    ICssMirrorContentData,
     IOpenEditorData,
     parseMessageFromChannel,
     TelemetryData,
@@ -70,6 +71,10 @@ export class MessageRouter {
                 const [url, line, column, ignoreTabChanges] = args;
                 this.openInEditor(url, line, column, ignoreTabChanges);
                 return true;
+            case 'cssMirrorContent':
+                const [cssUrl = url, newContent] = args;
+                this.cssMirrorContent(cssUrl, newContent);
+                return true;
             case 'openInNewTab':
                 this.openInNewTab(args[0]);
                 return true;
@@ -152,6 +157,12 @@ export class MessageRouter {
         encodeMessageForChannel(msg => vscode.postMessage(msg, '*'), 'openInEditor', request);
     }
 
+    private cssMirrorContent(url: string, newContent: string): void {
+        // Forward the data to the extension
+        const request: ICssMirrorContentData = { url, newContent };
+        encodeMessageForChannel(msg => vscode.postMessage(msg, '*'), 'cssMirrorContent', request);
+    }
+
     private openInNewTab(url: string): void {
         encodeMessageForChannel(msg => vscode.postMessage(msg, '*'), 'openUrl', {url});
     }


Test patch:
diff --git a/test/devtoolsPanel.test.ts b/test/devtoolsPanel.test.ts
index 6e3fea5..b0875c4 100644
--- a/test/devtoolsPanel.test.ts
+++ b/test/devtoolsPanel.test.ts
@@ -552,7 +552,7 @@ describe("devtoolsPanel", () => {
                 expect(mockVsCode.window.showInformationMessage ).toHaveBeenCalled();
             });
 
-            it("calls show document for open in editor", async () => {
+            it("calls openTextDocument for open in editor", async () => {
                 const expectedRequest = {
                     column: 5,
                     ignoreTabChanges: false,
@@ -577,13 +577,11 @@ describe("devtoolsPanel", () => {
                 jest.mock("vscode-chrome-debug-core", () => mockDebugCore);
 
                 const mockVsCode = jest.requireMock("vscode");
-                mockVsCode.Uri.file = jest.fn();
-
                 const dtp = await import("../src/devtoolsPanel");
                 dtp.DevToolsPanel.createOrShow(context, mockTelemetry, "", mockRuntimeConfig);
 
                 await hookedEvents.get("openInEditor")!(JSON.stringify(expectedRequest));
-                expect(mockVsCode.window.showTextDocument).toHaveBeenCalled();
+                expect(mockVsCode.workspace.openTextDocument).toHaveBeenCalled();
             });
 
             it("shows an error dialog for open in editor exceptions", async () => {
@@ -623,6 +621,28 @@ describe("devtoolsPanel", () => {
                 expect(mockVsCode.window.showErrorMessage).toHaveBeenCalled();
             });
 
+            it("calls openTextDocument for onSocketCssMirrorContent", async () => {
+                const expectedRequest = {
+                    url: "app.js",
+                    newContent: ".body{color: blue;}"
+                };
+
+                const mockVsCode = jest.requireMock("vscode");
+                const mockUtils = {
+                    applyPathMapping: jest.fn().mockImplementation((x) => x),
+                    fetchUri: jest.fn().mockRejectedValue(null),
+                };
+                jest.doMock("../src/utils", () => mockUtils);
+
+                const dtp = await import("../src/devtoolsPanel");
+                const { TextEncoder } = require('util');
+                global.TextEncoder = TextEncoder;
+                dtp.DevToolsPanel.createOrShow(context, mockTelemetry, "", mockRuntimeConfig);
+
+                await hookedEvents.get("cssMirrorContent")!(JSON.stringify(expectedRequest));
+                expect(mockVsCode.workspace.openTextDocument).toHaveBeenCalled();
+            });
+
             it("calls getVscodeSettings", async () => {
                 const expectedId = { id: 0 };
                 const expectedState = { enableNetwork: true, themeString: "System preference", welcome: true, isHeadless: false};
diff --git a/test/extension.test.ts b/test/extension.test.ts
index 4aa38f2..2acc010 100644
--- a/test/extension.test.ts
+++ b/test/extension.test.ts
@@ -81,8 +81,8 @@ describe("extension", () => {
             // Activation should add the commands as subscriptions on the context
             newExtension.activate(context);
 
-            expect(context.subscriptions.length).toBe(14);
-            expect(commandMock).toHaveBeenCalledTimes(13);
+            expect(context.subscriptions.length).toBe(16);
+            expect(commandMock).toHaveBeenCalledTimes(15);
             expect(commandMock)
                 .toHaveBeenNthCalledWith(1, `${SETTINGS_STORE_NAME}.attach`, expect.any(Function));
             expect(commandMock)
diff --git a/test/helpers/helpers.ts b/test/helpers/helpers.ts
index 4ce0c04..95a45ba 100644
--- a/test/helpers/helpers.ts
+++ b/test/helpers/helpers.ts
@@ -90,6 +90,8 @@ export function createFakeVSCode() {
                                 return true;
                             case "isHeadless":
                                 return false;
+                            case "mirrorEdits":
+                                return true;
                             default:
                                 return undefined;
                         }
@@ -107,7 +109,9 @@ export function createFakeVSCode() {
                             default:
                                 return {defaultValue: undefined};
                         }
-                    }
+                    },
+                    update: jest.fn(),
+                    has: jest.fn()
                 };
             }),
             onDidChangeConfiguration: jest.fn(),
@@ -116,7 +120,10 @@ export function createFakeVSCode() {
                 {
                     uri:  'file:///g%3A/GIT/testPage'
                 }
-            ]
+            ],
+            fs: {
+                writeFile: jest.fn()
+            }
         },
     };
 }
diff --git a/test/utils.test.ts b/test/utils.test.ts
index 399fae3..2abe1df 100644
--- a/test/utils.test.ts
+++ b/test/utils.test.ts
@@ -1014,8 +1014,6 @@ describe("utils", () => {
             vscodeMock.workspace.getConfiguration.mockImplementation(() => {
                 return {
                     ...originalWorkspaceMockConfig,
-                    fillerPropertyOne: 'Need to add filler values since the extension related telemetry functions removes the first 4 entries to create an array of extension settings',
-                    fillerPropertyTwo: 'The first 4 entries for a workspaceConfiguration object are class related functions, the rest are all extension related settings.',
                     themes: 'System preference',
                     welcome: 'true',
                     enableNetwork: 'false',