Header Overrides Editor: show original header after deleting override

When deleting a header override in the UI and the response as received
over the wire before any overrides already contains a header with the
same name, show this original header in place of the deleted header
override

Screencast: https://i.imgur.com/CMJUtuW.mp4

Bug: 1297533
Change-Id: Ic929b50535184207af0f1fd8857e08bd959534b6
Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/3974770
Reviewed-by: Danil Somsikov <dsv@chromium.org>
Commit-Queue: Wolfgang Beyer <wolfi@chromium.org>
diff --git a/front_end/panels/network/components/HeaderSectionRow.ts b/front_end/panels/network/components/HeaderSectionRow.ts
index 30e6e45..53df626 100644
--- a/front_end/panels/network/components/HeaderSectionRow.ts
+++ b/front_end/panels/network/components/HeaderSectionRow.ts
@@ -175,7 +175,7 @@
     // value of the contenteditable element and not the potentially outdated
     // value from the previous render.
     // clang-format off
-    return LitHtml.html`<span contenteditable="true" class="editable" tabindex="0" .innerText=${LitHtml.Directives.live(value)}></span>`;
+    return html`<span contenteditable="true" class="editable" tabindex="0" .innerText=${LitHtml.Directives.live(value)}></span>`;
     // clang-format on
   }
 
@@ -325,6 +325,10 @@
     const headerValueElement = this.#shadow.querySelector('.header-value .editable') as HTMLElement;
     const headerName = Platform.StringUtilities.toLowerCaseString(headerNameElement.innerText.slice(0, -1));
     const headerValue = headerValueElement.innerText;
+    const row = this.shadowRoot?.querySelector<HTMLDivElement>('.row');
+    if (row) {
+      row.classList.remove('header-overridden');
+    }
     this.dispatchEvent(new HeaderRemovedEvent(headerName, headerValue));
   }
 
diff --git a/front_end/panels/network/components/ResponseHeaderSection.ts b/front_end/panels/network/components/ResponseHeaderSection.ts
index 955c75b..50aff69 100644
--- a/front_end/panels/network/components/ResponseHeaderSection.ts
+++ b/front_end/panels/network/components/ResponseHeaderSection.ts
@@ -89,7 +89,7 @@
 export class ResponseHeaderSection extends HTMLElement {
   static readonly litTagName = LitHtml.literal`devtools-response-header-section`;
   readonly #shadow = this.attachShadow({mode: 'open'});
-  #request?: Readonly<SDK.NetworkRequest.NetworkRequest>;
+  #request?: SDK.NetworkRequest.NetworkRequest;
   #headerDetails: HeaderDetailsDescriptor[] = [];
   #headerEditors: HeaderEditorDescriptor[] = [];
   #uiSourceCode: Workspace.UISourceCode.UISourceCode|null = null;
@@ -343,9 +343,27 @@
     const rawFileName = this.#fileNameFromUrl(this.#request.url());
     this.#removeEntryFromOverrides(rawFileName, event.headerName, event.headerValue);
     this.#commitOverrides();
-    this.#headerEditors.splice(index, 1);
     if (index < this.#headerDetails.length) {
-      this.#headerDetails.splice(index, 1);
+      const originalHeaders =
+          (this.#request?.originalResponseHeaders ||
+           []).filter(header => Platform.StringUtilities.toLowerCaseString(header.name) === event.headerName);
+      if (originalHeaders.length === 1) {
+        // Remove the header override and replace it with the original non-
+        // overridden header in the UI.
+        this.#headerDetails[index].value = originalHeaders[0].value;
+        this.#headerEditors[index].value = originalHeaders[0].value;
+        this.#headerEditors[index].originalValue = originalHeaders[0].value;
+        this.#headerEditors[index].isOverride = false;
+      } else {
+        // If there is no (or multiple) matching originalResonseHeader,
+        // remove the header from the UI.
+        this.#headerDetails.splice(index, 1);
+        this.#headerEditors.splice(index, 1);
+      }
+    } else {
+      // This is the branch for headers which were added via the UI after the
+      // response was received. They can simply be removed.
+      this.#headerEditors.splice(index, 1);
     }
     this.#render();
   }
@@ -354,6 +372,13 @@
     if (!this.#request) {
       return;
     }
+    // If 'originalResponseHeaders' are not populated (because there was no
+    // request interception), fill them with a copy of 'sortedResponseHeaders'.
+    // This ensures we have access to the original values when undoing edits.
+    if (this.#request.originalResponseHeaders.length === 0) {
+      this.#request.originalResponseHeaders =
+          this.#request.sortedResponseHeaders.map(headerEntry => ({...headerEntry}));
+    }
 
     const previousName = this.#headerEditors[index].name;
     const previousValue = this.#headerEditors[index].value;
diff --git a/test/unittests/front_end/panels/network/components/ResponseHeaderSection_test.ts b/test/unittests/front_end/panels/network/components/ResponseHeaderSection_test.ts
index 7ed2f59..c2419e0 100644
--- a/test/unittests/front_end/panels/network/components/ResponseHeaderSection_test.ts
+++ b/test/unittests/front_end/panels/network/components/ResponseHeaderSection_test.ts
@@ -59,14 +59,16 @@
   dispatchFocusOutEvent(editable, {bubbles: true});
 }
 
