Allow modifying headers in the Network Panel

This CL allows the user to edit existing headers in the network
panel's headers view. The edits will be added to the header overrides,
such that upon refreshing the page, the response headers contain the
edited values.

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

The edits are stored to disk and sent to the backend when the editable
element loses focus.

If there are multiple headers with the same name e.g. 'foo', these are
treated as a single unit. This means that when overriding them, all
'foo' headers are removed and replaced with the override(s) for 'foo'.

Bug: 1363132
Change-Id: Ib2ee738bfbe0974d12f0f854210afaa16f0a7259
Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/3879629
Reviewed-by: Danil Somsikov <[email protected]>
Commit-Queue: Wolfgang Beyer <[email protected]>
diff --git a/front_end/core/i18n/locales/en-US.json b/front_end/core/i18n/locales/en-US.json
index 992b5e4..2e3de21 100644
--- a/front_end/core/i18n/locales/en-US.json
+++ b/front_end/core/i18n/locales/en-US.json
@@ -6947,6 +6947,9 @@
   "panels/network/components/RequestHeadersView.ts | responseHeaders": {
     "message": "Response Headers"
   },
+  "panels/network/components/RequestHeadersView.ts | revealHeaderOverrides": {
+    "message": "Reveal header override definitions"
+  },
   "panels/network/components/RequestHeadersView.ts | showMore": {
     "message": "Show more"
   },
diff --git a/front_end/core/i18n/locales/en-XL.json b/front_end/core/i18n/locales/en-XL.json
index a59cc47..aa9a868 100644
--- a/front_end/core/i18n/locales/en-XL.json
+++ b/front_end/core/i18n/locales/en-XL.json
@@ -6947,6 +6947,9 @@
   "panels/network/components/RequestHeadersView.ts | responseHeaders": {
     "message": "R̂éŝṕôńŝé Ĥéâd́êŕŝ"
   },
+  "panels/network/components/RequestHeadersView.ts | revealHeaderOverrides": {
+    "message": "R̂év̂éâĺ ĥéâd́êŕ ôv́êŕr̂íd̂é d̂éf̂ín̂ít̂íôńŝ"
+  },
   "panels/network/components/RequestHeadersView.ts | showMore": {
     "message": "Ŝh́ôẃ m̂ór̂é"
   },
diff --git a/front_end/models/persistence/NetworkPersistenceManager.ts b/front_end/models/persistence/NetworkPersistenceManager.ts
index 4432d12..ccd52d7 100644
--- a/front_end/models/persistence/NetworkPersistenceManager.ts
+++ b/front_end/models/persistence/NetworkPersistenceManager.ts
@@ -275,8 +275,7 @@
         (this.projectInternal as FileSystem).fileSystemPath(), '/', this.encodedPathFromUrl(url, ignoreInactive));
   }
 
-  private getHeadersUISourceCodeFromUrl(url: Platform.DevToolsPath.UrlString): Workspace.UISourceCode.UISourceCode
-      |null {
+  getHeadersUISourceCodeFromUrl(url: Platform.DevToolsPath.UrlString): Workspace.UISourceCode.UISourceCode|null {
     const fileUrlFromRequest = this.fileUrlFromNetworkUrl(url, /* ignoreNoActive */ true);
     const folderUrlFromRequest =
         Common.ParsedURL.ParsedURL.substring(fileUrlFromRequest, 0, fileUrlFromRequest.lastIndexOf('/'));
@@ -790,7 +789,7 @@
   }
   return arg.headers.every(
       (header: Protocol.Fetch.HeaderEntry) =>
-          header.name && typeof header.name === 'string' && header.value && typeof header.value === 'string');
+          header.name && typeof header.name === 'string' && typeof header.value === 'string');
 }
 
 export function escapeRegex(pattern: string): string {
diff --git a/front_end/panels/network/NetworkLogView.ts b/front_end/panels/network/NetworkLogView.ts
index 89ac524..f45a812 100644
--- a/front_end/panels/network/NetworkLogView.ts
+++ b/front_end/panels/network/NetworkLogView.ts
@@ -1713,26 +1713,17 @@
   }
 
   async #handleCreateResponseHeaderOverrideClick(request: SDK.NetworkRequest.NetworkRequest): Promise<void> {
-    if (Persistence.NetworkPersistenceManager.NetworkPersistenceManager.instance().project()) {
-      await this.#revealHeaderOverrideEditor(request);
+    const networkPersistanceManager = Persistence.NetworkPersistenceManager.NetworkPersistenceManager.instance();
+    if (networkPersistanceManager.project()) {
+      await networkPersistanceManager.getOrCreateHeadersUISourceCodeFromUrl(request.url());
     } else {  // If folder for local overrides has not been provided yet
       UI.InspectorView.InspectorView.instance().displaySelectOverrideFolderInfobar(async(): Promise<void> => {
         await Sources.SourcesNavigator.OverridesNavigatorView.instance().setupNewWorkspace();
-        await this.#revealHeaderOverrideEditor(request);
+        await networkPersistanceManager.getOrCreateHeadersUISourceCodeFromUrl(request.url());
       });
     }
   }
 
-  async #revealHeaderOverrideEditor(request: SDK.NetworkRequest.NetworkRequest): Promise<void> {
-    const networkPersistanceManager = Persistence.NetworkPersistenceManager.NetworkPersistenceManager.instance();
-    const uiSourceCode = await networkPersistanceManager.getOrCreateHeadersUISourceCodeFromUrl(request.url());
-    if (uiSourceCode) {
-      const sourcesPanel = Sources.SourcesPanel.SourcesPanel.instance();
-      sourcesPanel.showUISourceCode(uiSourceCode);
-      sourcesPanel.revealInNavigator(uiSourceCode);
-    }
-  }
-
   private clearBrowserCache(): void {
     if (confirm(i18nString(UIStrings.areYouSureYouWantToClearBrowser))) {
       SDK.NetworkManager.MultitargetNetworkManager.instance().clearBrowserCache();
diff --git a/front_end/panels/network/components/HeaderSectionRow.css b/front_end/panels/network/components/HeaderSectionRow.css
index 59dba36..ce9ec35 100644
--- a/front_end/panels/network/components/HeaderSectionRow.css
+++ b/front_end/panels/network/components/HeaderSectionRow.css
@@ -119,3 +119,24 @@
   border-left: 3px solid var(--color-accent-green);
   padding-left: 5px;
 }
+
+.editable {
+  cursor: text;
+  color: var(--color-text-primary);
+  overflow-wrap: anywhere;
+  min-height: 18px;
+  line-height: 18px;
+  min-width: 0.5em;
+  background: transparent;
+  border: none;
+  outline: none;
+  display: inline-block;
+  font-family: var(--monospace-font-family);
+  font-size: var(--monospace-font-size);
+}
+
+.editable:hover,
+.editable:focus {
+  box-shadow: 0 0 0 1px var(--color-details-hairline);
+  border-radius: 2px;
+}
diff --git a/front_end/panels/network/components/HeaderSectionRow.ts b/front_end/panels/network/components/HeaderSectionRow.ts
index 7ba2652..3bcc3af 100644
--- a/front_end/panels/network/components/HeaderSectionRow.ts
+++ b/front_end/panels/network/components/HeaderSectionRow.ts
@@ -43,6 +43,16 @@
 const str_ = i18n.i18n.registerUIStrings('panels/network/components/HeaderSectionRow.ts', UIStrings);
 const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
 
+export class HeaderValueChangedEvent extends Event {
+  static readonly eventName = 'headervaluechanged';
+  headerValue: string;
+
+  constructor(headerValue: string) {
+    super(HeaderValueChangedEvent.eventName, {});
+    this.headerValue = headerValue;
+  }
+}
+
 export interface HeaderSectionRowData {
   header: HeaderDescriptor;
 }
@@ -54,6 +64,10 @@
 
   connectedCallback(): void {
     this.#shadow.adoptedStyleSheets = [headerSectionRowStyles];
+    this.#shadow.addEventListener('focusin', this.#onFocusIn.bind(this));
+    this.#shadow.addEventListener('focusout', this.#onFocusOut.bind(this));
+    this.#shadow.addEventListener('keydown', this.#onKeyDown.bind(this));
+    this.#shadow.addEventListener('paste', this.#onPaste.bind(this));
   }
 
   set data(data: HeaderSectionRowData) {
@@ -86,7 +100,7 @@
           class="header-value ${this.#header.headerValueIncorrect ? 'header-warning' : ''}"
           @copy=${():void => Host.userMetrics.actionTaken(Host.UserMetrics.Action.NetworkPanelCopyValue)}
         >
-          ${this.#header.value || ''}
+          ${this.#header.editable ? this.#renderEditable(this.#header.value || '') : this.#header.value || ''}
           ${this.#maybeRenderHeaderValueSuffix(this.#header)}
         </div>
       </div>
@@ -95,6 +109,16 @@
     // clang-format on
   }
 
+  #renderEditable(value: string): LitHtml.TemplateResult {
+    // This uses LitHtml's `live`-directive, so that when checking whether to
+    // update during re-render, `value` is compared against the actual live DOM
+    // 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>`;
+    // clang-format on
+  }
+
   #maybeRenderHeaderValueSuffix(header: HeaderDescriptor): LitHtml.LitTemplate {
     if (header.name === 'set-cookie' && header.setCookieBlockedReasons) {
       const titleText =
@@ -188,6 +212,61 @@
     }
     return LitHtml.nothing;
   }
+
+  #selectAllText(target: HTMLElement): void {
+    const selection = window.getSelection();
+    const range = document.createRange();
+    range.selectNodeContents(target);
+    selection?.removeAllRanges();
+    selection?.addRange(range);
+  }
+
+  #onFocusIn(e: Event): void {
+    const target = e.target as HTMLElement;
+    if (target.matches('.editable')) {
+      this.#selectAllText(target);
+    }
+  }
+
+  #onFocusOut(e: Event): void {
+    const target = e.target as HTMLElement;
+    if (this.#header?.value !== target.innerText) {
+      this.dispatchEvent(new HeaderValueChangedEvent(target.innerText));
+    }
+
+    // clear selection
+    const selection = window.getSelection();
+    selection?.removeAllRanges();
+  }
+
+  #onKeyDown(event: Event): void {
+    const keyboardEvent = event as KeyboardEvent;
+    const target = event.target as HTMLElement;
+    if (keyboardEvent.key === 'Escape') {
+      event.consume();
+      target.innerText = this.#header?.value || '';
+      target.blur();
+    }
+    if (keyboardEvent.key === 'Enter') {
+      event.preventDefault();
+      target.blur();
+    }
+  }
+
+  #onPaste(event: Event): void {
+    const clipboardEvent = event as ClipboardEvent;
+    event.preventDefault();
+    if (clipboardEvent.clipboardData) {
+      const text = clipboardEvent.clipboardData.getData('text/plain');
+
+      const selection = this.#shadow.getSelection();
+      if (!selection) {
+        return;
+      }
+      selection.deleteFromDocument();
+      selection.getRangeAt(0).insertNode(document.createTextNode(text));
+    }
+  }
 }
 
 ComponentHelpers.CustomElements.defineComponent('devtools-header-section-row', HeaderSectionRow);
@@ -196,6 +275,10 @@
   interface HTMLElementTagNameMap {
     'devtools-header-section-row': HeaderSectionRow;
   }