-function removeHeaderRow(component: NetworkComponents.ResponseHeaderSection.ResponseHeaderSection): void {
+async function removeHeaderRow(
+    component: NetworkComponents.ResponseHeaderSection.ResponseHeaderSection, index: number): Promise<void> {
   assertShadowRoot(component.shadowRoot);
-  const row = component.shadowRoot.querySelector('devtools-header-section-row');
+  const row = component.shadowRoot.querySelectorAll('devtools-header-section-row')[index];
   assertElement(row, HTMLElement);
   assertShadowRoot(row.shadowRoot);
   const button = row.shadowRoot.querySelector('.remove-header');
   assertElement(button, HTMLElement);
   button.click();
+  await coordinator.done();
 }
 
 async function setupHeaderEditing(
@@ -516,6 +518,10 @@
           {
             "name": "cache-control",
             "value": "max-age=9999"
+          },
+          {
+            "name": "added",
+            "value": "foo"
           }
         ]
       }
@@ -524,6 +530,7 @@
     const actualHeaders = [
       {name: 'server', value: 'overridden server'},
       {name: 'cache-control', value: 'max-age=9999'},
+      {name: 'added', value: 'foo'},
     ];
 
     const originalHeaders = [
@@ -532,9 +539,35 @@
     ];
 
     const {component, spy} = await setupHeaderEditing(headerOverridesFileContent, actualHeaders, originalHeaders);
-    removeHeaderRow(component);
+    await removeHeaderRow(component, 0);
 
-    const expected = [{
+    let expected = [{
+      applyTo: 'index.html',
+      headers: [
+        {
+          name: 'cache-control',
+          value: 'max-age=9999',
+        },
+        {
+          name: 'added',
+          value: 'foo',
+        },
+      ],
+    }];
+    assert.strictEqual(spy.callCount, 1);
+    assert.isTrue(spy.calledOnceWith(JSON.stringify(expected, null, 2)));
+
+    assertShadowRoot(component.shadowRoot);
+    let rows = component.shadowRoot.querySelectorAll('devtools-header-section-row');
+    assert.strictEqual(rows.length, 3);
+    checkHeaderSectionRow(rows[0], 'server:', 'original server', false, false, true);
+    checkHeaderSectionRow(rows[1], 'cache-control:', 'max-age=9999', true, false, true);
+    checkHeaderSectionRow(rows[2], 'added:', 'foo', true, false, true);
+
+    spy.resetHistory();
+    await removeHeaderRow(component, 2);
+
+    expected = [{
       applyTo: 'index.html',
       headers: [
         {
@@ -545,6 +578,10 @@
     }];
     assert.strictEqual(spy.callCount, 1);
     assert.isTrue(spy.calledOnceWith(JSON.stringify(expected, null, 2)));
+    rows = component.shadowRoot.querySelectorAll('devtools-header-section-row');
+    assert.strictEqual(rows.length, 2);
+    checkHeaderSectionRow(rows[0], 'server:', 'original server', false, false, true);
+    checkHeaderSectionRow(rows[1], 'cache-control:', 'max-age=9999', true, false, true);
   });
 
   it('can remove the last header override', async () => {
@@ -569,7 +606,7 @@
     ];
 
     const {component, spy} = await setupHeaderEditing(headerOverridesFileContent, actualHeaders, originalHeaders);
-    removeHeaderRow(component);
+    await removeHeaderRow(component, 0);
 
     const expected: Persistence.NetworkPersistenceManager.HeaderOverride[] = [];
     assert.strictEqual(spy.callCount, 1);
@@ -669,6 +706,42 @@
     assert.isTrue(spy.getCall(-1).calledWith(JSON.stringify(expected, null, 2)));
   });
 
+  it('can remove a newly added header', async () => {
+    const actualHeaders = [
+      {name: 'server', value: 'original server'},
+    ];
+    const {component, spy} = await setupHeaderEditing('[]', actualHeaders, actualHeaders);
+    assertShadowRoot(component.shadowRoot);
+    const addHeaderButton = component.shadowRoot.querySelector('.add-header-button');
+    assertElement(addHeaderButton, HTMLElement);
+    addHeaderButton.click();
+    await coordinator.done();
+
+    const expected = [{
+      applyTo: 'index.html',
+      headers: [
+        {
+          name: 'header-name',
+          value: 'header value',
+        },
+      ],
+    }];
+    assert.isTrue(spy.getCall(-1).calledWith(JSON.stringify(expected, null, 2)));
+    let rows = component.shadowRoot.querySelectorAll('devtools-header-section-row');
+    assert.strictEqual(rows.length, 2);
+    checkHeaderSectionRow(rows[0], 'server:', 'original server', false, false, true);
+    checkHeaderSectionRow(rows[1], 'header-name:', 'header value', true, true, true);
+
+    spy.resetHistory();
+    await removeHeaderRow(component, 1);
+
+    assert.strictEqual(spy.callCount, 1);
+    assert.isTrue(spy.calledOnceWith(JSON.stringify([], null, 2)));
+    rows = component.shadowRoot.querySelectorAll('devtools-header-section-row');
+    assert.strictEqual(rows.length, 1);
+    checkHeaderSectionRow(rows[0], 'server:', 'original server', false, false, true);
+  });
+
   it('can edit multiple headers', async () => {
     const headerOverridesFileContent = `[
       {