+
+  interface HTMLElementEventMap {
+    [HeaderValueChangedEvent.eventName]: HeaderValueChangedEvent;
+  }
 }
 
 interface BlockedDetailsDescriptor {
@@ -213,10 +296,12 @@
 export interface HeaderDescriptor {
   name: Platform.StringUtilities.LowerCaseString;
   value: string|null;
+  originalValue?: string|null;
   headerValueIncorrect?: boolean;
   blockedDetails?: BlockedDetailsDescriptor;
   headerNotSet?: boolean;
   setCookieBlockedReasons?: Protocol.Network.SetCookieBlockedReason[];
   highlight?: boolean;
   isOverride?: boolean;
+  editable?: boolean;
 }
diff --git a/front_end/panels/network/components/RequestHeadersView.ts b/front_end/panels/network/components/RequestHeadersView.ts
index fc80357..58a556c 100644
--- a/front_end/panels/network/components/RequestHeadersView.ts
+++ b/front_end/panels/network/components/RequestHeadersView.ts
@@ -90,6 +90,10 @@
   */
   responseHeaders: 'Response Headers',
   /**
+  *@description Title text for a link to the Sources panel to the file containing the header override definitions
+  */
+  revealHeaderOverrides: 'Reveal header override definitions',
+  /**
   *@description Text to show more content
   */
   showMore: 'Show more',
@@ -270,7 +274,7 @@
     };
 
     return html`
-      <x-link @click=${revealHeadersFile} class="link devtools-link">
+      <x-link @click=${revealHeadersFile} class="link devtools-link" title=${UIStrings.revealHeaderOverrides}>
         ${fileIcon}${i18nString(UIStrings.headerOverrides)}
       </x-link>
     `;
diff --git a/front_end/panels/network/components/ResponseHeaderSection.ts b/front_end/panels/network/components/ResponseHeaderSection.ts
index 6c551c7..4917575 100644
--- a/front_end/panels/network/components/ResponseHeaderSection.ts
+++ b/front_end/panels/network/components/ResponseHeaderSection.ts
@@ -11,8 +11,16 @@
 import * as NetworkForward from '../../../panels/network/forward/forward.js';
 import * as Host from '../../../core/host/host.js';
 import * as IssuesManager from '../../../models/issues_manager/issues_manager.js';
-import {type HeaderDescriptor, HeaderSectionRow, type HeaderSectionRowData} from './HeaderSectionRow.js';
+import {
+  type HeaderDescriptor,
+  HeaderSectionRow,
+  type HeaderSectionRowData,
+  type HeaderValueChangedEvent,
+} from './HeaderSectionRow.js';
+import * as Persistence from '../../../models/persistence/persistence.js';
+import type * as Workspace from '../../../models/workspace/workspace.js';
 import * as Platform from '../../../core/platform/platform.js';
+import * as Common from '../../../core/common/common.js';
 
 import responseHeaderSectionStyles from './ResponseHeaderSection.css.js';
 
@@ -69,6 +77,8 @@
   readonly #shadow = this.attachShadow({mode: 'open'});
   #request?: Readonly<SDK.NetworkRequest.NetworkRequest>;
   #headers: HeaderDescriptor[] = [];
+  #uiSourceCode: Workspace.UISourceCode.UISourceCode|null = null;
+  #overrides: Persistence.NetworkPersistenceManager.HeaderOverride[] = [];
 
   connectedCallback(): void {
     this.#shadow.adoptedStyleSheets = [responseHeaderSectionStyles];
@@ -80,6 +90,7 @@
         this.#request.sortedResponseHeaders.map(header => ({
                                                   name: Platform.StringUtilities.toLowerCaseString(header.name),
                                                   value: header.value,
+                                                  originalValue: header.value,
                                                 }));
     this.#markOverrides();
 
@@ -147,9 +158,37 @@
       });
     }
 
+    void this.#loadOverridesInfo();
     this.#render();
   }
 
+  async #loadOverridesInfo(): Promise<void> {
+    if (!this.#request) {
+      return;
+    }
+    this.#uiSourceCode =
+        Persistence.NetworkPersistenceManager.NetworkPersistenceManager.instance().getHeadersUISourceCodeFromUrl(
+            this.#request.url());
+    if (!this.#uiSourceCode) {
+      return;
+    }
+    try {
+      const deferredContent = await this.#uiSourceCode.requestContent();
+      this.#overrides =
+          JSON.parse(deferredContent.content || '[]') as Persistence.NetworkPersistenceManager.HeaderOverride[];
+      if (!this.#overrides.every(Persistence.NetworkPersistenceManager.isHeaderOverride)) {
+        throw 'Type mismatch after parsing';
+      }
+      for (const header of this.#headers) {
+        header.editable = true;
+      }
+      this.#render();
+    } catch (error) {
+      console.error(
+          'Failed to parse', this.#uiSourceCode?.url() || 'source code file', 'for locally overriding headers.');
+    }
+  }
+
   #markOverrides(): void {
     if (!this.#request || this.#request.originalResponseHeaders.length === 0) {
       return;
@@ -213,6 +252,56 @@
     }
   }
 
+  #onHeaderValueChanged(event: HeaderValueChangedEvent): void {
+    const target = event.target as HTMLElement;
+    if (!this.#request || target.dataset.index === undefined) {
+      return;
+    }
+
+    const index = Number(target.dataset.index);
+    this.#headers[index].value = event.headerValue;
+
+    // If multiple headers have the same name 'foo', we treat them as a unit.
+    // If there are overrides for 'foo', all original 'foo' headers are removed
+    // and replaced with the override(s) for 'foo'.
+    const headerName = this.#headers[index].name;
+    const headersToUpdate = this.#headers.filter(
+        header => header.name === headerName && (header.value !== header.originalValue || header.isOverride));
+
+    const rawPath = Persistence.NetworkPersistenceManager.NetworkPersistenceManager.instance().rawPathFromUrl(
+        this.#request.url(), true);
+    const lastIndexOfSlash = rawPath.lastIndexOf('/');
+    const rawFileName = Common.ParsedURL.ParsedURL.substring(rawPath, lastIndexOfSlash + 1);
+
+    // If the last override-block matches 'rawFileName', use this last block.
+    // Otherwise just append a new block at the end. We are not using earlier
+    // blocks, because they could be overruled by later blocks, which contain
+    // wildcards in the filenames they apply to.
+    let block: Persistence.NetworkPersistenceManager.HeaderOverride|null = null;
+    const [lastOverride] = this.#overrides.slice(-1);
+    if (lastOverride?.applyTo === rawFileName) {
+      block = lastOverride;
+    } else {
+      block = {
+        applyTo: rawFileName,
+        headers: [],
+      };
+      this.#overrides.push(block);
+    }
+
+    // Keep header overrides for headers with a different name.
+    block.headers = block.headers.filter(header => header.name !== headerName);
+
+    // Append freshly edited header overrides.
+    for (const header of headersToUpdate) {
+      block.headers.push({name: header.name, value: header.value || ''});
+    }
+
+    this.#uiSourceCode?.setWorkingCopy(JSON.stringify(this.#overrides, null, 2));
+    this.#uiSourceCode?.commitWorkingCopy();
+    Persistence.NetworkPersistenceManager.NetworkPersistenceManager.instance().updateInterceptionPatterns();
+  }
+
   #render(): void {
     if (!this.#request) {
       return;
@@ -221,10 +310,10 @@
     // Disabled until https://crbug.com/1079231 is fixed.
     // clang-format off
     render(html`
-      ${this.#headers.map(header => html`
+      ${this.#headers.map((header, index) => html`
         <${HeaderSectionRow.litTagName} .data=${{
           header: header,
-        } as HeaderSectionRowData}></${HeaderSectionRow.litTagName}>
+        } as HeaderSectionRowData} @headervaluechanged=${this.#onHeaderValueChanged} data-index=${index}></${HeaderSectionRow.litTagName}>
       `)}
     `, this.#shadow, {host: this});
     // clang-format on
diff --git a/test/e2e/network/network-request-view_test.ts b/test/e2e/network/network-request-view_test.ts
index 6c58077..b949cec 100644
--- a/test/e2e/network/network-request-view_test.ts
+++ b/test/e2e/network/network-request-view_test.ts
@@ -11,7 +11,6 @@
   $$,
   click,
   enableExperiment,
-  getTestServerPort,
   step,
   typeText,
   waitFor,
@@ -381,7 +380,7 @@
     await target.evaluate(async () => await fetch('/?send_delayed'));
   });
 
-  it('can create header overrides via context menu', async () => {
+  it('can create header overrides', async () => {
     await enableExperiment('headerOverrides');
     await navigateToNetworkTab('hello.html');
     await selectRequestByName('hello.html', {button: 'right'});
@@ -392,12 +391,44 @@
     const button = await waitFor('.infobar-main-row .infobar-button', infoBar);
     await click(button);
 
-    await waitFor('devtools-button.add-block');
-    const folderElement = await waitFor('.tree-outline-disclosure ol.tree-outline .navigator-fs-folder-tree-item');
-    const textContent = await folderElement.evaluate(el => el.textContent || '');
-    assert.match(textContent, new RegExp(`localhost(:|%3A)${getTestServerPort()}/test/e2e/resources/network`));
+    await selectRequestByName('hello.html');
 
-    await waitFor('.tabbed-pane-header-tab[aria-label=".headers"]');
+    const networkView = await waitFor('.network-item-view');
+    const headersTabHeader = await waitFor('#tab-headersComponent', networkView);
+    await click(headersTabHeader);
+    await waitFor('#tab-headersComponent[role=tab][aria-selected=true]', networkView);
+    let responseHeaderSection = await waitFor('[aria-label="Response Headers"]', networkView);
+
+    const getTextFromRow = async (row: ElementHandle<Element>) => {
+      const headerNameElement = await waitFor('.header-name', row);
+      const headerNameText = await headerNameElement.evaluate(el => el.textContent || '');
+      const headerValueElement = await waitFor('.header-value', row);
+      const headerValueText = await headerValueElement.evaluate(el => el.textContent || '');
+      return [headerNameText.trim(), headerValueText.trim()];
+    };
+
+    let row = await waitFor('.row', responseHeaderSection);
+    assert.deepStrictEqual(await getTextFromRow(row), ['cache-control:', 'max-age=3600']);
+
+    await waitForFunction(async () => {
+      await click('.header-name', {root: row});
+      await click('.header-value', {root: row});
+      await typeText('Foo');
+      return (await getTextFromRow(row))[1] === 'Foo';
+    });
+
+    await click('[title="Reveal header override definitions"]');
+
+    const headersView = await waitFor('devtools-sources-headers-view');
+    const headersViewRow = await waitFor('.row.padded', headersView);
+    assert.deepStrictEqual(await getTextFromRow(headersViewRow), ['cache-control', 'Foo']);
+
+    await navigateToNetworkTab('hello.html');
+    await selectRequestByName('hello.html');
+
+    responseHeaderSection = await waitFor('[aria-label="Response Headers"]');
+    row = await waitFor('.row.header-overridden', responseHeaderSection);
+    assert.deepStrictEqual(await getTextFromRow(row), ['cache-control:', 'Foo']);
   });
 
   it('can search by headers name', async () => {
diff --git a/test/unittests/front_end/core/sdk/NetworkManager_test.ts b/test/unittests/front_end/core/sdk/NetworkManager_test.ts
index 74a22c5..bd859ac 100644
--- a/test/unittests/front_end/core/sdk/NetworkManager_test.ts
+++ b/test/unittests/front_end/core/sdk/NetworkManager_test.ts
@@ -264,7 +264,7 @@
       fetchAgent, request, Protocol.Network.ResourceType.Document, requestId, networkRequest, responseStatusCode,
       responseHeaders);
   interceptedRequest.responseBody = async () => {
-    return {error: null, content: responseBody, encoded: true};
+    return {error: null, content: responseBody, encoded: false};
   };
 
   assert.isTrue(spy.notCalled);
diff --git a/test/unittests/front_end/helpers/DOMHelpers.ts b/test/unittests/front_end/helpers/DOMHelpers.ts
index 5618f6a..0469ad4 100644
--- a/test/unittests/front_end/helpers/DOMHelpers.ts
+++ b/test/unittests/front_end/helpers/DOMHelpers.ts
@@ -196,6 +196,14 @@
 }
 
 /**
+ * Dispatches a clipboard paste event.
+ */
+export function dispatchPasteEvent<T extends Element>(element: T, options: ClipboardEventInit = {}) {
+  const pasteEvent = new ClipboardEvent('paste', options);
+  element.dispatchEvent(pasteEvent);
+}
+
+/**
  * Listens to an event of an element and returns a Promise that resolves to the
  * specified event type.
  */
diff --git a/test/unittests/front_end/helpers/OverridesHelpers.ts b/test/unittests/front_end/helpers/OverridesHelpers.ts
index 203f170..026d99c 100644
--- a/test/unittests/front_end/helpers/OverridesHelpers.ts
+++ b/test/unittests/front_end/helpers/OverridesHelpers.ts
@@ -28,6 +28,10 @@
   const fileSystem = {
     fileSystemPath: () => baseUrl,
     fileSystemBaseURL: baseUrl + '/',
+    type: () => Workspace.Workspace.projectTypes.FileSystem,
+    fileSystemInternal: {
+      supportsAutomapping: () => false,
+    },
   } as unknown as Persistence.FileSystemWorkspaceBinding.FileSystem;
 
   const uiSourceCodes = new Map<string, Workspace.UISourceCode.UISourceCode>();
@@ -40,6 +44,8 @@
         return {...fileSystem, requestFileBlob: () => new Blob([file.content])};
       },
       name: () => file.name,
+      setWorkingCopy: () => {},
+      commitWorkingCopy: () => {},
     } as unknown as Workspace.UISourceCode.UISourceCode);
   }
 
@@ -50,8 +56,15 @@
     uiSourceCodeForURL: (url: string) => {
       return uiSourceCodes.get(url) || null;
     },
+    type: () => Workspace.Workspace.projectTypes.FileSystem,
+    initialGitFolders: () => [],
+    fileSystemInternal: {
+      type: () => 'filesystem',
+    },
   } as unknown as Workspace.Workspace.Project;
 
   await networkPersistenceManager.setProject(mockProject);
+  const workspace = Workspace.Workspace.WorkspaceImpl.instance();
+  workspace.addProject(mockProject);
   return networkPersistenceManager;
 }
diff --git a/test/unittests/front_end/panels/network/components/HeaderSectionRow_test.ts b/test/unittests/front_end/panels/network/components/HeaderSectionRow_test.ts
index a4d84f4..e7763c9 100644
--- a/test/unittests/front_end/panels/network/components/HeaderSectionRow_test.ts
+++ b/test/unittests/front_end/panels/network/components/HeaderSectionRow_test.ts
@@ -11,6 +11,8 @@
   assertElement,
   assertShadowRoot,
   dispatchCopyEvent,
+  dispatchKeyDownEvent,
+  dispatchPasteEvent,
   getCleanTextContentFromElements,
   renderElementIntoDOM,
 } from '../../../helpers/DOMHelpers.js';
@@ -151,4 +153,110 @@
     const headerRowElement = component.shadowRoot.querySelector('.row.header-highlight');
     assertElement(headerRowElement, HTMLDivElement);
   });
+
+  it('emits "headervaluechanged" event on blur after being edited', async () => {
+    const headerData: NetworkComponents.HeaderSectionRow.HeaderDescriptor = {
+      name: Platform.StringUtilities.toLowerCaseString('some-header-name'),
+      value: 'someHeaderValue',
+      editable: true,
+    };
+    const editedHeaderValue = 'new value for header';
+
+    const component = await renderHeaderSectionRow(headerData);
+    assertShadowRoot(component.shadowRoot);
+
+    let headerValueFromEvent = '';
+    component.addEventListener('headervaluechanged', event => {
+      headerValueFromEvent = event.headerValue;
+    });
+
+    const editable = component.shadowRoot.querySelector('.editable');
+    assertElement(editable, HTMLSpanElement);
+    editable.focus();
+    editable.innerText = editedHeaderValue;
+    editable.blur();
+
+    assert.strictEqual(headerValueFromEvent, editedHeaderValue);
+  });
+
+  it('resets edited value on escape key', async () => {
+    const originalHeaderValue = 'someHeaderValue';
+    const headerData: NetworkComponents.HeaderSectionRow.HeaderDescriptor = {
+      name: Platform.StringUtilities.toLowerCaseString('some-header-name'),
+      value: originalHeaderValue,
+      editable: true,
+    };
+
+    const component = await renderHeaderSectionRow(headerData);
+    assertShadowRoot(component.shadowRoot);
+
+    let eventCount = 0;
+    component.addEventListener('headervaluechanged', () => {
+      eventCount++;
+    });
+
+    const editable = component.shadowRoot.querySelector('.editable');
+    assertElement(editable, HTMLSpanElement);
+    editable.focus();
+    editable.innerText = 'new value for header';
+    dispatchKeyDownEvent(editable, {key: 'Escape', bubbles: true});
+
+    assert.strictEqual(eventCount, 0);
+    assert.strictEqual(editable.innerText, originalHeaderValue);
+  });
+
+  it('confirms edited value and exits editing mode on "Enter"-key', async () => {
+    const headerData: NetworkComponents.HeaderSectionRow.HeaderDescriptor = {
+      name: Platform.StringUtilities.toLowerCaseString('some-header-name'),
+      value: 'someHeaderValue',
+      editable: true,
+    };
+    const editedHeaderValue = 'new value for header';
+
+    const component = await renderHeaderSectionRow(headerData);
+    assertShadowRoot(component.shadowRoot);
+
+    let headerValueFromEvent = '';
+    let eventCount = 0;
+    component.addEventListener('headervaluechanged', event => {
+      headerValueFromEvent = event.headerValue;
+      eventCount++;
+    });
+
+    const editable = component.shadowRoot.querySelector('.editable');
+    assertElement(editable, HTMLSpanElement);
+    editable.focus();
+    editable.innerText = editedHeaderValue;
+    dispatchKeyDownEvent(editable, {key: 'Enter', bubbles: true});
+
+    assert.strictEqual(headerValueFromEvent, editedHeaderValue);
+    assert.strictEqual(eventCount, 1);
+  });
+
+  it('removes formatting for pasted content', async () => {
+    const headerData: NetworkComponents.HeaderSectionRow.HeaderDescriptor = {
+      name: Platform.StringUtilities.toLowerCaseString('some-header-name'),
+      value: 'someHeaderValue',
+      editable: true,
+    };
+
+    const component = await renderHeaderSectionRow(headerData);
+    assertShadowRoot(component.shadowRoot);
+
+    let headerValueFromEvent = '';
+    component.addEventListener('headervaluechanged', event => {
+      headerValueFromEvent = event.headerValue;
+    });
+
+    const editable = component.shadowRoot.querySelector('.editable');
+    assertElement(editable, HTMLSpanElement);
+    editable.focus();
+    const dt = new DataTransfer();
+    dt.setData('text/plain', 'foo\nbar');
+    dt.setData('text/html', 'This is <b>bold</b>');
+    dispatchPasteEvent(editable, {clipboardData: dt, bubbles: true});
+    editable.blur();
+
+    assert.strictEqual(headerValueFromEvent, 'foo bar');
+  });
 });
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 c157e5f..9033d85 100644
--- a/test/unittests/front_end/panels/network/components/ResponseHeaderSection_test.ts
+++ b/test/unittests/front_end/panels/network/components/ResponseHeaderSection_test.ts
@@ -9,10 +9,14 @@
 import {
   assertElement,
   assertShadowRoot,
+  dispatchFocusOutEvent,
   getCleanTextContentFromElements,
   renderElementIntoDOM,
 } from '../../../helpers/DOMHelpers.js';
 import {describeWithEnvironment} from '../../../helpers/EnvironmentHelpers.js';
+import {setUpEnvironment} from '../../../helpers/OverridesHelpers.js';
+import type * as Platform from '../../../../../../front_end/core/platform/platform.js';
+import {createWorkspaceProject} from '../../../helpers/OverridesHelpers.js';
 
 const coordinator = Coordinator.RenderCoordinator.RenderCoordinator.instance();
 
@@ -30,7 +34,59 @@
   return component;
 }
 
+function editHeaderRow(
+    component: NetworkComponents.ResponseHeaderSection.ResponseHeaderSection, index: number, newValue: string) {
+  assertShadowRoot(component.shadowRoot);
+  const rows = component.shadowRoot.querySelectorAll('devtools-header-section-row');
+  assert.isTrue(rows.length >= index + 1, 'Trying to edit row with index greater than # of rows.');
+  const row = rows[index];
+  assertShadowRoot(row.shadowRoot);
+  const editable = row.shadowRoot.querySelector('.editable');
+  assertElement(editable, HTMLSpanElement);
+  editable.textContent = newValue;
+  dispatchFocusOutEvent(editable, {bubbles: true});
+}
+
+async function setupHeaderEditing(
+    headerOverridesFileContent: string, actualHeaders: SDK.NetworkRequest.NameValue[],
+    originalHeaders: SDK.NetworkRequest.NameValue[]) {
+  const networkPersistenceManager =
+      await createWorkspaceProject('file:///path/to/overrides' as Platform.DevToolsPath.UrlString, [
+        {
+          name: '.headers',
+          path: 'www.example.com/',
+          content: headerOverridesFileContent,
+        },
+      ]);
+
+  const project = networkPersistenceManager.project();
+  let spy = sinon.spy();
+  if (project) {
+    const uiSourceCode = project.uiSourceCodeForURL(
+        'file:///path/to/overrides/www.example.com/.headers' as Platform.DevToolsPath.UrlString);
+    if (uiSourceCode) {
+      spy = sinon.spy(uiSourceCode, 'setWorkingCopy');
+    }
+  }
+
+  const request = {
+    sortedResponseHeaders: actualHeaders,
+    originalResponseHeaders: originalHeaders,
+    blockedResponseCookies: () => [],
+    wasBlocked: () => false,
+    url: () => 'https://www.example.com/',
+  } as unknown as SDK.NetworkRequest.NetworkRequest;
+
+  const component = await renderResponseHeaderSection(request);
+  assertShadowRoot(component.shadowRoot);
+  return {component, spy};
+}
+
 describeWithEnvironment('ResponseHeaderSection', () => {
+  beforeEach(async () => {
+    await setUpEnvironment();
+  });
+
   it('renders detailed reason for blocked requests', async () => {
     const request = {
       sortedResponseHeaders: [
@@ -40,6 +96,7 @@
       wasBlocked: () => true,
       blockedReason: () => Protocol.Network.BlockedReason.CorpNotSameOriginAfterDefaultedToSameOriginByCoep,
       originalResponseHeaders: [],
+      url: () => 'https://www.example.com/',
     } as unknown as SDK.NetworkRequest.NetworkRequest;
 
     const component = await renderResponseHeaderSection(request);
@@ -72,6 +129,7 @@
       }],
       wasBlocked: () => false,
       originalResponseHeaders: [],
+      url: () => 'https://www.example.com/',
     } as unknown as SDK.NetworkRequest.NetworkRequest;
 
     const component = await renderResponseHeaderSection(request);
@@ -125,6 +183,7 @@
         {name: 'triplicate', value: '1'},
         {name: 'triplicate', value: '2'},
       ],
+      url: () => 'https://www.example.com/',
     } as unknown as SDK.NetworkRequest.NetworkRequest;
 
     const component = await renderResponseHeaderSection(request);
@@ -162,4 +221,331 @@
     assertShadowRoot(rows[11].shadowRoot);
     checkRow(rows[11].shadowRoot, 'triplicate:', '2', true);
   });
+
+  it('sets headers as "editable" when matching ".headers" file exists', async () => {
+    await createWorkspaceProject('file:///path/to/overrides' as Platform.DevToolsPath.UrlString, [
+      {
+        name: '.headers',
+        path: 'www.example.com/',
+        content: `[
+          {
+            "applyTo": "index.html",
+            "headers": [{
+              "name": "server",
+              "value": "overridden server"
+            }]
+          }
+        ]`,
+      },
+    ]);
+
+    const request = {
+      sortedResponseHeaders: [
+        {name: 'server', value: 'overridden server'},
+        {name: 'cache-control', value: 'max-age=600'},
+      ],
+      blockedResponseCookies: () => [],
+      wasBlocked: () => false,
+      originalResponseHeaders: [
+        {name: 'server', value: 'original server'},
+        {name: 'cache-control', value: 'max-age=600'},
+      ],
+      url: () => 'https://www.example.com/',
+    } as unknown as SDK.NetworkRequest.NetworkRequest;
+
+    const component = await renderResponseHeaderSection(request);
+    assertShadowRoot(component.shadowRoot);
+    const rows = component.shadowRoot.querySelectorAll('devtools-header-section-row');
+
+    const checkRow = (shadowRoot: ShadowRoot, headerName: string, headerValue: string, isOverride: boolean): void => {
+      assert.strictEqual(shadowRoot.querySelector('.header-name')?.textContent?.trim(), headerName);
+      assert.strictEqual(shadowRoot.querySelector('.header-value')?.textContent?.trim(), headerValue);
+      assert.strictEqual(shadowRoot.querySelector('.row')?.classList.contains('header-overridden'), isOverride);
+      const editable = shadowRoot.querySelector('.editable');
+      assertElement(editable, HTMLSpanElement);
+    };
+
+    assertShadowRoot(rows[0].shadowRoot);
+    checkRow(rows[0].shadowRoot, 'server:', 'overridden server', true);
+    assertShadowRoot(rows[1].shadowRoot);
+    checkRow(rows[1].shadowRoot, 'cache-control:', 'max-age=600', false);
+  });
+
+  it('does not set headers as "editable" when matching ".headers" file cannot be parsed correctly', async () => {
+    await createWorkspaceProject('file:///path/to/overrides' as Platform.DevToolsPath.UrlString, [
+      {
+        name: '.headers',
+        path: 'www.example.com/',
+        // 'headers' contains the invalid key 'no-name' and will therefore
+        // cause a parsing error.
+        content: `[
+          {
+            "applyTo": "index.html",
+            "headers": [{
+              "no-name": "server",
+              "value": "overridden server"
+            }]
+          }
+        ]`,
+      },
+    ]);
+
+    const request = {
+      sortedResponseHeaders: [
+        {name: 'server', value: 'overridden server'},
+        {name: 'cache-control', value: 'max-age=600'},
+      ],
+      blockedResponseCookies: () => [],
+      wasBlocked: () => false,
+      originalResponseHeaders: [
+        {name: 'server', value: 'original server'},
+        {name: 'cache-control', value: 'max-age=600'},
+      ],
+      url: () => 'https://www.example.com/',
+    } as unknown as SDK.NetworkRequest.NetworkRequest;
+
+    // A console error is emitted when '.headers' cannot be parsed correctly.
+    // We don't need that noise in the test output.
+    sinon.stub(console, 'error');
+
+    const component = await renderResponseHeaderSection(request);
+    assertShadowRoot(component.shadowRoot);
+    const rows = component.shadowRoot.querySelectorAll('devtools-header-section-row');
+
+    const checkRow = (shadowRoot: ShadowRoot, headerName: string, headerValue: string, isOverride: boolean): void => {
+      assert.strictEqual(shadowRoot.querySelector('.header-name')?.textContent?.trim(), headerName);
+      assert.strictEqual(shadowRoot.querySelector('.header-value')?.textContent?.trim(), headerValue);
+      assert.strictEqual(shadowRoot.querySelector('.row')?.classList.contains('header-overridden'), isOverride);
+      const editable = shadowRoot.querySelector('.editable');
+      assert.isNull(editable);
+    };
+
+    assertShadowRoot(rows[0].shadowRoot);
+    checkRow(rows[0].shadowRoot, 'server:', 'overridden server', true);
+    assertShadowRoot(rows[1].shadowRoot);
+    checkRow(rows[1].shadowRoot, 'cache-control:', 'max-age=600', false);
+  });
+
+  it('can edit original headers', async () => {
+    const headerOverridesFileContent = `[
+      {
+        "applyTo": "index.html",
+        "headers": [{
+          "name": "server",
+          "value": "overridden server"
+        }]
+      }
+    ]`;
+
+    const actualHeaders = [
+      {name: 'server', value: 'overridden server'},
+      {name: 'cache-control', value: 'max-age=600'},
+    ];
+
+    const originalHeaders = [
+      {name: 'server', value: 'original server'},
+      {name: 'cache-control', value: 'max-age=600'},
+    ];
+
+    const {component, spy} = await setupHeaderEditing(headerOverridesFileContent, actualHeaders, originalHeaders);
+    editHeaderRow(component, 1, 'max-age=9999');
+
+    const expected = [{
+      applyTo: 'index.html',
+      headers: [
+        {
+          name: 'server',
+          value: 'overridden server',
+        },
+        {
+          name: 'cache-control',
+          value: 'max-age=9999',
+        },
+      ],
+    }];
+    assert.isTrue(spy.calledOnceWith(JSON.stringify(expected, null, 2)));
+  });
+
+  it('can edit overridden headers', async () => {
+    const headerOverridesFileContent = `[
+      {
+        "applyTo": "index.html",
+        "headers": [{
+          "name": "server",
+          "value": "overridden server"
+        }]
+      }
+    ]`;
+
+    const actualHeaders = [
+      {name: 'server', value: 'overridden server'},
+      {name: 'cache-control', value: 'max-age=600'},
+    ];
+
+    const originalHeaders = [
+      {name: 'server', value: 'original server'},
+      {name: 'cache-control', value: 'max-age=600'},
+    ];
+
+    const {component, spy} = await setupHeaderEditing(headerOverridesFileContent, actualHeaders, originalHeaders);
+    editHeaderRow(component, 0, 'edited value');
+
+    const expected = [{
+      applyTo: 'index.html',
+      headers: [
+        {
+          name: 'server',
+          value: 'edited value',
+        },
+      ],
+    }];
+    assert.isTrue(spy.calledOnceWith(JSON.stringify(expected, null, 2)));
+  });
+
+  it('can edit multiple headers', async () => {
+    const headerOverridesFileContent = `[
+      {
+        "applyTo": "index.html",
+        "headers": [{
+          "name": "server",
+          "value": "overridden server"
+        }]
+      }
+    ]`;
+
+    const actualHeaders = [
+      {name: 'server', value: 'overridden server'},
+      {name: 'cache-control', value: 'max-age=600'},
+    ];
+
+    const originalHeaders = [
+      {name: 'server', value: 'original server'},
+      {name: 'cache-control', value: 'max-age=600'},
+    ];
+
+    const {component, spy} = await setupHeaderEditing(headerOverridesFileContent, actualHeaders, originalHeaders);
+    editHeaderRow(component, 0, 'edited server');
+    editHeaderRow(component, 1, 'edited cache-control');
+
+    const expected = [{
+      applyTo: 'index.html',
+      headers: [
+        {
+          name: 'server',
+          value: 'edited server',
+        },
+        {
+          name: 'cache-control',
+          value: 'edited cache-control',
+        },
+      ],
+    }];
+    assert.isTrue(spy.lastCall.calledWith(JSON.stringify(expected, null, 2)));
+  });
+
+  it('can edit multiple headers which have the same name', async () => {
+    const headerOverridesFileContent = '[]';
+
+    const actualHeaders = [
+      {name: 'link', value: 'first value'},
+      {name: 'link', value: 'second value'},
+    ];
+
+    const originalHeaders = [
+      {name: 'link', value: 'first value'},
+      {name: 'link', value: 'second value'},
+    ];
+
+    const {component, spy} = await setupHeaderEditing(headerOverridesFileContent, actualHeaders, originalHeaders);
+    editHeaderRow(component, 0, 'third value');
+
+    let expected = [{
+      applyTo: 'index.html',
+      headers: [
+        {
+          name: 'link',
+          value: 'third value',
+        },
+      ],
+    }];
+    assert.isTrue(spy.lastCall.calledWith(JSON.stringify(expected, null, 2)));
+
+    editHeaderRow(component, 1, 'fourth value');
+    expected = [{
+      applyTo: 'index.html',
+      headers: [
+        {
+          name: 'link',
+          value: 'third value',
+        },
+        {
+          name: 'link',
+          value: 'fourth value',
+        },
+      ],
+    }];
+    assert.isTrue(spy.lastCall.calledWith(JSON.stringify(expected, null, 2)));
+  });
+
+  it('can edit multiple headers which have the same name and which are already overridden', async () => {
+    const headerOverridesFileContent = `[
+      {
+        "applyTo": "index.html",
+        "headers": [
+          {
+            "name": "link",
+            "value": "third value"
+          },
+          {
+            "name": "link",
+            "value": "fourth value"
+          }
+        ]
+      }
+    ]`;
+
+    const actualHeaders = [
+      {name: 'link', value: 'third value'},
+      {name: 'link', value: 'fourth value'},
+    ];
+
+    const originalHeaders = [
+      {name: 'link', value: 'first value'},
+      {name: 'link', value: 'second value'},
+    ];
+
+    const {component, spy} = await setupHeaderEditing(headerOverridesFileContent, actualHeaders, originalHeaders);
+    editHeaderRow(component, 1, 'fifth value');
+
+    let expected = [{
+      applyTo: 'index.html',
+      headers: [
+        {
+          name: 'link',
+          value: 'third value',
+        },
+        {
+          name: 'link',
+          value: 'fifth value',
+        },
+      ],
+    }];
+    assert.isTrue(spy.lastCall.calledWith(JSON.stringify(expected, null, 2)));
+
+    editHeaderRow(component, 0, 'sixth value');
+    expected = [{
+      applyTo: 'index.html',
+      headers: [
+        {
+          name: 'link',
+          value: 'sixth value',
+        },
+        {
+          name: 'link',
+          value: 'fifth value',
+        },
+      ],
+    }];
+    assert.isTrue(spy.lastCall.calledWith(JSON.stringify(expected, null, 2)));
+  });
 });